Skip to content

Commit d086360

Browse files
author
Trent Guillory
committed
Improved snap location calculations, disallowed scroll past content.
1 parent 42b756e commit d086360

File tree

2 files changed

+65
-20
lines changed

2 files changed

+65
-20
lines changed

SnapToScrollDemo/Sources/ContentView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import SwiftUI
55

66
struct ContentView: View {
77

8-
@State var items = [("one", UUID()), ("two", UUID()), ("three", UUID()), ("four", UUID()), ("five", UUID()), ("six", UUID())]
8+
@State var items = [("one", 1), ("two", 2), ("three", 3), ("four", 4), ("five", 5), ("six", 6)]
9+
910

1011
var body: some View {
1112
VStack {

Sources/HStackSnap.swift

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)