├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md ├── Resources └── Demo.gif └── Sources └── SwiftUIPageView ├── API ├── HPageView.swift ├── HorizontalPageAlignment.swift ├── PageAlignment.swift ├── PageView.swift ├── PageViewProxy.swift ├── PageViewReader.swift ├── VPageView.swift ├── VerticalPageAlignment.swift └── View+.swift └── Internal ├── Animation+.swift ├── AnimationState.swift ├── CGFloat+.swift ├── DragAnimator.swift ├── DragGesture+.swift ├── EnvironmentValues+.swift ├── HorizontalPageAlignment+.swift ├── InteractionProxy.swift ├── InteractionProxyKey.swift ├── PageAlignment+.swift ├── PageGestureView.swift ├── PageLayoutView.swift ├── PageState.swift ├── PageView+.swift ├── VerticalPageAlignment+.swift └── ViewCounter.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ciaran O'Brien 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SwiftUIPageView", 7 | platforms: [ 8 | .iOS(.v14), 9 | .watchOS(.v7) 10 | ], 11 | products: [ 12 | .library( 13 | name: "SwiftUIPageView", 14 | targets: ["SwiftUIPageView"]), 15 | ], 16 | dependencies: [ 17 | ], 18 | targets: [ 19 | .target( 20 | name: "SwiftUIPageView", 21 | dependencies: []) 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUI PageView 2 | 3 | SwiftUI stack views with paged scrolling behaviour. 4 | 5 | > [!WARNING] 6 | > This package is no longer maintained. Use ScrollView and [scrollTargetBehaviour](https://developer.apple.com/documentation/swiftui/view/scrolltargetbehavior(_:)) instead. 7 | 8 | ## HPageView 9 | A view that arranges its children in a horizontal line, and provides paged scrolling behaviour. 10 | 11 | ### Usage 12 | ```swift 13 | HPageView(alignment: .leading, pageWidth: 250, spacing: 12) { 14 | //Pages 15 | } 16 | ``` 17 | 18 | ### Parameters 19 | * `alignment`: The guide for aligning the pages in this page view. 20 | * `pageWidth`: The width of each page, or `nil` if you want each page to fill the width of the page view. 21 | * `spacing`: The distance between adjacent pages, or `nil` if you want the page view to choose a default distance for each pair of pages. 22 | * `content`: A view builder that creates the content of this page view. 23 | 24 | ## VPageView 25 | A view that arranges its children in a vertical line, and provides paged scrolling behaviour. 26 | 27 | ### Usage 28 | ```swift 29 | VPageView(alignment: .top, pageHeight: 250, spacing: 12) { 30 | //Pages 31 | } 32 | ``` 33 | 34 | ### Parameters 35 | * `alignment`: The guide for aligning the pages in this page view. 36 | * `pageHeight`: The height of each page, or `nil` if you want each page to fill the height of the page view. 37 | * `spacing`: The distance between adjacent pages, or `nil` if you want the page view to choose a default distance for each pair of pages. 38 | * `content`: A view builder that creates the content of this page view. 39 | 40 | ## PageView 41 | A view that arranges its children in a line, and provides paged scrolling behaviour. 42 | 43 | **Changes to the layout axis will cause the pages to lose any internal state, and will not be animated.** 44 | 45 | ### Usage 46 | ```swift 47 | PageView(.horizontal, alignment: .leading, pageLength: 250, spacing: 12) { 48 | //Pages 49 | } 50 | ``` 51 | 52 | ### Parameters 53 | * `axis`: The layout axis of this page view. 54 | * `alignment`: The guide for aligning the pages in this page view. 55 | * `pageLength`: The length of each page, parallel to the layout axis, or `nil` if you want each page to fill the length of the page view. 56 | * `spacing`: The distance between adjacent pages, or `nil` if you want the page view to choose a default distance for each pair of pages. 57 | * `content`: A view builder that creates the content of this page view. 58 | 59 | ## PageViewReader 60 | A view that provides programmatic paging, by working with a proxy to move to child pages. 61 | 62 | ### Usage 63 | ```swift 64 | PageViewReader { proxy in 65 | HPageView { 66 | //Pages 67 | 68 | Button("First") { 69 | withAnimation { 70 | proxy.moveToFirst() 71 | } 72 | } 73 | } 74 | } 75 | ``` 76 | 77 | ### Parameters 78 | * `content`: The reader's content, containing a page view. 79 | 80 | ## PageViewProxy 81 | A proxy value that supports programmatic paging of the first page view within a view hierarchy. 82 | 83 | ### Functions 84 | * `moveTo(index:)`: Scans the first page view contained by the proxy for the page with the index closest to `index`, and then moves to that page. 85 | * `moveToFirst()`: Scans the first page view contained by the proxy for the first page, and then moves to that page. 86 | * `moveToLast()`: Scans the first page view contained by the proxy for the last page, and then moves to that page. 87 | 88 | ## Advanced Usage 89 | The `strictPageAlignment` view modifier can be used to control whether page views always use their provided alignment to position pages. Without this modifier pages will be aligned to prevent leaving empty space in the page view. 90 | 91 | ## Known Issues 92 | * Changes to the layout axis of a `PageView` will cause the pages to lose any internal state, and will not be animated. 93 | * Active paging animations in a page view may interfere with other animations when the number of pages changes. 94 | * Nested page views are not currently supported. 95 | 96 | ## Requirements 97 | 98 | * iOS 14.0+ or watchOS 7.0+ 99 | * Xcode 12.0+ 100 | 101 | ## Installation 102 | 103 | * Install with [Swift Package Manager](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app). 104 | * Import `SwiftUIPageView` to start using. 105 | 106 | ## Contact 107 | 108 | [@ciaranrobrien](https://twitter.com/ciaranrobrien) on Twitter. 109 | -------------------------------------------------------------------------------- /Resources/Demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ciaranrobrien/SwiftUIPageView/738e2498b21651165ac9731e3906b8793c378995/Resources/Demo.gif -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/API/HPageView.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | /// A view that arranges its children in a horizontal line, and provides 10 | /// paged scrolling behaviour. 11 | public struct HPageView: View 12 | where Content : View 13 | { 14 | public var body: PageView 15 | 16 | /// A view that arranges its children in a horizontal line, and provides 17 | /// paged scrolling behaviour. 18 | /// 19 | /// This view returns a flexible preferred size to its parent layout. 20 | /// 21 | /// - Parameters: 22 | /// - alignment: The guide for aligning the pages in this page view. 23 | /// - pageWidth: The width of each page, or `nil` if you want each 24 | /// page to fill the width of the page view. 25 | /// - spacing: The distance between adjacent pages, or `nil` if you 26 | /// want the page view to choose a default distance for each pair of 27 | /// pages. 28 | /// - content: A view builder that creates the content of this page view. 29 | public init(alignment: PageAlignment = .center, 30 | pageWidth: CGFloat? = nil, 31 | spacing: CGFloat? = nil, 32 | @ViewBuilder content: @escaping () -> Content) 33 | { 34 | body = PageView(alignment: alignment.alignment, 35 | axis: .horizontal, 36 | content: content, 37 | pageLength: pageWidth, 38 | spacing: spacing) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/API/HorizontalPageAlignment.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | /// An alignment position along the horizontal axis. 8 | public enum HorizontalPageAlignment: CaseIterable, Hashable { 9 | 10 | /// A guide marking the leading edge of the page. 11 | case leading 12 | 13 | /// A guide marking the horizontal center of the page. 14 | case center 15 | 16 | /// A guide marking the trailing edge of the page. 17 | case trailing 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/API/PageAlignment.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | /// An alignment in both axes. 10 | public struct PageAlignment: Equatable 11 | where HAlignment : Equatable, 12 | VAlignment : Equatable 13 | { 14 | 15 | /// The alignment on the horizontal axis. 16 | public var horizontal: HAlignment 17 | 18 | /// The alignment on the vertical axis. 19 | public var vertical: VAlignment 20 | 21 | private init() { 22 | fatalError("PageAlignment cannot be initialized using private init().") 23 | } 24 | } 25 | 26 | 27 | public extension PageAlignment 28 | where HAlignment == HorizontalPageAlignment, 29 | VAlignment == VerticalAlignment 30 | { 31 | 32 | /// Creates an instance with the given horizontal and vertical alignments. 33 | /// 34 | /// - Parameters: 35 | /// - horizontal: The alignment on the horizontal axis. 36 | /// - vertical: The alignment on the vertical axis. 37 | init(horizontal: HAlignment, vertical: VAlignment) { 38 | self.horizontal = horizontal 39 | self.vertical = vertical 40 | } 41 | 42 | /// A guide marking the top and leading edges of the page. 43 | static let topLeading = PageAlignment(horizontal: .leading, vertical: .top) 44 | 45 | /// A guide marking the top edge of the page. 46 | static let top = PageAlignment(horizontal: .center, vertical: .top) 47 | 48 | /// A guide marking the top and trailing edges of the page. 49 | static let topTrailing = PageAlignment(horizontal: .trailing, vertical: .top) 50 | 51 | /// A guide marking the leading edge of the page. 52 | static let leading = PageAlignment(horizontal: .leading, vertical: .center) 53 | 54 | /// A guide marking the center of the page. 55 | static let center = PageAlignment(horizontal: .center, vertical: .center) 56 | 57 | /// A guide marking the trailing edge of the page. 58 | static let trailing = PageAlignment(horizontal: .trailing, vertical: .center) 59 | 60 | /// A guide marking the bottom and leading edges of the page. 61 | static let bottomLeading = PageAlignment(horizontal: .leading, vertical: .bottom) 62 | 63 | /// A guide marking the bottom edge of the page. 64 | static let bottom = PageAlignment(horizontal: .center, vertical: .bottom) 65 | 66 | /// A guide marking the bottom and trailing edges of the page. 67 | static let bottomTrailing = PageAlignment(horizontal: .trailing, vertical: .bottom) 68 | } 69 | 70 | 71 | public extension PageAlignment 72 | where HAlignment == HorizontalAlignment, 73 | VAlignment == VerticalPageAlignment 74 | { 75 | /// Creates an instance with the given horizontal and vertical alignments. 76 | /// 77 | /// - Parameters: 78 | /// - horizontal: The alignment on the horizontal axis. 79 | /// - vertical: The alignment on the vertical axis. 80 | init(horizontal: HAlignment, vertical: VAlignment) { 81 | self.horizontal = horizontal 82 | self.vertical = vertical 83 | } 84 | 85 | /// A guide marking the top and leading edges of the page. 86 | static let topLeading = PageAlignment(horizontal: .leading, vertical: .top) 87 | 88 | /// A guide marking the top edge of the page. 89 | static let top = PageAlignment(horizontal: .center, vertical: .top) 90 | 91 | /// A guide marking the top and trailing edges of the page. 92 | static let topTrailing = PageAlignment(horizontal: .trailing, vertical: .top) 93 | 94 | /// A guide marking the leading edge of the page. 95 | static let leading = PageAlignment(horizontal: .leading, vertical: .center) 96 | 97 | /// A guide marking the center of the page. 98 | static let center = PageAlignment(horizontal: .center, vertical: .center) 99 | 100 | /// A guide marking the trailing edge of the page. 101 | static let trailing = PageAlignment(horizontal: .trailing, vertical: .center) 102 | 103 | /// A guide marking the bottom and leading edges of the page. 104 | static let bottomLeading = PageAlignment(horizontal: .leading, vertical: .bottom) 105 | 106 | /// A guide marking the bottom edge of the page. 107 | static let bottom = PageAlignment(horizontal: .center, vertical: .bottom) 108 | 109 | /// A guide marking the bottom and trailing edges of the page. 110 | static let bottomTrailing = PageAlignment(horizontal: .trailing, vertical: .bottom) 111 | } 112 | 113 | 114 | public extension PageAlignment 115 | where HAlignment == HorizontalPageAlignment, 116 | VAlignment == VerticalPageAlignment 117 | { 118 | /// Creates an instance with the given horizontal and vertical alignments. 119 | /// 120 | /// - Parameters: 121 | /// - horizontal: The alignment on the horizontal axis. 122 | /// - vertical: The alignment on the vertical axis. 123 | init(horizontal: HAlignment, vertical: VAlignment) { 124 | self.horizontal = horizontal 125 | self.vertical = vertical 126 | } 127 | 128 | /// A guide marking the top and leading edges of the page. 129 | static let topLeading = PageAlignment(horizontal: .leading, vertical: .top) 130 | 131 | /// A guide marking the top edge of the page. 132 | static let top = PageAlignment(horizontal: .center, vertical: .top) 133 | 134 | /// A guide marking the top and trailing edges of the page. 135 | static let topTrailing = PageAlignment(horizontal: .trailing, vertical: .top) 136 | 137 | /// A guide marking the leading edge of the page. 138 | static let leading = PageAlignment(horizontal: .leading, vertical: .center) 139 | 140 | /// A guide marking the center of the page. 141 | static let center = PageAlignment(horizontal: .center, vertical: .center) 142 | 143 | /// A guide marking the trailing edge of the page. 144 | static let trailing = PageAlignment(horizontal: .trailing, vertical: .center) 145 | 146 | /// A guide marking the bottom and leading edges of the page. 147 | static let bottomLeading = PageAlignment(horizontal: .leading, vertical: .bottom) 148 | 149 | /// A guide marking the bottom edge of the page. 150 | static let bottom = PageAlignment(horizontal: .center, vertical: .bottom) 151 | 152 | /// A guide marking the bottom and trailing edges of the page. 153 | static let bottomTrailing = PageAlignment(horizontal: .trailing, vertical: .bottom) 154 | } 155 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/API/PageView.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | /// A view that arranges its children in a line, and provides paged 10 | /// scrolling behaviour. 11 | public struct PageView: View 12 | where Content : View 13 | { 14 | @Environment(\.displayScale) internal var displayScale 15 | 16 | public var body: some View { 17 | GeometryReader { geometry in 18 | let spacing = spacing ?? 8 19 | let viewLength = viewLength(for: geometry) 20 | let pageLength = pageLength(viewLength: viewLength) 21 | let baseOffset = baseOffset(pageLength: pageLength, viewLength: viewLength) 22 | 23 | PageGestureView(alignment: alignment, 24 | axis: axis, 25 | baseOffset: baseOffset, 26 | content: content, 27 | pageLength: pageLength, 28 | spacing: spacing, 29 | viewLength: viewLength) 30 | } 31 | .animation(nil, value: axis) 32 | } 33 | 34 | internal var alignment: Alignment 35 | internal var axis: Axis 36 | internal var content: () -> Content 37 | internal var pageLength: CGFloat? 38 | internal var spacing: CGFloat? 39 | } 40 | 41 | 42 | public extension PageView { 43 | 44 | /// A view that arranges its children in a line, and provides paged 45 | /// scrolling behaviour. 46 | /// 47 | /// This view returns a flexible preferred size to its parent layout. 48 | /// 49 | /// Changes to the layout axis will cause the pages to lose any internal 50 | /// state, and will not be animated. 51 | /// 52 | /// - Parameters: 53 | /// - axis: The layout axis of this page view. 54 | /// - alignment: The guide for aligning the pages in this page view. 55 | /// - pageLength: The length of each page, parallel to the layout axis, 56 | /// or `nil` if you want each page to fill the length of the page view. 57 | /// - spacing: The distance between adjacent pages, or `nil` if you 58 | /// want the page view to choose a default distance for each pair of 59 | /// pages. 60 | /// - content: A view builder that creates the content of this page view. 61 | init(_ axis: Axis, 62 | alignment: PageAlignment = .center, 63 | pageLength: CGFloat? = nil, 64 | spacing: CGFloat? = nil, 65 | @ViewBuilder content: @escaping () -> Content) 66 | { 67 | self.alignment = alignment.alignment 68 | self.axis = axis 69 | self.content = content 70 | self.pageLength = pageLength 71 | self.spacing = spacing 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/API/PageViewProxy.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | /// A proxy value that supports programmatic paging of the first 10 | /// page view within a view hierarchy. 11 | public struct PageViewProxy { 12 | 13 | /// Scans the first page view contained by the proxy for the 14 | /// page with the index closest to `index`, and then moves 15 | /// to that page. 16 | /// 17 | /// - Parameters: 18 | /// - index: The index of the page to move to. 19 | public func moveTo(_ index: Int) { 20 | interactionProxy?.moveTo(CGFloat(index)) 21 | } 22 | 23 | /// Scans the first page view contained by the proxy for the 24 | /// first page, and then moves to that page. 25 | public func moveToFirst() { 26 | interactionProxy?.moveTo(-.infinity) 27 | } 28 | 29 | /// Scans the first page view contained by the proxy for the 30 | /// last page, and then moves to that page. 31 | public func moveToLast() { 32 | interactionProxy?.moveTo(.infinity) 33 | } 34 | 35 | internal var interactionProxy: InteractionProxy? = nil 36 | 37 | internal init() { } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/API/PageViewReader.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | /// A view that provides programmatic paging, by working with a proxy 10 | /// to move to child pages. 11 | public struct PageViewReader: View 12 | where Content : View 13 | { 14 | 15 | /// Creates an instance that can perform programmatic paging of its 16 | /// child page views. 17 | /// 18 | /// - Parameters: 19 | /// - content: The reader's content, containing a page view. 20 | public init(@ViewBuilder content: @escaping (PageViewProxy) -> Content) { 21 | self.content = content 22 | } 23 | 24 | public var body: some View { 25 | content(proxy) 26 | .onPreferenceChange(InteractionProxyKey.self) { proxy.interactionProxy = $0 } 27 | .transformPreference(InteractionProxyKey.self) { $0 = InteractionProxyKey.defaultValue } 28 | } 29 | 30 | @State private var proxy = PageViewProxy() 31 | private var content: (PageViewProxy) -> Content 32 | } 33 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/API/VPageView.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | /// A view that arranges its children in a vertical line, and provides 10 | /// paged scrolling behaviour. 11 | public struct VPageView: View 12 | where Content : View 13 | { 14 | public var body: PageView 15 | 16 | /// A view that arranges its children in a vertical line, and provides 17 | /// paged scrolling behaviour. 18 | /// 19 | /// This view returns a flexible preferred size to its parent layout. 20 | /// 21 | /// - Parameters: 22 | /// - alignment: The guide for aligning the pages in this page view. 23 | /// - pageHeight: The height of each page, or `nil` if you want each 24 | /// page to fill the height of the page view. 25 | /// - spacing: The distance between adjacent pages, or `nil` if you 26 | /// want the page view to choose a default distance for each pair of 27 | /// pages. 28 | /// - content: A view builder that creates the content of this page view. 29 | public init(alignment: PageAlignment = .center, 30 | pageHeight: CGFloat? = nil, 31 | spacing: CGFloat? = nil, 32 | @ViewBuilder content: @escaping () -> Content) 33 | { 34 | body = PageView(alignment: alignment.alignment, 35 | axis: .vertical, 36 | content: content, 37 | pageLength: pageHeight, 38 | spacing: spacing) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/API/VerticalPageAlignment.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | /// An alignment position along the vertical axis. 8 | public enum VerticalPageAlignment: CaseIterable, Hashable { 9 | 10 | /// A guide marking the top edge of the page. 11 | case top 12 | 13 | /// A guide marking the vertical center of the page. 14 | case center 15 | 16 | /// A guide marking the bottom edge of the page. 17 | case bottom 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/API/View+.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | public extension View { 10 | 11 | /// Adds a condition that controls whether page views always use 12 | /// their provided alignment to position pages. 13 | /// 14 | /// - Parameters: 15 | /// - strict: A Boolean value that determines whether page 16 | /// views always use their provided alignment to position pages. 17 | func strictPageAlignment(_ strict: Bool = true) -> some View { 18 | environment(\.strictPageAlignment, strict) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/Animation+.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal extension Animation { 10 | static func dragEnded(distance: CGFloat, velocity: CGFloat, viewLength: CGFloat) -> Animation { 11 | let mass: CGFloat = 1 12 | let stiffness: CGFloat = 250 13 | let dampingRatio: CGFloat = 1 14 | 15 | let damping = 2 * dampingRatio * sqrt(mass * stiffness) 16 | let initialVelocity: CGFloat 17 | 18 | if abs(velocity) < .velocityThreshold { 19 | initialVelocity = sqrt(abs(distance * 200 / viewLength)) 20 | } else if distance == 0 { 21 | initialVelocity = 0 22 | } else { 23 | initialVelocity = velocity / distance 24 | } 25 | 26 | return .interpolatingSpring(mass: mass, 27 | stiffness: stiffness, 28 | damping: damping, 29 | initialVelocity: initialVelocity) 30 | } 31 | 32 | static let dragStarted = Animation.linear(duration: 0) 33 | } 34 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/AnimationState.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal class AnimationState: ObservableObject { 10 | var dragAnimation: Animation? = nil 11 | var viewAnimation: Animation? = nil 12 | var viewAnimationCanUpdate = true 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/CGFloat+.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal extension CGFloat { 10 | static let velocityThreshold: CGFloat = 200 11 | 12 | func rubberBand(viewLength: CGFloat) -> CGFloat { 13 | (1 - (1 / ((magnitude * 0.55 / viewLength) + 1))) * viewLength * self / magnitude 14 | } 15 | func invertRubberBand(viewLength: CGFloat) -> CGFloat { 16 | (((1 / (1 - (magnitude / viewLength))) - 1) * viewLength / 0.55) * self / magnitude 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/DragAnimator.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal struct DragAnimator: AnimatableModifier { 10 | @State private var workItem: DispatchWorkItem? = nil 11 | 12 | var animatableData: CGFloat 13 | var computedOffset: CGFloat 14 | var pageState: PageState 15 | 16 | init(computedOffset: CGFloat, pageState: PageState) { 17 | self.animatableData = computedOffset 18 | self.computedOffset = computedOffset 19 | self.pageState = pageState 20 | } 21 | 22 | func body(content: Content) -> some View { 23 | content 24 | .onChange(of: animatableData) { animatableData in 25 | if workItem != nil { 26 | workItem?.cancel() 27 | workItem = nil 28 | } 29 | 30 | switch pageState.dragState { 31 | case .dragging, .ended: 32 | return 33 | 34 | case .ending, .nearlyEnded: 35 | pageState.offset = animatableData 36 | 37 | if animatableData == computedOffset { 38 | let computedOffset = computedOffset 39 | let workItem = DispatchWorkItem { 40 | if computedOffset == self.computedOffset { 41 | pageState.dragState = .ended 42 | } 43 | } 44 | 45 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) 46 | self.workItem = workItem 47 | } 48 | else if abs(animatableData - computedOffset) < 2.5 { 49 | if pageState.dragState != .nearlyEnded { 50 | pageState.dragState = .nearlyEnded 51 | } 52 | } 53 | else { 54 | if pageState.dragState != .ending { 55 | pageState.dragState = .ending 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/DragGesture+.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal extension DragGesture.Value { 10 | var velocity: CGSize { 11 | CGSize(width: (predictedEndTranslation.width - translation.width) * 4, 12 | height: (predictedEndTranslation.height - translation.height) * 4) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/EnvironmentValues+.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal extension EnvironmentValues { 10 | var strictPageAlignment: Bool { 11 | get { self[StrictPageAlignmentKey.self] } 12 | set { self[StrictPageAlignmentKey.self] = newValue } 13 | } 14 | } 15 | 16 | 17 | private struct StrictPageAlignmentKey: EnvironmentKey { 18 | static let defaultValue = false 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/HorizontalPageAlignment+.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal extension HorizontalPageAlignment { 10 | var alignment: HorizontalAlignment { 11 | switch self { 12 | case .leading: return .leading 13 | case .center: return .center 14 | case .trailing: return .trailing 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/InteractionProxy.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal struct InteractionProxy: Equatable { 10 | let id: UUID 11 | let moveTo: (CGFloat) -> Void 12 | 13 | static func == (lhs: InteractionProxy, rhs: InteractionProxy) -> Bool { 14 | lhs.id == rhs.id 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/InteractionProxyKey.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal struct InteractionProxyKey: PreferenceKey { 10 | static let defaultValue: InteractionProxy? = nil 11 | 12 | static func reduce(value: inout InteractionProxy?, nextValue: () -> InteractionProxy?) { 13 | value = nextValue() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/PageAlignment+.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal extension PageAlignment { 10 | var alignment: Alignment { 11 | if let horizontal = horizontal as? HorizontalAlignment, 12 | let vertical = vertical as? VerticalPageAlignment 13 | { 14 | return Alignment(horizontal: horizontal, vertical: vertical.alignment) 15 | } 16 | else if let horizontal = horizontal as? HorizontalPageAlignment, 17 | let vertical = vertical as? VerticalAlignment 18 | { 19 | return Alignment(horizontal: horizontal.alignment, vertical: vertical) 20 | } 21 | else if let horizontal = horizontal as? HorizontalPageAlignment, 22 | let vertical = vertical as? VerticalPageAlignment 23 | { 24 | return Alignment(horizontal: horizontal.alignment, vertical: vertical.alignment) 25 | } 26 | else 27 | { 28 | fatalError("PageAlignment cannot compute Alignment.") 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/PageGestureView.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal struct PageGestureView: View 10 | where Content : View 11 | { 12 | @Environment(\.strictPageAlignment) private var strictPageAlignment 13 | @GestureState private var isDragging = false 14 | @StateObject private var animationState = AnimationState() 15 | @StateObject private var pageState = PageState() 16 | 17 | var alignment: Alignment 18 | var axis: Axis 19 | var baseOffset: CGFloat 20 | var content: () -> Content 21 | var pageLength: CGFloat 22 | var spacing: CGFloat 23 | var viewLength: CGFloat 24 | 25 | var body: some View { 26 | PageLayoutView(alignment: alignment, 27 | animationState: animationState, 28 | axis: axis, 29 | content: content, 30 | pageLength: pageLength, 31 | pageState: pageState, 32 | spacing: spacing, 33 | viewLength: viewLength) 34 | .modifier(DragAnimator(computedOffset: computedOffset, pageState: pageState)) 35 | .offset(offset) 36 | .contentShape(Rectangle()) 37 | .highPriorityGesture(gesture) 38 | .preference(key: InteractionProxyKey.self, value: InteractionProxy(id: pageState.id, moveTo: pageTo)) 39 | .onChange(of: isCancelled, perform: onDragCancelled) 40 | } 41 | 42 | private var computedOffset: CGFloat { 43 | let computed = min(max(indexToOffset(pageState.index), offsetRange.lowerBound), offsetRange.upperBound) 44 | + indexToOffset(pageState.indexOffset) 45 | 46 | if computed > offsetRange.upperBound { 47 | return (computed - offsetRange.upperBound).rubberBand(viewLength: viewLength) + offsetRange.upperBound 48 | } else if computed < offsetRange.lowerBound { 49 | return (computed - offsetRange.lowerBound).rubberBand(viewLength: viewLength) + offsetRange.lowerBound 50 | } else { 51 | return computed 52 | } 53 | } 54 | private var gesture: some Gesture { 55 | let minimumDistance: CGFloat 56 | 57 | switch pageState.dragState { 58 | case .dragging, .nearlyEnded, .ended: minimumDistance = 15 59 | case .ending: minimumDistance = 0 60 | } 61 | 62 | return DragGesture(minimumDistance: minimumDistance) 63 | .onChanged(onDragChanged) 64 | .onEnded(onDragEnded) 65 | .updating($isDragging) { _, s, _ in s = true } 66 | } 67 | private var indexRange: ClosedRange { 68 | offsetToIndex(offsetRange.upperBound)...offsetToIndex(offsetRange.lowerBound) 69 | } 70 | private var isCancelled: Bool { 71 | switch pageState.dragState { 72 | case .dragging: return !isDragging 73 | case .ending, .nearlyEnded, .ended: return false 74 | } 75 | } 76 | private var offset: CGSize { 77 | switch axis { 78 | case .horizontal: return CGSize(width: baseOffset + computedOffset, height: 0) 79 | case .vertical: return CGSize(width: 0, height: baseOffset + computedOffset) 80 | } 81 | } 82 | private var offsetRange: ClosedRange { 83 | guard pageState.viewCount > 1 84 | else { return 0...0 } 85 | 86 | var lowerBound = -(CGFloat(pageState.viewCount - 1) * (pageLength + spacing)) 87 | var upperBound: CGFloat = 0 88 | 89 | if !strictPageAlignment { 90 | upperBound = -baseOffset 91 | lowerBound += (viewLength - pageLength) - baseOffset 92 | } 93 | 94 | return lowerBound...upperBound 95 | } 96 | 97 | private func indexToOffset(_ index: CGFloat) -> CGFloat { 98 | -index * (pageLength + spacing) 99 | } 100 | private func offsetToIndex(_ offset: CGFloat) -> CGFloat { 101 | -offset / (pageLength + spacing) 102 | } 103 | private func onDragChanged(value: DragGesture.Value) { 104 | if let initialIndex = pageState.initialIndex { 105 | onDragUpdated(value: value, initialIndex: initialIndex) 106 | } else { 107 | onDragStarted(value: value) 108 | } 109 | } 110 | private func onDragCancelled(isCancelled: Bool) { 111 | guard isCancelled else { return } 112 | 113 | DispatchQueue.main.async { 114 | guard self.isCancelled else { return } 115 | 116 | let index = min(max(pageState.index, indexRange.lowerBound), indexRange.upperBound) 117 | var newIndex = round(index) 118 | 119 | if newIndex <= indexRange.lowerBound { 120 | newIndex = -.infinity 121 | } else if newIndex >= indexRange.upperBound { 122 | newIndex = .infinity 123 | } 124 | 125 | let distance = min(max(indexToOffset(newIndex), offsetRange.lowerBound), offsetRange.upperBound) - computedOffset 126 | 127 | animationState.dragAnimation = .dragEnded(distance: distance, velocity: 0, viewLength: viewLength) 128 | pageState.dragState = distance == 0 ? .ended : .ending 129 | pageState.initialIndex = nil 130 | 131 | withAnimation(animationState.dragAnimation) { 132 | pageState.index = newIndex 133 | pageState.indexOffset = 0 134 | } 135 | } 136 | } 137 | private func onDragEnded(value: DragGesture.Value) { 138 | let index = min(max(pageState.index + pageState.indexOffset, indexRange.lowerBound), indexRange.upperBound) 139 | var newIndex: CGFloat 140 | let velocity: CGFloat 141 | 142 | switch axis { 143 | case .horizontal: velocity = value.velocity.width 144 | case .vertical: velocity = value.velocity.height 145 | } 146 | 147 | if velocity <= -.velocityThreshold { 148 | newIndex = floor(index + 1) 149 | } else if velocity >= .velocityThreshold { 150 | newIndex = ceil(index - 1) 151 | } else { 152 | newIndex = round(index) 153 | } 154 | 155 | if newIndex <= indexRange.lowerBound { 156 | newIndex = -.infinity 157 | } else if newIndex >= indexRange.upperBound { 158 | newIndex = .infinity 159 | } 160 | 161 | let distance = min(max(indexToOffset(newIndex), offsetRange.lowerBound), offsetRange.upperBound) - computedOffset 162 | 163 | animationState.dragAnimation = .dragEnded(distance: distance, velocity: velocity, viewLength: viewLength) 164 | pageState.dragState = distance == 0 ? .ended : .ending 165 | pageState.initialIndex = nil 166 | 167 | withAnimation(animationState.dragAnimation) { 168 | pageState.index = newIndex 169 | pageState.indexOffset = 0 170 | } 171 | } 172 | private func onDragStarted(value: DragGesture.Value) { 173 | let additionalOffset: CGFloat 174 | let initialOffset: CGFloat 175 | var offset: CGFloat 176 | 177 | switch axis { 178 | case .horizontal: additionalOffset = value.translation.width 179 | case .vertical: additionalOffset = value.translation.height 180 | } 181 | 182 | switch pageState.dragState { 183 | case .dragging, .nearlyEnded, .ended: offset = computedOffset 184 | case .ending: offset = pageState.offset 185 | } 186 | 187 | if offset < offsetRange.lowerBound { 188 | initialOffset = additionalOffset - (offset - offsetRange.lowerBound).invertRubberBand(viewLength: viewLength) 189 | offset = offsetRange.lowerBound 190 | } else if offset > offsetRange.upperBound { 191 | initialOffset = additionalOffset - (offset - offsetRange.upperBound).invertRubberBand(viewLength: viewLength) 192 | offset = offsetRange.upperBound 193 | } else { 194 | initialOffset = additionalOffset 195 | } 196 | 197 | animationState.dragAnimation = .dragStarted 198 | pageState.dragState = .dragging 199 | pageState.initialIndex = offsetToIndex(initialOffset) 200 | 201 | withAnimation(animationState.dragAnimation) { 202 | pageState.index = offsetToIndex(offset) 203 | pageState.indexOffset = offsetToIndex(additionalOffset - initialOffset) 204 | } 205 | } 206 | private func onDragUpdated(value: DragGesture.Value, initialIndex: CGFloat) { 207 | let additionalOffset: CGFloat 208 | 209 | switch axis { 210 | case .horizontal: additionalOffset = value.translation.width 211 | case .vertical: additionalOffset = value.translation.height 212 | } 213 | 214 | pageState.indexOffset = offsetToIndex(additionalOffset) - initialIndex 215 | } 216 | private func pageTo(index: CGFloat) { 217 | let newIndex: CGFloat 218 | 219 | if index <= indexRange.lowerBound { 220 | newIndex = -.infinity 221 | } else if index >= indexRange.upperBound { 222 | newIndex = .infinity 223 | } else { 224 | newIndex = index 225 | } 226 | 227 | let distance = min(max(indexToOffset(newIndex), offsetRange.lowerBound), offsetRange.upperBound) - computedOffset 228 | 229 | pageState.dragState = distance == 0 ? .ended : .ending 230 | pageState.index = newIndex 231 | pageState.indexOffset = 0 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/PageLayoutView.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal struct PageLayoutView: View 10 | where Content : View 11 | { 12 | var alignment: Alignment 13 | var animationState: AnimationState 14 | var axis: Axis 15 | var content: () -> Content 16 | var pageLength: CGFloat 17 | var pageState: PageState 18 | var spacing: CGFloat 19 | var viewLength: CGFloat 20 | 21 | var body: some View { 22 | Group { 23 | switch axis { 24 | case .horizontal: 25 | HStack(alignment: alignment.vertical, spacing: spacing) { 26 | content() 27 | .frame(width: pageLength) 28 | } 29 | .modifier(ViewCounter(animationState: animationState, 30 | axis: axis, 31 | pageLength: pageLength, 32 | pageState: pageState, 33 | spacing: spacing)) 34 | .frame(width: viewLength, alignment: .leading) 35 | 36 | case .vertical: 37 | VStack(alignment: alignment.horizontal, spacing: spacing) { 38 | content() 39 | .frame(height: pageLength) 40 | } 41 | .modifier(ViewCounter(animationState: animationState, 42 | axis: axis, 43 | pageLength: pageLength, 44 | pageState: pageState, 45 | spacing: spacing)) 46 | .frame(height: viewLength, alignment: .top) 47 | } 48 | } 49 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/PageState.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal class PageState: ObservableObject { 10 | @Published var dragState = DragState.ended 11 | @Published var index: CGFloat = 0 12 | @Published var indexOffset: CGFloat = 0 13 | @Published var initialIndex: CGFloat? = nil 14 | var offset: CGFloat = 0 15 | @Published var viewCount = 0 16 | 17 | let id = UUID() 18 | 19 | enum DragState { 20 | case dragging 21 | case ending 22 | case nearlyEnded 23 | case ended 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/PageView+.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal extension PageView { 10 | func baseOffset(pageLength: CGFloat, viewLength: CGFloat) -> CGFloat { 11 | switch axis { 12 | case .horizontal: 13 | switch alignment.horizontal { 14 | case .leading: return 0 15 | case .center: return (viewLength - pageLength) / 2 16 | case .trailing: return (viewLength - pageLength) 17 | default: fatalError("Unexpected HorizontalAlignment.") 18 | } 19 | 20 | case .vertical: 21 | switch alignment.vertical { 22 | case .top: return 0 23 | case .center: return (viewLength - pageLength) / 2 24 | case .bottom: return (viewLength - pageLength) 25 | default: fatalError("Unexpected VerticalAlignment.") 26 | } 27 | } 28 | } 29 | func pageLength(viewLength: CGFloat) -> CGFloat { 30 | max(round((pageLength ?? viewLength) * displayScale) / displayScale, 0) 31 | } 32 | func viewLength(for geometry: GeometryProxy) -> CGFloat { 33 | switch axis { 34 | case .horizontal: return geometry.size.width 35 | case .vertical: return geometry.size.height 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/VerticalPageAlignment+.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal extension VerticalPageAlignment { 10 | var alignment: VerticalAlignment { 11 | switch self { 12 | case .top: return .top 13 | case .center: return .center 14 | case .bottom: return .bottom 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftUIPageView/Internal/ViewCounter.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * SwiftUIPageView 3 | * Copyright (c) Ciaran O'Brien 2022 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import SwiftUI 8 | 9 | internal struct ViewCounter: ViewModifier { 10 | var animationState: AnimationState 11 | var axis: Axis 12 | var pageLength: CGFloat 13 | var pageState: PageState 14 | var spacing: CGFloat 15 | 16 | func body(content: Content) -> some View { 17 | content 18 | .transaction { onAnimation($0.animation) } 19 | .background( 20 | GeometryReader { geometry in 21 | let state = ViewState(pageLength: pageLength, size: geometry.size, spacing: spacing) 22 | 23 | Color.clear 24 | .onAppear { onState(state: state) } 25 | .onChange(of: state, perform: onState) 26 | } 27 | .hidden() 28 | ) 29 | } 30 | 31 | private func onAnimation(_ animation: Animation?) { 32 | if animation != animationState.dragAnimation && animationState.viewAnimationCanUpdate { 33 | animationState.viewAnimation = animation 34 | animationState.viewAnimationCanUpdate = false 35 | 36 | DispatchQueue.main.async { 37 | animationState.viewAnimationCanUpdate = true 38 | } 39 | } 40 | } 41 | private func onState(state: ViewState) { 42 | let count: Int 43 | let itemLength = state.pageLength + state.spacing 44 | 45 | if itemLength > 0 { 46 | let stackLength: CGFloat 47 | 48 | switch axis { 49 | case .horizontal: stackLength = state.size.width 50 | case .vertical: stackLength = state.size.height 51 | } 52 | 53 | count = Int(max(round((stackLength + state.spacing) / itemLength), 0)) 54 | } else { 55 | count = 0 56 | } 57 | 58 | if pageState.viewCount != count { 59 | withAnimation(animationState.viewAnimation) { 60 | pageState.viewCount = count 61 | } 62 | } 63 | } 64 | 65 | private struct ViewState: Equatable { 66 | var pageLength: CGFloat 67 | var size: CGSize 68 | var spacing: CGFloat 69 | } 70 | } 71 | --------------------------------------------------------------------------------