├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Package.swift
├── README.md
├── Resources
└── Demo.gif
└── Sources
└── SwiftUISnappingScrollView
├── API
├── ScrollDecelerationRate.swift
├── SnappingScrollView.swift
└── View+.swift
└── Internal
├── AnchorsKey.swift
├── Axis+.swift
├── EdgeInsets+.swift
├── OnUpdate.swift
├── OnVisibleModifier.swift
├── ScrollDecelerationRate+.swift
├── ScrollViewFrameKey.swift
├── SnappingScrollViewDelegate.swift
├── UIScrollViewBridge.swift
└── View+Internal.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 | /**
4 | * SwiftUISnappingScrollView
5 | * Copyright (c) Ciaran O'Brien 2022
6 | * MIT license, see LICENSE file for details
7 | */
8 |
9 | import PackageDescription
10 |
11 | let package = Package(
12 | name: "SwiftUISnappingScrollView",
13 | platforms: [
14 | .iOS(.v13)
15 | // .macCatalyst(.v14),
16 | // .macOS(.v11),
17 | // .tvOS(.v14),
18 | // .watchOS(.v7)
19 | ],
20 | products: [
21 | .library(
22 | name: "SwiftUISnappingScrollView",
23 | targets: ["SwiftUISnappingScrollView"]),
24 | ],
25 | dependencies: [],
26 | targets: [
27 | .target(
28 | name: "SwiftUISnappingScrollView",
29 | dependencies: [])
30 | ]
31 | )
32 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SwiftUI Snapping ScrollView
2 |
3 | Add snapping behaviour to a SwiftUI ScrollView.
4 |
5 | > [!WARNING]
6 | > This package is no longer maintained. Use [scrollTargetBehaviour](https://developer.apple.com/documentation/swiftui/view/scrolltargetbehavior(_:)) instead.
7 |
8 | ## SnappingScrollView
9 |
10 | A scrollable view that supports snapping.
11 |
12 | ### Usage
13 | ```swift
14 | SnappingScrollView(.vertical) {
15 | //Header view
16 | .snappingScrollAnchor(.bounds)
17 |
18 | //Content views
19 | }
20 | ```
21 |
22 | ### Parameters
23 | * `axis`: The scroll view's scrollable axis. The default axis is the vertical axis.
24 | * `decelerationRate`: A floating-point value that determines the rate of deceleration after the user ends dragging. The default value for this parameter is `.normal`.
25 | * `showsIndicators`: A Boolean value that indicates whether the scroll view displays the scrollable component of the content offset, in a way suitable for the platform. The default value for this parameter is `true`.
26 | * `content`: The view builder that creates the scrollable view.
27 |
28 | ## snappingScrollAnchor
29 |
30 | A function that sets the scroll snapping anchor rect for a view.
31 |
32 | Avoid setting the scroll snapping anchor rect on a child of a lazy view, such as a `LazyHGrid`, `LazyVGrid`, `LazyHStack` or `LazyVStack`.
33 |
34 | ### Parameters
35 | * `source`: The source of the anchor rect.
36 |
37 | ## Advanced Usage
38 |
39 | `SnappingScrollView` can provide paging behaviour when initialised with a `decelerationRate` of `.fast`. Add the pages to a stack view with `spacing` set to 0. Any desired spacing should be added as padding to the edge of each page view:
40 |
41 | ```swift
42 | SnappingScrollView(.horizontal, decelerationRate: .fast) {
43 | HStack(spacing: 0) {
44 | ForEach(...) {
45 | //Page view
46 | .padding(.trailing)
47 | .snappingScrollAnchor(.bounds)
48 | }
49 | }
50 | }
51 | ```
52 |
53 | `snappingScrollAnchor` should not be used on a child of a lazy view. Instead, use non-lazy parent views, and use `onVisible` to load data or update subviews when the view's bounds move into the visible frame. Provide `padding` to increase the distance from the visible frame that the `action` is called.
54 |
55 | ## Requirements
56 |
57 | * iOS 14.0+
58 | * Xcode 12.0+
59 |
60 | ## Installation
61 |
62 | * Install with [Swift Package Manager](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app).
63 | * Import `SwiftUISnappingScrollView` to start using.
64 |
65 | ## Contact
66 |
67 | [@ciaranrobrien](https://twitter.com/ciaranrobrien) on Twitter.
68 |
69 |
--------------------------------------------------------------------------------
/Resources/Demo.gif:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/Sources/SwiftUISnappingScrollView/API/ScrollDecelerationRate.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUISnappingScrollView
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | /// A floating-point value that determines the rate of deceleration after
8 | /// the user ends dragging in a scrolling view.
9 | public enum ScrollDecelerationRate: Hashable, CaseIterable {
10 |
11 | /// The default deceleration rate.
12 | case normal
13 |
14 | /// A fast deceleration rate.
15 | case fast
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/SwiftUISnappingScrollView/API/SnappingScrollView.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUISnappingScrollView
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | public struct SnappingScrollView: View
10 | where Content : View
11 | {
12 | public var body: some View {
13 | ScrollView(axis.set, showsIndicators: showsIndicators) {
14 | Group {
15 | switch axis {
16 | case .horizontal:
17 | HStack(content: content)
18 | case .vertical:
19 | VStack(content: content)
20 | }
21 | }
22 | .environment(\.scrollViewFrame, frame)
23 | .backgroundPreferenceValue(AnchorsKey.self) { anchors in
24 | GeometryReader { geometry in
25 | let frames = anchors.map { geometry[$0] }
26 |
27 | Color.clear
28 | .onAppear { delegate.frames = frames }
29 | .onUpdate(of: frames) { delegate.frames = $0 }
30 | }
31 | .hidden()
32 | }
33 | .transformPreference(AnchorsKey.self) { $0 = AnchorsKey.defaultValue }
34 | .background(UIScrollViewBridge(decelerationRate: decelerationRate.rate, delegate: delegate))
35 | }
36 | .background(
37 | GeometryReader { geometry in
38 | Color.clear
39 | .onAppear {
40 | DispatchQueue.main.async {
41 | if frame == nil {
42 | frame = geometry.frame(in: .global)
43 | }
44 | }
45 | }
46 | .onUpdate(of: geometry.frame(in: .global)) { frame = $0 }
47 | }
48 | .ignoresSafeArea()
49 | .hidden()
50 | )
51 | }
52 |
53 | @StateObject private var delegate = SnappingScrollViewDelegate()
54 | @State private var frame: CGRect? = nil
55 |
56 | private var axis: Axis
57 | private var content: () -> Content
58 | private var decelerationRate: ScrollDecelerationRate
59 | private var showsIndicators: Bool
60 | }
61 |
62 |
63 | public extension SnappingScrollView {
64 |
65 | /// Creates a new instance that's scrollable in the direction of the given
66 | /// axis and can show indicators while scrolling.
67 | ///
68 | /// - Parameters:
69 | /// - axis: The scroll view's scrollable axis. The default axis is the
70 | /// vertical axis.
71 | /// - decelerationRate: A floating-point value that determines the rate
72 | /// of deceleration after the user ends dragging. The default value for this
73 | /// parameter is `.normal`.
74 | /// - showsIndicators: A Boolean value that indicates whether the scroll
75 | /// view displays the scrollable component of the content offset, in a way
76 | /// suitable for the platform. The default value for this parameter is
77 | /// `true`.
78 | /// - content: The view builder that creates the scrollable view.
79 | init(_ axis: Axis = .vertical,
80 | decelerationRate: ScrollDecelerationRate = .normal,
81 | showsIndicators: Bool = true,
82 | @ViewBuilder content: @escaping () -> Content)
83 | {
84 | self.axis = axis
85 | self.content = content
86 | self.decelerationRate = decelerationRate
87 | self.showsIndicators = showsIndicators
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/Sources/SwiftUISnappingScrollView/API/View+.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUISnappingScrollView
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 | /// Sets the scroll snapping anchor rect for this view.
12 | ///
13 | /// A parent `SnappingScrollView` will prefer to end scrolling
14 | /// outside, or on an edge, of the anchor rect defined by the source.
15 | ///
16 | /// Avoid setting the scroll snapping anchor rect on a child of a lazy
17 | /// view, such as a `LazyHGrid`, `LazyVGrid`, `LazyHStack`
18 | /// or `LazyVStack`.
19 | ///
20 | /// - Parameters:
21 | /// - source: The source of the anchor rect.
22 | func scrollSnappingAnchor(_ source: Anchor.Source?) -> some View {
23 | anchorPreference(key: AnchorsKey.self, value: source ?? .bounds) {
24 | source != nil ? [$0] : []
25 | }
26 | }
27 | }
28 |
29 |
30 | public extension View {
31 |
32 | /// Adds an action to perform when this view's bounds moves into or
33 | /// out of the visible frame.
34 | ///
35 | /// You can use `onVisible` when the view is a child of a
36 | /// `SnappingScrollView`.
37 | ///
38 | /// `onVisible` is called on the main thread. Avoid performing
39 | /// long-running tasks on the main thread. If you need to perform a
40 | /// long-running task in response to the visibility changing, you should
41 | /// dispatch to a background queue.
42 | ///
43 | /// - Parameters:
44 | /// - padding: The padding around all edges of the view's bounds.
45 | /// The default value for this parameter is `0`.
46 | /// - action: A closure to run when the visibility changes.
47 | /// - isVisible: The view's visibility.
48 | @inlinable func onVisible(padding length: CGFloat = 0, action: @escaping (_ isVisible: Bool) -> Void) -> some View {
49 | onVisible(padding: EdgeInsets(top: length, leading: length, bottom: length, trailing: length),
50 | action: action)
51 | }
52 |
53 | /// Adds an action to perform when this view's bounds moves into or
54 | /// out of the visible frame.
55 | ///
56 | /// You can use `onVisible` when the view is a child of a
57 | /// `SnappingScrollView`.
58 | ///
59 | /// `onVisible` is called on the main thread. Avoid performing
60 | /// long-running tasks on the main thread. If you need to perform a
61 | /// long-running task in response to the visibility changing, you should
62 | /// dispatch to a background queue.
63 | ///
64 | /// - Parameters:
65 | /// - padding: The padding around each edge of the view's bounds.
66 | /// - action: A closure to run when the visibility changes.
67 | /// - isVisible: The new value that failed the comparison check.
68 | func onVisible(padding insets: EdgeInsets, action: @escaping (_ isVisible: Bool) -> Void) -> some View {
69 | modifier(OnVisibleModifier(action: action, insets: insets))
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/Sources/SwiftUISnappingScrollView/Internal/AnchorsKey.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUISnappingScrollView
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | internal struct AnchorsKey: PreferenceKey {
10 | static let defaultValue = [Anchor]()
11 |
12 | static func reduce(value: inout [Anchor], nextValue: () -> [Anchor]) {
13 | value.append(contentsOf: nextValue())
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/SwiftUISnappingScrollView/Internal/Axis+.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUISnappingScrollView
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | internal extension Axis {
10 | var set: Axis.Set {
11 | switch self {
12 | case .horizontal: return .horizontal
13 | case .vertical: return .vertical
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/SwiftUISnappingScrollView/Internal/EdgeInsets+.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUISnappingScrollView
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | internal extension EdgeInsets {
10 | func negated() -> EdgeInsets {
11 | var insets = self
12 | insets.negate()
13 | return insets
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/SwiftUISnappingScrollView/Internal/OnUpdate.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUISnappingScrollView
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import Combine
8 | import SwiftUI
9 |
10 | internal extension View {
11 | @ViewBuilder @inlinable func onUpdate(of value: V, perform action: @escaping (_ newValue: V) -> Void) -> some View
12 | where V : Equatable
13 | {
14 | if #available(iOS 14.0, *) {
15 | onChange(of: value, perform: action)
16 | } else {
17 | onReceive(Just(value), perform: action)
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/SwiftUISnappingScrollView/Internal/OnVisibleModifier.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUISnappingScrollView
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | internal struct OnVisibleModifier: ViewModifier {
10 | @Environment(\.scrollViewFrame) private var scrollViewFrame
11 | @State private var isVisible: Bool? = nil
12 |
13 | var action: (Bool) -> Void
14 | var insets: EdgeInsets
15 |
16 | func body(content: Content) -> some View {
17 | content
18 | .background(
19 | GeometryReader { geometry in
20 | let isVisible = scrollViewFrame?.intersects(geometry.frame(in: .global))
21 |
22 | Color.clear
23 | .onAppear { onVisible(isVisible) }
24 | .onUpdate(of: isVisible, perform: onVisible)
25 | }
26 | .padding(insets.negated())
27 | .hidden()
28 | )
29 | }
30 |
31 | private func onVisible(_ newValue: Bool?) {
32 | if let newValue = newValue, isVisible != newValue {
33 | isVisible = newValue
34 | action(newValue)
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Sources/SwiftUISnappingScrollView/Internal/ScrollDecelerationRate+.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUISnappingScrollView
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | internal extension ScrollDecelerationRate {
10 | var rate: UIScrollView.DecelerationRate {
11 | switch self {
12 | case .normal: return .normal
13 | case .fast: return .fast
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/SwiftUISnappingScrollView/Internal/ScrollViewFrameKey.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUISnappingScrollView
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 scrollViewFrame: CGRect? {
11 | get { self[ScrollViewFrameKey.self] }
12 | set { self[ScrollViewFrameKey.self] = newValue }
13 | }
14 | }
15 |
16 | private struct ScrollViewFrameKey: EnvironmentKey {
17 | static let defaultValue: CGRect? = nil
18 | }
19 |
--------------------------------------------------------------------------------
/Sources/SwiftUISnappingScrollView/Internal/SnappingScrollViewDelegate.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUISnappingScrollView
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | internal class SnappingScrollViewDelegate: NSObject, ObservableObject, UIScrollViewDelegate {
10 | var frames = [CGRect]()
11 |
12 | private var naturalInset: UIEdgeInsets? = nil
13 |
14 | func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) {
15 | let initialOffset = scrollView.contentOffset
16 | scrollView.setContentOffset(.zero, animated: false)
17 |
18 | naturalInset = scrollView.contentInset
19 | scrollView.setContentOffset(initialOffset, animated: false)
20 | }
21 |
22 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
23 | if naturalInset == nil {
24 | scrollViewDidChangeAdjustedContentInset(scrollView)
25 | }
26 | }
27 |
28 | func scrollViewWillEndDragging(_ scrollView: UIScrollView,
29 | withVelocity velocity: CGPoint,
30 | targetContentOffset: UnsafeMutablePointer)
31 | {
32 | //Prevent large navigation title from interfering with target offset
33 | if (targetContentOffset.pointee.y <= -naturalInset!.top && scrollView.alwaysBounceVertical) {
34 | return
35 | }
36 |
37 | var targetOffset = targetContentOffset.pointee
38 |
39 | let minX = -scrollView.contentInset.left
40 | let maxX = scrollView.contentSize.width + scrollView.contentInset.right - scrollView.frame.width
41 | let minY = -scrollView.contentInset.top
42 | let maxY = scrollView.contentSize.height + scrollView.contentInset.bottom - scrollView.frame.height
43 |
44 | let localFrames = frames.map { $0.offsetBy(dx: minX, dy: minY) }
45 |
46 | targetOffset.x = localFrames
47 | .reduce([PointRange(start: minX, end: maxX)]) { values, frame in
48 | values
49 | .flatMap {
50 | $0.excluding(PointRange(start: max(frame.minX, minX), end: min(frame.maxX, maxX)))
51 | }
52 | .reduce([]) {
53 | $0.contains($1) ? $0 : $0 + [$1]
54 | }
55 | }
56 | .sorted { $0.distance(to: targetOffset.x) < $1.distance(to: targetOffset.x) }
57 | .first?
58 | .resolving(targetOffset.y) ?? minX
59 |
60 | targetOffset.y = localFrames
61 | .reduce([PointRange(start: minY, end: maxY)]) { values, frame in
62 | values
63 | .flatMap {
64 | $0.excluding(PointRange(start: max(frame.minY, minY), end: min(frame.maxY, maxY)))
65 | }
66 | .reduce([]) {
67 | $0.contains($1) ? $0 : $0 + [$1]
68 | }
69 | }
70 | .sorted { $0.distance(to: targetOffset.y) < $1.distance(to: targetOffset.y) }
71 | .first?
72 | .resolving(targetOffset.y) ?? minY
73 |
74 | if (scrollView.contentOffset.x > targetOffset.x && velocity.x > 0)
75 | || (scrollView.contentOffset.x < targetOffset.x && velocity.x < 0)
76 | || (scrollView.contentOffset.y > targetOffset.y && velocity.y > 0)
77 | || (scrollView.contentOffset.y < targetOffset.y && velocity.y < 0)
78 | {
79 | //Fixes immediate jump to target offset
80 | targetContentOffset.pointee = scrollView.contentOffset
81 | scrollView.setContentOffset(targetOffset, animated: true)
82 | } else {
83 | targetContentOffset.pointee = targetOffset
84 | }
85 | }
86 | }
87 |
88 |
89 | private struct PointRange: Hashable {
90 | let start: CGFloat
91 | let end: CGFloat
92 |
93 | private func contains(_ point: CGFloat) -> Bool {
94 | (start...end).contains(point)
95 | }
96 |
97 | func distance(to point: CGFloat) -> CGFloat {
98 | if contains(point) {
99 | return 0
100 | } else {
101 | return min(abs(start - point), abs(end - point))
102 | }
103 | }
104 |
105 | func excluding(_ other: PointRange) -> [PointRange] {
106 | if other.start < start {
107 | if other.end <= end {
108 | if other.end <= start {
109 | return [self]
110 | } else {
111 | return [PointRange(start: other.end, end: end)]
112 | }
113 | } else {
114 | return []
115 | }
116 | } else {
117 | if other.end <= end {
118 | return [PointRange(start: start, end: other.start),
119 | PointRange(start: other.end, end: end)]
120 | } else {
121 | if other.start > end {
122 | return [self]
123 | } else {
124 | return [PointRange(start: start, end: other.start)]
125 | }
126 | }
127 | }
128 | }
129 |
130 | func resolving(_ point: CGFloat) -> CGFloat {
131 | if contains(point) {
132 | return point
133 | } else {
134 | return abs(start - point) < abs(end - point) ? start : end
135 | }
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/Sources/SwiftUISnappingScrollView/Internal/UIScrollViewBridge.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUISnappingScrollView
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | internal struct UIScrollViewBridge: UIViewRepresentable {
10 | var decelerationRate: UIScrollView.DecelerationRate
11 | var delegate: UIScrollViewDelegate
12 |
13 | func makeUIView(context: Context) -> UIView {
14 | let view = UIView()
15 | view.isHidden = true
16 | view.isUserInteractionEnabled = false
17 | return view
18 | }
19 |
20 | func updateUIView(_ uiView: UIView, context: Context) {
21 | DispatchQueue.main.async {
22 | if let scrollView = uiView.parentScrollView() {
23 | scrollView.decelerationRate = decelerationRate
24 | scrollView.delegate = delegate
25 |
26 | //Prevent SwiftUI from reverting deceleration rate
27 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
28 | scrollView.decelerationRate = decelerationRate
29 | }
30 | }
31 | }
32 | }
33 | }
34 |
35 |
36 | private extension UIView {
37 | func parentScrollView() -> UIScrollView? {
38 | if let scrollView = self as? UIScrollView {
39 | return scrollView
40 | }
41 |
42 | if let superview = superview {
43 | for subview in superview.subviews {
44 | if subview != self, let scrollView = subview as? UIScrollView {
45 | return scrollView
46 | }
47 | }
48 |
49 | if let scrollView = superview.parentScrollView() {
50 | return scrollView
51 | }
52 | }
53 |
54 | return nil
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Sources/SwiftUISnappingScrollView/Internal/View+Internal.swift:
--------------------------------------------------------------------------------
1 | /**
2 | * SwiftUISnappingScrollView
3 | * Copyright (c) Ciaran O'Brien 2022
4 | * MIT license, see LICENSE file for details
5 | */
6 |
7 | import SwiftUI
8 |
9 | internal extension View {
10 | @ViewBuilder @inlinable func ignoresSafeArea() -> some View {
11 | if #available(iOS 14.0, *) {
12 | ignoresSafeArea(.all, edges: .all)
13 | } else {
14 | edgesIgnoringSafeArea(.all)
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------