From c77322acc8c0466534e9018ae93108bba2a003ff Mon Sep 17 00:00:00 2001 From: Oleksii Bulenok Date: Tue, 20 Oct 2026 13:47:35 +0200 Subject: [PATCH 1/6] 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 4b6a45f5..557cf8b0 100644 --- a/ios/PagerView.swift +++ b/ios/PagerView.swift @@ -10,6 +10,7 @@ import react_native_pager_view 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? @@ -80,6 +81,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 @@ -107,6 +113,7 @@ struct PagerView: View { } } .onChange(of: props.currentPage) { newValue in + gestureObserver.currentPage = newValue delegate?.onPageSelected(position: newValue) } .onChange(of: props.scrollEnabled) { newValue in From 22d40357f10d975b218b5af126a7aef1556d7fc5 Mon Sep 17 00:00:00 2001 From: Oleksii Bulenok Date: Wed, 21 Oct 2026 13:27:35 +0200 Subject: [PATCH 2/6] 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 5cba82938bcae5449979872edea262727faf47e9 Mon Sep 17 00:00:00 2001 From: Oleksii Bulenok Date: Wed, 21 Oct 2026 13:45:09 +0200 Subject: [PATCH 3/6] 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 557cf8b0..56ec98a3 100644 --- a/ios/PagerView.swift +++ b/ios/PagerView.swift @@ -83,6 +83,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 @@ -97,6 +99,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 From a973ae52c29dad919eae48b84abac50de79a2112 Mon Sep 17 00:00:00 2001 From: Oleksii Bulenok <63914185+abulenok@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:51:57 +0200 Subject: [PATCH 4/6] Update ios/PagerGestureObserver.swift Co-authored-by: John Fanidis --- ios/PagerGestureObserver.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/PagerGestureObserver.swift b/ios/PagerGestureObserver.swift index 3dd3d823..5632338d 100644 --- a/ios/PagerGestureObserver.swift +++ b/ios/PagerGestureObserver.swift @@ -72,7 +72,7 @@ final class PagerGestureObserver: NSObject { var navigationController: UINavigationController? while let current = responder { - if let found = current as? UINavigationController { + if let found = current as? UINavigationController, found.viewControllers.count > 1 { navigationController = found break } @@ -80,7 +80,7 @@ final class PagerGestureObserver: NSObject { responder = current.next } - guard let navigationController, navigationController.viewControllers.count > 1 else { + guard let navigationController else { return false } From 367087fbdf89b6edb64a9de9b9108f4a47e4f207 Mon Sep 17 00:00:00 2001 From: Oleksii Bulenok Date: Fri, 23 Oct 2026 20:44:22 +0200 Subject: [PATCH 5/6] include ios/PagerGestureObserver.swift in package sources --- ios/Package.swift | 1 + ios/PagerGestureObserver.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/ios/Package.swift b/ios/Package.swift index 25d09711..69477f3c 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -49,6 +49,7 @@ let package = Package( path: ".", sources: [ "Extensions.swift", + "PagerGestureObserver.swift", "PagerScrollDelegate.swift", "PagerView.swift", "PagerViewIOS15Compat.swift", diff --git a/ios/PagerGestureObserver.swift b/ios/PagerGestureObserver.swift index 5632338d..1bc5fef9 100644 --- a/ios/PagerGestureObserver.swift +++ b/ios/PagerGestureObserver.swift @@ -1,5 +1,8 @@ import ObjectiveC import UIKit +#if SWIFT_PACKAGE +import react_native_pager_view +#endif /** Keeps the pager's pan from swallowing the navigation controller's back gesture From 9c52c40811112208a6802b73068076181265305e Mon Sep 17 00:00:00 2001 From: Oleksii Bulenok Date: Fri, 23 Oct 2026 23:13:31 +0200 Subject: [PATCH 6/6] Add tests --- .../issues/issue-1050-back-gesture.e2e.ts | 134 ++++++++++++++++++ example/src/App.tsx | 20 ++- .../gh-issues/Issue1050BackGestureRepro.tsx | 58 ++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 e2e/tests/issues/issue-1050-back-gesture.e2e.ts create mode 100644 example/src/gh-issues/Issue1050BackGestureRepro.tsx diff --git a/e2e/tests/issues/issue-1050-back-gesture.e2e.ts b/e2e/tests/issues/issue-1050-back-gesture.e2e.ts new file mode 100644 index 00000000..44aa1195 --- /dev/null +++ b/e2e/tests/issues/issue-1050-back-gesture.e2e.ts @@ -0,0 +1,134 @@ +import { freshAppBeforeEach, expect, test } from '../support/test.ts'; +import type { App, Screen } from '../support/test.ts'; +import { openExample, restoreLtrAfterEach } from '../support/app.ts'; +import type { LayoutDirection } from '../support/app.ts'; + +freshAppBeforeEach(); + +// iOS-only: the fix arbitrates against the navigation controller's back +// gesture. A mid-screen back swipe is the full-screen content pop of iOS 26, +// which the native stack gets without fullScreenGestureEnabled. +const options = { tags: ['regression', 'issue-1050'], platforms: ['ios'] }; + +const EXAMPLE = 'Issue #1050 Back Gesture Repro'; +const NESTED_EXAMPLE = 'Issue #1050 Nested Stack Back Gesture Repro'; + +async function openRepro( + app: App, + screen: Screen, + example: string, + direction: LayoutDirection = 'ltr' +): Promise { + await openExample(app, screen, example, direction); + await expectPage(screen, 0); +} + +/** + * Drags across the middle of the pager, away from the screen edge so the edge + * pop recognizer can't take it. `back` drags the way a back swipe goes in the + * layout direction, otherwise towards the next page. + */ +async function dragAcrossPager( + screen: Screen, + direction: LayoutDirection, + back: boolean +): Promise { + const box = await screen.getByTestId('issue-1050-pager').boundingBox(); + if (!box) throw new Error('issue-1050-pager has no bounds'); + const y = box.y + box.height / 2; + const left = box.x + box.width * 0.3; + const right = box.x + box.width * 0.9; + const towardsRight = back === (direction === 'ltr'); + await screen.swipe({ + from: { x: towardsRight ? left : right, y }, + to: { x: towardsRight ? right : left, y }, + duration: 300, + }); +} + +async function expectPage(screen: Screen, page: number): Promise { + await expect(screen.getByTestId('issue-1050-status')).toHaveText( + `Selected: ${page}` + ); +} + +/** The home list stays scrolled to the example's entry after the pop. */ +async function expectPopped(screen: Screen, example: string): Promise { + await expect(screen.getByTestId(example)).toBeVisible(); + await expect(screen.getByTestId('issue-1050-pager')).toBeHidden(); +} + +function describeBackGesture(direction: LayoutDirection): void { + test( + 'a back swipe on the first page pops the screen', + options, + async ({ app, screen }) => { + await openRepro(app, screen, EXAMPLE, direction); + + // Before the fix the pager claimed the drag and the screen stayed. + await dragAcrossPager(screen, direction, true); + await expectPopped(screen, EXAMPLE); + } + ); + + test( + 'a back swipe on a later page goes to the previous page', + options, + async ({ app, screen }) => { + await openRepro(app, screen, EXAMPLE, direction); + await dragAcrossPager(screen, direction, false); + await expectPage(screen, 1); + + await dragAcrossPager(screen, direction, true); + await expectPage(screen, 0); + } + ); + + test( + 'a forward swipe on the first page still goes to the next page', + options, + async ({ app, screen }) => { + await openRepro(app, screen, EXAMPLE, direction); + + await dragAcrossPager(screen, direction, false); + await expectPage(screen, 1); + } + ); +} + +test.describe('back gesture over the pager', () => { + test.describe('in LTR', () => { + describeBackGesture('ltr'); + }); + + test.describe('in RTL', () => { + restoreLtrAfterEach(); + describeBackGesture('rtl'); + }); +}); + +test.describe('back gesture over a pager in a nested stack', () => { + test( + 'a back swipe on the first page pops the outer stack', + options, + async ({ app, screen }) => { + await openRepro(app, screen, NESTED_EXAMPLE); + + // The inner stack has only its root screen. Before skipping stacks that + // can't pop, the pager stopped there and kept the drag. + await dragAcrossPager(screen, 'ltr', true); + await expectPopped(screen, NESTED_EXAMPLE); + } + ); + + test( + 'a forward swipe on the first page still goes to the next page', + options, + async ({ app, screen }) => { + await openRepro(app, screen, NESTED_EXAMPLE); + + await dragAcrossPager(screen, 'ltr', false); + await expectPage(screen, 1); + } + ); +}); diff --git a/example/src/App.tsx b/example/src/App.tsx index c86465f1..92f5d899 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -35,10 +35,17 @@ import CoverflowExample from './tabView/CoverflowExample'; import { TabViewInsideScrollViewExample } from './tabView/TabViewInsideScrollViewExample'; import ReanimatedOnPageScrollExample from './ReanimatedOnPageScrollExample'; import { MaterialTopBarExample } from './MaterialTopTabExample'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { + createNativeStackNavigator, + type NativeStackNavigationOptions, +} from '@react-navigation/native-stack'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { PagerHookExample } from './PagerHookExample'; import { NestedHorizontalScrollViewExample } from './NestedHorizontalScrollViewExample'; +import { + Issue1050BackGestureRepro, + Issue1050NestedStackBackGestureRepro, +} from './gh-issues/Issue1050BackGestureRepro'; import { Issue1096KeyboardShrinkRepro } from './gh-issues/Issue1096KeyboardShrinkRepro'; import { Issue1098NestedPagerRepro } from './gh-issues/Issue1098NestedPagerRepro'; import { Issue1099SafeAreaRepro } from './gh-issues/Issue1099SafeAreaRepro'; @@ -69,6 +76,7 @@ type Example = { component: React.ComponentType; name: string; testID?: string; + options?: NativeStackNavigationOptions; }; const chevron = @@ -82,6 +90,7 @@ const sections: { title: string; data: Example[] }[] = [ component: BasicPagerViewExampleScreen, name: 'Basic Example', testID: 'example-basic-horizontal', + options: { fullScreenGestureEnabled: false }, }, { component: VerticalBasicPagerViewExampleScreen, @@ -147,6 +156,14 @@ const sections: { title: string; data: Example[] }[] = [ component: Issue1083ModalSetPageExample, name: 'Issue #1083 Modal SetPage Repro', }, + { + component: Issue1050BackGestureRepro, + name: 'Issue #1050 Back Gesture Repro', + }, + { + component: Issue1050NestedStackBackGestureRepro, + name: 'Issue #1050 Nested Stack Back Gesture Repro', + }, { component: Issue1096KeyboardShrinkRepro, name: 'Issue #1096 Keyboard Shrink Repro', @@ -290,6 +307,7 @@ export function Navigation() { key={index} name={example.name} component={example.component} + options={example.options} /> ))} + + {`Selected: ${selectedPage}`} + + setSelectedPage(e.nativeEvent.position)} + > + {[0, 1, 2].map((page) => ( + + {`Page ${page}`} + + ))} + + + ); +} + +export function Issue1050NestedStackBackGestureRepro() { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: 'white' }, + status: { padding: 16, fontSize: 16 }, + pager: { flex: 1 }, + page: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + label: { fontSize: 24 }, +});