@@ -27,12 +27,61 @@ public struct HStackSnap<Content: View>: View {
27
27
HStack ( content: content)
28
28
. frame ( maxWidth: . infinity)
29
29
. offset ( x: scrollOffset, y: . zero)
30
+ . animation ( . easeOut( duration: 0.2 ) )
30
31
}
31
32
. onPreferenceChange ( ContentPreferenceKey . self, perform: { preferences in
32
33
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 {
34
37
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
36
85
}
37
86
38
87
} )
@@ -55,37 +104,31 @@ public struct HStackSnap<Content: View>: View {
55
104
56
105
} . onEnded { event in
57
106
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
61
109
62
- let currDistance = distanceToTarget (
63
- x: closestFrame. rect. minX)
64
- let newDistance = distanceToTarget ( x: value. rect. minX)
110
+ for (_, offset) in snapLocations {
65
111
66
- if abs ( newDistance ) < abs ( currDistance ) {
112
+ if abs ( offset - currOffset ) < abs ( closestSnapLocation - currOffset ) {
67
113
68
- closestFrame = value
114
+ closestSnapLocation = offset
69
115
}
70
116
}
71
117
72
- withAnimation ( . easeOut( duration: 0.2 ) ) {
73
-
74
- scrollOffset += distanceToTarget (
75
- x: closestFrame. rect. minX)
76
- }
77
-
118
+ scrollOffset = closestSnapLocation
78
119
prevScrollOffset = scrollOffset
79
120
}
80
121
}
81
122
82
- func distanceToTarget ( x: CGFloat ) -> CGFloat {
123
+ func scrollOffset ( for x: CGFloat ) -> CGFloat {
83
124
84
- return targetOffset - x
125
+ return ( targetOffset * 2 ) - x
85
126
}
86
127
87
128
// MARK: Private
88
129
130
+ @State private var hasCalculatedFrames : Bool = false
131
+
89
132
/// Current scroll offset.
90
133
@State private var scrollOffset : CGFloat
91
134
@@ -95,7 +138,8 @@ public struct HStackSnap<Content: View>: View {
95
138
/// Calculated offset based on `SnapLocation`
96
139
@State private var targetOffset : CGFloat
97
140
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 ] = [ : ]
99
143
100
144
private let coordinateSpace : String
101
145
}
0 commit comments