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 }, +}); 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 new file mode 100644 index 00000000..1bc5fef9 --- /dev/null +++ b/ios/PagerGestureObserver.swift @@ -0,0 +1,196 @@ +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 + (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 + var layoutDirection: PagerLayoutDirection = .ltr + var orientation: UICollectionView.ScrollDirection = .horizontal + + /// 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 } + + objc_setAssociatedObject( + scrollView, + &PagerGestureObserver.associationKey, + self, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + PagerGestureObserver.installGestureArbitration(on: scrollView) + } + + fileprivate func yieldsToBackGesture(in scrollView: UIScrollView) -> Bool { + guard orientation == .horizontal, currentPage == 0, backGestureCanHandleTouch(in: 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 backGestureCanHandleTouch(in scrollView: UIScrollView) -> Bool { + var responder: UIResponder? = scrollView.next + var navigationController: UINavigationController? + + while let current = responder { + if let found = current as? UINavigationController, found.viewControllers.count > 1 { + navigationController = found + break + } + + responder = current.next + } + + guard let navigationController 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 + } + } +} + +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 scrollView: UIScrollView) { + guard let currentClass = object_getClass(scrollView) 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(scrollView, 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(scrollView, subclass) + } +} diff --git a/ios/PagerView.swift b/ios/PagerView.swift index 4b6a45f5..56ec98a3 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,13 @@ struct PagerView: View { scrollDelegate.orientation = .horizontal collectionView.delegate = scrollDelegate } + + 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 } } // GeometryReader is the root since the vertical layout; without this it @@ -91,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 @@ -107,6 +117,7 @@ struct PagerView: View { } } .onChange(of: props.currentPage) { newValue in + gestureObserver.currentPage = newValue delegate?.onPageSelected(position: newValue) } .onChange(of: props.scrollEnabled) { newValue in 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