├── .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 | --------------------------------------------------------------------------------