├── .gitignore
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ └── xcschemes
│ └── SnapToScroll.xcscheme
├── Package.swift
├── README.md
├── SnapToScrollDemo
├── SnapToScrollDemo.xcodeproj
│ ├── project.pbxproj
│ └── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
└── Sources
│ ├── AppDelegate.swift
│ ├── ContentView.swift
│ ├── Example 2
│ ├── Example2ContentView.swift
│ ├── TripModel.swift
│ ├── TripTupleView.swift
│ └── TripView.swift
│ ├── Example1
│ ├── Example1ContentView.swift
│ ├── Example1HeaderView.swift
│ ├── TagModel.swift
│ └── TagView.swift
│ ├── Example3
│ ├── Example3ContentView.swift
│ ├── GettingStartedModel.swift
│ └── GettingStartedView.swift
│ ├── Resource Files
│ ├── Assets.xcassets
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ ├── AppIcon.appiconset
│ │ │ └── Contents.json
│ │ ├── Contents.json
│ │ ├── Cream.colorset
│ │ │ └── Contents.json
│ │ ├── Egypt.imageset
│ │ │ ├── Contents.json
│ │ │ └── Egypt.jpeg
│ │ ├── LightPink.colorset
│ │ │ └── Contents.json
│ │ ├── London.imageset
│ │ │ ├── Contents.json
│ │ │ └── London.jpeg
│ │ ├── MtFuji.imageset
│ │ │ ├── Contents.json
│ │ │ └── MtFuji.jpg
│ │ ├── NewOrleans.imageset
│ │ │ ├── Contents.json
│ │ │ └── NewOrleans.jpeg
│ │ ├── NewYork.imageset
│ │ │ ├── Contents.json
│ │ │ └── NewYork.jpeg
│ │ └── Venice.imageset
│ │ │ ├── Contents.json
│ │ │ └── Venice.jpeg
│ ├── Base.lproj
│ │ └── LaunchScreen.storyboard
│ ├── Info.plist
│ └── Preview Content
│ │ └── Preview Assets.xcassets
│ │ └── Contents.json
│ └── SceneDelegate.swift
└── Sources
├── HStackSnap.swift
├── Model
├── ContentPreferenceKey.swift
├── SizeOverride.swift
├── SnapAlignment.swift
└── SnapToScrollEvent.swift
└── Views
├── GeometryReaderOverlay.swift
├── HStackSnapCore.swift
└── SnapAlignmentHelper.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/SnapToScroll.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
43 |
44 |
50 |
51 |
57 |
58 |
59 |
60 |
62 |
63 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "SnapToScroll",
8 | platforms: [.macOS(.v10_15), .iOS(.v13),],
9 | products: [
10 | // Products define the executables and libraries a package produces, and make them visible to other packages.
11 | .library(
12 | name: "SnapToScroll",
13 | targets: ["SnapToScroll"]),
14 | ],
15 | dependencies: [
16 | // Dependencies declare other packages that this package depends on.
17 | // .package(url: /* package url */, from: "1.0.0"),
18 | ],
19 | targets: [
20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
21 | // Targets can depend on other targets in this package, and on products in packages this package depends on.
22 | .target(
23 | name: "SnapToScroll",
24 | dependencies: [],
25 | path: "Sources")
26 | ]
27 | )
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SnapToScroll
2 |
3 | Drop-in SwiftUI-based container view for horizontal snapping.
4 |
5 | To see the rest of the SwiftUI Library, visit [our website](https://swiftuilibrary.com).
6 |
7 | https://user-images.githubusercontent.com/8763719/131696736-0474ae54-35ad-4579-ab1e-366ae101949b.mp4
8 |
9 | ## Getting Started
10 |
11 | Using `SnapToScroll` is straightforward. There's just three steps.
12 |
13 | 1. Import `SnapToScroll`
14 | 2. Replace `HStack` with `HStackSnap`
15 | 3. Add `.snapAlignmentHelper` to your view.
16 |
17 | An example:
18 |
19 | ```swift
20 | import SnapToScroll // Step 1
21 | ...
22 |
23 | HStackSnap(alignment: .center(32)) { // Step 2
24 |
25 | ForEach(myModels) { viewModel in
26 |
27 | MyView(
28 | selectedIndex: $selectedIndex,
29 | viewModel: viewModel
30 | )
31 | .snapAlignmentHelper(id: viewModel.id) // Step 3
32 | }
33 | }
34 | ```
35 | For more examples, see `SnapToScrollDemo/ContentView.swift`.
36 |
37 | ## Configuration
38 |
39 | `HStackSnap` comes with two customizable properties:
40 |
41 | - `alignment`: The way you'd like your elements to be arranged.
42 | - `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.
43 | - `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.
44 | - `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`.
45 |
46 | `.snapAlignmentHelper` comes with two options as well:
47 |
48 | - `id`: Required. A unique ID for the element.
49 | - `coordinateSpace`: Same as above.
50 |
51 | ## Limitations
52 |
53 | - `HStackSnap` is currently designed to work with static content.
54 |
55 | ## How it Works
56 |
57 | 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.
58 |
59 | Read through `HStackSnap.swift` and `Views/HStackSnapCore.swift` for more details.
60 |
61 | ## Credits
62 |
63 | Thanks to pixeltrue for the [illustrations](https://www.pixeltrue.com/scenic-illustrations#download) used in example 2.
64 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/SnapToScrollDemo.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 52;
7 | objects = {
8 |
9 | /* 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 */; };
15 | 841B0F8326DD39A4008A436B /* TripTupleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841B0F8226DD39A4008A436B /* TripTupleView.swift */; };
16 | 84B568D926DD271000D37CF2 /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B568D826DD271000D37CF2 /* TagView.swift */; };
17 | 84B568DB26DD309400D37CF2 /* Example1HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B568DA26DD309400D37CF2 /* Example1HeaderView.swift */; };
18 | 84B568DE26DD351900D37CF2 /* TripModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B568DD26DD351900D37CF2 /* TripModel.swift */; };
19 | 84B568E026DD37A600D37CF2 /* TripView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B568DF26DD37A600D37CF2 /* TripView.swift */; };
20 | 84C99CD826D99BA100C1D5C4 /* TagModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C99CD726D99BA100C1D5C4 /* TagModel.swift */; };
21 | 84D9FA3626D9753600F87EF5 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D9FA3526D9753600F87EF5 /* AppDelegate.swift */; };
22 | 84D9FA3826D9753600F87EF5 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D9FA3726D9753600F87EF5 /* SceneDelegate.swift */; };
23 | 84D9FA3A26D9753600F87EF5 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D9FA3926D9753600F87EF5 /* ContentView.swift */; };
24 | 84D9FA3C26D9753700F87EF5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 84D9FA3B26D9753700F87EF5 /* Assets.xcassets */; };
25 | 84D9FA3F26D9753700F87EF5 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 84D9FA3E26D9753700F87EF5 /* Preview Assets.xcassets */; };
26 | 84D9FA4226D9753700F87EF5 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 84D9FA4026D9753700F87EF5 /* LaunchScreen.storyboard */; };
27 | 84F9E43826D9763A003F9483 /* SnapToScroll in Frameworks */ = {isa = PBXBuildFile; productRef = 84F9E43726D9763A003F9483 /* SnapToScroll */; };
28 | /* End PBXBuildFile section */
29 |
30 | /* Begin PBXFileReference section */
31 | 840130AE26DFB8DC00E4A8A3 /* GettingStartedModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GettingStartedModel.swift; sourceTree = ""; };
32 | 840130B026DFB93400E4A8A3 /* GettingStartedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GettingStartedView.swift; sourceTree = ""; };
33 | 840130B226DFC27700E4A8A3 /* Example3ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example3ContentView.swift; sourceTree = ""; };
34 | 840130B426DFC2FB00E4A8A3 /* Example2ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example2ContentView.swift; sourceTree = ""; };
35 | 840130B626DFC33100E4A8A3 /* Example1ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example1ContentView.swift; sourceTree = ""; };
36 | 841B0F8226DD39A4008A436B /* TripTupleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripTupleView.swift; sourceTree = ""; };
37 | 84B568D826DD271000D37CF2 /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = ""; };
38 | 84B568DA26DD309400D37CF2 /* Example1HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example1HeaderView.swift; sourceTree = ""; };
39 | 84B568DD26DD351900D37CF2 /* TripModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripModel.swift; sourceTree = ""; };
40 | 84B568DF26DD37A600D37CF2 /* TripView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripView.swift; sourceTree = ""; };
41 | 84C99CD726D99BA100C1D5C4 /* TagModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagModel.swift; sourceTree = ""; };
42 | 84D9FA3226D9753600F87EF5 /* SnapToScrollDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SnapToScrollDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
43 | 84D9FA3526D9753600F87EF5 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
44 | 84D9FA3726D9753600F87EF5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
45 | 84D9FA3926D9753600F87EF5 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
46 | 84D9FA3B26D9753700F87EF5 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
47 | 84D9FA3E26D9753700F87EF5 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
48 | 84D9FA4126D9753700F87EF5 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
49 | 84D9FA4326D9753700F87EF5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
50 | 84F9E43526D9762C003F9483 /* SnapToScroll */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SnapToScroll; path = ..; sourceTree = ""; };
51 | /* End PBXFileReference section */
52 |
53 | /* Begin PBXFrameworksBuildPhase section */
54 | 84D9FA2F26D9753600F87EF5 /* Frameworks */ = {
55 | isa = PBXFrameworksBuildPhase;
56 | buildActionMask = 2147483647;
57 | files = (
58 | 84F9E43826D9763A003F9483 /* SnapToScroll in Frameworks */,
59 | );
60 | runOnlyForDeploymentPostprocessing = 0;
61 | };
62 | /* End PBXFrameworksBuildPhase section */
63 |
64 | /* 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 = "";
74 | };
75 | 84B568D626DD26A800D37CF2 /* Example1 */ = {
76 | isa = PBXGroup;
77 | children = (
78 | 840130B626DFC33100E4A8A3 /* Example1ContentView.swift */,
79 | 84C99CD726D99BA100C1D5C4 /* TagModel.swift */,
80 | 84B568D826DD271000D37CF2 /* TagView.swift */,
81 | 84B568DA26DD309400D37CF2 /* Example1HeaderView.swift */,
82 | );
83 | path = Example1;
84 | sourceTree = "";
85 | };
86 | 84B568DC26DD311F00D37CF2 /* Example 2 */ = {
87 | isa = PBXGroup;
88 | children = (
89 | 84B568DD26DD351900D37CF2 /* TripModel.swift */,
90 | 84B568DF26DD37A600D37CF2 /* TripView.swift */,
91 | 841B0F8226DD39A4008A436B /* TripTupleView.swift */,
92 | 840130B426DFC2FB00E4A8A3 /* Example2ContentView.swift */,
93 | );
94 | path = "Example 2";
95 | sourceTree = "";
96 | };
97 | 84D9FA2926D9753600F87EF5 = {
98 | isa = PBXGroup;
99 | children = (
100 | 84F9E43526D9762C003F9483 /* SnapToScroll */,
101 | 84D9FA3426D9753600F87EF5 /* Sources */,
102 | 84D9FA3326D9753600F87EF5 /* Products */,
103 | 84F9E43626D9763A003F9483 /* Frameworks */,
104 | );
105 | sourceTree = "";
106 | };
107 | 84D9FA3326D9753600F87EF5 /* Products */ = {
108 | isa = PBXGroup;
109 | children = (
110 | 84D9FA3226D9753600F87EF5 /* SnapToScrollDemo.app */,
111 | );
112 | name = Products;
113 | sourceTree = "";
114 | };
115 | 84D9FA3426D9753600F87EF5 /* Sources */ = {
116 | isa = PBXGroup;
117 | children = (
118 | 84D9FA4926D9757400F87EF5 /* Resource Files */,
119 | 84D9FA3526D9753600F87EF5 /* AppDelegate.swift */,
120 | 84B568D626DD26A800D37CF2 /* Example1 */,
121 | 84B568DC26DD311F00D37CF2 /* Example 2 */,
122 | 840130AD26DFB8C800E4A8A3 /* Example3 */,
123 | 84D9FA3726D9753600F87EF5 /* SceneDelegate.swift */,
124 | 84D9FA3926D9753600F87EF5 /* ContentView.swift */,
125 | );
126 | path = Sources;
127 | sourceTree = "";
128 | };
129 | 84D9FA3D26D9753700F87EF5 /* Preview Content */ = {
130 | isa = PBXGroup;
131 | children = (
132 | 84D9FA3E26D9753700F87EF5 /* Preview Assets.xcassets */,
133 | );
134 | path = "Preview Content";
135 | sourceTree = "";
136 | };
137 | 84D9FA4926D9757400F87EF5 /* Resource Files */ = {
138 | isa = PBXGroup;
139 | children = (
140 | 84D9FA3D26D9753700F87EF5 /* Preview Content */,
141 | 84D9FA3B26D9753700F87EF5 /* Assets.xcassets */,
142 | 84D9FA4026D9753700F87EF5 /* LaunchScreen.storyboard */,
143 | 84D9FA4326D9753700F87EF5 /* Info.plist */,
144 | );
145 | path = "Resource Files";
146 | sourceTree = "";
147 | };
148 | 84F9E43626D9763A003F9483 /* Frameworks */ = {
149 | isa = PBXGroup;
150 | children = (
151 | );
152 | name = Frameworks;
153 | sourceTree = "";
154 | };
155 | /* End PBXGroup section */
156 |
157 | /* Begin PBXNativeTarget section */
158 | 84D9FA3126D9753600F87EF5 /* SnapToScrollDemo */ = {
159 | isa = PBXNativeTarget;
160 | buildConfigurationList = 84D9FA4626D9753700F87EF5 /* Build configuration list for PBXNativeTarget "SnapToScrollDemo" */;
161 | buildPhases = (
162 | 84D9FA2E26D9753600F87EF5 /* Sources */,
163 | 84D9FA2F26D9753600F87EF5 /* Frameworks */,
164 | 84D9FA3026D9753600F87EF5 /* Resources */,
165 | );
166 | buildRules = (
167 | );
168 | dependencies = (
169 | );
170 | name = SnapToScrollDemo;
171 | packageProductDependencies = (
172 | 84F9E43726D9763A003F9483 /* SnapToScroll */,
173 | );
174 | productName = SnapToScrollDemo;
175 | productReference = 84D9FA3226D9753600F87EF5 /* SnapToScrollDemo.app */;
176 | productType = "com.apple.product-type.application";
177 | };
178 | /* End PBXNativeTarget section */
179 |
180 | /* Begin PBXProject section */
181 | 84D9FA2A26D9753600F87EF5 /* Project object */ = {
182 | isa = PBXProject;
183 | attributes = {
184 | LastSwiftUpdateCheck = 1250;
185 | LastUpgradeCheck = 1250;
186 | TargetAttributes = {
187 | 84D9FA3126D9753600F87EF5 = {
188 | CreatedOnToolsVersion = 12.5.1;
189 | };
190 | };
191 | };
192 | buildConfigurationList = 84D9FA2D26D9753600F87EF5 /* Build configuration list for PBXProject "SnapToScrollDemo" */;
193 | compatibilityVersion = "Xcode 9.3";
194 | developmentRegion = en;
195 | hasScannedForEncodings = 0;
196 | knownRegions = (
197 | en,
198 | Base,
199 | );
200 | mainGroup = 84D9FA2926D9753600F87EF5;
201 | productRefGroup = 84D9FA3326D9753600F87EF5 /* Products */;
202 | projectDirPath = "";
203 | projectRoot = "";
204 | targets = (
205 | 84D9FA3126D9753600F87EF5 /* SnapToScrollDemo */,
206 | );
207 | };
208 | /* End PBXProject section */
209 |
210 | /* Begin PBXResourcesBuildPhase section */
211 | 84D9FA3026D9753600F87EF5 /* Resources */ = {
212 | isa = PBXResourcesBuildPhase;
213 | buildActionMask = 2147483647;
214 | files = (
215 | 84D9FA4226D9753700F87EF5 /* LaunchScreen.storyboard in Resources */,
216 | 84D9FA3F26D9753700F87EF5 /* Preview Assets.xcassets in Resources */,
217 | 84D9FA3C26D9753700F87EF5 /* Assets.xcassets in Resources */,
218 | );
219 | runOnlyForDeploymentPostprocessing = 0;
220 | };
221 | /* End PBXResourcesBuildPhase section */
222 |
223 | /* Begin PBXSourcesBuildPhase section */
224 | 84D9FA2E26D9753600F87EF5 /* Sources */ = {
225 | isa = PBXSourcesBuildPhase;
226 | buildActionMask = 2147483647;
227 | files = (
228 | 84B568DB26DD309400D37CF2 /* Example1HeaderView.swift in Sources */,
229 | 84D9FA3626D9753600F87EF5 /* AppDelegate.swift in Sources */,
230 | 840130AF26DFB8DD00E4A8A3 /* GettingStartedModel.swift in Sources */,
231 | 841B0F8326DD39A4008A436B /* TripTupleView.swift in Sources */,
232 | 840130B726DFC33100E4A8A3 /* Example1ContentView.swift in Sources */,
233 | 84B568D926DD271000D37CF2 /* TagView.swift in Sources */,
234 | 840130B326DFC27700E4A8A3 /* Example3ContentView.swift in Sources */,
235 | 84B568E026DD37A600D37CF2 /* TripView.swift in Sources */,
236 | 840130B526DFC2FB00E4A8A3 /* Example2ContentView.swift in Sources */,
237 | 84B568DE26DD351900D37CF2 /* TripModel.swift in Sources */,
238 | 840130B126DFB93400E4A8A3 /* GettingStartedView.swift in Sources */,
239 | 84D9FA3826D9753600F87EF5 /* SceneDelegate.swift in Sources */,
240 | 84D9FA3A26D9753600F87EF5 /* ContentView.swift in Sources */,
241 | 84C99CD826D99BA100C1D5C4 /* TagModel.swift in Sources */,
242 | );
243 | runOnlyForDeploymentPostprocessing = 0;
244 | };
245 | /* End PBXSourcesBuildPhase section */
246 |
247 | /* Begin PBXVariantGroup section */
248 | 84D9FA4026D9753700F87EF5 /* LaunchScreen.storyboard */ = {
249 | isa = PBXVariantGroup;
250 | children = (
251 | 84D9FA4126D9753700F87EF5 /* Base */,
252 | );
253 | name = LaunchScreen.storyboard;
254 | sourceTree = "";
255 | };
256 | /* End PBXVariantGroup section */
257 |
258 | /* Begin XCBuildConfiguration section */
259 | 84D9FA4426D9753700F87EF5 /* Debug */ = {
260 | isa = XCBuildConfiguration;
261 | buildSettings = {
262 | ALWAYS_SEARCH_USER_PATHS = NO;
263 | CLANG_ANALYZER_NONNULL = YES;
264 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
265 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
266 | CLANG_CXX_LIBRARY = "libc++";
267 | CLANG_ENABLE_MODULES = YES;
268 | CLANG_ENABLE_OBJC_ARC = YES;
269 | CLANG_ENABLE_OBJC_WEAK = YES;
270 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
271 | CLANG_WARN_BOOL_CONVERSION = YES;
272 | CLANG_WARN_COMMA = YES;
273 | CLANG_WARN_CONSTANT_CONVERSION = YES;
274 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
275 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
276 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
277 | CLANG_WARN_EMPTY_BODY = YES;
278 | CLANG_WARN_ENUM_CONVERSION = YES;
279 | CLANG_WARN_INFINITE_RECURSION = YES;
280 | CLANG_WARN_INT_CONVERSION = YES;
281 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
282 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
283 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
284 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
285 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
286 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
287 | CLANG_WARN_STRICT_PROTOTYPES = YES;
288 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
289 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
290 | CLANG_WARN_UNREACHABLE_CODE = YES;
291 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
292 | COPY_PHASE_STRIP = NO;
293 | DEBUG_INFORMATION_FORMAT = dwarf;
294 | ENABLE_STRICT_OBJC_MSGSEND = YES;
295 | ENABLE_TESTABILITY = YES;
296 | GCC_C_LANGUAGE_STANDARD = gnu11;
297 | GCC_DYNAMIC_NO_PIC = NO;
298 | GCC_NO_COMMON_BLOCKS = YES;
299 | GCC_OPTIMIZATION_LEVEL = 0;
300 | GCC_PREPROCESSOR_DEFINITIONS = (
301 | "DEBUG=1",
302 | "$(inherited)",
303 | );
304 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
305 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
306 | GCC_WARN_UNDECLARED_SELECTOR = YES;
307 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
308 | GCC_WARN_UNUSED_FUNCTION = YES;
309 | GCC_WARN_UNUSED_VARIABLE = YES;
310 | IPHONEOS_DEPLOYMENT_TARGET = 14.5;
311 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
312 | MTL_FAST_MATH = YES;
313 | ONLY_ACTIVE_ARCH = YES;
314 | SDKROOT = iphoneos;
315 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
316 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
317 | };
318 | name = Debug;
319 | };
320 | 84D9FA4526D9753700F87EF5 /* Release */ = {
321 | isa = XCBuildConfiguration;
322 | buildSettings = {
323 | ALWAYS_SEARCH_USER_PATHS = NO;
324 | CLANG_ANALYZER_NONNULL = YES;
325 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
326 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
327 | CLANG_CXX_LIBRARY = "libc++";
328 | CLANG_ENABLE_MODULES = YES;
329 | CLANG_ENABLE_OBJC_ARC = YES;
330 | CLANG_ENABLE_OBJC_WEAK = YES;
331 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
332 | CLANG_WARN_BOOL_CONVERSION = YES;
333 | CLANG_WARN_COMMA = YES;
334 | CLANG_WARN_CONSTANT_CONVERSION = YES;
335 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
336 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
337 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
338 | CLANG_WARN_EMPTY_BODY = YES;
339 | CLANG_WARN_ENUM_CONVERSION = YES;
340 | CLANG_WARN_INFINITE_RECURSION = YES;
341 | CLANG_WARN_INT_CONVERSION = YES;
342 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
343 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
344 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
345 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
346 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
347 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
348 | CLANG_WARN_STRICT_PROTOTYPES = YES;
349 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
350 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
351 | CLANG_WARN_UNREACHABLE_CODE = YES;
352 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
353 | COPY_PHASE_STRIP = NO;
354 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
355 | ENABLE_NS_ASSERTIONS = NO;
356 | ENABLE_STRICT_OBJC_MSGSEND = YES;
357 | GCC_C_LANGUAGE_STANDARD = gnu11;
358 | GCC_NO_COMMON_BLOCKS = YES;
359 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
360 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
361 | GCC_WARN_UNDECLARED_SELECTOR = YES;
362 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
363 | GCC_WARN_UNUSED_FUNCTION = YES;
364 | GCC_WARN_UNUSED_VARIABLE = YES;
365 | IPHONEOS_DEPLOYMENT_TARGET = 14.5;
366 | MTL_ENABLE_DEBUG_INFO = NO;
367 | MTL_FAST_MATH = YES;
368 | SDKROOT = iphoneos;
369 | SWIFT_COMPILATION_MODE = wholemodule;
370 | SWIFT_OPTIMIZATION_LEVEL = "-O";
371 | VALIDATE_PRODUCT = YES;
372 | };
373 | name = Release;
374 | };
375 | 84D9FA4726D9753700F87EF5 /* Debug */ = {
376 | isa = XCBuildConfiguration;
377 | buildSettings = {
378 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
379 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
380 | CODE_SIGN_STYLE = Automatic;
381 | DEVELOPMENT_ASSET_PATHS = "\"Sources/Resource Files/Preview Content\"";
382 | DEVELOPMENT_TEAM = 3XTTWH436M;
383 | ENABLE_PREVIEWS = YES;
384 | INFOPLIST_FILE = "Sources/Resource Files/Info.plist";
385 | IPHONEOS_DEPLOYMENT_TARGET = 13.0;
386 | LD_RUNPATH_SEARCH_PATHS = (
387 | "$(inherited)",
388 | "@executable_path/Frameworks",
389 | );
390 | PRODUCT_BUNDLE_IDENTIFIER = com.trentguillory.SnapToScrollDemoApp;
391 | PRODUCT_NAME = "$(TARGET_NAME)";
392 | SWIFT_VERSION = 5.0;
393 | TARGETED_DEVICE_FAMILY = "1,2";
394 | };
395 | name = Debug;
396 | };
397 | 84D9FA4826D9753700F87EF5 /* Release */ = {
398 | isa = XCBuildConfiguration;
399 | buildSettings = {
400 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
401 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
402 | CODE_SIGN_STYLE = Automatic;
403 | DEVELOPMENT_ASSET_PATHS = "\"Sources/Resource Files/Preview Content\"";
404 | DEVELOPMENT_TEAM = 3XTTWH436M;
405 | ENABLE_PREVIEWS = YES;
406 | INFOPLIST_FILE = "Sources/Resource Files/Info.plist";
407 | IPHONEOS_DEPLOYMENT_TARGET = 13.0;
408 | LD_RUNPATH_SEARCH_PATHS = (
409 | "$(inherited)",
410 | "@executable_path/Frameworks",
411 | );
412 | PRODUCT_BUNDLE_IDENTIFIER = com.trentguillory.SnapToScrollDemoApp;
413 | PRODUCT_NAME = "$(TARGET_NAME)";
414 | SWIFT_VERSION = 5.0;
415 | TARGETED_DEVICE_FAMILY = "1,2";
416 | };
417 | name = Release;
418 | };
419 | /* End XCBuildConfiguration section */
420 |
421 | /* Begin XCConfigurationList section */
422 | 84D9FA2D26D9753600F87EF5 /* Build configuration list for PBXProject "SnapToScrollDemo" */ = {
423 | isa = XCConfigurationList;
424 | buildConfigurations = (
425 | 84D9FA4426D9753700F87EF5 /* Debug */,
426 | 84D9FA4526D9753700F87EF5 /* Release */,
427 | );
428 | defaultConfigurationIsVisible = 0;
429 | defaultConfigurationName = Release;
430 | };
431 | 84D9FA4626D9753700F87EF5 /* Build configuration list for PBXNativeTarget "SnapToScrollDemo" */ = {
432 | isa = XCConfigurationList;
433 | buildConfigurations = (
434 | 84D9FA4726D9753700F87EF5 /* Debug */,
435 | 84D9FA4826D9753700F87EF5 /* Release */,
436 | );
437 | defaultConfigurationIsVisible = 0;
438 | defaultConfigurationName = Release;
439 | };
440 | /* End XCConfigurationList section */
441 |
442 | /* Begin XCSwiftPackageProductDependency section */
443 | 84F9E43726D9763A003F9483 /* SnapToScroll */ = {
444 | isa = XCSwiftPackageProductDependency;
445 | productName = SnapToScroll;
446 | };
447 | /* End XCSwiftPackageProductDependency section */
448 | };
449 | rootObject = 84D9FA2A26D9753600F87EF5 /* Project object */;
450 | }
451 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/SnapToScrollDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/SnapToScrollDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | @main
4 | class AppDelegate: UIResponder, UIApplicationDelegate {
5 |
6 | func application(
7 | _ application: UIApplication,
8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?)
9 | -> Bool {
10 | // Override point for customization after application launch.
11 | return true
12 | }
13 |
14 | // MARK: UISceneSession Lifecycle
15 |
16 | func application(
17 | _ application: UIApplication,
18 | configurationForConnecting connectingSceneSession: UISceneSession,
19 | options: UIScene.ConnectionOptions) -> UISceneConfiguration {
20 | // Called when a new scene session is being created.
21 | // Use this method to select a configuration to create the new scene with.
22 | return UISceneConfiguration(
23 | name: "Default Configuration",
24 | sessionRole: connectingSceneSession.role)
25 | }
26 |
27 | func application(
28 | _ application: UIApplication,
29 | didDiscardSceneSessions sceneSessions: Set) {
30 | // Called when the user discards a scene session.
31 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
32 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/ContentView.swift:
--------------------------------------------------------------------------------
1 | import SnapToScroll
2 | import SwiftUI
3 |
4 | // MARK: - ContentView
5 |
6 | struct ContentView: View {
7 |
8 | var body: some View {
9 | VStack {
10 |
11 | ScrollView {
12 |
13 | VerticalSpace
14 |
15 | LargeHeader(text: "Example 1")
16 |
17 | Example1ContentView()
18 |
19 | VerticalSpace
20 |
21 | LargeHeader(text: "Example 2")
22 |
23 | Example2ContentView()
24 |
25 | VerticalSpace
26 |
27 | LargeHeader(text: "Example 3")
28 |
29 | Example3ContentView()
30 | }
31 | }
32 | .preferredColorScheme(.light)
33 | }
34 |
35 | var VerticalSpace: some View {
36 |
37 | VStack {}
38 | .frame(height: 64)
39 | }
40 |
41 | func LargeHeader(text: String) -> some View {
42 |
43 | return
44 | Text(text)
45 | .font(.largeTitle)
46 | .fontWeight(.bold)
47 | .opacity(0.5)
48 | .frame(maxWidth: .infinity, alignment: .leading)
49 | .padding(.leading, 16)
50 | }
51 | }
52 |
53 | // MARK: - ContentView_Previews
54 |
55 | struct ContentView_Previews: PreviewProvider {
56 | static var previews: some View {
57 | ContentView()
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Example 2/Example2ContentView.swift:
--------------------------------------------------------------------------------
1 | import SnapToScroll
2 | import SwiftUI
3 |
4 | // MARK: - Example2ContentView
5 |
6 | struct Example2ContentView: View {
7 |
8 | var body: some View {
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 | } eventHandler: { event in
25 |
26 | handleSnapToScrollEvent(event: event)
27 | }
28 | .frame(height: 130)
29 | .padding(.top, 4)
30 | }
31 | }
32 |
33 | func handleSnapToScrollEvent(event: SnapToScrollEvent) {
34 |
35 | switch event {
36 |
37 | case let .didLayout(layoutInfo: layoutInfo):
38 |
39 | print("\(layoutInfo.keys.count) items layed out")
40 |
41 | case let .swipe(index: index):
42 |
43 | print("swiped to index: \(index)")
44 | }
45 | }
46 | }
47 |
48 | // MARK: - Example2ContentView_Previews
49 |
50 | struct Example2ContentView_Previews: PreviewProvider {
51 | static var previews: some View {
52 | Example2ContentView()
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Example 2/TripModel.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // MARK: - TripModel
4 |
5 | struct TripModel {
6 |
7 | static let newYork: TripModel = .init(
8 | imageName: "NewYork",
9 | destination: "New York",
10 | startingPrice: 1230)
11 | static let newOrleans: TripModel = .init(
12 | imageName: "NewOrleans",
13 | destination: "New Orleans",
14 | startingPrice: 1850)
15 | static let venice: TripModel = .init(
16 | imageName: "Venice",
17 | destination: "Venice",
18 | startingPrice: 2340)
19 | static let london: TripModel = .init(
20 | imageName: "London",
21 | destination: "London",
22 | startingPrice: 2200)
23 | static let mtFuji: TripModel = .init(
24 | imageName: "MtFuji",
25 | destination: "Mt. Fuji",
26 | startingPrice: 2900)
27 | static let egypt: TripModel = .init(
28 | imageName: "Egypt",
29 | destination: "Egypt",
30 | startingPrice: 2450)
31 |
32 | let imageName: String
33 | let destination: String
34 | let startingPrice: Double
35 | }
36 |
37 | // MARK: - TripModelTuple
38 |
39 | struct TripTupleModel: Identifiable {
40 |
41 | static let exampleModels: [TripTupleModel] = [
42 | .init(id: 0, trip1: TripModel.newYork, trip2: TripModel.newOrleans),
43 | .init(id: 1, trip1: TripModel.venice, trip2: TripModel.london),
44 | .init(id: 2, trip1: TripModel.mtFuji, trip2: TripModel.egypt),
45 | ]
46 |
47 | let id: Int
48 | let trip1: TripModel
49 | let trip2: TripModel
50 | }
51 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Example 2/TripTupleView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - TripTupleView
4 |
5 | struct TripTupleView: View {
6 |
7 | let viewModel: TripTupleModel
8 |
9 | var body: some View {
10 |
11 | VStack(alignment: .leading) {
12 |
13 | TripView(viewModel: viewModel.trip1)
14 |
15 |
16 | TripView(viewModel: viewModel.trip2)
17 | }
18 | }
19 | }
20 |
21 | // MARK: - TripTupleView_Previews
22 |
23 | struct TripTupleView_Previews: PreviewProvider {
24 | static var previews: some View {
25 |
26 | TripTupleView(viewModel: TripTupleModel.exampleModels.first!)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Example 2/TripView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - TripView
4 |
5 | struct TripView: View {
6 |
7 | let viewModel: TripModel
8 |
9 | var body: some View {
10 |
11 | VStack(alignment: .leading) {
12 |
13 | HStack(spacing: 12) {
14 |
15 | Image(viewModel.imageName)
16 | .resizable()
17 | .aspectRatio(contentMode: .fill)
18 | .frame(width: 75, height: 75)
19 | .cornerRadius(18)
20 | .clipped()
21 |
22 | VStack(alignment: .leading, spacing: 6) {
23 |
24 | Text(viewModel.destination)
25 | .font(.headline)
26 | Text("$\(Int(viewModel.startingPrice)) starting")
27 | .font(.subheadline)
28 | }
29 |
30 | Spacer()
31 | }
32 | }
33 | }
34 | }
35 |
36 | // MARK: - TripView_Previews
37 |
38 | struct TripView_Previews: PreviewProvider {
39 |
40 | static var previews: some View {
41 |
42 | TripView(viewModel: TripModel.newYork)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Example1/Example1ContentView.swift:
--------------------------------------------------------------------------------
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 | }
36 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Example1/Example1HeaderView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Example1HeaderView
4 |
5 | struct Example1HeaderView: View {
6 | var body: some View {
7 |
8 | HStack {
9 |
10 | Text("Downtown")
11 | .foregroundColor(.gray)
12 |
13 | Spacer()
14 |
15 | Image(systemName: "magnifyingglass")
16 | .foregroundColor(.gray)
17 | }
18 | .padding(8)
19 | .background(Color.white)
20 | .cornerRadius(8)
21 | .shadow(color: Color.gray.opacity(0.4), radius: 4, x: 0, y: 3)
22 | .padding([.leading, .trailing], 16)
23 | }
24 | }
25 |
26 | // MARK: - Example1HeaderView_Previews
27 |
28 | struct Example1HeaderView_Previews: PreviewProvider {
29 | static var previews: some View {
30 | Example1HeaderView()
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Example1/TagModel.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftUI
3 |
4 | struct TagModel: Identifiable {
5 |
6 | // For testing purposes
7 | static let exampleModels: [TagModel] = [
8 | .init(id: 1, systemImage: "car", label: "Car", timeEstimate: 19),
9 | .init(id: 2, systemImage: "tram", label: "Tram", timeEstimate: 32),
10 | .init(id: 3, systemImage: "bus", label: "Bus", timeEstimate: 34),
11 | .init(id: 4, systemImage: "figure.walk", label: "Walking", timeEstimate: 59),
12 | ]
13 |
14 | let id: Int
15 | let systemImage: String
16 | let label: String
17 | let timeEstimate: TimeInterval
18 | }
19 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Example1/TagView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - TagView
4 |
5 | struct TagView: View {
6 |
7 | let viewModel: TagModel
8 |
9 | var body: some View {
10 |
11 | HStack {
12 |
13 | Image(systemName: viewModel.systemImage)
14 | .foregroundColor(.blue)
15 |
16 | Text(viewModel.label)
17 | .fontWeight(.semibold)
18 | .foregroundColor(.blue)
19 |
20 | Text("\(Int(viewModel.timeEstimate)) min")
21 | .foregroundColor(.blue)
22 | }
23 | .padding(6)
24 | .background(Color.blue.opacity(0.1))
25 | .cornerRadius(6)
26 | }
27 | }
28 |
29 | // MARK: - TagView_Previews
30 |
31 | struct TagView_Previews: PreviewProvider {
32 | static var previews: some View {
33 |
34 | TagView(viewModel: .init(id: 1, systemImage: "tram", label: "Tram", timeEstimate: 23))
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Example3/Example3ContentView.swift:
--------------------------------------------------------------------------------
1 | import SnapToScroll
2 | import SwiftUI
3 |
4 | // MARK: - Example3ContentView
5 |
6 | struct Example3ContentView: View {
7 |
8 | var body: some View {
9 |
10 | VStack {
11 |
12 | Text("Getting Started")
13 | .font(.system(size: 22, weight: .semibold, design: .rounded))
14 | .foregroundColor(.white)
15 | .frame(maxWidth: .infinity, alignment: .leading)
16 | .padding([.top, .leading], 32)
17 |
18 | HStackSnap(alignment: .center(32)) {
19 |
20 | ForEach(GettingStartedModel.exampleModels) { viewModel in
21 |
22 | GettingStartedView(
23 | selectedIndex: $selectedGettingStartedIndex,
24 | viewModel: viewModel)
25 | .snapAlignmentHelper(id: viewModel.id)
26 | }
27 | } eventHandler: { event in
28 |
29 | handleSnapToScrollEvent(event: event)
30 | }
31 | .frame(height: 200)
32 | .padding(.top, 4)
33 | }
34 | .padding([.top, .bottom], 64)
35 | .background(LinearGradient(
36 | colors: [Color("Cream"), Color("LightPink")],
37 | startPoint: .top,
38 | endPoint: .bottom))
39 | }
40 |
41 | func handleSnapToScrollEvent(event: SnapToScrollEvent) {
42 | switch event {
43 | case let .didLayout(layoutInfo: layoutInfo):
44 |
45 | print("\(layoutInfo.keys.count) items layed out")
46 |
47 | case let .swipe(index: index):
48 |
49 | print("swiped to index: \(index)")
50 | selectedGettingStartedIndex = index
51 | }
52 | }
53 |
54 | // MARK: Private
55 |
56 | @State private var selectedGettingStartedIndex: Int = 0
57 | }
58 |
59 | // MARK: - Example3ContentView_Previews
60 |
61 | struct Example3ContentView_Previews: PreviewProvider {
62 | static var previews: some View {
63 | Example3ContentView()
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Example3/GettingStartedModel.swift:
--------------------------------------------------------------------------------
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 | }
33 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Example3/GettingStartedView.swift:
--------------------------------------------------------------------------------
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 | }
54 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "scale" : "2x",
6 | "size" : "20x20"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "scale" : "3x",
11 | "size" : "20x20"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "scale" : "2x",
16 | "size" : "29x29"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "scale" : "3x",
21 | "size" : "29x29"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "scale" : "2x",
26 | "size" : "40x40"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "40x40"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "scale" : "2x",
36 | "size" : "60x60"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "scale" : "3x",
41 | "size" : "60x60"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "scale" : "1x",
46 | "size" : "20x20"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "scale" : "2x",
51 | "size" : "20x20"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "scale" : "1x",
56 | "size" : "29x29"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "scale" : "2x",
61 | "size" : "29x29"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "scale" : "1x",
66 | "size" : "40x40"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "scale" : "2x",
71 | "size" : "40x40"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "scale" : "1x",
76 | "size" : "76x76"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "scale" : "2x",
81 | "size" : "76x76"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "scale" : "2x",
86 | "size" : "83.5x83.5"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "scale" : "1x",
91 | "size" : "1024x1024"
92 | }
93 | ],
94 | "info" : {
95 | "author" : "xcode",
96 | "version" : 1
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/Cream.colorset/Contents.json:
--------------------------------------------------------------------------------
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 | }
39 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/Egypt.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Egypt.jpeg",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/Egypt.imageset/Egypt.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiftui-library/hstack-snap-to-scroll/d40c4cbe9f8e9bd8b424fc8b61cd3c55bd85d30a/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/Egypt.imageset/Egypt.jpeg
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/LightPink.colorset/Contents.json:
--------------------------------------------------------------------------------
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 | }
39 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/London.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "London.jpeg",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/London.imageset/London.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiftui-library/hstack-snap-to-scroll/d40c4cbe9f8e9bd8b424fc8b61cd3c55bd85d30a/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/London.imageset/London.jpeg
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/MtFuji.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "MtFuji.jpg",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/MtFuji.imageset/MtFuji.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiftui-library/hstack-snap-to-scroll/d40c4cbe9f8e9bd8b424fc8b61cd3c55bd85d30a/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/MtFuji.imageset/MtFuji.jpg
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/NewOrleans.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "NewOrleans.jpeg",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/NewOrleans.imageset/NewOrleans.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiftui-library/hstack-snap-to-scroll/d40c4cbe9f8e9bd8b424fc8b61cd3c55bd85d30a/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/NewOrleans.imageset/NewOrleans.jpeg
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/NewYork.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "NewYork.jpeg",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/NewYork.imageset/NewYork.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiftui-library/hstack-snap-to-scroll/d40c4cbe9f8e9bd8b424fc8b61cd3c55bd85d30a/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/NewYork.imageset/NewYork.jpeg
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/Venice.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Venice.jpeg",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/Venice.imageset/Venice.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swiftui-library/hstack-snap-to-scroll/d40c4cbe9f8e9bd8b424fc8b61cd3c55bd85d30a/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/Venice.imageset/Venice.jpeg
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 | UISceneConfigurations
28 |
29 | UIWindowSceneSessionRoleApplication
30 |
31 |
32 | UISceneConfigurationName
33 | Default Configuration
34 | UISceneDelegateClassName
35 | $(PRODUCT_MODULE_NAME).SceneDelegate
36 |
37 |
38 |
39 |
40 | UIApplicationSupportsIndirectInputEvents
41 |
42 | UILaunchStoryboardName
43 | LaunchScreen
44 | UIRequiredDeviceCapabilities
45 |
46 | armv7
47 |
48 | UISupportedInterfaceOrientations
49 |
50 | UIInterfaceOrientationPortrait
51 | UIInterfaceOrientationLandscapeLeft
52 | UIInterfaceOrientationLandscapeRight
53 |
54 | UISupportedInterfaceOrientations~ipad
55 |
56 | UIInterfaceOrientationPortrait
57 | UIInterfaceOrientationPortraitUpsideDown
58 | UIInterfaceOrientationLandscapeLeft
59 | UIInterfaceOrientationLandscapeRight
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/Resource Files/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/SnapToScrollDemo/Sources/SceneDelegate.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UIKit
3 |
4 | class SceneDelegate: UIResponder, UIWindowSceneDelegate {
5 |
6 | var window: UIWindow?
7 |
8 | func scene(
9 | _ scene: UIScene,
10 | willConnectTo session: UISceneSession,
11 | options connectionOptions: UIScene.ConnectionOptions) {
12 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
13 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
14 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
15 |
16 | // Create the SwiftUI view that provides the window contents.
17 | let contentView = ContentView()
18 |
19 | // Use a UIHostingController as window root view controller.
20 | if let windowScene = scene as? UIWindowScene {
21 | let window = UIWindow(windowScene: windowScene)
22 | window.rootViewController = UIHostingController(rootView: contentView)
23 | self.window = window
24 | window.makeKeyAndVisible()
25 | }
26 | }
27 |
28 | func sceneDidDisconnect(_ scene: UIScene) {
29 | // Called as the scene is being released by the system.
30 | // This occurs shortly after the scene enters the background, or when its session is discarded.
31 | // Release any resources associated with this scene that can be re-created the next time the scene connects.
32 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
33 | }
34 |
35 | func sceneDidBecomeActive(_ scene: UIScene) {
36 | // Called when the scene has moved from an inactive state to an active state.
37 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
38 | }
39 |
40 | func sceneWillResignActive(_ scene: UIScene) {
41 | // Called when the scene will move from an active state to an inactive state.
42 | // This may occur due to temporary interruptions (ex. an incoming phone call).
43 | }
44 |
45 | func sceneWillEnterForeground(_ scene: UIScene) {
46 | // Called as the scene transitions from the background to the foreground.
47 | // Use this method to undo the changes made on entering the background.
48 | }
49 |
50 | func sceneDidEnterBackground(_ scene: UIScene) {
51 | // Called as the scene transitions from the foreground to the background.
52 | // Use this method to save data, release shared resources, and store enough scene-specific state information
53 | // to restore the scene back to its current state.
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Sources/HStackSnap.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftUI
3 |
4 | public struct HStackSnap: View {
5 |
6 | // MARK: Lifecycle
7 |
8 | public init(
9 | alignment: SnapAlignment,
10 | spacing: CGFloat? = nil,
11 | coordinateSpace: String = "SnapToScroll",
12 | @ViewBuilder content: @escaping () -> Content,
13 | eventHandler: SnapToScrollEventHandler? = .none) {
14 |
15 | self.content = content
16 | self.alignment = alignment
17 | self.leadingOffset = alignment.scrollOffset
18 | self.spacing = spacing
19 | self.coordinateSpace = coordinateSpace
20 | self.eventHandler = eventHandler
21 | }
22 |
23 | // MARK: Public
24 |
25 | public var body: some View {
26 |
27 | func calculatedItemWidth(parentWidth: CGFloat, offset: CGFloat) -> CGFloat {
28 |
29 | print(parentWidth)
30 | return parentWidth - offset * 2
31 | }
32 |
33 | return GeometryReader { geometry in
34 |
35 | HStackSnapCore(
36 | leadingOffset: leadingOffset,
37 | spacing: spacing,
38 | coordinateSpace: coordinateSpace,
39 | content: content,
40 | eventHandler: eventHandler)
41 | .environmentObject(SizeOverride(itemWidth: alignment.shouldSetWidth ? calculatedItemWidth(parentWidth: geometry.size.width, offset: alignment.scrollOffset) : .none))
42 | }
43 | }
44 |
45 | // MARK: Internal
46 |
47 | var content: () -> Content
48 |
49 | // MARK: Private
50 |
51 | private let alignment: SnapAlignment
52 |
53 | /// Calculated offset based on `SnapLocation`
54 | private let leadingOffset: CGFloat
55 |
56 | private let spacing: CGFloat?
57 |
58 | private var eventHandler: SnapToScrollEventHandler?
59 |
60 | private let coordinateSpace: String
61 | }
62 |
--------------------------------------------------------------------------------
/Sources/Model/ContentPreferenceKey.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftUI
3 |
4 | // MARK: - ExerciseEditorCellPreferenceData
5 |
6 | struct ContentPreferenceData: Equatable {
7 |
8 | let id: Int
9 | let rect: CGRect
10 | }
11 |
12 | // MARK: - ExerciseEditorCellPreferenceKey
13 |
14 | struct ContentPreferenceKey: PreferenceKey {
15 |
16 | typealias Value = [ContentPreferenceData]
17 |
18 | // MARK: Internal
19 |
20 | static var defaultValue: [ContentPreferenceData] = []
21 |
22 | static func reduce(
23 | value: inout Value,
24 | nextValue: () -> Value) {
25 |
26 | value.append(contentsOf: nextValue())
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Sources/Model/SizeOverride.swift:
--------------------------------------------------------------------------------
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 | }
13 |
--------------------------------------------------------------------------------
/Sources/Model/SnapAlignment.swift:
--------------------------------------------------------------------------------
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 | }
27 |
--------------------------------------------------------------------------------
/Sources/Model/SnapToScrollEvent.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftUI
3 |
4 | public enum SnapToScrollEvent {
5 |
6 | /// Swiped to index.
7 | case swipe(index: Int)
8 |
9 | /// HStackSnap completed layout calculations. (item index, item leading offset)
10 | case didLayout(layoutInfo: [Int: CGFloat])
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/Views/GeometryReaderOverlay.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftUI
3 |
4 | public struct GeometryReaderOverlay: View {
5 |
6 | // MARK: Lifecycle
7 |
8 | public init(id: ID, coordinateSpace: String?) {
9 |
10 | self.id = id
11 | optionalCoordinateSpace = coordinateSpace
12 | }
13 |
14 | // MARK: Public
15 |
16 | public var body: some View {
17 |
18 | GeometryReader { geometry in
19 |
20 | Rectangle().fill(Color.clear)
21 | .preference(
22 | key: ContentPreferenceKey.self,
23 | value: [ContentPreferenceData(
24 | id: id.hashValue,
25 | rect: geometry.frame(in: .named(coordinateSpace)))])
26 | }
27 | }
28 |
29 | // MARK: Internal
30 |
31 | let id: ID
32 | let optionalCoordinateSpace: String?
33 |
34 | let defaultCoordinateSpace = "SnapToScroll"
35 |
36 | var coordinateSpace: String {
37 |
38 | return optionalCoordinateSpace ?? defaultCoordinateSpace
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Sources/Views/HStackSnapCore.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftUI
3 |
4 | public typealias SnapToScrollEventHandler = ((SnapToScrollEvent) -> Void)
5 |
6 | // MARK: - HStackSnapCore
7 |
8 | public struct HStackSnapCore: View {
9 | // MARK: Lifecycle
10 |
11 | public init(
12 | leadingOffset: CGFloat,
13 | spacing: CGFloat? = nil,
14 | coordinateSpace: String = "SnapToScroll",
15 | @ViewBuilder content: @escaping () -> Content,
16 | eventHandler: SnapToScrollEventHandler? = .none) {
17 |
18 | self.content = content
19 | self.targetOffset = leadingOffset
20 | self.spacing = spacing
21 | self.scrollOffset = leadingOffset
22 | self.coordinateSpace = coordinateSpace
23 | self.eventHandler = eventHandler
24 | self._prevScrollOffset = State(initialValue: leadingOffset)
25 | }
26 |
27 | // MARK: Public
28 |
29 | public var body: some View {
30 | GeometryReader { geometry in
31 |
32 | HStack {
33 | HStack(spacing: spacing, content: content)
34 | .offset(x: scrollOffset, y: .zero)
35 |
36 | Spacer()
37 | }
38 | // TODO: Make this less... janky.
39 | .frame(width: 10000)
40 | .onPreferenceChange(ContentPreferenceKey.self, perform: { preferences in
41 | self.preferences = preferences
42 |
43 | // Calculate all values once, on render. On-the-fly calculations with GeometryReader
44 | // proved occasionally unstable in testing.
45 | if !hasCalculatedFrames {
46 | let screenWidth = geometry.frame(in: .named(coordinateSpace)).width
47 |
48 | var itemScrollPositions: [Int: CGFloat] = [:]
49 |
50 | var frameMaxXVals: [CGFloat] = []
51 |
52 | for (index, preference) in preferences.enumerated() {
53 | itemScrollPositions[index] = scrollOffset(for: preference.rect.minX)
54 | frameMaxXVals.append(preference.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 | guard let maxX = preferences.last?.rect.maxX else { break }
63 | let widthToEnd = maxX - currMinX
64 |
65 | contentFitMap.append(widthToEnd)
66 | }
67 |
68 | var frameTrim: Int = 0
69 | let reversedFitMap = Array(contentFitMap.reversed())
70 |
71 | // Calculate how many snap locations should be trimmed.
72 | for i in 0 ..< reversedFitMap.count {
73 | if reversedFitMap[i] > screenWidth {
74 | frameTrim = max(i - 1, 0)
75 | break
76 | }
77 | }
78 |
79 | // Write valid snap locations to state.
80 | for (i, item) in itemScrollPositions.sorted(by: { $0.value > $1.value })
81 | .enumerated()
82 | {
83 | guard i < (itemScrollPositions.count - frameTrim) else { break }
84 |
85 | snapLocations[item.key] = item.value
86 | }
87 |
88 | hasCalculatedFrames = true
89 |
90 | eventHandler?(.didLayout(layoutInfo: itemScrollPositions))
91 | }
92 | })
93 | .contentShape(Rectangle())
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 | DragGesture()
105 | .onChanged { gesture in
106 |
107 | self.scrollOffset = gesture.translation.width + prevScrollOffset
108 | }.onEnded { _ in
109 |
110 | let currOffset = scrollOffset
111 | var closestSnapLocation: CGFloat = snapLocations.first?.value ?? targetOffset
112 |
113 | // Calculate closest snap location
114 | for (_, offset) in snapLocations {
115 | if abs(offset - currOffset) < abs(closestSnapLocation - currOffset) {
116 | closestSnapLocation = offset
117 | }
118 | }
119 |
120 | // Handle swipe callback
121 | let selectedIndex = snapLocations.map { $0.value }.sorted(by: { $0 > $1 })
122 | .firstIndex(of: closestSnapLocation) ?? 0
123 |
124 | if selectedIndex != previouslySentIndex {
125 | eventHandler?(.swipe(index: selectedIndex))
126 | previouslySentIndex = selectedIndex
127 | }
128 |
129 | // Update state
130 | withAnimation(.easeOut(duration: 0.2)) {
131 | scrollOffset = closestSnapLocation
132 | }
133 | prevScrollOffset = scrollOffset
134 | }
135 | }
136 |
137 | func scrollOffset(for x: CGFloat) -> CGFloat {
138 | return (targetOffset * 2) - x
139 | }
140 |
141 | // MARK: Private
142 |
143 | /// Used to check if children configuration (ids or sizes) have changed.
144 | @State private var preferences: [ContentPreferenceData] = [] {
145 | didSet {
146 | if oldValue.map(\.id) != preferences.map(\.id) || oldValue.map { $0.rect.size } != preferences.map { $0.rect.size } {
147 | hasCalculatedFrames = false
148 | }
149 | }
150 | }
151 |
152 | @State private var hasCalculatedFrames: Bool = false
153 |
154 | /// Current scroll offset.
155 | @State private var scrollOffset: CGFloat
156 |
157 | /// Stored offset of previous scroll, so scroll state is resumed between drags.
158 | @State private var prevScrollOffset: CGFloat
159 |
160 | /// Calculated offset based on `SnapLocation`
161 | @State private var targetOffset: CGFloat
162 |
163 | /// Space between content views`
164 | @State private var spacing: CGFloat?
165 |
166 | /// The original offset of each frame, used to calculate `scrollOffset`
167 | @State private var snapLocations: [Int: CGFloat] = [:]
168 |
169 | private var eventHandler: SnapToScrollEventHandler?
170 |
171 | @State private var previouslySentIndex: Int = 0
172 |
173 | private let coordinateSpace: String
174 | }
175 |
--------------------------------------------------------------------------------
/Sources/Views/SnapAlignmentHelper.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftUI
3 |
4 | // MARK: - SnapAlignmentHelper
5 |
6 | struct SnapAlignmentHelper: 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(
34 | id: ID,
35 | coordinateSpace: String? = .none) -> some View {
36 |
37 | modifier(SnapAlignmentHelper(id: id, coordinateSpace: coordinateSpace))
38 | }
39 | }
40 |
--------------------------------------------------------------------------------