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