Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions ios/PagerGestureObserver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import ObjectiveC
import UIKit

/**
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 {
navigationController = found
break
}

responder = current.next
}

guard let navigationController, navigationController.viewControllers.count > 1 else {
return false
}
Comment on lines +74 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stops at the nearest UINavigationController and returns false when it has one view controller. A pager on the root screen of a nested stack therefore never yields, even when the outer stack can pop (common with nested navigators in Expo Router and react-navigation). Skipping stacks that can't pop fixes it:

Suggested change
while let current = responder {
if let found = current as? UINavigationController {
navigationController = found
break
}
responder = current.next
}
guard let navigationController, navigationController.viewControllers.count > 1 else {
return false
}
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<String> = [
"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)
}
}
11 changes: 11 additions & 0 deletions ios/PagerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -77,6 +78,13 @@ struct PagerView: View {
scrollDelegate.orientation = .horizontal
collectionView.delegate = scrollDelegate
}

gestureObserver.attach(to: collectionView)
Comment thread
abulenok marked this conversation as resolved.
// 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
Expand All @@ -88,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
Expand All @@ -104,6 +114,7 @@ struct PagerView: View {
}
}
.onChange(of: props.currentPage) { newValue in
gestureObserver.currentPage = newValue
delegate?.onPageSelected(position: newValue)
}
.onChange(of: props.scrollEnabled) { newValue in
Expand Down
18 changes: 16 additions & 2 deletions ios/PagerViewIOS15Compat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading