From 0f76ed7918e8c52bab86fc98458f3db5f5e26b26 Mon Sep 17 00:00:00 2001 From: Daiki Matoba Date: Wed, 14 Oct 2026 13:55:50 +0900 Subject: [PATCH] fix(ios): emit onPageScroll and onPageScrollStateChanged on iOS 15 --- ios/Package.swift | 1 + ios/PagerScrollDelegateIOS15.swift | 16 ++++++++ ios/PagerView.swift | 8 +++- ios/PagerViewIOS15Compat.swift | 59 +++++++++++++++++++++++++++++- 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 ios/PagerScrollDelegateIOS15.swift diff --git a/ios/Package.swift b/ios/Package.swift index 69477f3c..aa6bc06c 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -51,6 +51,7 @@ let package = Package( "Extensions.swift", "PagerGestureObserver.swift", "PagerScrollDelegate.swift", + "PagerScrollDelegateIOS15.swift", "PagerView.swift", "PagerViewIOS15Compat.swift", "PagerViewProps.swift", diff --git a/ios/PagerScrollDelegateIOS15.swift b/ios/PagerScrollDelegateIOS15.swift new file mode 100644 index 00000000..27f988c4 --- /dev/null +++ b/ios/PagerScrollDelegateIOS15.swift @@ -0,0 +1,16 @@ +import UIKit + +/// iOS 15 only, see `PagerViewIOS15Compat`. Delete once the minimum supported iOS version is 16. +/// +/// The scroll views that page on iOS 15 only exist once the collection view displays its pages, so +/// a `PagerScrollDelegate` is attached to them from here. +final class PagerScrollDelegateIOS15: PagerScrollDelegate { + override func collectionView( + _ collectionView: UICollectionView, + willDisplay cell: UICollectionViewCell, + forItemAt indexPath: IndexPath + ) { + PagerViewIOS15Compat.attachScrollDelegate(to: collectionView, delegate: delegate) + super.collectionView(collectionView, willDisplay: cell, forItemAt: indexPath) + } +} diff --git a/ios/PagerView.swift b/ios/PagerView.swift index 56ec98a3..69e564c2 100644 --- a/ios/PagerView.swift +++ b/ios/PagerView.swift @@ -9,7 +9,13 @@ import react_native_pager_view struct PagerView: View { @ObservedObject var props: PagerViewProps - @State private var scrollDelegate = PagerScrollDelegate() + @State private var scrollDelegate: PagerScrollDelegate = { + if #available(iOS 16, *) { + return PagerScrollDelegate() + } + // iOS 15 only, see PagerViewIOS15Compat.swift (delete together with the iOS 15 support). + return PagerScrollDelegateIOS15() + }() @State private var gestureObserver = PagerGestureObserver() @State private var didEmitInitialPageSelection = false weak var delegate: PagerViewProviderDelegate? diff --git a/ios/PagerViewIOS15Compat.swift b/ios/PagerViewIOS15Compat.swift index bea731c0..34a42edb 100644 --- a/ios/PagerViewIOS15Compat.swift +++ b/ios/PagerViewIOS15Compat.swift @@ -1,4 +1,7 @@ import UIKit +#if SWIFT_PACKAGE +import react_native_pager_view +#endif /// Workarounds for the SwiftUI paged `TabView` on iOS 15. /// @@ -8,14 +11,20 @@ import UIKit /// the collection view itself only scrolls vertically. Because `PagerView` can only toggle the /// collection view, on iOS 15 `scrollEnabled={false}` / `overdrag={false}` never reached the view /// that actually handles the swipe, and the back-gesture arbitration of `PagerGestureObserver` -/// never reached the pan that blocks it. +/// never reached the pan that blocks it. For the same reason `PagerScrollDelegate`, installed on +/// the collection view, never saw the paging scroll, so `onPageScroll` / `onPageScrollStateChanged` +/// were never emitted. /// /// On iOS 16 and later the collection view itself is the pager, so none of this is needed — the /// `#available` checks below make every function in here a no-op on those versions. /// -/// - Note: Delete this file together with the `PagerViewIOS15Compat` calls in `PagerView.swift` +/// - Note: Delete this file and `PagerScrollDelegateIOS15.swift` together with the +/// `PagerViewIOS15Compat` calls and the `PagerScrollDelegateIOS15` choice in `PagerView.swift` /// once the minimum supported iOS version is 16. enum PagerViewIOS15Compat { + private static var scrollDelegateKey: UInt8 = 0 + private static var originalDelegateKey: UInt8 = 0 + /// Mirrors `scrollEnabled` onto the scroll views SwiftUI embedded in the paging collection view. static func applyScrollEnabled(_ scrollEnabled: Bool, to collectionView: UICollectionView?) { if #available(iOS 16, *) { @@ -47,6 +56,33 @@ enum PagerViewIOS15Compat { pagingScrollViews(in: collectionView).forEach { gestureObserver.attach(to: $0) } } + /// Puts a `PagerScrollDelegate` in front of the delegate of the scroll views SwiftUI embedded in + /// the paging collection view. They only exist once the collection view has laid out its pages. + static func attachScrollDelegate( + to collectionView: UICollectionView, + delegate: PagerViewProviderDelegate? + ) { + if #available(iOS 16, *) { + return + } + + for scrollView in pagingScrollViews(in: collectionView) { + if scrollView.delegate is PagerScrollDelegate { + continue + } + + let originalDelegate = ScrollViewDelegateForwarder(target: scrollView.delegate) + let scrollDelegate = PagerScrollDelegate() + scrollDelegate.originalDelegate = originalDelegate + scrollDelegate.delegate = delegate + // Both delegate references are weak; the scroll view owns them instead. + let policy = objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC + objc_setAssociatedObject(scrollView, &scrollDelegateKey, scrollDelegate, policy) + objc_setAssociatedObject(scrollView, &originalDelegateKey, originalDelegate, policy) + scrollView.delegate = scrollDelegate + } + } + /// Scroll views that SwiftUI embedded in the paging collection view. /// /// Scroll views that belong to the pages' React Native content live inside the paging cells, so @@ -75,3 +111,22 @@ enum PagerViewIOS15Compat { return scrollViews } } + +/// `PagerScrollDelegate` forwards to a `UICollectionViewDelegate`, but the embedded scroll views' +/// own delegate (UIKit's `_UICollectionViewOrthogonalScrollerSectionController`) is only a +/// `UIScrollViewDelegate`. This passes every call through to it. +private final class ScrollViewDelegateForwarder: NSObject, UICollectionViewDelegate { + private weak var target: UIScrollViewDelegate? + + init(target: UIScrollViewDelegate?) { + self.target = target + } + + override func responds(to aSelector: Selector!) -> Bool { + target?.responds(to: aSelector) ?? false + } + + override func forwardingTarget(for aSelector: Selector!) -> Any? { + target + } +}