Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 4e866ee

Browse files
authoredSep 1, 2021
Merge pull request #1 from swiftui-library/feat/snap-to-center
Feat/snap to center
2 parents dea316f + 40a9169 commit 4e866ee

File tree

16 files changed

+632
-170
lines changed

16 files changed

+632
-170
lines changed
 

‎README.md

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,47 +6,56 @@ https://user-images.githubusercontent.com/8763719/131393666-6af82d2a-998e-4dba-a
66

77
## Getting Started
88

9-
Using `SnapToScroll` is straightforward. There's only two requirements:
9+
Using `SnapToScroll` is straightforward. There's just three steps.
1010

11-
1. Replace `HStack` with `HStackSnap`
12-
2. Add `GeometryReaderOverlay` to your view.
11+
1. Import `SnapToScroll`
12+
2. Replace `HStack` with `HStackSnap`
13+
3. Add `.snapAlignmentHelper` to your view.
1314

1415
An example:
1516

1617
```swift
18+
import SnapToScroll // Step 1
19+
...
1720

18-
HStackSnap(leadingOffset: 16) {
21+
HStackSnap(alignment: .center(32)) { // Step 2
1922

20-
ForEach(modelArray) { viewModel in
23+
ForEach(myModels) { viewModel in
2124

22-
myView(viewModel: viewModel)
23-
.frame(maxWidth: 250)
24-
.overlay(GeometryReaderOverlay(id: viewModel.id))
25-
}
26-
}
25+
MyView(
26+
selectedIndex: $selectedIndex,
27+
viewModel: viewModel
28+
)
29+
.snapAlignmentHelper(id: viewModel.id) // Step 3
30+
}
2731
}
2832

2933
```
3034
For more examples, see `SnapToScrollDemo/ContentView.swift`.
3135

32-
## Options
36+
## Configuration
3337

3438
`HStackSnap` comes with two customizable properties:
3539

36-
- `leadingOffset`: The leading padding you'd like each element to snap to.
37-
- `coordinateSpace`: Option to set custom name for the coordinate space, in the case you're using multiple `HStackSnap`s of various sizes.
40+
- `alignment`: The way you'd like your elements to be arranged.
41+
- `leading(CGFloat)`: Aligns your child views to the leading edge of `HStackSnap`. This configuration supports elements of various sizes, so long as they don't take up all available horizontal space (which would extend beyond the screen). Use the value to set the size of the left offset.
42+
- `center(CGFloat)`: Automatically aligns your child view to the center of the screen, using the offset value you've provided. This is accomplished with inside of the `.snapAlignmentHelper` which sets the frame width based on the available space. Note that setting your own width elsewhere may produce unexpected layouts.
43+
- `coordinateSpace`: Option to set custom name for the coordinate space, in the case you're using multiple `HStackSnap`s of various sizes. If you use this, set the same value in `.snapAlignmentHelper`.
44+
45+
`.snapAlignmentHelper` comes with two options as well:
46+
47+
- `id`: Required. A unique ID for the element.
48+
- `coordinateSpace`: Same as above.
3849

3950
## Limitations
4051

41-
1. If your child views are designed to take up all available horizontal space, they'll expand beyond the visible view. Prevent this with `.frame(maxWidth: x)`
42-
2. `HStackSnap` is currently designed to work with static content.
43-
3. At the moment, `HStackSnap` offers snapping to the leading edge only. If you'd like to offer a PR that adds support for `.center` and / or `.trailing`, I'd love to look it over!
52+
- `HStackSnap` is currently designed to work with static content.
4453

4554
## How it Works
4655

4756
At render, `HStackSnap` reads the frame data of each child element and calculates the `scrollOffset` each element should use. Then, on `DragGesture.onEnded`, the nearest snap location is calculated, and the scroll offset is set to this point.
4857

49-
Read through `HStackSnap` for more details.
58+
Read through `HStackSnap.swift` and `Views/HStackSnapCore.swift` for more details.
5059

5160
## Credits
5261

‎SnapToScrollDemo/SnapToScrollDemo.xcodeproj/project.pbxproj

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
840130AF26DFB8DD00E4A8A3 /* GettingStartedModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840130AE26DFB8DC00E4A8A3 /* GettingStartedModel.swift */; };
11+
840130B126DFB93400E4A8A3 /* GettingStartedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840130B026DFB93400E4A8A3 /* GettingStartedView.swift */; };
12+
840130B326DFC27700E4A8A3 /* Example3ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840130B226DFC27700E4A8A3 /* Example3ContentView.swift */; };
13+
840130B526DFC2FB00E4A8A3 /* Example2ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840130B426DFC2FB00E4A8A3 /* Example2ContentView.swift */; };
14+
840130B726DFC33100E4A8A3 /* Example1ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840130B626DFC33100E4A8A3 /* Example1ContentView.swift */; };
1015
841B0F8326DD39A4008A436B /* TripTupleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841B0F8226DD39A4008A436B /* TripTupleView.swift */; };
1116
84B568D926DD271000D37CF2 /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B568D826DD271000D37CF2 /* TagView.swift */; };
1217
84B568DB26DD309400D37CF2 /* Example1HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B568DA26DD309400D37CF2 /* Example1HeaderView.swift */; };
@@ -23,6 +28,11 @@
2328
/* End PBXBuildFile section */
2429

2530
/* Begin PBXFileReference section */
31+
840130AE26DFB8DC00E4A8A3 /* GettingStartedModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GettingStartedModel.swift; sourceTree = "<group>"; };
32+
840130B026DFB93400E4A8A3 /* GettingStartedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GettingStartedView.swift; sourceTree = "<group>"; };
33+
840130B226DFC27700E4A8A3 /* Example3ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example3ContentView.swift; sourceTree = "<group>"; };
34+
840130B426DFC2FB00E4A8A3 /* Example2ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example2ContentView.swift; sourceTree = "<group>"; };
35+
840130B626DFC33100E4A8A3 /* Example1ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example1ContentView.swift; sourceTree = "<group>"; };
2636
841B0F8226DD39A4008A436B /* TripTupleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripTupleView.swift; sourceTree = "<group>"; };
2737
84B568D826DD271000D37CF2 /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = "<group>"; };
2838
84B568DA26DD309400D37CF2 /* Example1HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example1HeaderView.swift; sourceTree = "<group>"; };
@@ -52,9 +62,20 @@
5262
/* End PBXFrameworksBuildPhase section */
5363

5464
/* Begin PBXGroup section */
65+
840130AD26DFB8C800E4A8A3 /* Example3 */ = {
66+
isa = PBXGroup;
67+
children = (
68+
840130B226DFC27700E4A8A3 /* Example3ContentView.swift */,
69+
840130AE26DFB8DC00E4A8A3 /* GettingStartedModel.swift */,
70+
840130B026DFB93400E4A8A3 /* GettingStartedView.swift */,
71+
);
72+
path = Example3;
73+
sourceTree = "<group>";
74+
};
5575
84B568D626DD26A800D37CF2 /* Example1 */ = {
5676
isa = PBXGroup;
5777
children = (
78+
840130B626DFC33100E4A8A3 /* Example1ContentView.swift */,
5879
84C99CD726D99BA100C1D5C4 /* TagModel.swift */,
5980
84B568D826DD271000D37CF2 /* TagView.swift */,
6081
84B568DA26DD309400D37CF2 /* Example1HeaderView.swift */,
@@ -68,6 +89,7 @@
6889
84B568DD26DD351900D37CF2 /* TripModel.swift */,
6990
84B568DF26DD37A600D37CF2 /* TripView.swift */,
7091
841B0F8226DD39A4008A436B /* TripTupleView.swift */,
92+
840130B426DFC2FB00E4A8A3 /* Example2ContentView.swift */,
7193
);
7294
path = "Example 2";
7395
sourceTree = "<group>";
@@ -97,6 +119,7 @@
97119
84D9FA3526D9753600F87EF5 /* AppDelegate.swift */,
98120
84B568D626DD26A800D37CF2 /* Example1 */,
99121
84B568DC26DD311F00D37CF2 /* Example 2 */,
122+
840130AD26DFB8C800E4A8A3 /* Example3 */,
100123
84D9FA3726D9753600F87EF5 /* SceneDelegate.swift */,
101124
84D9FA3926D9753600F87EF5 /* ContentView.swift */,
102125
);
@@ -204,10 +227,15 @@
204227
files = (
205228
84B568DB26DD309400D37CF2 /* Example1HeaderView.swift in Sources */,
206229
84D9FA3626D9753600F87EF5 /* AppDelegate.swift in Sources */,
230+
840130AF26DFB8DD00E4A8A3 /* GettingStartedModel.swift in Sources */,
207231
841B0F8326DD39A4008A436B /* TripTupleView.swift in Sources */,
232+
840130B726DFC33100E4A8A3 /* Example1ContentView.swift in Sources */,
208233
84B568D926DD271000D37CF2 /* TagView.swift in Sources */,
234+
840130B326DFC27700E4A8A3 /* Example3ContentView.swift in Sources */,
209235
84B568E026DD37A600D37CF2 /* TripView.swift in Sources */,
236+
840130B526DFC2FB00E4A8A3 /* Example2ContentView.swift in Sources */,
210237
84B568DE26DD351900D37CF2 /* TripModel.swift in Sources */,
238+
840130B126DFB93400E4A8A3 /* GettingStartedView.swift in Sources */,
211239
84D9FA3826D9753600F87EF5 /* SceneDelegate.swift in Sources */,
212240
84D9FA3A26D9753600F87EF5 /* ContentView.swift in Sources */,
213241
84C99CD826D99BA100C1D5C4 /* TagModel.swift in Sources */,

‎SnapToScrollDemo/Sources/ContentView.swift

Lines changed: 11 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,55 +5,28 @@ import SwiftUI
55

66
struct ContentView: View {
77

8-
@State var items = [("one", 1), ("two", 2), ("three", 3), ("four", 4), ("five", 5), ("six", 6)]
9-
108
var body: some View {
119
VStack {
1210

1311
ScrollView {
1412

1513
VerticalSpace
1614

17-
VStack {
18-
19-
LargeHeader(text: "Example 1")
20-
21-
Example1HeaderView()
22-
23-
// Don't forget to attach GeometryReaderOverlay!
24-
HStackSnap(leadingOffset: 16) {
25-
26-
ForEach(TagModel.exampleModels) { viewModel in
27-
28-
TagView(viewModel: viewModel)
29-
.overlay(GeometryReaderOverlay(id: viewModel.id))
30-
}
31-
}.padding(.top, 4)
32-
}
15+
LargeHeader(text: "Example 1")
16+
17+
Example1ContentView()
3318

3419
VerticalSpace
3520

36-
VStack {
37-
38-
LargeHeader(text: "Example 2")
39-
40-
Text("Explore Nearby")
41-
.font(.system(size: 22, weight: .semibold, design: .rounded))
42-
.frame(maxWidth: .infinity, alignment: .leading)
43-
.padding([.top, .leading], 16)
44-
45-
HStackSnap(leadingOffset: 16) {
46-
47-
ForEach(TripTupleModel.exampleModels) { viewModel in
21+
LargeHeader(text: "Example 2")
22+
23+
Example2ContentView()
24+
25+
VerticalSpace
4826

49-
TripTupleView(viewModel: viewModel)
50-
.frame(maxWidth: 250)
51-
.overlay(GeometryReaderOverlay(id: viewModel.id))
52-
}
53-
}
54-
.frame(height: 200)
55-
.padding(.top, 4)
56-
}
27+
LargeHeader(text: "Example 3")
28+
29+
Example3ContentView()
5730
}
5831
}
5932
.preferredColorScheme(.light)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import SwiftUI
2+
import SnapToScroll
3+
4+
// MARK: - Example2ContentView
5+
6+
struct Example2ContentView: View {
7+
var body: some View {
8+
9+
VStack {
10+
11+
Text("Explore Nearby")
12+
.font(.system(size: 22, weight: .semibold, design: .rounded))
13+
.frame(maxWidth: .infinity, alignment: .leading)
14+
.padding([.top, .leading], 16)
15+
16+
HStackSnap(alignment: .leading(16)) {
17+
18+
ForEach(TripTupleModel.exampleModels) { viewModel in
19+
20+
TripTupleView(viewModel: viewModel)
21+
.frame(maxWidth: 250)
22+
.snapAlignmentHelper(id: viewModel.id)
23+
}
24+
} onSwipe: { index in
25+
26+
print(index)
27+
}
28+
.frame(height: 130)
29+
.padding(.top, 4)
30+
}
31+
}
32+
}
33+
34+
// MARK: - Example2ContentView_Previews
35+
36+
struct Example2ContentView_Previews: PreviewProvider {
37+
static var previews: some View {
38+
Example2ContentView()
39+
}
40+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// Example2ContentView.swift
3+
// Example2ContentView
4+
//
5+
// Created by Trent Guillory on 9/1/21.
6+
//
7+
8+
import SwiftUI
9+
import SnapToScroll
10+
11+
struct Example1ContentView: View {
12+
var body: some View {
13+
14+
VStack {
15+
16+
Example1HeaderView()
17+
18+
// Don't forget to attach snapAlignmentHelper!
19+
HStackSnap(alignment: .leading(16)) {
20+
21+
ForEach(TagModel.exampleModels) { viewModel in
22+
23+
TagView(viewModel: viewModel)
24+
.snapAlignmentHelper(id: viewModel.id)
25+
}
26+
}.padding(.top, 4)
27+
}
28+
}
29+
}
30+
31+
struct Example1ContentView_Previews: PreviewProvider {
32+
static var previews: some View {
33+
Example2ContentView()
34+
}
35+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import SnapToScroll
2+
import SwiftUI
3+
4+
// MARK: - Example3ContentView
5+
6+
struct Example3ContentView: View {
7+
8+
// MARK: Internal
9+
10+
var body: some View {
11+
12+
VStack {
13+
14+
Text("Getting Started")
15+
.font(.system(size: 22, weight: .semibold, design: .rounded))
16+
.foregroundColor(.white)
17+
.frame(maxWidth: .infinity, alignment: .leading)
18+
.padding([.top, .leading], 32)
19+
20+
HStackSnap(alignment: .center(32)) {
21+
22+
ForEach(GettingStartedModel.exampleModels) { viewModel in
23+
24+
GettingStartedView(
25+
selectedIndex: $selectedGettingStartedIndex,
26+
viewModel: viewModel)
27+
.snapAlignmentHelper(id: viewModel.id)
28+
}
29+
} onSwipe: { index in
30+
31+
selectedGettingStartedIndex = index
32+
}
33+
.frame(height: 200)
34+
.padding(.top, 4)
35+
}
36+
.padding([.top, .bottom], 64)
37+
.background(LinearGradient(
38+
colors: [Color("Cream"), Color("LightPink")],
39+
startPoint: .top,
40+
endPoint: .bottom))
41+
}
42+
43+
// MARK: Private
44+
45+
@State private var selectedGettingStartedIndex: Int = 0
46+
}
47+
48+
// MARK: - Example3ContentView_Previews
49+
50+
struct Example3ContentView_Previews: PreviewProvider {
51+
static var previews: some View {
52+
Example3ContentView()
53+
}
54+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import Foundation
2+
3+
struct GettingStartedModel: Identifiable {
4+
5+
static let exampleModels: [GettingStartedModel] = [
6+
.init(
7+
id: 0,
8+
systemImage: "camera.aperture",
9+
title: "Snap a Pic",
10+
body: "We feature the viewfinder front and center in this throwback app."),
11+
.init(
12+
id: 1,
13+
systemImage: "camera.filters",
14+
title: "Filter it Up",
15+
body: "Add filters - from detailed colorization to film effects."),
16+
.init(
17+
id: 2,
18+
systemImage: "paperplane",
19+
title: "Send It",
20+
body: "Share your photos with your contacts. Or the entire world."),
21+
.init(
22+
id: 3,
23+
systemImage: "sparkles",
24+
title: "Be Awesome",
25+
body: "You're clearly already doing this. Just wanted to remind you. 😉"),
26+
]
27+
28+
let id: Int
29+
let systemImage: String
30+
let title: String
31+
let body: String
32+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import SwiftUI
2+
3+
// MARK: - GettingStartedView
4+
5+
struct GettingStartedView: View {
6+
7+
@Binding var selectedIndex: Int
8+
9+
let viewModel: GettingStartedModel
10+
11+
var body: some View {
12+
13+
VStack(alignment: .leading) {
14+
15+
Image(systemName: viewModel.systemImage)
16+
.foregroundColor(isSelected ? Color("LightPink") : .gray)
17+
.font(.system(size: 32))
18+
.padding(.bottom, 2)
19+
20+
Text(viewModel.title)
21+
.fontWeight(.semibold)
22+
.foregroundColor(.black)
23+
.frame(maxWidth: .infinity, alignment: .leading)
24+
.padding(.bottom, 1)
25+
.opacity(0.8)
26+
27+
Text(viewModel.body)
28+
.foregroundColor(.black)
29+
.multilineTextAlignment(.leading)
30+
.opacity(0.8)
31+
}
32+
.padding()
33+
.background(Color.white)
34+
.cornerRadius(12)
35+
.opacity(isSelected ? 1 : 0.8)
36+
}
37+
38+
var isSelected: Bool {
39+
40+
return selectedIndex == viewModel.id
41+
}
42+
}
43+
44+
// MARK: - GettingStartedView_Previews
45+
46+
struct GettingStartedView_Previews: PreviewProvider {
47+
static var previews: some View {
48+
49+
GettingStartedView(
50+
selectedIndex: .constant(0),
51+
viewModel: GettingStartedModel.exampleModels.first!)
52+
}
53+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"colors" : [
3+
{
4+
"color" : {
5+
"color-space" : "srgb",
6+
"components" : {
7+
"alpha" : "1.000",
8+
"blue" : "0xB4",
9+
"green" : "0xD4",
10+
"red" : "0xDB"
11+
}
12+
},
13+
"idiom" : "universal"
14+
},
15+
{
16+
"appearances" : [
17+
{
18+
"appearance" : "luminosity",
19+
"value" : "dark"
20+
}
21+
],
22+
"color" : {
23+
"color-space" : "srgb",
24+
"components" : {
25+
"alpha" : "1.000",
26+
"blue" : "0.706",
27+
"green" : "0.831",
28+
"red" : "0.859"
29+
}
30+
},
31+
"idiom" : "universal"
32+
}
33+
],
34+
"info" : {
35+
"author" : "xcode",
36+
"version" : 1
37+
}
38+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"colors" : [
3+
{
4+
"color" : {
5+
"color-space" : "srgb",
6+
"components" : {
7+
"alpha" : "1.000",
8+
"blue" : "0xC0",
9+
"green" : "0x95",
10+
"red" : "0xCC"
11+
}
12+
},
13+
"idiom" : "universal"
14+
},
15+
{
16+
"appearances" : [
17+
{
18+
"appearance" : "luminosity",
19+
"value" : "dark"
20+
}
21+
],
22+
"color" : {
23+
"color-space" : "srgb",
24+
"components" : {
25+
"alpha" : "1.000",
26+
"blue" : "0xC0",
27+
"green" : "0x95",
28+
"red" : "0xCC"
29+
}
30+
},
31+
"idiom" : "universal"
32+
}
33+
],
34+
"info" : {
35+
"author" : "xcode",
36+
"version" : 1
37+
}
38+
}

‎Sources/HStackSnap.swift

Lines changed: 25 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -3,143 +3,56 @@ import SwiftUI
33

44
public struct HStackSnap<Content: View>: View {
55

6+
public typealias SwipeEventHandler = ((Int) -> Void)
7+
68
// MARK: Lifecycle
79

810
public init(
9-
leadingOffset: CGFloat,
11+
alignment: SnapAlignment,
1012
coordinateSpace: String = "SnapToScroll",
11-
@ViewBuilder content: @escaping () -> Content) {
13+
@ViewBuilder content: @escaping () -> Content,
14+
onSwipe: SwipeEventHandler? = .none) {
1215

1316
self.content = content
14-
targetOffset = leadingOffset
15-
scrollOffset = leadingOffset
17+
self.alignment = alignment
18+
leadingOffset = alignment.scrollOffset
1619
self.coordinateSpace = coordinateSpace
20+
swipeEventHandler = onSwipe
1721
}
1822

1923
// MARK: Public
2024

2125
public var body: some View {
26+
27+
func calculatedItemWidth(parentWidth: CGFloat, offset: CGFloat) -> CGFloat {
28+
29+
print(parentWidth)
30+
return parentWidth - offset * 2
31+
}
2232

23-
GeometryReader { geometry in
24-
25-
HStack {
26-
27-
HStack(content: content)
28-
.offset(x: scrollOffset, y: .zero)
29-
.animation(.easeOut(duration: 0.2))
30-
31-
Spacer()
32-
}
33-
// TODO: Make this less... janky.
34-
.frame(width: 10000)
35-
.onPreferenceChange(ContentPreferenceKey.self, perform: { preferences in
36-
37-
// Calculate all values once, on render. On-the-fly calculations with GeometryReader
38-
// proved occasionally unstable in testing.
39-
if !hasCalculatedFrames {
40-
41-
let screenWidth = geometry.frame(in: .named(coordinateSpace)).width
42-
43-
var itemScrollPositions: [Int: CGFloat] = [:]
44-
45-
var frameMaxXVals: [CGFloat] = []
46-
47-
for pref in preferences {
48-
49-
itemScrollPositions[pref.id.hashValue] = scrollOffset(for: pref.rect.minX)
50-
frameMaxXVals.append(pref.rect.maxX)
51-
}
52-
53-
// Array of content widths from currentElement.minX to lastElement.maxX
54-
var contentFitMap: [CGFloat] = []
55-
56-
// Calculate content widths (used to trim snap positions later)
57-
for currMinX in preferences.map({ $0.rect.minX }) {
58-
59-
guard let maxX = preferences.last?.rect.maxX else { break }
60-
let widthToEnd = maxX - currMinX
61-
62-
contentFitMap.append(widthToEnd)
63-
}
64-
65-
var frameTrim: Int = 0
66-
let reversedFitMap = Array(contentFitMap.reversed())
67-
68-
// Calculate how many snap locations should be trimmed.
69-
for i in 0 ..< reversedFitMap.count {
70-
71-
if reversedFitMap[i] > screenWidth {
72-
73-
frameTrim = max(i - 1, 0)
74-
break
75-
}
76-
}
77-
78-
// Write valid snap locations to state.
79-
for (i, item) in itemScrollPositions.sorted(by: { $0.value > $1.value })
80-
.enumerated() {
81-
82-
guard i < (itemScrollPositions.count - frameTrim) else { break }
83-
84-
snapLocations[item.key] = item.value
85-
}
33+
return GeometryReader { geometry in
8634

87-
hasCalculatedFrames = true
88-
}
89-
})
90-
.gesture(snapDrag)
35+
HStackSnapCore(
36+
leadingOffset: leadingOffset,
37+
coordinateSpace: coordinateSpace,
38+
content: content,
39+
onSwipe: swipeEventHandler)
40+
.environmentObject(SizeOverride(itemWidth: alignment.shouldSetWidth ? calculatedItemWidth(parentWidth: geometry.size.width, offset: alignment.scrollOffset) : .none))
9141
}
92-
.coordinateSpace(name: coordinateSpace)
9342
}
9443

9544
// MARK: Internal
9645

9746
var content: () -> Content
9847

99-
var snapDrag: some Gesture {
100-
101-
DragGesture()
102-
.onChanged { gesture in
103-
104-
self.scrollOffset = gesture.translation.width + prevScrollOffset
105-
}.onEnded { event in
106-
107-
let currOffset = scrollOffset
108-
var closestSnapLocation: CGFloat = snapLocations.first?.value ?? targetOffset
109-
110-
for (_, offset) in snapLocations {
111-
112-
if abs(offset - currOffset) < abs(closestSnapLocation - currOffset) {
113-
114-
closestSnapLocation = offset
115-
}
116-
}
117-
118-
scrollOffset = closestSnapLocation
119-
prevScrollOffset = scrollOffset
120-
}
121-
}
122-
123-
func scrollOffset(for x: CGFloat) -> CGFloat {
124-
125-
return (targetOffset * 2) - x
126-
}
127-
12848
// MARK: Private
129-
130-
@State private var hasCalculatedFrames: Bool = false
131-
132-
/// Current scroll offset.
133-
@State private var scrollOffset: CGFloat
134-
135-
/// Stored offset of previous scroll, so scroll state is resumed between drags.
136-
@State private var prevScrollOffset: CGFloat = 0
49+
50+
private let alignment: SnapAlignment
13751

13852
/// Calculated offset based on `SnapLocation`
139-
@State private var targetOffset: CGFloat
53+
private let leadingOffset: CGFloat
14054

141-
/// The original offset of each frame, used to calculate `scrollOffset`
142-
@State private var snapLocations: [Int: CGFloat] = [:]
55+
private var swipeEventHandler: SwipeEventHandler?
14356

14457
private let coordinateSpace: String
14558
}

‎Sources/Model/SizeOverride.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import Foundation
2+
import SwiftUI
3+
4+
class SizeOverride: ObservableObject {
5+
6+
init(itemWidth: CGFloat?) {
7+
8+
self.itemWidth = itemWidth
9+
}
10+
11+
var itemWidth: CGFloat?
12+
}

‎Sources/Model/SnapAlignment.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Foundation
2+
import SwiftUI
3+
4+
public enum SnapAlignment {
5+
6+
case leading(CGFloat)
7+
case center(CGFloat)
8+
9+
internal var scrollOffset: CGFloat {
10+
11+
switch self {
12+
13+
case let .leading(offset): return offset
14+
case let .center(offset): return offset
15+
}
16+
}
17+
18+
internal var shouldSetWidth: Bool {
19+
20+
switch self {
21+
22+
case .leading: return false
23+
case .center: return true
24+
}
25+
}
26+
}

‎Sources/Views/GeometryReaderOverlay.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ public struct GeometryReaderOverlay<ID: Hashable>: View {
55

66
// MARK: Lifecycle
77

8-
public init(id: ID, coordinateSpace: String = "SnapToScroll") {
8+
public init(id: ID, coordinateSpace: String?) {
99

1010
self.id = id
11-
self.coordinateSpace = coordinateSpace
11+
optionalCoordinateSpace = coordinateSpace
1212
}
1313

1414
// MARK: Public
@@ -29,5 +29,12 @@ public struct GeometryReaderOverlay<ID: Hashable>: View {
2929
// MARK: Internal
3030

3131
let id: ID
32-
let coordinateSpace: String
32+
let optionalCoordinateSpace: String?
33+
34+
let defaultCoordinateSpace = "SnapToScroll"
35+
36+
var coordinateSpace: String {
37+
38+
return optionalCoordinateSpace ?? defaultCoordinateSpace
39+
}
3340
}

‎Sources/Views/HStackSnapCore.swift

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import Foundation
2+
import SwiftUI
3+
4+
public struct HStackSnapCore<Content: View>: View {
5+
6+
public typealias SwipeEventHandler = ((Int) -> Void)
7+
8+
// MARK: Lifecycle
9+
10+
public init(
11+
leadingOffset: CGFloat,
12+
coordinateSpace: String = "SnapToScroll",
13+
@ViewBuilder content: @escaping () -> Content,
14+
onSwipe: SwipeEventHandler? = .none) {
15+
16+
self.content = content
17+
targetOffset = leadingOffset
18+
scrollOffset = leadingOffset
19+
self.coordinateSpace = coordinateSpace
20+
swipeEventHandler = onSwipe
21+
}
22+
23+
// MARK: Public
24+
25+
public var body: some View {
26+
27+
GeometryReader { geometry in
28+
29+
HStack {
30+
31+
HStack(content: content)
32+
.offset(x: scrollOffset, y: .zero)
33+
.animation(.easeOut(duration: 0.2))
34+
35+
Spacer()
36+
}
37+
// TODO: Make this less... janky.
38+
.frame(width: 10000)
39+
.onPreferenceChange(ContentPreferenceKey.self, perform: { preferences in
40+
41+
// Calculate all values once, on render. On-the-fly calculations with GeometryReader
42+
// proved occasionally unstable in testing.
43+
if !hasCalculatedFrames {
44+
45+
let screenWidth = geometry.frame(in: .named(coordinateSpace)).width
46+
47+
var itemScrollPositions: [Int: CGFloat] = [:]
48+
49+
var frameMaxXVals: [CGFloat] = []
50+
51+
for pref in preferences {
52+
53+
itemScrollPositions[pref.id.hashValue] = scrollOffset(for: pref.rect.minX)
54+
frameMaxXVals.append(pref.rect.maxX)
55+
}
56+
57+
// Array of content widths from currentElement.minX to lastElement.maxX
58+
var contentFitMap: [CGFloat] = []
59+
60+
// Calculate content widths (used to trim snap positions later)
61+
for currMinX in preferences.map({ $0.rect.minX }) {
62+
63+
guard let maxX = preferences.last?.rect.maxX else { break }
64+
let widthToEnd = maxX - currMinX
65+
66+
contentFitMap.append(widthToEnd)
67+
}
68+
69+
var frameTrim: Int = 0
70+
let reversedFitMap = Array(contentFitMap.reversed())
71+
72+
// Calculate how many snap locations should be trimmed.
73+
for i in 0 ..< reversedFitMap.count {
74+
75+
if reversedFitMap[i] > screenWidth {
76+
77+
frameTrim = max(i - 1, 0)
78+
break
79+
}
80+
}
81+
82+
// Write valid snap locations to state.
83+
for (i, item) in itemScrollPositions.sorted(by: { $0.value > $1.value })
84+
.enumerated() {
85+
86+
guard i < (itemScrollPositions.count - frameTrim) else { break }
87+
88+
snapLocations[item.key] = item.value
89+
}
90+
91+
hasCalculatedFrames = true
92+
}
93+
})
94+
.gesture(snapDrag)
95+
}
96+
.coordinateSpace(name: coordinateSpace)
97+
}
98+
99+
// MARK: Internal
100+
101+
var content: () -> Content
102+
103+
var snapDrag: some Gesture {
104+
105+
DragGesture()
106+
.onChanged { gesture in
107+
108+
self.scrollOffset = gesture.translation.width + prevScrollOffset
109+
}.onEnded { event in
110+
111+
let currOffset = scrollOffset
112+
var closestSnapLocation: CGFloat = snapLocations.first?.value ?? targetOffset
113+
114+
// Calculate closest snap location
115+
for (_, offset) in snapLocations {
116+
117+
if abs(offset - currOffset) < abs(closestSnapLocation - currOffset) {
118+
119+
closestSnapLocation = offset
120+
}
121+
}
122+
123+
// Handle swipe callback
124+
let selectedIndex = snapLocations.map { $0.value }.sorted(by: { $0 > $1 })
125+
.firstIndex(of: closestSnapLocation) ?? 0
126+
127+
if selectedIndex != previouslySentIndex {
128+
129+
swipeEventHandler?(selectedIndex)
130+
previouslySentIndex = selectedIndex
131+
}
132+
133+
// Update state
134+
scrollOffset = closestSnapLocation
135+
prevScrollOffset = scrollOffset
136+
}
137+
}
138+
139+
func scrollOffset(for x: CGFloat) -> CGFloat {
140+
141+
return (targetOffset * 2) - x
142+
}
143+
144+
// MARK: Private
145+
146+
@State private var hasCalculatedFrames: Bool = false
147+
148+
/// Current scroll offset.
149+
@State private var scrollOffset: CGFloat
150+
151+
/// Stored offset of previous scroll, so scroll state is resumed between drags.
152+
@State private var prevScrollOffset: CGFloat = 0
153+
154+
/// Calculated offset based on `SnapLocation`
155+
@State private var targetOffset: CGFloat
156+
157+
/// The original offset of each frame, used to calculate `scrollOffset`
158+
@State private var snapLocations: [Int: CGFloat] = [:]
159+
160+
private var swipeEventHandler: SwipeEventHandler?
161+
162+
@State private var previouslySentIndex: Int = 0
163+
164+
private let coordinateSpace: String
165+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Foundation
2+
import SwiftUI
3+
4+
// MARK: - SnapAlignmentHelper
5+
6+
struct SnapAlignmentHelper<ID: Hashable>: ViewModifier {
7+
8+
@EnvironmentObject var sizeOverride: SizeOverride
9+
10+
var id: ID
11+
var coordinateSpace: String?
12+
13+
func body(content: Content) -> some View {
14+
15+
switch sizeOverride.itemWidth {
16+
17+
case let .some(value):
18+
19+
content
20+
.frame(width: value)
21+
.overlay(GeometryReaderOverlay(id: id, coordinateSpace: coordinateSpace))
22+
23+
case .none:
24+
25+
content
26+
.overlay(GeometryReaderOverlay(id: id, coordinateSpace: coordinateSpace))
27+
}
28+
}
29+
}
30+
31+
extension View {
32+
33+
public func snapAlignmentHelper<ID: Hashable>(
34+
id: ID,
35+
coordinateSpace: String? = .none) -> some View {
36+
37+
modifier(SnapAlignmentHelper(id: id, coordinateSpace: coordinateSpace))
38+
}
39+
}

0 commit comments

Comments
 (0)
Please sign in to comment.