From 3024c5914f46e4754c1d10c9c961ab76ab6bfcde Mon Sep 17 00:00:00 2001 From: Julian Asamer Date: Tue, 29 Jul 2025 17:53:28 +0200 Subject: [PATCH] Use content offset observation instead of delegate methods to update content offsets --- ...faultSimultaneouslyScrollViewHandler.swift | 60 ++++++----- .../Helper/MulticastScrollViewDelegate.swift | 100 ------------------ .../Helper/ScrollViewDecorator.swift | 8 +- .../Helper/WeakObjectStore.swift | 43 -------- 4 files changed, 35 insertions(+), 176 deletions(-) delete mode 100644 Sources/SimultaneouslyScrollView/Helper/MulticastScrollViewDelegate.swift delete mode 100644 Sources/SimultaneouslyScrollView/Helper/WeakObjectStore.swift diff --git a/Sources/SimultaneouslyScrollView/DefaultSimultaneouslyScrollViewHandler.swift b/Sources/SimultaneouslyScrollView/DefaultSimultaneouslyScrollViewHandler.swift index 0bbf3d8..dfc44ca 100644 --- a/Sources/SimultaneouslyScrollView/DefaultSimultaneouslyScrollViewHandler.swift +++ b/Sources/SimultaneouslyScrollView/DefaultSimultaneouslyScrollViewHandler.swift @@ -6,6 +6,7 @@ internal class DefaultSimultaneouslyScrollViewHandler: NSObject, SimultaneouslyS private var scrollViewsStore: [ScrollViewDecorator] = [] private weak var lastScrollingScrollView: UIScrollView? + private var cancellables: Set = [] private let scrolledToBottomSubject = PassthroughSubject() var scrolledToBottomPublisher: AnyPublisher { @@ -21,36 +22,30 @@ internal class DefaultSimultaneouslyScrollViewHandler: NSObject, SimultaneouslyS return } - let currentDelegate: UIScrollViewDelegate? = scrollView.delegate - - let multicastDelegate = { - if let multicastDelegate = scrollView.delegate as? MulticastScrollViewDelegate { - return multicastDelegate - } else { - let multicastDelegate = MulticastScrollViewDelegate() - if let currentDelegate { - multicastDelegate.addDelegate(currentDelegate) - } - scrollView.delegate = multicastDelegate - return multicastDelegate - } - }() - - multicastDelegate.addDelegate(self) - scrollViewsStore.append( ScrollViewDecorator( scrollView: scrollView, - delegate: multicastDelegate, + handler: self, directions: scrollDirections ) ) - + + scrollViewsStore.removeAll(where: {$0.scrollView == nil}) + // Scroll the new `ScrollView` to the current position of the others. // Using the first `ScrollView` should be enough as all should be synchronized at this point already. guard let decorator = scrollViewsStore.first else { return } + + scrollView.publisher(for: \.contentOffset) + .scan((old: scrollView.contentOffset, new: scrollView.contentOffset)) { (old: $0.new, new: $1) } + .sink { [weak self, weak scrollView] in + if let self, let scrollView { + self.handleContentOffsetChange(for: scrollView, oldValue: $0.old) + } + } + .store(in: &cancellables) sync(scrollView: scrollView, with: decorator) @@ -107,23 +102,30 @@ internal class DefaultSimultaneouslyScrollViewHandler: NSObject, SimultaneouslyS registeredScrollView.setContentOffset(scrollView.contentOffset, animated: false) } } + + var isUpdatingContentOffset: Bool = false } extension DefaultSimultaneouslyScrollViewHandler: UIScrollViewDelegate { - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - lastScrollingScrollView = scrollView - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { + func handleContentOffsetChange(for scrollView: UIScrollView, oldValue: CGPoint?) { checkIsContentOffsetAtBottom() - - guard lastScrollingScrollView == scrollView else { + + guard oldValue != scrollView.contentOffset else { return } - + + guard !isUpdatingContentOffset else { + return + } + + isUpdatingContentOffset = true + defer { isUpdatingContentOffset = false } + scrollViewsStore - .filter { $0.scrollView != lastScrollingScrollView } - .forEach { sync(scrollView: scrollView, with: $0) } + .filter { $0.scrollView != scrollView } + .forEach { + sync(scrollView: scrollView, with: $0) + } } } #endif diff --git a/Sources/SimultaneouslyScrollView/Helper/MulticastScrollViewDelegate.swift b/Sources/SimultaneouslyScrollView/Helper/MulticastScrollViewDelegate.swift deleted file mode 100644 index e7ad874..0000000 --- a/Sources/SimultaneouslyScrollView/Helper/MulticastScrollViewDelegate.swift +++ /dev/null @@ -1,100 +0,0 @@ -#if os(iOS) || os(tvOS) || os(visionOS) - -import UIKit - -internal class MulticastScrollViewDelegate: NSObject, UIScrollViewDelegate { - private var delegates = WeakObjectStore() - - func addDelegate(_ delegate: UIScrollViewDelegate) { - delegates.append(delegate) - } - - func removeDelegate(_ delegate: UIScrollViewDelegate) { - delegates.remove(delegate) - } - - func callAll(_ closure: (_ delegate: UIScrollViewDelegate) -> Void) { - delegates.allObjects.forEach(closure) - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - callAll { $0.scrollViewDidScroll?(scrollView) } - } - - func scrollViewDidZoom(_ scrollView: UIScrollView) { - callAll { $0.scrollViewDidZoom?(scrollView) } - } - - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - callAll { $0.scrollViewWillBeginDragging?(scrollView) } - } - - func scrollViewWillEndDragging( - _ scrollView: UIScrollView, - withVelocity velocity: CGPoint, - targetContentOffset: UnsafeMutablePointer - ) { - let originalTargetContentOffset = targetContentOffset.pointee - var proposedOffsets: [CGPoint] = [] - - for delegate in delegates.allObjects { - targetContentOffset.pointee = originalTargetContentOffset - delegate.scrollViewWillEndDragging?( - scrollView, - withVelocity: velocity, - targetContentOffset: targetContentOffset - ) - proposedOffsets.append(targetContentOffset.pointee) - } - - let offsetProposals = proposedOffsets.filter { $0 != originalTargetContentOffset } - assert( - offsetProposals.count <= 1, - "Multiple delegates returned a custom targetContentOffset. Only one delegate may do so." - ) - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - callAll { $0.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) } - } - - func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) { - callAll { $0.scrollViewWillBeginDecelerating?(scrollView) } - } - - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - callAll { $0.scrollViewDidEndDecelerating?(scrollView) } - } - - func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - callAll { $0.scrollViewDidEndScrollingAnimation?(scrollView) } - } - - func viewForZooming(in scrollView: UIScrollView) -> UIView? { - let views = delegates.allObjects.compactMap { $0.viewForZooming?(in: scrollView) } - assert(views.count <= 1, "Multiple delegates returned a view for zooming. Only one delegate may return a view.") - return views.first - } - - func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { - callAll { $0.scrollViewWillBeginZooming?(scrollView, with: view) } - } - - func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { - callAll { $0.scrollViewDidEndZooming?(scrollView, with: view, atScale: scale) } - } - - func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { - delegates.allObjects - .allSatisfy { $0.scrollViewShouldScrollToTop?(scrollView) ?? true } - } - - func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { - callAll { $0.scrollViewDidScrollToTop?(scrollView) } - } - - func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) { - callAll { $0.scrollViewDidChangeAdjustedContentInset?(scrollView) } - } -} -#endif diff --git a/Sources/SimultaneouslyScrollView/Helper/ScrollViewDecorator.swift b/Sources/SimultaneouslyScrollView/Helper/ScrollViewDecorator.swift index 2b601d5..5ef9a02 100644 --- a/Sources/SimultaneouslyScrollView/Helper/ScrollViewDecorator.swift +++ b/Sources/SimultaneouslyScrollView/Helper/ScrollViewDecorator.swift @@ -4,16 +4,16 @@ import UIKit internal class ScrollViewDecorator { weak var scrollView: UIScrollView? - var delegate: MulticastScrollViewDelegate + weak var handler: DefaultSimultaneouslyScrollViewHandler? var directions: SimultaneouslyScrollViewDirection? - + init( scrollView: UIScrollView, - delegate: MulticastScrollViewDelegate, + handler: DefaultSimultaneouslyScrollViewHandler?, directions: SimultaneouslyScrollViewDirection? ) { self.scrollView = scrollView - self.delegate = delegate + self.handler = handler self.directions = directions } } diff --git a/Sources/SimultaneouslyScrollView/Helper/WeakObjectStore.swift b/Sources/SimultaneouslyScrollView/Helper/WeakObjectStore.swift deleted file mode 100644 index ef3c0b4..0000000 --- a/Sources/SimultaneouslyScrollView/Helper/WeakObjectStore.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation - -internal class WeakObjectStore { - private var internalObjects: [WeakObjectHolder] = [] - - var allObjects: [ObjectType] { - trimNils() - return internalObjects.compactMap { $0.object } - } - - func append(_ object: ObjectType) { - trimNils() - - guard !contains(object) else { return } - - let weakObjectHolder = WeakObjectHolder(object: object) - internalObjects.append(weakObjectHolder) - } - - func remove(_ object: ObjectType) { - trimNils() - - guard !contains(object) else { return } - - internalObjects.removeAll { $0.object === object } - } - - func contains(_ object: ObjectType) -> Bool { - internalObjects.contains { $0.object === object } - } - - private func trimNils() { - internalObjects = internalObjects.filter { $0.object != nil } - } -} - -private class WeakObjectHolder { - private(set) weak var object: T? - - init(object: T) { - self.object = object - } -}