├── .github └── workflows │ ├── build.yml │ └── swiftlint.yml ├── .gitignore ├── LICENSE ├── Package.swift ├── README.md └── Sources └── SimultaneouslyScrollView ├── DefaultSimultaneouslyScrollViewHandler.swift ├── Extensions └── UIScrollView+OffsetHelper.swift ├── Helper ├── MulticastScrollViewDelegate.swift ├── ScrollViewDecorator.swift └── WeakObjectStore.swift ├── SimultaneouslyScrollViewDirection.swift ├── SimultaneouslyScrollViewHandler.swift └── SimultaneouslyScrollViewHandlerFactory.swift /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | pull_request: 6 | types: 7 | - opened 8 | 9 | jobs: 10 | build-ios: 11 | name: Build iOS 12 | runs-on: macOS-14 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: maxim-lobanov/setup-xcode@v1 17 | with: 18 | xcode-version: '15.2' 19 | 20 | - name: Build iOS 21 | run: xcodebuild -scheme SimultaneouslyScrollView -destination "platform=iOS Simulator,name=iPhone 15 Pro,OS=latest" 22 | 23 | build-tvos: 24 | name: Build tvOS 25 | runs-on: macOS-14 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - uses: maxim-lobanov/setup-xcode@v1 30 | with: 31 | xcode-version: '15.2' 32 | 33 | - name: Build tvOS 34 | run: xcodebuild -scheme SimultaneouslyScrollView -destination "platform=tvOS Simulator,name=Apple TV 4K (3rd generation),OS=latest" 35 | 36 | build-visionos: 37 | name: Build visionOS 38 | runs-on: macOS-14 39 | steps: 40 | - uses: actions/checkout@v4 41 | 42 | - uses: maxim-lobanov/setup-xcode@v1 43 | with: 44 | xcode-version: '15.2' 45 | 46 | - name: Build visionOS 47 | run: xcodebuild -scheme SimultaneouslyScrollView -destination "platform=visionOS Simulator,name=Apple Vision Pro,OS=latest" 48 | -------------------------------------------------------------------------------- /.github/workflows/swiftlint.yml: -------------------------------------------------------------------------------- 1 | name: SwiftLint 2 | 3 | on: 4 | push: 5 | pull_request: 6 | types: 7 | - opened 8 | 9 | jobs: 10 | swiftlint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: SwiftLint 15 | uses: norio-nomura/action-swiftlint@3.2.1 16 | with: 17 | args: --strict 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | # SPM 93 | .swiftpm/ 94 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 David Steinacher 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.9 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "SimultaneouslyScrollView", 6 | platforms: [ 7 | .iOS(.v13), 8 | .tvOS(.v13), 9 | .visionOS(.v1) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "SimultaneouslyScrollView", 15 | targets: ["SimultaneouslyScrollView"] 16 | ) 17 | ], 18 | dependencies: [ 19 | // Dependencies declare other packages that this package depends on. 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "SimultaneouslyScrollView", 26 | dependencies: [] 27 | ) 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

SimultaneouslyScrollView

4 |

Simultaneously scrolling ScrollViews with SwiftUI support

5 |

6 |
7 | 8 | [![Build](https://github.com/stonko1994/SimultaneouslyScrollView/actions/workflows/build.yml/badge.svg)](https://github.com/stonko1994/SimultaneouslyScrollView/actions/workflows/build.yml) 9 | [![SwiftLint](https://github.com/stonko1994/SimultaneouslyScrollView/actions/workflows/swiftlint.yml/badge.svg)](https://github.com/stonko1994/SimultaneouslyScrollView/actions/workflows/swiftlint.yml) 10 | 11 | - [Installation](#installation) 12 | - [Swift Package Manager](#using-swift-package-manager) 13 | - [Usage](#usage) 14 | - [SwiftUI support](#swiftui-support) 15 | - [Example](#example) 16 | 17 | ## Installation 18 | ### Using [Swift Package Manager](https://swift.org/package-manager/) 19 | [Swift Package Manager](https://swift.org/package-manager/) is a tool for managing the distribution of Swift frameworks. It integrates with the Swift build system to automate the process of downloading, compiling, and linking dependencies. 20 | 21 | #### Using Xcode 22 | To integrate using Xcode 13, open your Project file and specify it in `Project > Package Dependencies` using the following URL: 23 | 24 | ``` 25 | https://github.com/stonko1994/SimultaneouslyScrollView.git 26 | ``` 27 | 28 | ## Usage 29 | ```swift 30 | import SimultaneouslyScrollView 31 | ``` 32 | 33 | ### Synchronize multiple `UIScrollView`s 34 | 1) Create an `SimultaneouslyScrollViewHandler` instance by using the factory method and the create function: 35 | ```swift 36 | let simultaneouslyScrollViewHandler = SimultaneouslyScrollViewHandlerFactory.create() 37 | ``` 38 | 1) Register `UIScrollViews` that should be synchronized: 39 | ```swift 40 | simultaneouslyScrollViewHandler.register(scrollView: scrollView) 41 | ``` 42 | 43 | ### SwiftUI support 44 | To enable simultaneously scrolling in `SwiftUI` we need to utilize another library that allows access to the underlying `UIScrollView` for a `SwiftUI.ScrollView`. 45 | 46 | [SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect) 🚀 47 | 48 | #### Synchronize multiple `ScrollView`s 49 | 1) Follow the installataion steps from [SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect) 50 | ``` 51 | Recommended is to use version 1.0.0 or higher. 52 | ``` 53 | 1) Import `Introspect` in addition to `SimultaneouslyScrollView` 54 | ```swift 55 | import SimultaneouslyScrollView 56 | import SwiftUIIntrospect 57 | ``` 58 | 1) Access the `UIScrollView` from your `ScrollView` and register it to the `SimultaneouslyScrollViewHandler`. 59 | ```swift 60 | ScrollView { 61 | ... 62 | } 63 | .introspect(.scrollView, on: .iOS(.v13, .v14, .v15, .v16, .v17)) { 64 | viewModel.simultaneouslyScrollViewHandler.register(scrollView: $0) 65 | } 66 | ``` 67 | 1) That's it 🥳🎉 68 | 69 | I recommend storing the `simultaneouslyScrollViewHandler` inside some view-model. E.g. an `@ObservedObject` or a `@StateObject`. 70 | 71 | #### How it works 72 | `SwiftUI` doesn't provide any API to specify the `contentOffset` for `ScrollViews`. Therefore we need to access the underlying `UIKit` element and set the `contentOffset` there. This is where [SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect) comes in handy by providing access to the `UIKit` elements. 73 | 74 | As every redraw of the `View` creates a new `ScrollView` and a new `UIScrollView` instance, it is important not to store strong references of the registered `UIScrollView`s. The `SimultaneouslyScrollViewHandler` manages this using a custom implementation using the `WeakObjectStore`. 75 | 76 | When a `ScrollView` is scrolled by the user, the `SimultaneouslyScrollViewHandler` gets notified about this via the `UIScrollViewDelegate`. When this happens, the `contentOffset` of every other registered `UIScrollView` will be adapted to the new `contentOffset` of the currently scrolled `UIScrollView`. 77 | 78 | ## Example 79 | I use this package in one of my own Apps that is currently in the Appstore. So there shouldn't be any issues using this in any production code except the possibility that new SwiftUI versions could break [SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect). 80 | 81 | > Please note that this introspection method might break in future SwiftUI releases. Future implementations might not use the same hierarchy, or might not use UIKit elements that are being looked for. Though the library is unlikely to crash, the .introspect() method will not be called in those cases. 82 | 83 |

84 | 85 |

86 | Download Scoretastic 🥳 87 |

88 |

89 | 90 | --- 91 | 92 | Buy Me A Coffee 93 | -------------------------------------------------------------------------------- /Sources/SimultaneouslyScrollView/DefaultSimultaneouslyScrollViewHandler.swift: -------------------------------------------------------------------------------- 1 | #if os(iOS) || os(tvOS) || os(visionOS) 2 | import Combine 3 | import UIKit 4 | 5 | internal class DefaultSimultaneouslyScrollViewHandler: NSObject, SimultaneouslyScrollViewHandler { 6 | private var scrollViewsStore: [ScrollViewDecorator] = [] 7 | private weak var lastScrollingScrollView: UIScrollView? 8 | 9 | private let scrolledToBottomSubject = PassthroughSubject() 10 | 11 | var scrolledToBottomPublisher: AnyPublisher { 12 | scrolledToBottomSubject.eraseToAnyPublisher() 13 | } 14 | 15 | func register(scrollView: UIScrollView) { 16 | register(scrollView: scrollView, scrollDirections: nil) 17 | } 18 | 19 | func register(scrollView: UIScrollView, scrollDirections: SimultaneouslyScrollViewDirection?) { 20 | guard !scrollViewsStore.contains(where: { $0.scrollView == scrollView }) else { 21 | return 22 | } 23 | 24 | let currentDelegate: UIScrollViewDelegate? = scrollView.delegate 25 | 26 | let multicastDelegate = { 27 | if let multicastDelegate = scrollView.delegate as? MulticastScrollViewDelegate { 28 | return multicastDelegate 29 | } else { 30 | let multicastDelegate = MulticastScrollViewDelegate() 31 | if let currentDelegate { 32 | multicastDelegate.addDelegate(currentDelegate) 33 | } 34 | scrollView.delegate = multicastDelegate 35 | return multicastDelegate 36 | } 37 | }() 38 | 39 | multicastDelegate.addDelegate(self) 40 | 41 | scrollViewsStore.append( 42 | ScrollViewDecorator( 43 | scrollView: scrollView, 44 | delegate: multicastDelegate, 45 | directions: scrollDirections 46 | ) 47 | ) 48 | 49 | // Scroll the new `ScrollView` to the current position of the others. 50 | // Using the first `ScrollView` should be enough as all should be synchronized at this point already. 51 | guard let decorator = scrollViewsStore.first else { 52 | return 53 | } 54 | 55 | sync(scrollView: scrollView, with: decorator) 56 | 57 | checkIsContentOffsetAtBottom() 58 | } 59 | 60 | func scrollAllToBottom(animated: Bool) { 61 | guard !scrollViewsStore.isEmpty, 62 | let scrollView = scrollViewsStore.first?.scrollView, 63 | scrollView.hasContentToFillScrollView 64 | else { 65 | return 66 | } 67 | 68 | let bottomContentOffset = CGPoint( 69 | x: 0, 70 | y: scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom 71 | ) 72 | 73 | scrollViewsStore 74 | .compactMap { $0.scrollView } 75 | .forEach { $0.setContentOffset(bottomContentOffset, animated: animated) } 76 | } 77 | 78 | private func checkIsContentOffsetAtBottom() { 79 | guard !scrollViewsStore.isEmpty, 80 | let scrollView = scrollViewsStore.first?.scrollView, 81 | scrollView.hasContentToFillScrollView 82 | else { 83 | scrolledToBottomSubject.send(true) 84 | return 85 | } 86 | 87 | if scrollView.isAtBottom { 88 | scrolledToBottomSubject.send(true) 89 | } else { 90 | scrolledToBottomSubject.send(false) 91 | } 92 | } 93 | 94 | private func sync(scrollView: UIScrollView, with decorator: ScrollViewDecorator) { 95 | guard let registeredScrollView = decorator.scrollView else { 96 | return 97 | } 98 | 99 | switch decorator.directions { 100 | case [.horizontal]: 101 | let offset = CGPoint(x: scrollView.contentOffset.x, y: registeredScrollView.contentOffset.y) 102 | registeredScrollView.setContentOffset(offset, animated: false) 103 | case [.vertical]: 104 | let offset = CGPoint(x: registeredScrollView.contentOffset.x, y: scrollView.contentOffset.y) 105 | registeredScrollView.setContentOffset(offset, animated: false) 106 | default: 107 | registeredScrollView.setContentOffset(scrollView.contentOffset, animated: false) 108 | } 109 | } 110 | } 111 | 112 | extension DefaultSimultaneouslyScrollViewHandler: UIScrollViewDelegate { 113 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { 114 | lastScrollingScrollView = scrollView 115 | } 116 | 117 | func scrollViewDidScroll(_ scrollView: UIScrollView) { 118 | checkIsContentOffsetAtBottom() 119 | 120 | guard lastScrollingScrollView == scrollView else { 121 | return 122 | } 123 | 124 | scrollViewsStore 125 | .filter { $0.scrollView != lastScrollingScrollView } 126 | .forEach { sync(scrollView: scrollView, with: $0) } 127 | } 128 | } 129 | #endif 130 | -------------------------------------------------------------------------------- /Sources/SimultaneouslyScrollView/Extensions/UIScrollView+OffsetHelper.swift: -------------------------------------------------------------------------------- 1 | #if os(iOS) || os(tvOS) || os(visionOS) 2 | import UIKit 3 | 4 | internal extension UIScrollView { 5 | var isAtBottom: Bool { 6 | contentOffset.y >= (contentSize.height - frame.size.height) 7 | } 8 | 9 | var hasContentToFillScrollView: Bool { 10 | contentSize.height > bounds.size.height 11 | } 12 | } 13 | #endif 14 | -------------------------------------------------------------------------------- /Sources/SimultaneouslyScrollView/Helper/MulticastScrollViewDelegate.swift: -------------------------------------------------------------------------------- 1 | #if os(iOS) || os(tvOS) || os(visionOS) 2 | 3 | import UIKit 4 | 5 | internal class MulticastScrollViewDelegate: NSObject, UIScrollViewDelegate { 6 | private var delegates = WeakObjectStore() 7 | 8 | func addDelegate(_ delegate: UIScrollViewDelegate) { 9 | delegates.append(delegate) 10 | } 11 | 12 | func removeDelegate(_ delegate: UIScrollViewDelegate) { 13 | delegates.remove(delegate) 14 | } 15 | 16 | func callAll(_ closure: (_ delegate: UIScrollViewDelegate) -> Void) { 17 | delegates.allObjects.forEach(closure) 18 | } 19 | 20 | func scrollViewDidScroll(_ scrollView: UIScrollView) { 21 | callAll { $0.scrollViewDidScroll?(scrollView) } 22 | } 23 | 24 | func scrollViewDidZoom(_ scrollView: UIScrollView) { 25 | callAll { $0.scrollViewDidZoom?(scrollView) } 26 | } 27 | 28 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { 29 | callAll { $0.scrollViewWillBeginDragging?(scrollView) } 30 | } 31 | 32 | func scrollViewWillEndDragging( 33 | _ scrollView: UIScrollView, 34 | withVelocity velocity: CGPoint, 35 | targetContentOffset: UnsafeMutablePointer 36 | ) { 37 | let originalTargetContentOffset = targetContentOffset.pointee 38 | var proposedOffsets: [CGPoint] = [] 39 | 40 | for delegate in delegates.allObjects { 41 | targetContentOffset.pointee = originalTargetContentOffset 42 | delegate.scrollViewWillEndDragging?( 43 | scrollView, 44 | withVelocity: velocity, 45 | targetContentOffset: targetContentOffset 46 | ) 47 | proposedOffsets.append(targetContentOffset.pointee) 48 | } 49 | 50 | let offsetProposals = proposedOffsets.filter { $0 != originalTargetContentOffset } 51 | assert( 52 | offsetProposals.count <= 1, 53 | "Multiple delegates returned a custom targetContentOffset. Only one delegate may do so." 54 | ) 55 | } 56 | 57 | func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { 58 | callAll { $0.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) } 59 | } 60 | 61 | func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) { 62 | callAll { $0.scrollViewWillBeginDecelerating?(scrollView) } 63 | } 64 | 65 | func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { 66 | callAll { $0.scrollViewDidEndDecelerating?(scrollView) } 67 | } 68 | 69 | func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { 70 | callAll { $0.scrollViewDidEndScrollingAnimation?(scrollView) } 71 | } 72 | 73 | func viewForZooming(in scrollView: UIScrollView) -> UIView? { 74 | let views = delegates.allObjects.compactMap { $0.viewForZooming?(in: scrollView) } 75 | assert(views.count <= 1, "Multiple delegates returned a view for zooming. Only one delegate may return a view.") 76 | return views.first 77 | } 78 | 79 | func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { 80 | callAll { $0.scrollViewWillBeginZooming?(scrollView, with: view) } 81 | } 82 | 83 | func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { 84 | callAll { $0.scrollViewDidEndZooming?(scrollView, with: view, atScale: scale) } 85 | } 86 | 87 | func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { 88 | delegates.allObjects 89 | .allSatisfy { $0.scrollViewShouldScrollToTop?(scrollView) ?? true } 90 | } 91 | 92 | func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { 93 | callAll { $0.scrollViewDidScrollToTop?(scrollView) } 94 | } 95 | 96 | func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) { 97 | callAll { $0.scrollViewDidChangeAdjustedContentInset?(scrollView) } 98 | } 99 | } 100 | #endif 101 | -------------------------------------------------------------------------------- /Sources/SimultaneouslyScrollView/Helper/ScrollViewDecorator.swift: -------------------------------------------------------------------------------- 1 | #if os(iOS) || os(tvOS) || os(visionOS) 2 | import Foundation 3 | import UIKit 4 | 5 | internal class ScrollViewDecorator { 6 | weak var scrollView: UIScrollView? 7 | var delegate: MulticastScrollViewDelegate 8 | var directions: SimultaneouslyScrollViewDirection? 9 | 10 | init( 11 | scrollView: UIScrollView, 12 | delegate: MulticastScrollViewDelegate, 13 | directions: SimultaneouslyScrollViewDirection? 14 | ) { 15 | self.scrollView = scrollView 16 | self.delegate = delegate 17 | self.directions = directions 18 | } 19 | } 20 | #endif 21 | -------------------------------------------------------------------------------- /Sources/SimultaneouslyScrollView/Helper/WeakObjectStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal class WeakObjectStore { 4 | private var internalObjects: [WeakObjectHolder] = [] 5 | 6 | var allObjects: [ObjectType] { 7 | trimNils() 8 | return internalObjects.compactMap { $0.object } 9 | } 10 | 11 | func append(_ object: ObjectType) { 12 | trimNils() 13 | 14 | guard !contains(object) else { return } 15 | 16 | let weakObjectHolder = WeakObjectHolder(object: object) 17 | internalObjects.append(weakObjectHolder) 18 | } 19 | 20 | func remove(_ object: ObjectType) { 21 | trimNils() 22 | 23 | guard !contains(object) else { return } 24 | 25 | internalObjects.removeAll { $0.object === object } 26 | } 27 | 28 | func contains(_ object: ObjectType) -> Bool { 29 | internalObjects.contains { $0.object === object } 30 | } 31 | 32 | private func trimNils() { 33 | internalObjects = internalObjects.filter { $0.object != nil } 34 | } 35 | } 36 | 37 | private class WeakObjectHolder { 38 | private(set) weak var object: T? 39 | 40 | init(object: T) { 41 | self.object = object 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/SimultaneouslyScrollView/SimultaneouslyScrollViewDirection.swift: -------------------------------------------------------------------------------- 1 | #if os(iOS) || os(tvOS) || os(visionOS) 2 | import Foundation 3 | 4 | public struct SimultaneouslyScrollViewDirection: OptionSet { 5 | public var rawValue: Int 6 | 7 | public static let horizontal = SimultaneouslyScrollViewDirection(rawValue: 1 << 0) 8 | public static let vertical = SimultaneouslyScrollViewDirection(rawValue: 2 << 0) 9 | 10 | public init(rawValue: Int) { 11 | self.rawValue = rawValue 12 | } 13 | } 14 | #endif 15 | -------------------------------------------------------------------------------- /Sources/SimultaneouslyScrollView/SimultaneouslyScrollViewHandler.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | #if os(iOS) || os(tvOS) || os(visionOS) 3 | import UIKit 4 | #endif 5 | 6 | /// Handler to enable simultaneously scrolling of `ScrollView`s 7 | @available(iOS 13, *) 8 | @available(tvOS 13, *) 9 | @available(visionOS 1, *) 10 | @available(macOS, unavailable) 11 | public protocol SimultaneouslyScrollViewHandler { 12 | #if os(iOS) || os(tvOS) || os(visionOS) 13 | /// Publisher to notify if the `ScrollView`s are scrolled to the bottom 14 | var scrolledToBottomPublisher: AnyPublisher { get } 15 | 16 | /// Adds the `ScrollView` to a list and keep the content offset in sync. 17 | /// When any registered `ScrollView` will be scrolled all other registered `ScrollView` will adjust 18 | /// its content offset automatically. 19 | /// - Parameters: 20 | /// - scrollView: The `ScrollView` that should be registered for simultaneously scrolling 21 | func register(scrollView: UIScrollView) 22 | 23 | /// Adds the `ScrollView` to a list and keep the content offset in sync. 24 | /// When any registered `ScrollView` will be scrolled all other registered `ScrollView` will adjust 25 | /// its content offset automatically. 26 | /// - Parameters: 27 | /// - scrollView: The `ScrollView` that should be registered for simultaneously scrolling 28 | /// - scrollDirections: The `SimultaneouslyScrollViewDirection` directions interested 29 | func register(scrollView: UIScrollView, scrollDirections: SimultaneouslyScrollViewDirection?) 30 | 31 | /// Helper method to scroll all registered `ScrollView`s to the bottom. 32 | func scrollAllToBottom(animated: Bool) 33 | #endif 34 | } 35 | -------------------------------------------------------------------------------- /Sources/SimultaneouslyScrollView/SimultaneouslyScrollViewHandlerFactory.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Factory class to create `SimultaneouslyScrollViewHandler` instance 4 | @available(iOS 13, *) 5 | @available(tvOS 13, *) 6 | @available(visionOS 1, *) 7 | @available(macOS, unavailable) 8 | public class SimultaneouslyScrollViewHandlerFactory { 9 | #if os(iOS) || os(tvOS) || os(visionOS) 10 | /// Creates a new `SimultaneouslyScrollViewHandler` instance 11 | /// - Returns: A new `SimultaneouslyScrollViewHandler` instance 12 | public static func create() -> SimultaneouslyScrollViewHandler { 13 | DefaultSimultaneouslyScrollViewHandler() 14 | } 15 | #else 16 | public static func create() -> SimultaneouslyScrollViewHandler { 17 | // Stub for other platforms 18 | fatalError() 19 | } 20 | #endif 21 | } 22 | --------------------------------------------------------------------------------