@@ -27,12 +27,61 @@ public struct HStackSnap<Content: View>: View {
2727 HStack ( content: content)
2828 . frame ( maxWidth: . infinity)
2929 . offset ( x: scrollOffset, y: . zero)
30+ . animation ( . easeOut( duration: 0.2 ) )
3031 }
3132 . onPreferenceChange ( ContentPreferenceKey . self, perform: { preferences in
3233
33- for pref in preferences {
34+ // Calculate all values once, on render. On-the-fly calculations with GeometryReader
35+ // proved occasionally unstable in testing.
36+ if !hasCalculatedFrames {
3437
35- itemFrames [ pref. id. hashValue] = pref
38+ let viewWidth = geometry. frame ( in: . named( coordinateSpace) ) . width
39+
40+ var itemScrollPositions : [ Int : CGFloat ] = [ : ]
41+
42+ var frameMaxXVals : [ CGFloat ] = [ ]
43+
44+ for pref in preferences {
45+
46+ itemScrollPositions [ pref. id. hashValue] = scrollOffset ( for: pref. rect. minX)
47+ frameMaxXVals. append ( pref. rect. maxX)
48+ }
49+
50+ // Array of content widths from currentElement.minX to lastElement.maxX
51+ var contentFitMap : [ CGFloat ] = [ ]
52+
53+ // Calculate content widths (used to trim snap positions later)
54+ for currMinX in preferences. map ( { $0. rect. minX } ) {
55+
56+ guard let maxX = preferences. last? . rect. maxX else { break }
57+ let widthToEnd = maxX - currMinX
58+
59+ contentFitMap. append ( widthToEnd)
60+ }
61+
62+ var frameTrim : Int = 0
63+ let reversedFitMap = Array ( contentFitMap. reversed ( ) )
64+
65+ // Calculate how many snap locations should be trimmed.
66+ for i in 0 ..< reversedFitMap. count {
67+
68+ if reversedFitMap [ i] > viewWidth {
69+
70+ frameTrim = max ( i - 1 , 0 )
71+ break
72+ }
73+ }
74+
75+ // Write valid snap locations to state.
76+ for (i, item) in itemScrollPositions. sorted ( by: { $0. value > $1. value } )
77+ . enumerated ( ) {
78+
79+ guard i < ( itemScrollPositions. count - frameTrim) else { break }
80+
81+ snapLocations [ item. key] = item. value
82+ }
83+
84+ hasCalculatedFrames = true
3685 }
3786
3887 } )
@@ -55,37 +104,31 @@ public struct HStackSnap<Content: View>: View {
55104
56105 } . onEnded { event in
57106
58- guard var closestFrame: ContentPreferenceData = itemFrames. first? . value else { return }
59-
60- for (_, value) in itemFrames {
107+ let currOffset = scrollOffset
108+ var closestSnapLocation : CGFloat = snapLocations. first? . value ?? targetOffset
61109
62- let currDistance = distanceToTarget (
63- x: closestFrame. rect. minX)
64- let newDistance = distanceToTarget ( x: value. rect. minX)
110+ for (_, offset) in snapLocations {
65111
66- if abs ( newDistance ) < abs ( currDistance ) {
112+ if abs ( offset - currOffset ) < abs ( closestSnapLocation - currOffset ) {
67113
68- closestFrame = value
114+ closestSnapLocation = offset
69115 }
70116 }
71117
72- withAnimation ( . easeOut( duration: 0.2 ) ) {
73-
74- scrollOffset += distanceToTarget (
75- x: closestFrame. rect. minX)
76- }
77-
118+ scrollOffset = closestSnapLocation
78119 prevScrollOffset = scrollOffset
79120 }
80121 }
81122
82- func distanceToTarget ( x: CGFloat ) -> CGFloat {
123+ func scrollOffset ( for x: CGFloat ) -> CGFloat {
83124
84- return targetOffset - x
125+ return ( targetOffset * 2 ) - x
85126 }
86127
87128 // MARK: Private
88129
130+ @State private var hasCalculatedFrames : Bool = false
131+
89132 /// Current scroll offset.
90133 @State private var scrollOffset : CGFloat
91134
@@ -95,7 +138,8 @@ public struct HStackSnap<Content: View>: View {
95138 /// Calculated offset based on `SnapLocation`
96139 @State private var targetOffset : CGFloat
97140
98- @State private var itemFrames : [ Int : ContentPreferenceData ] = [ : ]
141+ /// The original offset of each frame, used to calculate `scrollOffset`
142+ @State private var snapLocations : [ Int : CGFloat ] = [ : ]
99143
100144 private let coordinateSpace : String
101145}
0 commit comments