diff --git a/README.md b/README.md index 4a01d8a..a3fb5dc 100644 --- a/README.md +++ b/README.md @@ -6,47 +6,56 @@ https://user-images.githubusercontent.com/8763719/131393666-6af82d2a-998e-4dba-a ## Getting Started -Using `SnapToScroll` is straightforward. There's only two requirements: +Using `SnapToScroll` is straightforward. There's just three steps. -1. Replace `HStack` with `HStackSnap` -2. Add `GeometryReaderOverlay` to your view. +1. Import `SnapToScroll` +2. Replace `HStack` with `HStackSnap` +3. Add `.snapAlignmentHelper` to your view. An example: ```swift +import SnapToScroll // Step 1 +... -HStackSnap(leadingOffset: 16) { +HStackSnap(alignment: .center(32)) { // Step 2 - ForEach(modelArray) { viewModel in + ForEach(myModels) { viewModel in - myView(viewModel: viewModel) - .frame(maxWidth: 250) - .overlay(GeometryReaderOverlay(id: viewModel.id)) - } - } + MyView( + selectedIndex: $selectedIndex, + viewModel: viewModel + ) + .snapAlignmentHelper(id: viewModel.id) // Step 3 + } } ``` For more examples, see `SnapToScrollDemo/ContentView.swift`. -## Options +## Configuration `HStackSnap` comes with two customizable properties: -- `leadingOffset`: The leading padding you'd like each element to snap to. -- `coordinateSpace`: Option to set custom name for the coordinate space, in the case you're using multiple `HStackSnap`s of various sizes. +- `alignment`: The way you'd like your elements to be arranged. + - `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. + - `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. +- `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`. + +`.snapAlignmentHelper` comes with two options as well: + +- `id`: Required. A unique ID for the element. +- `coordinateSpace`: Same as above. ## Limitations -1. If your child views are designed to take up all available horizontal space, they'll expand beyond the visible view. Prevent this with `.frame(maxWidth: x)` -2. `HStackSnap` is currently designed to work with static content. -3. At the moment, `HStackSnap` offers snapping to the leading edge only. If you'd like to offer a PR that adds support for `.center` and / or `.trailing`, I'd love to look it over! +- `HStackSnap` is currently designed to work with static content. ## How it Works 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. -Read through `HStackSnap` for more details. +Read through `HStackSnap.swift` and `Views/HStackSnapCore.swift` for more details. ## Credits diff --git a/SnapToScrollDemo/SnapToScrollDemo.xcodeproj/project.pbxproj b/SnapToScrollDemo/SnapToScrollDemo.xcodeproj/project.pbxproj index e4925f7..51f19f5 100644 --- a/SnapToScrollDemo/SnapToScrollDemo.xcodeproj/project.pbxproj +++ b/SnapToScrollDemo/SnapToScrollDemo.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 840130AF26DFB8DD00E4A8A3 /* GettingStartedModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840130AE26DFB8DC00E4A8A3 /* GettingStartedModel.swift */; }; + 840130B126DFB93400E4A8A3 /* GettingStartedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840130B026DFB93400E4A8A3 /* GettingStartedView.swift */; }; + 840130B326DFC27700E4A8A3 /* Example3ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840130B226DFC27700E4A8A3 /* Example3ContentView.swift */; }; + 840130B526DFC2FB00E4A8A3 /* Example2ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840130B426DFC2FB00E4A8A3 /* Example2ContentView.swift */; }; + 840130B726DFC33100E4A8A3 /* Example1ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840130B626DFC33100E4A8A3 /* Example1ContentView.swift */; }; 841B0F8326DD39A4008A436B /* TripTupleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841B0F8226DD39A4008A436B /* TripTupleView.swift */; }; 84B568D926DD271000D37CF2 /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B568D826DD271000D37CF2 /* TagView.swift */; }; 84B568DB26DD309400D37CF2 /* Example1HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B568DA26DD309400D37CF2 /* Example1HeaderView.swift */; }; @@ -23,6 +28,11 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 840130AE26DFB8DC00E4A8A3 /* GettingStartedModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GettingStartedModel.swift; sourceTree = ""; }; + 840130B026DFB93400E4A8A3 /* GettingStartedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GettingStartedView.swift; sourceTree = ""; }; + 840130B226DFC27700E4A8A3 /* Example3ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example3ContentView.swift; sourceTree = ""; }; + 840130B426DFC2FB00E4A8A3 /* Example2ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example2ContentView.swift; sourceTree = ""; }; + 840130B626DFC33100E4A8A3 /* Example1ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example1ContentView.swift; sourceTree = ""; }; 841B0F8226DD39A4008A436B /* TripTupleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripTupleView.swift; sourceTree = ""; }; 84B568D826DD271000D37CF2 /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = ""; }; 84B568DA26DD309400D37CF2 /* Example1HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example1HeaderView.swift; sourceTree = ""; }; @@ -52,9 +62,20 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 840130AD26DFB8C800E4A8A3 /* Example3 */ = { + isa = PBXGroup; + children = ( + 840130B226DFC27700E4A8A3 /* Example3ContentView.swift */, + 840130AE26DFB8DC00E4A8A3 /* GettingStartedModel.swift */, + 840130B026DFB93400E4A8A3 /* GettingStartedView.swift */, + ); + path = Example3; + sourceTree = ""; + }; 84B568D626DD26A800D37CF2 /* Example1 */ = { isa = PBXGroup; children = ( + 840130B626DFC33100E4A8A3 /* Example1ContentView.swift */, 84C99CD726D99BA100C1D5C4 /* TagModel.swift */, 84B568D826DD271000D37CF2 /* TagView.swift */, 84B568DA26DD309400D37CF2 /* Example1HeaderView.swift */, @@ -68,6 +89,7 @@ 84B568DD26DD351900D37CF2 /* TripModel.swift */, 84B568DF26DD37A600D37CF2 /* TripView.swift */, 841B0F8226DD39A4008A436B /* TripTupleView.swift */, + 840130B426DFC2FB00E4A8A3 /* Example2ContentView.swift */, ); path = "Example 2"; sourceTree = ""; @@ -97,6 +119,7 @@ 84D9FA3526D9753600F87EF5 /* AppDelegate.swift */, 84B568D626DD26A800D37CF2 /* Example1 */, 84B568DC26DD311F00D37CF2 /* Example 2 */, + 840130AD26DFB8C800E4A8A3 /* Example3 */, 84D9FA3726D9753600F87EF5 /* SceneDelegate.swift */, 84D9FA3926D9753600F87EF5 /* ContentView.swift */, ); @@ -204,10 +227,15 @@ files = ( 84B568DB26DD309400D37CF2 /* Example1HeaderView.swift in Sources */, 84D9FA3626D9753600F87EF5 /* AppDelegate.swift in Sources */, + 840130AF26DFB8DD00E4A8A3 /* GettingStartedModel.swift in Sources */, 841B0F8326DD39A4008A436B /* TripTupleView.swift in Sources */, + 840130B726DFC33100E4A8A3 /* Example1ContentView.swift in Sources */, 84B568D926DD271000D37CF2 /* TagView.swift in Sources */, + 840130B326DFC27700E4A8A3 /* Example3ContentView.swift in Sources */, 84B568E026DD37A600D37CF2 /* TripView.swift in Sources */, + 840130B526DFC2FB00E4A8A3 /* Example2ContentView.swift in Sources */, 84B568DE26DD351900D37CF2 /* TripModel.swift in Sources */, + 840130B126DFB93400E4A8A3 /* GettingStartedView.swift in Sources */, 84D9FA3826D9753600F87EF5 /* SceneDelegate.swift in Sources */, 84D9FA3A26D9753600F87EF5 /* ContentView.swift in Sources */, 84C99CD826D99BA100C1D5C4 /* TagModel.swift in Sources */, diff --git a/SnapToScrollDemo/Sources/ContentView.swift b/SnapToScrollDemo/Sources/ContentView.swift index 047c855..2596c4d 100644 --- a/SnapToScrollDemo/Sources/ContentView.swift +++ b/SnapToScrollDemo/Sources/ContentView.swift @@ -5,8 +5,6 @@ import SwiftUI struct ContentView: View { - @State var items = [("one", 1), ("two", 2), ("three", 3), ("four", 4), ("five", 5), ("six", 6)] - var body: some View { VStack { @@ -14,46 +12,21 @@ struct ContentView: View { VerticalSpace - VStack { - - LargeHeader(text: "Example 1") - - Example1HeaderView() - - // Don't forget to attach GeometryReaderOverlay! - HStackSnap(leadingOffset: 16) { - - ForEach(TagModel.exampleModels) { viewModel in - - TagView(viewModel: viewModel) - .overlay(GeometryReaderOverlay(id: viewModel.id)) - } - }.padding(.top, 4) - } + LargeHeader(text: "Example 1") + + Example1ContentView() VerticalSpace - VStack { - - LargeHeader(text: "Example 2") - - Text("Explore Nearby") - .font(.system(size: 22, weight: .semibold, design: .rounded)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding([.top, .leading], 16) - - HStackSnap(leadingOffset: 16) { - - ForEach(TripTupleModel.exampleModels) { viewModel in + LargeHeader(text: "Example 2") + + Example2ContentView() + + VerticalSpace - TripTupleView(viewModel: viewModel) - .frame(maxWidth: 250) - .overlay(GeometryReaderOverlay(id: viewModel.id)) - } - } - .frame(height: 200) - .padding(.top, 4) - } + LargeHeader(text: "Example 3") + + Example3ContentView() } } .preferredColorScheme(.light) diff --git a/SnapToScrollDemo/Sources/Example 2/Example2ContentView.swift b/SnapToScrollDemo/Sources/Example 2/Example2ContentView.swift new file mode 100644 index 0000000..6005215 --- /dev/null +++ b/SnapToScrollDemo/Sources/Example 2/Example2ContentView.swift @@ -0,0 +1,40 @@ +import SwiftUI +import SnapToScroll + +// MARK: - Example2ContentView + +struct Example2ContentView: View { + var body: some View { + + VStack { + + Text("Explore Nearby") + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding([.top, .leading], 16) + + HStackSnap(alignment: .leading(16)) { + + ForEach(TripTupleModel.exampleModels) { viewModel in + + TripTupleView(viewModel: viewModel) + .frame(maxWidth: 250) + .snapAlignmentHelper(id: viewModel.id) + } + } onSwipe: { index in + + print(index) + } + .frame(height: 130) + .padding(.top, 4) + } + } +} + +// MARK: - Example2ContentView_Previews + +struct Example2ContentView_Previews: PreviewProvider { + static var previews: some View { + Example2ContentView() + } +} diff --git a/SnapToScrollDemo/Sources/Example1/Example1ContentView.swift b/SnapToScrollDemo/Sources/Example1/Example1ContentView.swift new file mode 100644 index 0000000..ffb7934 --- /dev/null +++ b/SnapToScrollDemo/Sources/Example1/Example1ContentView.swift @@ -0,0 +1,35 @@ +// +// Example2ContentView.swift +// Example2ContentView +// +// Created by Trent Guillory on 9/1/21. +// + +import SwiftUI +import SnapToScroll + +struct Example1ContentView: View { + var body: some View { + + VStack { + + Example1HeaderView() + + // Don't forget to attach snapAlignmentHelper! + HStackSnap(alignment: .leading(16)) { + + ForEach(TagModel.exampleModels) { viewModel in + + TagView(viewModel: viewModel) + .snapAlignmentHelper(id: viewModel.id) + } + }.padding(.top, 4) + } + } +} + +struct Example1ContentView_Previews: PreviewProvider { + static var previews: some View { + Example2ContentView() + } +} diff --git a/SnapToScrollDemo/Sources/Example3/Example3ContentView.swift b/SnapToScrollDemo/Sources/Example3/Example3ContentView.swift new file mode 100644 index 0000000..63683a2 --- /dev/null +++ b/SnapToScrollDemo/Sources/Example3/Example3ContentView.swift @@ -0,0 +1,54 @@ +import SnapToScroll +import SwiftUI + +// MARK: - Example3ContentView + +struct Example3ContentView: View { + + // MARK: Internal + + var body: some View { + + VStack { + + Text("Getting Started") + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundColor(.white) + .frame(maxWidth: .infinity, alignment: .leading) + .padding([.top, .leading], 32) + + HStackSnap(alignment: .center(32)) { + + ForEach(GettingStartedModel.exampleModels) { viewModel in + + GettingStartedView( + selectedIndex: $selectedGettingStartedIndex, + viewModel: viewModel) + .snapAlignmentHelper(id: viewModel.id) + } + } onSwipe: { index in + + selectedGettingStartedIndex = index + } + .frame(height: 200) + .padding(.top, 4) + } + .padding([.top, .bottom], 64) + .background(LinearGradient( + colors: [Color("Cream"), Color("LightPink")], + startPoint: .top, + endPoint: .bottom)) + } + + // MARK: Private + + @State private var selectedGettingStartedIndex: Int = 0 +} + +// MARK: - Example3ContentView_Previews + +struct Example3ContentView_Previews: PreviewProvider { + static var previews: some View { + Example3ContentView() + } +} diff --git a/SnapToScrollDemo/Sources/Example3/GettingStartedModel.swift b/SnapToScrollDemo/Sources/Example3/GettingStartedModel.swift new file mode 100644 index 0000000..97a6b50 --- /dev/null +++ b/SnapToScrollDemo/Sources/Example3/GettingStartedModel.swift @@ -0,0 +1,32 @@ +import Foundation + +struct GettingStartedModel: Identifiable { + + static let exampleModels: [GettingStartedModel] = [ + .init( + id: 0, + systemImage: "camera.aperture", + title: "Snap a Pic", + body: "We feature the viewfinder front and center in this throwback app."), + .init( + id: 1, + systemImage: "camera.filters", + title: "Filter it Up", + body: "Add filters - from detailed colorization to film effects."), + .init( + id: 2, + systemImage: "paperplane", + title: "Send It", + body: "Share your photos with your contacts. Or the entire world."), + .init( + id: 3, + systemImage: "sparkles", + title: "Be Awesome", + body: "You're clearly already doing this. Just wanted to remind you. 😉"), + ] + + let id: Int + let systemImage: String + let title: String + let body: String +} diff --git a/SnapToScrollDemo/Sources/Example3/GettingStartedView.swift b/SnapToScrollDemo/Sources/Example3/GettingStartedView.swift new file mode 100644 index 0000000..1b13883 --- /dev/null +++ b/SnapToScrollDemo/Sources/Example3/GettingStartedView.swift @@ -0,0 +1,53 @@ +import SwiftUI + +// MARK: - GettingStartedView + +struct GettingStartedView: View { + + @Binding var selectedIndex: Int + + let viewModel: GettingStartedModel + + var body: some View { + + VStack(alignment: .leading) { + + Image(systemName: viewModel.systemImage) + .foregroundColor(isSelected ? Color("LightPink") : .gray) + .font(.system(size: 32)) + .padding(.bottom, 2) + + Text(viewModel.title) + .fontWeight(.semibold) + .foregroundColor(.black) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 1) + .opacity(0.8) + + Text(viewModel.body) + .foregroundColor(.black) + .multilineTextAlignment(.leading) + .opacity(0.8) + } + .padding() + .background(Color.white) + .cornerRadius(12) + .opacity(isSelected ? 1 : 0.8) + } + + var isSelected: Bool { + + return selectedIndex == viewModel.id + } +} + +// MARK: - GettingStartedView_Previews + +struct GettingStartedView_Previews: PreviewProvider { + static var previews: some View { + + GettingStartedView( + selectedIndex: .constant(0), + viewModel: GettingStartedModel.exampleModels.first!) + } +} diff --git a/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/Cream.colorset/Contents.json b/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/Cream.colorset/Contents.json new file mode 100644 index 0000000..3dd3509 --- /dev/null +++ b/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/Cream.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xB4", + "green" : "0xD4", + "red" : "0xDB" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.706", + "green" : "0.831", + "red" : "0.859" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/LightPink.colorset/Contents.json b/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/LightPink.colorset/Contents.json new file mode 100644 index 0000000..333a0b2 --- /dev/null +++ b/SnapToScrollDemo/Sources/Resource Files/Assets.xcassets/LightPink.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xC0", + "green" : "0x95", + "red" : "0xCC" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xC0", + "green" : "0x95", + "red" : "0xCC" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/HStackSnap.swift b/Sources/HStackSnap.swift index b825332..3ff75ba 100644 --- a/Sources/HStackSnap.swift +++ b/Sources/HStackSnap.swift @@ -3,143 +3,56 @@ import SwiftUI public struct HStackSnap: View { + public typealias SwipeEventHandler = ((Int) -> Void) + // MARK: Lifecycle public init( - leadingOffset: CGFloat, + alignment: SnapAlignment, coordinateSpace: String = "SnapToScroll", - @ViewBuilder content: @escaping () -> Content) { + @ViewBuilder content: @escaping () -> Content, + onSwipe: SwipeEventHandler? = .none) { self.content = content - targetOffset = leadingOffset - scrollOffset = leadingOffset + self.alignment = alignment + leadingOffset = alignment.scrollOffset self.coordinateSpace = coordinateSpace + swipeEventHandler = onSwipe } // MARK: Public public var body: some View { + + func calculatedItemWidth(parentWidth: CGFloat, offset: CGFloat) -> CGFloat { + + print(parentWidth) + return parentWidth - offset * 2 + } - GeometryReader { geometry in - - HStack { - - HStack(content: content) - .offset(x: scrollOffset, y: .zero) - .animation(.easeOut(duration: 0.2)) - - Spacer() - } - // TODO: Make this less... janky. - .frame(width: 10000) - .onPreferenceChange(ContentPreferenceKey.self, perform: { preferences in - - // Calculate all values once, on render. On-the-fly calculations with GeometryReader - // proved occasionally unstable in testing. - if !hasCalculatedFrames { - - let screenWidth = geometry.frame(in: .named(coordinateSpace)).width - - var itemScrollPositions: [Int: CGFloat] = [:] - - var frameMaxXVals: [CGFloat] = [] - - for pref in preferences { - - itemScrollPositions[pref.id.hashValue] = scrollOffset(for: pref.rect.minX) - frameMaxXVals.append(pref.rect.maxX) - } - - // Array of content widths from currentElement.minX to lastElement.maxX - var contentFitMap: [CGFloat] = [] - - // Calculate content widths (used to trim snap positions later) - for currMinX in preferences.map({ $0.rect.minX }) { - - guard let maxX = preferences.last?.rect.maxX else { break } - let widthToEnd = maxX - currMinX - - contentFitMap.append(widthToEnd) - } - - var frameTrim: Int = 0 - let reversedFitMap = Array(contentFitMap.reversed()) - - // Calculate how many snap locations should be trimmed. - for i in 0 ..< reversedFitMap.count { - - if reversedFitMap[i] > screenWidth { - - frameTrim = max(i - 1, 0) - break - } - } - - // Write valid snap locations to state. - for (i, item) in itemScrollPositions.sorted(by: { $0.value > $1.value }) - .enumerated() { - - guard i < (itemScrollPositions.count - frameTrim) else { break } - - snapLocations[item.key] = item.value - } + return GeometryReader { geometry in - hasCalculatedFrames = true - } - }) - .gesture(snapDrag) + HStackSnapCore( + leadingOffset: leadingOffset, + coordinateSpace: coordinateSpace, + content: content, + onSwipe: swipeEventHandler) + .environmentObject(SizeOverride(itemWidth: alignment.shouldSetWidth ? calculatedItemWidth(parentWidth: geometry.size.width, offset: alignment.scrollOffset) : .none)) } - .coordinateSpace(name: coordinateSpace) } // MARK: Internal var content: () -> Content - var snapDrag: some Gesture { - - DragGesture() - .onChanged { gesture in - - self.scrollOffset = gesture.translation.width + prevScrollOffset - }.onEnded { event in - - let currOffset = scrollOffset - var closestSnapLocation: CGFloat = snapLocations.first?.value ?? targetOffset - - for (_, offset) in snapLocations { - - if abs(offset - currOffset) < abs(closestSnapLocation - currOffset) { - - closestSnapLocation = offset - } - } - - scrollOffset = closestSnapLocation - prevScrollOffset = scrollOffset - } - } - - func scrollOffset(for x: CGFloat) -> CGFloat { - - return (targetOffset * 2) - x - } - // MARK: Private - - @State private var hasCalculatedFrames: Bool = false - - /// Current scroll offset. - @State private var scrollOffset: CGFloat - - /// Stored offset of previous scroll, so scroll state is resumed between drags. - @State private var prevScrollOffset: CGFloat = 0 + + private let alignment: SnapAlignment /// Calculated offset based on `SnapLocation` - @State private var targetOffset: CGFloat + private let leadingOffset: CGFloat - /// The original offset of each frame, used to calculate `scrollOffset` - @State private var snapLocations: [Int: CGFloat] = [:] + private var swipeEventHandler: SwipeEventHandler? private let coordinateSpace: String } diff --git a/Sources/Model/SizeOverride.swift b/Sources/Model/SizeOverride.swift new file mode 100644 index 0000000..5f7367e --- /dev/null +++ b/Sources/Model/SizeOverride.swift @@ -0,0 +1,12 @@ +import Foundation +import SwiftUI + +class SizeOverride: ObservableObject { + + init(itemWidth: CGFloat?) { + + self.itemWidth = itemWidth + } + + var itemWidth: CGFloat? +} diff --git a/Sources/Model/SnapAlignment.swift b/Sources/Model/SnapAlignment.swift new file mode 100644 index 0000000..7456a18 --- /dev/null +++ b/Sources/Model/SnapAlignment.swift @@ -0,0 +1,26 @@ +import Foundation +import SwiftUI + +public enum SnapAlignment { + + case leading(CGFloat) + case center(CGFloat) + + internal var scrollOffset: CGFloat { + + switch self { + + case let .leading(offset): return offset + case let .center(offset): return offset + } + } + + internal var shouldSetWidth: Bool { + + switch self { + + case .leading: return false + case .center: return true + } + } +} diff --git a/Sources/Views/GeometryReaderOverlay.swift b/Sources/Views/GeometryReaderOverlay.swift index 69892d9..50a1b52 100644 --- a/Sources/Views/GeometryReaderOverlay.swift +++ b/Sources/Views/GeometryReaderOverlay.swift @@ -5,10 +5,10 @@ public struct GeometryReaderOverlay: View { // MARK: Lifecycle - public init(id: ID, coordinateSpace: String = "SnapToScroll") { + public init(id: ID, coordinateSpace: String?) { self.id = id - self.coordinateSpace = coordinateSpace + optionalCoordinateSpace = coordinateSpace } // MARK: Public @@ -29,5 +29,12 @@ public struct GeometryReaderOverlay: View { // MARK: Internal let id: ID - let coordinateSpace: String + let optionalCoordinateSpace: String? + + let defaultCoordinateSpace = "SnapToScroll" + + var coordinateSpace: String { + + return optionalCoordinateSpace ?? defaultCoordinateSpace + } } diff --git a/Sources/Views/HStackSnapCore.swift b/Sources/Views/HStackSnapCore.swift new file mode 100644 index 0000000..1257594 --- /dev/null +++ b/Sources/Views/HStackSnapCore.swift @@ -0,0 +1,165 @@ +import Foundation +import SwiftUI + +public struct HStackSnapCore: View { + + public typealias SwipeEventHandler = ((Int) -> Void) + + // MARK: Lifecycle + + public init( + leadingOffset: CGFloat, + coordinateSpace: String = "SnapToScroll", + @ViewBuilder content: @escaping () -> Content, + onSwipe: SwipeEventHandler? = .none) { + + self.content = content + targetOffset = leadingOffset + scrollOffset = leadingOffset + self.coordinateSpace = coordinateSpace + swipeEventHandler = onSwipe + } + + // MARK: Public + + public var body: some View { + + GeometryReader { geometry in + + HStack { + + HStack(content: content) + .offset(x: scrollOffset, y: .zero) + .animation(.easeOut(duration: 0.2)) + + Spacer() + } + // TODO: Make this less... janky. + .frame(width: 10000) + .onPreferenceChange(ContentPreferenceKey.self, perform: { preferences in + + // Calculate all values once, on render. On-the-fly calculations with GeometryReader + // proved occasionally unstable in testing. + if !hasCalculatedFrames { + + let screenWidth = geometry.frame(in: .named(coordinateSpace)).width + + var itemScrollPositions: [Int: CGFloat] = [:] + + var frameMaxXVals: [CGFloat] = [] + + for pref in preferences { + + itemScrollPositions[pref.id.hashValue] = scrollOffset(for: pref.rect.minX) + frameMaxXVals.append(pref.rect.maxX) + } + + // Array of content widths from currentElement.minX to lastElement.maxX + var contentFitMap: [CGFloat] = [] + + // Calculate content widths (used to trim snap positions later) + for currMinX in preferences.map({ $0.rect.minX }) { + + guard let maxX = preferences.last?.rect.maxX else { break } + let widthToEnd = maxX - currMinX + + contentFitMap.append(widthToEnd) + } + + var frameTrim: Int = 0 + let reversedFitMap = Array(contentFitMap.reversed()) + + // Calculate how many snap locations should be trimmed. + for i in 0 ..< reversedFitMap.count { + + if reversedFitMap[i] > screenWidth { + + frameTrim = max(i - 1, 0) + break + } + } + + // Write valid snap locations to state. + for (i, item) in itemScrollPositions.sorted(by: { $0.value > $1.value }) + .enumerated() { + + guard i < (itemScrollPositions.count - frameTrim) else { break } + + snapLocations[item.key] = item.value + } + + hasCalculatedFrames = true + } + }) + .gesture(snapDrag) + } + .coordinateSpace(name: coordinateSpace) + } + + // MARK: Internal + + var content: () -> Content + + var snapDrag: some Gesture { + + DragGesture() + .onChanged { gesture in + + self.scrollOffset = gesture.translation.width + prevScrollOffset + }.onEnded { event in + + let currOffset = scrollOffset + var closestSnapLocation: CGFloat = snapLocations.first?.value ?? targetOffset + + // Calculate closest snap location + for (_, offset) in snapLocations { + + if abs(offset - currOffset) < abs(closestSnapLocation - currOffset) { + + closestSnapLocation = offset + } + } + + // Handle swipe callback + let selectedIndex = snapLocations.map { $0.value }.sorted(by: { $0 > $1 }) + .firstIndex(of: closestSnapLocation) ?? 0 + + if selectedIndex != previouslySentIndex { + + swipeEventHandler?(selectedIndex) + previouslySentIndex = selectedIndex + } + + // Update state + scrollOffset = closestSnapLocation + prevScrollOffset = scrollOffset + } + } + + func scrollOffset(for x: CGFloat) -> CGFloat { + + return (targetOffset * 2) - x + } + + // MARK: Private + + @State private var hasCalculatedFrames: Bool = false + + /// Current scroll offset. + @State private var scrollOffset: CGFloat + + /// Stored offset of previous scroll, so scroll state is resumed between drags. + @State private var prevScrollOffset: CGFloat = 0 + + /// Calculated offset based on `SnapLocation` + @State private var targetOffset: CGFloat + + /// The original offset of each frame, used to calculate `scrollOffset` + @State private var snapLocations: [Int: CGFloat] = [:] + + private var swipeEventHandler: SwipeEventHandler? + + @State private var previouslySentIndex: Int = 0 + + private let coordinateSpace: String +} diff --git a/Sources/Views/SnapAlignmentHelper.swift b/Sources/Views/SnapAlignmentHelper.swift new file mode 100644 index 0000000..89ae79f --- /dev/null +++ b/Sources/Views/SnapAlignmentHelper.swift @@ -0,0 +1,39 @@ +import Foundation +import SwiftUI + +// MARK: - SnapAlignmentHelper + +struct SnapAlignmentHelper: ViewModifier { + + @EnvironmentObject var sizeOverride: SizeOverride + + var id: ID + var coordinateSpace: String? + + func body(content: Content) -> some View { + + switch sizeOverride.itemWidth { + + case let .some(value): + + content + .frame(width: value) + .overlay(GeometryReaderOverlay(id: id, coordinateSpace: coordinateSpace)) + + case .none: + + content + .overlay(GeometryReaderOverlay(id: id, coordinateSpace: coordinateSpace)) + } + } +} + +extension View { + + public func snapAlignmentHelper( + id: ID, + coordinateSpace: String? = .none) -> some View { + + modifier(SnapAlignmentHelper(id: id, coordinateSpace: coordinateSpace)) + } +}