diff --git a/README.md b/README.md index 6adb068..b0d4b3b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ SwiftUIPager provides a `Pager` component built with SwiftUI native components. `Pager` is a view that renders a scrollable container to display a handful of pages. These pages are recycled on scroll, so you don't have to worry about memory issues. +Create vertical or horizontal pagers, align the cards, change the direction of the scroll, animate the pagintation... `Pager` lets you do anything you want. + Example of usage ## Requirements @@ -33,12 +35,14 @@ Go to XCode: Creating a `Pager` is very simple. You just need to pass: - `Binding` to page index -- Array of items that conform to `Equatable` and `Identifiable` +- `Array` of items +- `KeyPath` to an identifier. - `ViewBuilder` factory method to create each page ```swift Pager(page: self.$pageIndex, data: self.items, + id: \.identifier, content: { item in // create a page based on the data passed self.pageView(item) @@ -47,7 +51,7 @@ Creating a `Pager` is very simple. You just need to pass: ### UI customization -`Pager` is easily customizable through a number of view-modifier functions. You can change the vertical insets, spacing between items or the page aspect ratio, among others: +`Pager` is easily customizable through a number of view-modifier functions. You can change the orientation, the direction of the scroll, the alignment, the space between items or the page aspect ratio, among others: ```swift Pager(...) @@ -72,9 +76,21 @@ Pager(...) PageAspectRatio greater than 1 +You can customize the alignment and the direction of the scroll. For instance, you can have a horizontal `Pager` that scrolls right-to-left that it's aligned at the start of the scroll: + +```swift +Pager(...) + .itemSpacing(10) + .alignment(.start) + .horizontal(.rightToLeft) + .itemAspectRatio(0.6) +``` + +PageAspectRatio greater than 1 + ### Animations -Use `interactive` to pass a shrink ratio that will be applied to those components that are not focused, that is, those elements whose index is different from `pageIndex` binding: +Use `interactive` add a scale animation effect to those pages that are unfocused, that is, those elements whose index is different from `pageIndex`: ```swift Pager(...) @@ -96,9 +112,11 @@ Pager(...) ### Gestures `Pager` comes with the following built-in gestures: -- Tap on any item to bring it to focus. +- Tap on any item to bring it to focus. Enable this gesture with `itemTappable` - Swipe acroos the items +You can disable any interaction by calling `disableInteraction`. + ### Events Use `onPageChanged` to react to any change on the page index: diff --git a/Sample.xcodeproj/project.pbxproj b/Sample.xcodeproj/project.pbxproj index 4f21ba8..323fa25 100644 --- a/Sample.xcodeproj/project.pbxproj +++ b/Sample.xcodeproj/project.pbxproj @@ -13,7 +13,7 @@ 17D9E0FA23D4CF6900C5AE93 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 17D9E0F923D4CF6900C5AE93 /* Assets.xcassets */; }; 17D9E0FD23D4CF6900C5AE93 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 17D9E0FC23D4CF6900C5AE93 /* Assets.xcassets */; }; 17D9E10023D4CF6900C5AE93 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 17D9E0FE23D4CF6900C5AE93 /* LaunchScreen.storyboard */; }; - 6BB892E023D9F13C00AC9331 /* SwiftUIPager in Frameworks */ = {isa = PBXBuildFile; productRef = 6BB892DF23D9F13C00AC9331 /* SwiftUIPager */; }; + 6BEA272523DA3596001A2082 /* SwiftUIPager in Frameworks */ = {isa = PBXBuildFile; productRef = 6BEA272423DA3596001A2082 /* SwiftUIPager */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -32,7 +32,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 6BB892E023D9F13C00AC9331 /* SwiftUIPager in Frameworks */, + 6BEA272523DA3596001A2082 /* SwiftUIPager in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -94,7 +94,7 @@ ); name = Sample; packageProductDependencies = ( - 6BB892DF23D9F13C00AC9331 /* SwiftUIPager */, + 6BEA272423DA3596001A2082 /* SwiftUIPager */, ); productName = SwiftUIPager; productReference = 17D9E0F023D4CF6700C5AE93 /* Sample.app */; @@ -125,7 +125,7 @@ ); mainGroup = 17D9E0E723D4CF6700C5AE93; packageReferences = ( - 6BB892DE23D9F13C00AC9331 /* XCRemoteSwiftPackageReference "SwiftUIPager" */, + 6BEA272323DA3596001A2082 /* XCRemoteSwiftPackageReference "SwiftUIPager" */, ); productRefGroup = 17D9E0F123D4CF6700C5AE93 /* Products */; projectDirPath = ""; @@ -352,20 +352,20 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 6BB892DE23D9F13C00AC9331 /* XCRemoteSwiftPackageReference "SwiftUIPager" */ = { + 6BEA272323DA3596001A2082 /* XCRemoteSwiftPackageReference "SwiftUIPager" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/fermoya/SwiftUIPager"; requirement = { - branch = "fix/page-offset"; + branch = "feat/adding-alignment"; kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 6BB892DF23D9F13C00AC9331 /* SwiftUIPager */ = { + 6BEA272423DA3596001A2082 /* SwiftUIPager */ = { isa = XCSwiftPackageProductDependency; - package = 6BB892DE23D9F13C00AC9331 /* XCRemoteSwiftPackageReference "SwiftUIPager" */; + package = 6BEA272323DA3596001A2082 /* XCRemoteSwiftPackageReference "SwiftUIPager" */; productName = SwiftUIPager; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bea2a7f..16a5092 100644 --- a/Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "package": "SwiftUIPager", "repositoryURL": "https://github.com/fermoya/SwiftUIPager", "state": { - "branch": "fix/page-offset", - "revision": "3b05829020ec5e10a0997455b2a7fb18dfb4af53", + "branch": "feat/adding-alignment", + "revision": "0d5ce99d889b105b600baeb5219d3b90f90667f8", "version": null } } diff --git a/Sample/ContentView.swift b/Sample/ContentView.swift index a631980..9fccb6e 100644 --- a/Sample/ContentView.swift +++ b/Sample/ContentView.swift @@ -9,56 +9,40 @@ import SwiftUI import SwiftUIPager -extension Int: Identifiable { - public var id: Int { return self } -} - struct ContentView: View { @State var isPresented: Bool = false @State var pageIndex: Int = 0 - var data: [Int] = Array((0...20)) + var data: [Int] = Array((0...5)) var body: some View { - Button(action: { - self.isPresented.toggle() - }, label: { - Text("Tap me") - }).sheet(isPresented: $isPresented, content: { - self.presentedView - }) - } - - var presentedView: some View { GeometryReader { proxy in - ScrollView { - VStack { - Pager(page: self.$pageIndex, - data: self.data, - content: { index in - self.pageView(index) - .cornerRadius(10) - .shadow(radius: 5) - }) - .interactive(0.8) - .itemSpacing(10) - .padding(8) - .itemAspectRatio(0.8) - .itemTappable(true) - .frame(width: min(proxy.size.width, - proxy.size.height), - height: min(proxy.size.width, - proxy.size.height)) - .border(Color.red, width: 2) - ForEach(self.data) { i in - Text("Page: \(i)") - .bold() - .padding() - } - } + VStack { + Pager(page: self.$pageIndex, + data: self.data, + id: \.self, + content: { index in + self.pageView(index) + .cornerRadius(10) + .shadow(radius: 5) + }) + .itemSpacing(10) + .alignment(.start) + .horizontal(.rightToLeft) + .itemAspectRatio(0.6) + .frame(width: min(proxy.size.width, + proxy.size.height), + height: min(proxy.size.width, + proxy.size.height)) + .border(Color.red, width: 2) + Spacer() + Text("Page: \(self.pageIndex)") + .bold() + Spacer() } } } + } extension ContentView { diff --git a/Sources/SwiftUIPager/Pager+Buildable.swift b/Sources/SwiftUIPager/Pager+Buildable.swift index de7397b..45fa386 100644 --- a/Sources/SwiftUIPager/Pager+Buildable.swift +++ b/Sources/SwiftUIPager/Pager+Buildable.swift @@ -10,19 +10,53 @@ import SwiftUI extension Pager: Buildable { + /// Swipe direction for a vertical `Pager` + public enum HorizontalSwipeDirection { + + /// Pages move from left to right + case leftToRight + + /// Pages move from right to left + case rightToLeft + } + + /// Swipe direction for a horizontal `Pager` + public enum VerticalSwipeDirection { + + /// Pages move from top left to bottom + case topToBottom + + /// Pages move from bottom to top + case bottomToTop + } + + /// Changes the a the alignment of the pages relative to their container + public func alignment(_ value: Alignment) -> Self { + mutating(keyPath: \.alignment, value: value) + } + /// Adds a `TapGesture` to the items to bring them to focus public func itemTappable(_ value: Bool) -> Self { mutating(keyPath: \.isItemTappable, value: value) } + /// Disables any gesture interaction + public func disableInteraction(_ value: Bool) -> Self { + mutating(keyPath: \.isUserInteractionEnabled, value: value) + } + /// Returns a horizontal pager - public func horizontal() -> Self { - mutating(keyPath: \.isHorizontal, value: true) + public func horizontal(_ swipeDirection: HorizontalSwipeDirection = .leftToRight) -> Self { + let scrollDirectionAngle: Angle = swipeDirection == .leftToRight ? .zero : Angle(degrees: 180) + return mutating(keyPath: \.isHorizontal, value: true) + .mutating(keyPath: \.scrollDirectionAngle, value: scrollDirectionAngle) } /// Returns a vertical pager - public func vertical() -> Self { - mutating(keyPath: \.isHorizontal, value: false) + public func vertical(_ swipeDirection: VerticalSwipeDirection = .topToBottom) -> Self { + let scrollDirectionAngle: Angle = swipeDirection == .topToBottom ? .zero : Angle(degrees: 180) + return mutating(keyPath: \.isHorizontal, value: false) + .mutating(keyPath: \.scrollDirectionAngle, value: scrollDirectionAngle) } /// Call this method to provide a shrink ratio that will apply to the items that are not focused. diff --git a/Sources/SwiftUIPager/Pager+Helper.swift b/Sources/SwiftUIPager/Pager+Helper.swift index 6a2bd5b..136be7d 100644 --- a/Sources/SwiftUIPager/Pager+Helper.swift +++ b/Sources/SwiftUIPager/Pager+Helper.swift @@ -47,13 +47,13 @@ extension Pager { /// Minimum offset allowed. This allows a bounce offset var offsetLowerbound: CGFloat { guard currentPage == 0 else { return CGFloat(numberOfPages) * self.size.width } - return CGFloat(numberOfPagesDisplayed) / 2 * pageDistance - pageDistance / 4 + return CGFloat(numberOfPagesDisplayed) / 2 * pageDistance - pageDistance / 4 + alignmentOffset } /// Maximum offset allowed. This allows a bounce offset var offsetUpperbound: CGFloat { guard currentPage == numberOfPages - 1 else { return -CGFloat(numberOfPages) * self.size.width } - return -CGFloat(numberOfPagesDisplayed) / 2 * pageDistance + pageDistance / 4 + return -CGFloat(numberOfPagesDisplayed) / 2 * pageDistance + pageDistance / 4 + alignmentOffset } /// Addition of `draggingOffset` and `contentOffset` @@ -107,12 +107,35 @@ extension Pager { return min(numberOfPages, maximumNumberOfPages + page) } + /// Extra offset to complentate the alignment + var alignmentOffset: CGFloat { + let offset: CGFloat + switch alignment { + case .center: + offset = 0 + case .end(let insets): + if isVertical { + offset = (size.height - pageSize.height) / 2 - insets + } else { + offset = (size.width - pageSize.width) / 2 - insets + } + case .start(let insets): + if isVertical { + offset = -(size.height - pageSize.height) / 2 + insets + } else { + offset = -(size.width - pageSize.width) / 2 + insets + } + } + + return offset + } + /// Offset applied to `HStack`. It's limitted by `offsetUpperbound` and `offsetUpperbound` var xOffset: CGFloat { let page = CGFloat(self.page - lowerPageDisplayed) let numberOfPages = CGFloat(numberOfPagesDisplayed) let xIncrement = pageDistance / 2 - let offset = (numberOfPages / 2 - page) * pageDistance - xIncrement + totalOffset + let offset = (numberOfPages / 2 - page) * pageDistance - xIncrement + totalOffset + alignmentOffset return max(offsetUpperbound, min(offsetLowerbound, offset)) } @@ -120,27 +143,27 @@ extension Pager { func angle(for item: Element) -> Angle { guard shouldRotate else { return .zero } guard let index = data.firstIndex(of: item) else { return .zero } - + let totalIncrement = abs(totalOffset / pageDistance) - + let currentAngle = index == page ? .zero : index < page ? Angle(degrees: rotationDegrees) : Angle(degrees: -rotationDegrees) guard isDragging else { return currentAngle } - + let newAngle = direction == .forward ? Angle(degrees: currentAngle.degrees + rotationDegrees * Double(totalIncrement)) : Angle(degrees: currentAngle.degrees - rotationDegrees * Double(totalIncrement) ) return newAngle } - + /// Axis for the rotations effect func axis(for item: Element) -> (CGFloat, CGFloat, CGFloat) { guard shouldRotate else { return (0, 0, 0) } guard let index = data.firstIndex(of: item) else { return (0, 0, 0) } - + let currentXAxis: CGFloat = index == page ? 0 : index < page ? rotationAxis.x : -rotationAxis.x return (currentXAxis, rotationAxis.y, rotationAxis.z) } - + /// Scale that applies to a particular item func scale(for item: Element) -> CGFloat { guard isDragging else { return isFocused(item) ? 1 : interactiveScale } diff --git a/Sources/SwiftUIPager/Pager.swift b/Sources/SwiftUIPager/Pager.swift index 81d840e..9bf7836 100644 --- a/Sources/SwiftUIPager/Pager.swift +++ b/Sources/SwiftUIPager/Pager.swift @@ -29,7 +29,7 @@ import SwiftUI /// - 30 px of vertical insets /// - 0.6 shrink ratio for items that aren't focused. /// -public struct Pager: View where PageView: View, Element: Identifiable & Equatable { +public struct Pager: View where PageView: View, Element: Equatable, ID: Hashable { /// `Direction` determines the direction of the swipe gesture enum Direction { @@ -39,41 +39,77 @@ public struct Pager: View where PageView: View, Element: Ide case backward } + /// `Alignment` determines the focused page alignment inside `Pager` + public enum Alignment { + /// Sets the alignment to be centered + case center + + /// Sets the alignment to be at the start of the container with the specified insets: + /// + /// - Left, if horizontal + /// - Top, if vertical + case start(CGFloat) + + /// Sets the alignment to be at the start of the container with the specified insets: + /// + /// - Right, if horizontal + /// - Bottom, if vertical + case end(CGFloat) + + /// Sets the alignment at the start, with 0 px of margin + public static var start: Alignment { .start(0) } + + /// Sets the alignment at the end, with 0 px of margin + public static var end: Alignment { .end(0) } + } + /*** Constants ***/ /// Manages the number of items that should be displayed in the screen. /// A ratio of 3, for instance, would mean the items held in memory are enough /// to cover 3 times the size of the pager let recyclingRatio = 4 - + /// Angle of rotation when should rotate let rotationDegrees: Double = 20 - + /// Angle of rotation when should rotate let rotationInteractiveScale: CGFloat = 0.7 - + /// Axis of rotation when should rotate let rotationAxis: (x: CGFloat, y: CGFloat, z: CGFloat) = (0, 1, 0) /*** Dependencies ***/ - + + /// Angle to dermine the direction of the scroll + var scrollDirectionAngle: Angle = .zero + /// `ViewBuilder` block to create each page let content: (Element) -> PageView + /// `KeyPath` to data id property + let id: KeyPath + /// Array of items that will populate each page var data: [Element] /*** ViewModified properties ***/ + /// The elements alignment relative to the container + var alignment: Alignment = .center + /// `true` if items are tapable var isItemTappable: Bool = false + /// `true` if items user interaction is enabled + var isUserInteractionEnabled: Bool = true + /// `true` if the pager is horizontal var isHorizontal: Bool = true /// Shrink ratio that affects the items that aren't focused var interactiveScale: CGFloat = 1 - + /// `true` if pages should have a 3D rotation effect var shouldRotate: Bool = false @@ -91,7 +127,7 @@ public struct Pager: View where PageView: View, Element: Ide /// Callback for every new page var onPageChanged: ((Int) -> Void)? - + /*** State and Binding properties ***/ /// Size of the view @@ -114,35 +150,54 @@ public struct Pager: View where PageView: View, Element: Ide /// /// - Parameter page: Binding to the index of the focused page /// - Parameter data: Array of items to populate the content + /// - Parameter id: KeyPath to identifiable property /// - Parameter content: Factory method to build new pages - public init(page: Binding, data: [Element], @ViewBuilder content: @escaping (Element) -> PageView) { + public init(page: Binding, data: [Element], id: KeyPath, @ViewBuilder content: @escaping (Element) -> PageView) { self._page = page self.data = data + self.id = id self.content = content } public var body: some View { - HStack(spacing: self.interactiveItemSpacing) { - ForEach(self.dataDisplayed) { item in + HStack(spacing: interactiveItemSpacing) { + ForEach(dataDisplayed, id: id) { item in self.content(item) .frame(size: self.pageSize) .scaleEffect(self.scale(for: item)) - .rotation3DEffect(self.isHorizontal ? .zero : Angle(degrees: -90), + .rotation3DEffect((self.isHorizontal ? .zero : Angle(degrees: -90)) - self.scrollDirectionAngle, axis: (0, 0, 1)) .rotation3DEffect(self.angle(for: item), axis: self.axis(for: item)) .gesture(self.tapGesture(for: item)) - .disabled(self.isFocused(item) || !self.isItemTappable) + .disabled(self.isFocused(item) || !self.isItemTappable || !self.isUserInteractionEnabled) } .offset(x: self.xOffset, y : 0) } - .gesture(self.swipeGesture) - .rotation3DEffect(isHorizontal ? .zero : Angle(degrees: 90), + .gesture(swipeGesture) + .disabled(!isUserInteractionEnabled) + .rotation3DEffect((isHorizontal ? .zero : Angle(degrees: 90)) + scrollDirectionAngle, axis: (0, 0, 1)) .sizeTrackable($size) } } +extension Pager where ID == Element.ID, Element : Identifiable { + + /// Initializes a new Pager. + /// + /// - Parameter page: Binding to the index of the focused page + /// - Parameter data: Array of items to populate the content + /// - Parameter content: Factory method to build new pages + public init(page: Binding, data: [Element], @ViewBuilder content: @escaping (Element) -> PageView) { + self._page = page + self.data = data + self.id = \Element.id + self.content = content + } + +} + // MARK: Gestures extension Pager { @@ -174,7 +229,7 @@ extension Pager { let velocity = -Double(value.translation.width) / value.time.timeIntervalSince(self.draggingStartTime ?? Date()) var newPage = self.currentPage if newPage == self.page, abs(velocity) > 1000 { - newPage = newPage + Int(velocity / velocity) + newPage = newPage + Int(velocity / abs(velocity)) } newPage = max(0, min(self.numberOfPages - 1, newPage)) withAnimation(.easeOut) { diff --git a/SwiftUIPager.podspec b/SwiftUIPager.podspec index 806c24d..3a246af 100644 --- a/SwiftUIPager.podspec +++ b/SwiftUIPager.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SwiftUIPager" - s.version = "1.1.1" + s.version = "1.2.0" s.summary = "Native pager for SwiftUI. Easily to use, easy to customize." s.description = <<-DESC diff --git a/resources/orientation-alignment.gif b/resources/orientation-alignment.gif new file mode 100644 index 0000000..8d3bd52 Binary files /dev/null and b/resources/orientation-alignment.gif differ