├── .github ├── FUNDING.yml └── workflows │ └── cocoapods.yml ├── .gitignore ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── ScrollViewProxy.podspec └── Sources └── ScrollViewProxy └── ScrollViewProxy.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Amzd] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/cocoapods.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Cocoapods release 3 | on: 4 | push: 5 | paths: 6 | "ScrollViewProxy.podspec" 7 | 8 | jobs: 9 | build: 10 | runs-on: macOS-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Publish to CocoaPod register 15 | env: 16 | COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} 17 | run: | 18 | pod trunk push ScrollViewProxy.podspec --allow-warnings 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Casper "Amzd" Zandbergen 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.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Introspect", 6 | "repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "36ecf80429d00a4cd1e81fbfe4655b1d99ebd651", 10 | "version": "0.1.2" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ScrollViewProxy", 8 | platforms: [ 9 | .macOS(.v10_13), 10 | .iOS(.v11), 11 | .tvOS(.v11) 12 | ], 13 | products: [ 14 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 15 | .library( 16 | name: "ScrollViewProxy", 17 | targets: ["ScrollViewProxy"] 18 | ), 19 | ], 20 | dependencies: [ 21 | // Dependencies declare other packages that this package depends on. 22 | // .package(url: /* package url */, from: "1.0.0"), 23 | .package(name: "Introspect", url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.2") 24 | ], 25 | targets: [ 26 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 27 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 28 | .target( 29 | name: "ScrollViewProxy", 30 | dependencies: ["Introspect"]), 31 | // .testTarget( 32 | // name: "ScrollViewProxyTests", 33 | // dependencies: ["ScrollViewProxy"]), 34 | ] 35 | ) 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | As of June 22 2020 this is included in the SwiftUI 2 beta. [https://developer.apple.com/documentation/swiftui/scrollviewproxy](https://developer.apple.com/documentation/swiftui/scrollviewproxy) 2 | 3 | The Apple implementation uses just `.id(_:)` and I had update issues with that where Views with an id sometimes won't update when their ObservedObject changed. Maybe this has been fixed in the new SwiftUI 2 beta. 4 | 5 | Also the Apple implementation only supports iOS 14 so I think this repo is still useful for backwards compatibility. 6 | 7 | **Note:** An important difference between this library and Apples implementation is that the ScrollViewReader goes *inside* the ScrollView. If you place the ScrollViewReader around the ScrollView the scrollTo function will not scroll to the correct location (due to its coordinateSpace not being part of the scrolling content). I considered fixing this to align with Apple but that would break backwards compatibility with projects already using ScrollViewProxy. 8 | 9 | Maybe it's possible to detect if we're inside or outside of a ScrollView in the reader but then how do we handle nested ScrollViews? If you have any ideas please open an Issue/PR or email me. 10 | 11 | # ScrollViewProxy 12 | 13 | Adds `ScrollViewReader` and `ScrollViewProxy` that help you scroll to locations in a ScrollView 14 | 15 | 16 | To get a ScrollViewProxy you can either use the conveinience init on ScrollView 17 | 18 | ```swift 19 | ScrollView { proxy in 20 | ... 21 | } 22 | ``` 23 | 24 | or add a ScrollViewReader to any View that creates a UIScrollView under the hood 25 | 26 | ```swift 27 | List { 28 | ScrollViewReader { proxy in 29 | ... 30 | } 31 | } 32 | ``` 33 | 34 | The ScrollViewProxy currently has one variable and two functions you can call 35 | 36 | ```swift 37 | /// A publisher that publishes changes to the scroll views offset 38 | public var offset: OffsetPublisher 39 | 40 | /// Scrolls to an edge or corner 41 | public func scrollTo(_ alignment: Alignment, animated: Bool = true) 42 | 43 | /// Scrolls the view with ID to an edge or corner 44 | public func scrollTo(_ id: ID, alignment: Alignment = .top, animated: Bool = true) 45 | ``` 46 | 47 | To use the scroll to ID function you have to add an ID to the view you want to scroll to 48 | 49 | ```swift 50 | ScrollView { proxy in 51 | HStack { ... } 52 | .scrollId("someId") 53 | } 54 | ``` 55 | *This is the only part that is different from the SwiftUI 2.0 implementation because I don't know how to access Views by ID from the `.id(_:)` function* 56 | 57 | ## Example 58 | 59 | Everything put together in an example 60 | 61 | ```swift 62 | struct ScrollViewProxyExample: View { 63 | 64 | @State var randomInt = Int.random(in: 0..<200) 65 | @State var proxy: ScrollViewProxy? = nil 66 | @State var offset: CGPoint = .zero 67 | 68 | var body: some View { 69 | // GeometryReader for safeAreaInsets on Sticky View 70 | GeometryReader { geometry in 71 | VStack { 72 | ScrollView { proxy in 73 | Text("Sticky View") 74 | .background(Color.white) 75 | .onReceive(proxy.offset) { self.offset = $0 } 76 | .offset(x: offset.x, y: offset.y + geometry.safeAreaInsets.top) 77 | .zIndex(1) 78 | 79 | VStack(spacing: 20) { 80 | ForEach(0..<200) { index in 81 | HStack { 82 | Spacer() 83 | Text("Item: \(index)").font(.title) 84 | Spacer() 85 | }.scrollId(index) 86 | } 87 | } 88 | .zIndex(0) 89 | .onAppear { 90 | self.proxy = proxy 91 | } 92 | } 93 | HStack { 94 | Button(action: { 95 | self.proxy?.scrollTo(self.randomInt, alignment: .center) 96 | self.randomInt = Int.random(in: 0..<200) 97 | }, label: { 98 | Text("Go to \(self.randomInt)") 99 | }) 100 | Spacer() 101 | Button(action: { self.proxy?.scrollTo(.bottom) }, label: { 102 | Text("Bottom") 103 | }) 104 | Spacer() 105 | Button(action: { self.proxy?.scrollTo(.center) }, label: { 106 | Text("Center") 107 | }) 108 | }.padding() 109 | } 110 | } 111 | } 112 | } 113 | ``` 114 | 115 | ## Deintegrate 116 | 117 | Want to drop iOS 13 support and move to the SwiftUI 2.0 version? 118 | 119 | 1. Remove the Package 120 | 2. Add this extension: 121 | 122 | ```swift 123 | extension View { 124 | public func scrollId(_ id: ID) -> some View { 125 | id(id) 126 | } 127 | } 128 | ``` 129 | 130 | (Or replace all .scrollId with .id) 131 | -------------------------------------------------------------------------------- /ScrollViewProxy.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'ScrollViewProxy' 3 | s.version = '1.0.3' 4 | s.summary = 'Helps with scroll to locations in a ScrollView' 5 | s.license = { type: 'MIT' } 6 | s.homepage = 'https://github.com/amzd/ScrollViewProxy' 7 | s.author = { 'Casper Zandbergen' => 'info@casperzandbergen.nl' } 8 | s.source = { :git => 'https://github.com/Amzd/ScrollViewProxy.git', :tag => s.version.to_s } 9 | s.dependency 'Introspect', '>= 0.1.2' 10 | s.source_files = 'Sources/**/*.swift' 11 | 12 | s.swift_version = '5.1' 13 | s.ios.deployment_target = '11.0' 14 | s.tvos.deployment_target = '11.0' 15 | s.osx.deployment_target = '10.13' 16 | end 17 | -------------------------------------------------------------------------------- /Sources/ScrollViewProxy/ScrollViewProxy.swift: -------------------------------------------------------------------------------- 1 | // Created by Casper Zandbergen on 01/06/2020. 2 | // https://twitter.com/amzdme 3 | 4 | import Introspect 5 | import SwiftUI 6 | import Combine 7 | 8 | // MARK: Fix for name collision when using SwiftUI 2.0 9 | 10 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) 11 | public typealias AmzdScrollViewProxy = ScrollViewProxy 12 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) 13 | public typealias AmzdScrollViewReader = ScrollViewReader 14 | 15 | // MARK: Platform specifics 16 | 17 | #if os(macOS) 18 | public typealias PlatformScrollView = NSScrollView 19 | 20 | var visibleSizePath = \PlatformScrollView.documentVisibleRect.size 21 | var adjustedContentInsetPath = \PlatformScrollView.contentInsets 22 | var contentSizePath = \PlatformScrollView.documentView!.frame.size 23 | 24 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) 25 | extension NSScrollView { 26 | func scrollRectToVisible(_ rect: CGRect, animated: Bool) { 27 | if animated { 28 | NSAnimationContext.beginGrouping() 29 | NSAnimationContext.current.duration = 0.3 30 | contentView.scrollToVisible(rect) 31 | NSAnimationContext.endGrouping() 32 | } else { 33 | contentView.scrollToVisible(rect) 34 | } 35 | } 36 | var offsetPublisher: OffsetPublisher { 37 | publisher(for: \.contentView.bounds.origin).eraseToAnyPublisher() 38 | } 39 | } 40 | 41 | extension NSEdgeInsets { 42 | /// top + bottom 43 | var vertical: CGFloat { 44 | return top + bottom 45 | } 46 | /// left + right 47 | var horizontal: CGFloat { 48 | return left + right 49 | } 50 | } 51 | #elseif os(iOS) || os(tvOS) 52 | public typealias PlatformScrollView = UIScrollView 53 | 54 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) 55 | var visibleSizePath = \PlatformScrollView.visibleSize 56 | var adjustedContentInsetPath = \PlatformScrollView.adjustedContentInset 57 | var contentSizePath = \PlatformScrollView.contentSize 58 | 59 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) 60 | extension UIScrollView { 61 | var offsetPublisher: OffsetPublisher { 62 | publisher(for: \.contentOffset).eraseToAnyPublisher() 63 | } 64 | } 65 | 66 | extension UIEdgeInsets { 67 | /// top + bottom 68 | var vertical: CGFloat { 69 | return top + bottom 70 | } 71 | /// left + right 72 | var horizontal: CGFloat { 73 | return left + right 74 | } 75 | } 76 | #endif 77 | 78 | // MARK: Helper extensions 79 | 80 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) 81 | extension ScrollView { 82 | /// Creates a ScrollView with a ScrollViewReader 83 | public init(_ axes: Axis.Set = .vertical, showsIndicators: Bool = true, @ViewBuilder content: @escaping (ScrollViewProxy) -> ProxyContent) where Content == ScrollViewReader { 84 | self.init(axes, showsIndicators: showsIndicators, content: { 85 | ScrollViewReader(content: content) 86 | }) 87 | } 88 | } 89 | 90 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) 91 | extension View { 92 | /// Adds an ID to this view so you can scroll to it with `ScrollViewProxy.scrollTo(_:alignment:animated:)` 93 | public func scrollId(_ id: ID) -> some View { 94 | modifier(ScrollViewProxyPreferenceModifier(id: id)) 95 | } 96 | 97 | @available(swift, obsoleted: 1.0, renamed: "scrollId(_:)") 98 | public func id(_ id: ID, scrollView: ScrollViewProxy) -> some View { self } 99 | } 100 | 101 | // MARK: Preferences 102 | 103 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) 104 | struct ScrollViewProxyPreferenceData: Equatable { 105 | static func == (lhs: Self, rhs: Self) -> Bool { 106 | lhs.id == rhs.id 107 | } 108 | 109 | var geometry: GeometryProxy 110 | var id: AnyHashable 111 | } 112 | 113 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) 114 | struct ScrollViewProxyPreferenceKey: PreferenceKey { 115 | static var defaultValue: [ScrollViewProxyPreferenceData] { [] } 116 | static func reduce(value: inout Value, nextValue: () -> Value) { 117 | value.append(contentsOf: nextValue()) 118 | } 119 | } 120 | 121 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) 122 | struct ScrollViewProxyPreferenceModifier: ViewModifier { 123 | let id: AnyHashable 124 | func body(content: Content) -> some View { 125 | content.background(GeometryReader { geometry in 126 | Color.clear.preference( 127 | key: ScrollViewProxyPreferenceKey.self, 128 | value: [.init(geometry: geometry, id: self.id)] 129 | ) 130 | }) 131 | } 132 | } 133 | 134 | // MARK: ScrollViewReader 135 | 136 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) 137 | public struct ScrollViewReader: View { 138 | private var content: (ScrollViewProxy) -> Content 139 | 140 | @State private var proxy = ScrollViewProxy() 141 | 142 | public init(@ViewBuilder content: @escaping (ScrollViewProxy) -> Content) { 143 | self.content = content 144 | } 145 | 146 | public var body: some View { 147 | content(proxy) 148 | .coordinateSpace(name: proxy.space) 149 | .transformPreference(ScrollViewProxyPreferenceKey.self) { preferences in 150 | preferences.forEach { preference in 151 | self.proxy.save(geometry: preference.geometry, for: preference.id) 152 | } 153 | } 154 | .onPreferenceChange(ScrollViewProxyPreferenceKey.self) { _ in 155 | // seems this will not be called due to ScrollView/Preference issues 156 | // https://stackoverflow.com/a/61765994/3019595 157 | } 158 | .introspectScrollView { 159 | if self.proxy.coordinator.scrollView != $0 { 160 | self.proxy.coordinator.scrollView = $0 161 | self.proxy.offset = $0.offsetPublisher 162 | } 163 | } 164 | } 165 | } 166 | 167 | // MARK: ScrollViewProxy 168 | 169 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) 170 | public typealias OffsetPublisher = AnyPublisher 171 | 172 | @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) 173 | public struct ScrollViewProxy { 174 | fileprivate class Coordinator { 175 | var frames = [AnyHashable: CGRect]() 176 | weak var scrollView: PlatformScrollView? 177 | } 178 | fileprivate var coordinator = Coordinator() 179 | fileprivate var space: UUID = UUID() 180 | 181 | fileprivate init() {} 182 | 183 | /// A publisher that publishes changes to the scroll views offset 184 | public fileprivate(set) var offset: OffsetPublisher = Just(.zero).eraseToAnyPublisher() 185 | 186 | /// Scrolls to an edge or corner 187 | public func scrollTo(_ alignment: Alignment, animated: Bool = true) { 188 | guard let scrollView = coordinator.scrollView else { return } 189 | 190 | let contentRect = CGRect(origin: .zero, size: scrollView.contentSize) 191 | let visibleFrame = frame(contentRect, with: alignment) 192 | scrollView.scrollRectToVisible(visibleFrame, animated: animated) 193 | } 194 | 195 | /// Scrolls the view with ID to an edge or corner 196 | public func scrollTo(_ id: ID, alignment: Alignment = .top, animated: Bool = true) { 197 | guard let scrollView = coordinator.scrollView else { return } 198 | guard let cellFrame = coordinator.frames[id] else { 199 | return print("ID (\(id)) not found, make sure to add views with `.id(_:scrollView:)`. Did find: \(coordinator.frames)") 200 | } 201 | 202 | let visibleFrame = frame(cellFrame, with: alignment) 203 | scrollView.scrollRectToVisible(visibleFrame, animated: animated) 204 | } 205 | 206 | private func frame(_ frame: CGRect, with alignment: Alignment) -> CGRect { 207 | guard let scrollView = coordinator.scrollView else { return frame } 208 | 209 | var visibleSize = scrollView[keyPath: visibleSizePath] 210 | visibleSize.width -= scrollView[keyPath: adjustedContentInsetPath].horizontal 211 | visibleSize.height -= scrollView[keyPath: adjustedContentInsetPath].vertical 212 | 213 | var origin = CGPoint.zero 214 | switch alignment { 215 | case .center: 216 | origin.x = frame.midX - visibleSize.width / 2 217 | origin.y = frame.midY - visibleSize.height / 2 218 | case .leading: 219 | origin.x = frame.minX 220 | origin.y = frame.midY - visibleSize.height / 2 221 | case .trailing: 222 | origin.x = frame.maxX - visibleSize.width 223 | origin.y = frame.midY - visibleSize.height / 2 224 | case .top: 225 | origin.x = frame.midX - visibleSize.width / 2 226 | origin.y = frame.minY 227 | case .bottom: 228 | origin.x = frame.midX - visibleSize.width / 2 229 | origin.y = frame.maxY - visibleSize.height 230 | case .topLeading: 231 | origin.x = frame.minX 232 | origin.y = frame.minY 233 | case .topTrailing: 234 | origin.x = frame.maxX - visibleSize.width 235 | origin.y = frame.minY 236 | case .bottomLeading: 237 | origin.x = frame.minX 238 | origin.y = frame.maxY - visibleSize.height 239 | case .bottomTrailing: 240 | origin.x = frame.maxX - visibleSize.width 241 | origin.y = frame.maxY - visibleSize.height 242 | default: 243 | fatalError("Not implemented") 244 | } 245 | 246 | origin.x = max(0, min(origin.x, scrollView[keyPath: contentSizePath].width - visibleSize.width)) 247 | origin.y = max(0, min(origin.y, scrollView[keyPath: contentSizePath].height - visibleSize.height)) 248 | return CGRect(origin: origin, size: visibleSize) 249 | } 250 | 251 | fileprivate func save(geometry: GeometryProxy, for id: AnyHashable) { 252 | coordinator.frames[id] = geometry.frame(in: .named(space)) 253 | } 254 | } 255 | 256 | --------------------------------------------------------------------------------