├── .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 ScrollView
s with SwiftUI
support
5 |
6 |
7 |
8 | [](https://github.com/stonko1994/SimultaneouslyScrollView/actions/workflows/build.yml)
9 | [](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 |
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 |
--------------------------------------------------------------------------------