From 8f21907315af6778f6954422b955aa9c51b65456 Mon Sep 17 00:00:00 2001 From: Oleksii Bulenok Date: Tue, 20 Oct 2026 13:47:35 +0200 Subject: [PATCH 1/3] fix(ios): yield to the navigation back gesture on the first page The SwiftUI pager's collection view claimed every horizontal drag, so react-native-screens' full-screen back swipe (its own pan recognizer before iOS 26, interactiveContentPopGestureRecognizer on iOS 26) never won, even on the first page. Recognizing simultaneously does not help because UIKit still hands the touch to the scroll view, so the pan has to be refused before it begins. UIKit only asks the scroll view itself in gestureRecognizerShouldBegin, so the introspected collection view gets a runtime subclass, the same approach disableSafeArea() uses. The pan is refused only on page 0 of a horizontal pager, only in the back direction, and only while the nearest navigation controller can pop. iOS 26 also attaches UIScrollViewPagingSwipeGestureRecognizer to paging scroll views, which flips a page on a quick flick even when the pan never begins; it is refused under the same conditions. Fixes #1050 Co-Authored-By: Claude Opus 5.5 --- ios/PagerGestureObserver.swift | 153 +++++++++++++++++++++++++++++++++ ios/PagerView.swift | 7 ++ 2 files changed, 160 insertions(+) create mode 100644 ios/PagerGestureObserver.swift diff --git a/ios/PagerGestureObserver.swift b/ios/PagerGestureObserver.swift new file mode 100644 index 00000000..956a1afe --- /dev/null +++ b/ios/PagerGestureObserver.swift @@ -0,0 +1,153 @@ +import ObjectiveC +import UIKit + +/** + Keeps the pager's pan from swallowing the navigation controller's back gesture. + + react-native-screens' full-screen swipe (its own pan recognizer before iOS 26, + the system `interactiveContentPopGestureRecognizer` from iOS 26) spans the whole + screen, so a pager on top of it takes every horizontal drag + (callstack/react-native-pager-view#1050). The 6.x UIKit pager solved this in a + gesture delegate; the SwiftUI pager has none. Allowing the two to recognize + simultaneously does not help, UIKit still hands the touch to the scroll view, + so the pan has to be refused before it begins. That answer belongs to + `gestureRecognizerShouldBegin`, which the pan asks its delegate, and UIKit + insists that delegate is the scroll view itself. Hence the runtime subclass, + the same trick `disableSafeArea()` uses in `Extensions.swift`. + + Only the first page of a horizontal pager yields, and only while there is + somewhere to go back to; there a back-swipe has nothing to scroll to anyway. + Every other drag reaches the original implementation untouched. + + iOS 26 adds a private `UIScrollViewPagingSwipeGestureRecognizer` to paging + scroll views that flips a page on a quick flick even when the pan never + begins, and it asks the same delegate method. It has to be refused too, or + only slow drags reach the back gesture. + */ +final class PagerGestureObserver: NSObject { + var currentPage = 0 + var layoutDirection: PagerLayoutDirection = .ltr + var orientation: UICollectionView.ScrollDirection = .horizontal + + private weak var collectionView: UICollectionView? + + func attach(to collectionView: UICollectionView) { + guard self.collectionView !== collectionView else { return } + + self.collectionView = collectionView + objc_setAssociatedObject( + collectionView, + &PagerGestureObserver.associationKey, + self, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + PagerGestureObserver.installGestureArbitration(on: collectionView) + } + + fileprivate func yieldsToBackGesture(in scrollView: UIScrollView) -> Bool { + guard orientation == .horizontal, currentPage == 0, canNavigateBack(from: scrollView) else { + return false + } + + // The pan keeps tracking the touch even after it was refused, so it still + // tells the direction when the paging swipe recognizer asks later in the + // same touch sequence. Fall back to the translation when the velocity has + // not been sampled yet. + let pan = scrollView.panGestureRecognizer + var dx = pan.velocity(in: scrollView).x + if dx == 0 { + dx = pan.translation(in: scrollView).x + } + + return layoutDirection == .ltr ? dx > 0 : dx < 0 + } + + fileprivate static func isPagingRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + of scrollView: UIScrollView + ) -> Bool { + if gestureRecognizer === scrollView.panGestureRecognizer { + return true + } + return NSStringFromClass(type(of: gestureRecognizer)) == pagingSwipeRecognizerClassName + } + + /// Walks the responder chain because the pod does not import React's `UIView` + /// category, so `reactViewController()` is unavailable here. + private func canNavigateBack(from view: UIView) -> Bool { + var responder: UIResponder? = view.next + + while let current = responder { + if let navigationController = current as? UINavigationController { + return navigationController.viewControllers.count > 1 + } + + responder = current.next + } + + return false + } +} + +private extension PagerGestureObserver { + static var associationKey: UInt8 = 0 + static let subclassSuffix = "_PagerBackGesture" + static let pagingSwipeRecognizerClassName = "UIScrollViewPagingSwipeGestureRecognizer" + + static func installGestureArbitration(on collectionView: UICollectionView) { + guard let currentClass = object_getClass(collectionView) else { return } + + let currentName = String(cString: class_getName(currentClass)) + + // Already wearing the subclass, e.g. after a recycle. + guard !currentName.hasSuffix(subclassSuffix) else { return } + + let subclassName = currentName.appending(subclassSuffix) + + if let existing = NSClassFromString(subclassName) { + object_setClass(collectionView, existing) + return + } + + let selector = #selector(UIGestureRecognizerDelegate.gestureRecognizerShouldBegin(_:)) + + guard + let subclassNameUtf8 = (subclassName as NSString).utf8String, + let subclass = objc_allocateClassPair(currentClass, subclassNameUtf8, 0), + let method = class_getInstanceMethod(currentClass, selector) + else { return } + + typealias Implementation = @convention(c) (AnyObject, Selector, UIGestureRecognizer) -> Bool + + let superImplementation = unsafeBitCast( + method_getImplementation(method), + to: Implementation.self + ) + + let override: @convention(block) (AnyObject, UIGestureRecognizer) -> Bool = { + object, gestureRecognizer in + if + let scrollView = object as? UIScrollView, + PagerGestureObserver.isPagingRecognizer(gestureRecognizer, of: scrollView), + let observer = objc_getAssociatedObject( + scrollView, &PagerGestureObserver.associationKey + ) as? PagerGestureObserver, + observer.yieldsToBackGesture(in: scrollView) + { + return false + } + + return superImplementation(object, selector, gestureRecognizer) + } + + class_addMethod( + subclass, + selector, + imp_implementationWithBlock(override), + method_getTypeEncoding(method) + ) + + objc_registerClassPair(subclass) + object_setClass(collectionView, subclass) + } +} diff --git a/ios/PagerView.swift b/ios/PagerView.swift index e0283a35..30975bae 100644 --- a/ios/PagerView.swift +++ b/ios/PagerView.swift @@ -7,6 +7,7 @@ import SwiftUI struct PagerView: View { @ObservedObject var props: PagerViewProps @State private var scrollDelegate = PagerScrollDelegate() + @State private var gestureObserver = PagerGestureObserver() @State private var didEmitInitialPageSelection = false weak var delegate: PagerViewProviderDelegate? @@ -77,6 +78,11 @@ struct PagerView: View { scrollDelegate.orientation = .horizontal collectionView.delegate = scrollDelegate } + + gestureObserver.attach(to: collectionView) + gestureObserver.currentPage = props.currentPage + gestureObserver.layoutDirection = props.layoutDirection + gestureObserver.orientation = props.orientation } } // GeometryReader is the root since the vertical layout; without this it @@ -104,6 +110,7 @@ struct PagerView: View { } } .onChange(of: props.currentPage) { newValue in + gestureObserver.currentPage = newValue delegate?.onPageSelected(position: newValue) } .onChange(of: props.scrollEnabled) { newValue in From 231ae493bde7ea6eafa0ccc99c5d040eee9976ae Mon Sep 17 00:00:00 2001 From: Oleksii Bulenok Date: Wed, 21 Oct 2026 13:27:35 +0200 Subject: [PATCH 2/3] Be smart about overdrag vs back gesture --- ios/PagerGestureObserver.swift | 94 ++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 27 deletions(-) diff --git a/ios/PagerGestureObserver.swift b/ios/PagerGestureObserver.swift index 956a1afe..e879d4df 100644 --- a/ios/PagerGestureObserver.swift +++ b/ios/PagerGestureObserver.swift @@ -2,27 +2,20 @@ import ObjectiveC import UIKit /** - Keeps the pager's pan from swallowing the navigation controller's back gesture. - - react-native-screens' full-screen swipe (its own pan recognizer before iOS 26, - the system `interactiveContentPopGestureRecognizer` from iOS 26) spans the whole - screen, so a pager on top of it takes every horizontal drag - (callstack/react-native-pager-view#1050). The 6.x UIKit pager solved this in a - gesture delegate; the SwiftUI pager has none. Allowing the two to recognize - simultaneously does not help, UIKit still hands the touch to the scroll view, - so the pan has to be refused before it begins. That answer belongs to - `gestureRecognizerShouldBegin`, which the pan asks its delegate, and UIKit - insists that delegate is the scroll view itself. Hence the runtime subclass, - the same trick `disableSafeArea()` uses in `Extensions.swift`. - - Only the first page of a horizontal pager yields, and only while there is - somewhere to go back to; there a back-swipe has nothing to scroll to anyway. - Every other drag reaches the original implementation untouched. - - iOS 26 adds a private `UIScrollViewPagingSwipeGestureRecognizer` to paging - scroll views that flips a page on a quick flick even when the pan never - begins, and it asks the same delegate method. It has to be refused too, or - only slow drags reach the back gesture. + Keeps the pager's pan from swallowing the navigation controller's back gesture + (callstack/react-native-pager-view#1050). UIKit hands the touch to the scroll + view even when recognizing simultaneously, so the pan must be refused in + `gestureRecognizerShouldBegin`, whose delegate has to be the scroll view + itself. Hence the runtime subclass, as in `disableSafeArea()`. + + Only an outward drag on the first page of a horizontal pager yields, and only + while a back recognizer is actually tracking the touch. A deeper stack alone is + not enough: the pop recognizers may be disabled, refused by their delegate, or + edge-only with the drag starting mid-screen, and yielding would just kill the + overdrag bounce. + + iOS 26's private `UIScrollViewPagingSwipeGestureRecognizer` flips pages on a + flick without the pan and asks the same method, so it is refused too. */ final class PagerGestureObserver: NSObject { var currentPage = 0 @@ -45,7 +38,7 @@ final class PagerGestureObserver: NSObject { } fileprivate func yieldsToBackGesture(in scrollView: UIScrollView) -> Bool { - guard orientation == .horizontal, currentPage == 0, canNavigateBack(from: scrollView) else { + guard orientation == .horizontal, currentPage == 0, backGestureCanHandleTouch(in: scrollView) else { return false } @@ -74,18 +67,61 @@ final class PagerGestureObserver: NSObject { /// Walks the responder chain because the pod does not import React's `UIView` /// category, so `reactViewController()` is unavailable here. - private func canNavigateBack(from view: UIView) -> Bool { - var responder: UIResponder? = view.next + private func backGestureCanHandleTouch(in scrollView: UIScrollView) -> Bool { + var responder: UIResponder? = scrollView.next + var navigationController: UINavigationController? while let current = responder { - if let navigationController = current as? UINavigationController { - return navigationController.viewControllers.count > 1 + if let found = current as? UINavigationController { + navigationController = found + break } responder = current.next } - return false + guard let navigationController, navigationController.viewControllers.count > 1 else { + return false + } + + var candidates: [UIGestureRecognizer] = [] + + if let recognizer = navigationController.interactivePopGestureRecognizer { + candidates.append(recognizer) + } + + if #available(iOS 26.0, *), + let recognizer = navigationController.interactiveContentPopGestureRecognizer + { + candidates.append(recognizer) + } + + // react-native-screens installs its own swipe recognizers on the stack view + // above the navigation controller's view. + var ancestor = scrollView.superview + while let view = ancestor { + for recognizer in view.gestureRecognizers ?? [] + where Self.screensBackRecognizerClassNames.contains(NSStringFromClass(type(of: recognizer))) { + candidates.append(recognizer) + } + ancestor = view.superview + } + + return candidates.contains(where: Self.isTrackingTouch) + } + + /// A recognizer whose delegate refused the touch never receives it, and an + /// edge recognizer fails once the touch starts away from its edge, so only + /// one still holding touches in a live state can take over the drag. + private static func isTrackingTouch(_ recognizer: UIGestureRecognizer) -> Bool { + guard recognizer.isEnabled, recognizer.numberOfTouches > 0 else { return false } + + switch recognizer.state { + case .possible, .began, .changed: + return true + default: + return false + } } } @@ -93,6 +129,10 @@ private extension PagerGestureObserver { static var associationKey: UInt8 = 0 static let subclassSuffix = "_PagerBackGesture" static let pagingSwipeRecognizerClassName = "UIScrollViewPagingSwipeGestureRecognizer" + static let screensBackRecognizerClassNames: Set = [ + "RNSPanGestureRecognizer", + "RNSScreenEdgeGestureRecognizer", + ] static func installGestureArbitration(on collectionView: UICollectionView) { guard let currentClass = object_getClass(collectionView) else { return } From 6868074f7dd9af15f106dad88f360b4433e7cf13 Mon Sep 17 00:00:00 2001 From: Oleksii Bulenok Date: Wed, 21 Oct 2026 13:45:09 +0200 Subject: [PATCH 3/3] Support back gesture on iOS 15 too --- ios/PagerGestureObserver.swift | 22 +++++++++++----------- ios/PagerView.swift | 4 ++++ ios/PagerViewIOS15Compat.swift | 18 ++++++++++++++++-- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/ios/PagerGestureObserver.swift b/ios/PagerGestureObserver.swift index e879d4df..3dd3d823 100644 --- a/ios/PagerGestureObserver.swift +++ b/ios/PagerGestureObserver.swift @@ -22,19 +22,19 @@ final class PagerGestureObserver: NSObject { var layoutDirection: PagerLayoutDirection = .ltr var orientation: UICollectionView.ScrollDirection = .horizontal - private weak var collectionView: UICollectionView? + /// Attaches to the scroll view that pages: the collection view on iOS 16+, + /// the scroll views it embeds on iOS 15 (see `PagerViewIOS15Compat`). + func attach(to scrollView: UIScrollView) { + let attached = objc_getAssociatedObject(scrollView, &PagerGestureObserver.associationKey) + guard attached as? PagerGestureObserver !== self else { return } - func attach(to collectionView: UICollectionView) { - guard self.collectionView !== collectionView else { return } - - self.collectionView = collectionView objc_setAssociatedObject( - collectionView, + scrollView, &PagerGestureObserver.associationKey, self, .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) - PagerGestureObserver.installGestureArbitration(on: collectionView) + PagerGestureObserver.installGestureArbitration(on: scrollView) } fileprivate func yieldsToBackGesture(in scrollView: UIScrollView) -> Bool { @@ -134,8 +134,8 @@ private extension PagerGestureObserver { "RNSScreenEdgeGestureRecognizer", ] - static func installGestureArbitration(on collectionView: UICollectionView) { - guard let currentClass = object_getClass(collectionView) else { return } + static func installGestureArbitration(on scrollView: UIScrollView) { + guard let currentClass = object_getClass(scrollView) else { return } let currentName = String(cString: class_getName(currentClass)) @@ -145,7 +145,7 @@ private extension PagerGestureObserver { let subclassName = currentName.appending(subclassSuffix) if let existing = NSClassFromString(subclassName) { - object_setClass(collectionView, existing) + object_setClass(scrollView, existing) return } @@ -188,6 +188,6 @@ private extension PagerGestureObserver { ) objc_registerClassPair(subclass) - object_setClass(collectionView, subclass) + object_setClass(scrollView, subclass) } } diff --git a/ios/PagerView.swift b/ios/PagerView.swift index 30975bae..1a4350f9 100644 --- a/ios/PagerView.swift +++ b/ios/PagerView.swift @@ -80,6 +80,8 @@ struct PagerView: View { } gestureObserver.attach(to: collectionView) + // iOS 15 only, see PagerViewIOS15Compat.swift (delete together with the iOS 15 support). + PagerViewIOS15Compat.attachGestureObserver(gestureObserver, to: collectionView) gestureObserver.currentPage = props.currentPage gestureObserver.layoutDirection = props.layoutDirection gestureObserver.orientation = props.orientation @@ -94,6 +96,8 @@ struct PagerView: View { DispatchQueue.main.async { applyScrollEnabled(props.scrollEnabled) applyOverdrag(props.overdrag) + // iOS 15 only: the embedded scroll views may not exist yet when introspected. + PagerViewIOS15Compat.attachGestureObserver(gestureObserver, to: collectionView) } // `onChange` does not fire for the initial `currentPage` value. Emit the diff --git a/ios/PagerViewIOS15Compat.swift b/ios/PagerViewIOS15Compat.swift index 569033a7..bea731c0 100644 --- a/ios/PagerViewIOS15Compat.swift +++ b/ios/PagerViewIOS15Compat.swift @@ -7,12 +7,13 @@ import UIKit /// collection view (private class `_UICollectionViewOrthogonalScrollerEmbeddedScrollView`), while /// 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. +/// that actually handles the swipe, and the back-gesture arbitration of `PagerGestureObserver` +/// never reached the pan that blocks it. /// /// 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 two `PagerViewIOS15Compat` calls in `PagerView.swift` +/// - Note: Delete this file together with the `PagerViewIOS15Compat` calls in `PagerView.swift` /// once the minimum supported iOS version is 16. enum PagerViewIOS15Compat { /// Mirrors `scrollEnabled` onto the scroll views SwiftUI embedded in the paging collection view. @@ -33,6 +34,19 @@ enum PagerViewIOS15Compat { pagingScrollViews(in: collectionView).forEach { $0.bounces = overdrag } } + /// Attaches the back-gesture arbitration to the scroll views SwiftUI embedded in the paging + /// collection view. + static func attachGestureObserver( + _ gestureObserver: PagerGestureObserver, + to collectionView: UICollectionView? + ) { + if #available(iOS 16, *) { + return + } + + pagingScrollViews(in: collectionView).forEach { gestureObserver.attach(to: $0) } + } + /// 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