You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[iOS] On iPad, it is conflicting header and back button. Also active tab indication not worked. #1067
Confirming this on an iPad running iOS 15.x with react-native-pager-view 9.0.9. We observed three symptoms together and confirmed that the combined native workaround described below resolves all three in our app.
This overlaps #1080. The embedded scroll-delegate change is based on @d-mato's #1179; credit for that fix belongs to that PR. The header and touch-cancellation workarounds below address the additional symptoms we observed. I have not tested each patch component independently, so this is not a claim that all three have one root cause or that #1179 alone fixes all three.
Environment
react-native-pager-view: 9.0.9
react-native-tab-view: 4.3.4
react-native-screens: 4.30.0
@react-navigation/native-stack: 7.21.0
React Native: 0.87.1, New Architecture / Fabric
Affected device: iPad, iOS 15.x (exact model and minor OS version were not recorded for this report)
Reproduction outline
These steps describe our app's setup; I have not extracted a standalone reproduction repository yet.
Use a native stack with a root screen, a search screen, and a plain screen without a pager. Configure the search screen with headerShown: false and render a custom TextInput at the top.
In the search screen, render react-native-tab-view with three routes. Pages contain tappable items whose onPress navigates to a detail screen.
Push the search screen on an iPad running iOS 15.
Switch tabs by tapping their labels and by swiping horizontally, starting the swipe on a tappable item.
Navigate from the search screen to the plain screen, then go back.
Actual behavior
A default native navigation bar appears over the custom search input despite headerShown: false, including the route title and native back button. Opening the plain non-pager screen and returning makes this unwanted bar disappear. The plain screen itself does not show the unwanted header.
Page content switches, but the tab indicator does not track the selected page.
A horizontal swipe can both change pages and trigger the onPress of the item where the gesture started, unexpectedly navigating to its detail screen on release.
Delaying input focus until the native-stack transition finishes did not resolve the header issue.
Native workaround
A PanResponderAdapter fallback on iOS 15 avoided the symptoms, but degraded paging performance in our app. We replaced it with a native patch, retaining the normal PagerViewAdapter.
Retries attachment for initial layout and later page creation, retains the proxy without retaining its event sink, and avoids installing a proxy in front of itself.
Suppresses page-progress/state events from the outer collection view on iOS 15, so outer layout events do not overwrite the actual horizontal paging position.
Cancels active ancestor RCTSurfaceTouchHandler presses when the real paging scroller starts dragging on iOS 15. Normal taps and programmatic page changes do not invoke this cancellation path.
Prevents the embedded UIHostingController from accessing its ancestor navigation controller on iOS 15, leaving header visibility to native-stack.
The navigation-controller override and touch-handler reset should be treated as compatibility workarounds for review, not as a proposal for a new public API. The iOS-specific paths are gated to iOS 15; no Android change is included.
Validation
All three original symptoms are confirmed resolved on the affected iOS 15 device with the combined native patch, without the JS pager fallback.
Swift sources compile against an iOS 15.1 deployment target; the Objective-C++ bridge passes a syntax check against the generated Swift header.
Additional native helper checks passed on an iOS 27 simulator: fractional/final scroll positions, drag/settling/idle events, original-delegate forwarding, repeated installation, traversal stopping at page cells, ancestor touch-handler cancellation without disabling the paging pan, and proxy/event-sink teardown. Those helper checks do not replace the iOS 15 device test or constitute an end-to-end RN gesture test.
Combined native patch against react-native-pager-view 9.0.9
Paths are relative to the library root. This includes the #1179-based event fix plus the additional app-validated compatibility changes described above.
diff --git a/ios/PagerScrollDelegate.swift b/ios/PagerScrollDelegate.swift--- a/ios/PagerScrollDelegate.swift+++ b/ios/PagerScrollDelegate.swift@@ -4,9 +4,10 @@
Scroll delegate used to control underlying TabView's collection view.
*/
class PagerScrollDelegate: NSObject, UIScrollViewDelegate, UICollectionViewDelegate {
- weak var originalDelegate: UICollectionViewDelegate?+ weak var originalDelegate: UIScrollViewDelegate?
weak var delegate: PagerViewProviderDelegate?
var orientation: UICollectionView.ScrollDirection = .horizontal
+ var emitsPagingEvents = true
private let handledSelectors: Set<Selector> = [
#selector(scrollViewDidScroll(_:)),
@@ -20,6 +21,8 @@
]
func scrollViewDidScroll(_ scrollView: UIScrollView) {
+ defer { originalDelegate?.scrollViewDidScroll?(scrollView) }+ guard emitsPagingEvents else { return }
let isHorizontal = orientation == .horizontal
let pageSize = isHorizontal ? scrollView.frame.width : scrollView.frame.height
let contentOffset = isHorizontal ? scrollView.contentOffset.x : scrollView.contentOffset.y
@@ -31,12 +34,12 @@
let eventData = OnPageScrollEventData(position: position, offset: offset)
delegate?.onPageScroll(data: eventData)
- originalDelegate?.scrollViewDidScroll?(scrollView)
}
/// Emits a final onPageScroll with offset 0 before transitioning to idle,
/// clearing any residual floating-point offset from the scroll animation.
private func emitIdleWithCleanOffset(_ scrollView: UIScrollView) {
+ guard emitsPagingEvents else { return }
let isHorizontal = orientation == .horizontal
let pageSize = isHorizontal ? scrollView.frame.width : scrollView.frame.height
let contentOffset = isHorizontal ? scrollView.contentOffset.x : scrollView.contentOffset.y
@@ -51,12 +54,16 @@
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
- delegate?.onPageScrollStateChanged(state: .dragging)+ if emitsPagingEvents {+ delegate?.onPageScrollStateChanged(state: .dragging)+ }
originalDelegate?.scrollViewWillBeginDragging?(scrollView)
}
func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
- delegate?.onPageScrollStateChanged(state: .settling)+ if emitsPagingEvents {+ delegate?.onPageScrollStateChanged(state: .settling)+ }
originalDelegate?.scrollViewWillBeginDecelerating?(scrollView)
}
@@ -78,11 +85,13 @@
}
func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
- originalDelegate?.collectionView?(collectionView, didEndDisplaying: cell, forItemAt: indexPath)+ (originalDelegate as? UICollectionViewDelegate)?.collectionView?(collectionView, didEndDisplaying: cell, forItemAt: indexPath)
}
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
- originalDelegate?.collectionView?(collectionView, willDisplay: cell, forItemAt: indexPath)+ // iOS 15 only, see PagerViewIOS15Compat.swift (delete together with the iOS 15 support).+ PagerViewIOS15Compat.attachScrollDelegate(to: collectionView, delegate: delegate)+ (originalDelegate as? UICollectionViewDelegate)?.collectionView?(collectionView, willDisplay: cell, forItemAt: indexPath)
}
override func responds(to aSelector: Selector!) -> Bool {
diff --git a/ios/PagerViewIOS15Compat.swift b/ios/PagerViewIOS15Compat.swift--- a/ios/PagerViewIOS15Compat.swift+++ b/ios/PagerViewIOS15Compat.swift@@ -1,4 +1,21 @@
import UIKit
++@objc public final class PagerTouchHandler: NSObject {+ @objc public static func cancelTouches(in view: UIView, handlerClass: AnyClass) {+ var ancestor: UIView? = view+ while let current = ancestor {+ for recognizer in current.gestureRecognizers ?? [] {+ if recognizer.isKind(of: handlerClass) && recognizer.isEnabled {+ // Resetting RN's surface handler dispatches touchCancel to Pressability.+ // Keep the native paging pan recognizer running and preserve disabled handlers.+ recognizer.isEnabled = false+ recognizer.isEnabled = true+ }+ }+ ancestor = current.superview+ }+ }+}
/// Workarounds for the SwiftUI paged `TabView` on iOS 15.
///
@@ -7,14 +24,18 @@
/// 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. 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 two `PagerViewIOS15Compat` calls in `PagerView.swift`-/// once the minimum supported iOS version is 16.+/// - Note: Remove the compatibility calls in `PagerView.swift`, `PagerScrollDelegate.swift`,+/// and `RNCPagerViewComponentView.mm` when dropping iOS 15 support.
enum PagerViewIOS15Compat {
+ private static var scrollDelegateKey: 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, *) {
@@ -33,11 +54,39 @@
pagingScrollViews(in: collectionView).forEach { $0.bounces = overdrag }
}
+ /// 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) {+ scrollView.keyboardDismissMode = collectionView?.keyboardDismissMode ?? .none+ installScrollDelegate(on: scrollView, delegate: delegate)+ }+ }++ static func installScrollDelegate(on scrollView: UIScrollView, delegate: PagerViewProviderDelegate?) {+ if let scrollDelegate = scrollView.delegate as? PagerScrollDelegate {+ scrollDelegate.delegate = delegate+ return+ }++ let scrollDelegate = PagerScrollDelegate()+ scrollDelegate.originalDelegate = scrollView.delegate+ scrollDelegate.delegate = delegate+ // UIScrollView holds its delegate weakly. Retain only the proxy; its original+ // delegate and event sink remain weak to avoid cycles during screen teardown.+ objc_setAssociatedObject(scrollView, &scrollDelegateKey, scrollDelegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)+ 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
/// the traversal stops at cell boundaries and never touches the user's own scroll views.
- private static func pagingScrollViews(in collectionView: UICollectionView?) -> [UIScrollView] {+ static func pagingScrollViews(in collectionView: UICollectionView?) -> [UIScrollView] {
guard let collectionView else { return [] }
return collectPagingScrollViews(in: collectionView)
diff --git a/ios/PagerView.swift b/ios/PagerView.swift--- a/ios/PagerView.swift+++ b/ios/PagerView.swift@@ -70,13 +70,21 @@
}
}
- if scrollDelegate.originalDelegate == nil {+ scrollDelegate.delegate = delegate+ // On iOS 15 only the embedded orthogonal scroller reports page motion.+ // Outer collection-view layout events must not reset the indicator to 0.+ if #available(iOS 16, *) {+ scrollDelegate.emitsPagingEvents = true+ } else {+ scrollDelegate.emitsPagingEvents = false+ }+ if collectionView.delegate !== scrollDelegate {
scrollDelegate.originalDelegate = collectionView.delegate
- scrollDelegate.delegate = delegate
// VTabView-style rotation preserves TabView's horizontal collection view.
scrollDelegate.orientation = .horizontal
collectionView.delegate = scrollDelegate
}
+ PagerViewIOS15Compat.attachScrollDelegate(to: collectionView, delegate: delegate)
}
}
// GeometryReader is the root since the vertical layout; without this it
@@ -88,6 +96,9 @@
DispatchQueue.main.async {
applyScrollEnabled(props.scrollEnabled)
applyOverdrag(props.overdrag)
+ // Initial cells can predate delegate installation. Retry after layout;+ // later page creation is covered by collectionView(_:willDisplay:forItemAt:).+ PagerViewIOS15Compat.attachScrollDelegate(to: collectionView, delegate: delegate)
}
// `onChange` does not fire for the initial `currentPage` value. Emit the
@@ -114,6 +125,7 @@
}
.onChange(of: props.keyboardDismissMode) { newValue in
collectionView?.keyboardDismissMode = newValue
+ PagerViewIOS15Compat.attachScrollDelegate(to: collectionView, delegate: delegate)
}
}
}
diff --git a/ios/PagerViewProvider.swift b/ios/PagerViewProvider.swift--- a/ios/PagerViewProvider.swift+++ b/ios/PagerViewProvider.swift@@ -1,5 +1,15 @@
import SwiftUI
import UIKit
++private final class PagerHostingController: UIHostingController<PagerView> {+ override var navigationController: UINavigationController? {+ if #available(iOS 16, *) {+ return super.navigationController+ }+ // The embedded SwiftUI host must not override native-stack's header state.+ return nil+ }+}
@objc public enum PageScrollState: Int {
case idle
@@ -236,10 +246,10 @@
// has them at full size. `PageChildViewController` reads *this* view's
// insets (the RN screen's, including a native search bar) and re-injects
// them; the two are independent.
- let hostingController = UIHostingController(- rootView: PagerView(props: props, delegate: delegate),- ignoreSafeArea: true+ let hostingController = PagerHostingController(+ rootView: PagerView(props: props, delegate: delegate)
)
+ hostingController.disableSafeArea()
self.hostingController = hostingController
parentViewController.addChild(hostingController)
diff --git a/ios/RNCPagerViewComponentView.mm b/ios/RNCPagerViewComponentView.mm--- a/ios/RNCPagerViewComponentView.mm+++ b/ios/RNCPagerViewComponentView.mm@@ -7,6 +7,7 @@
#import "RCTFabricComponentsPlugins.h"
#import "React/RCTConversions.h"
+#import <React/RCTSurfaceTouchHandler.h>
#import "RCTOnPageScrollEvent.h"
@@ -102,17 +103,30 @@
#pragma mark - PagerViewProviderDelegate
- (void)onPageScrollWithData:(OnPageScrollEventData *)data {
- const auto eventEmitter = [self pagerEventEmitter];
[self sendScrollEventsForPosition:data.position offset:data.offset];
}
- (void)onPageSelectedWithPosition:(NSInteger)position {
const auto eventEmitter = [self pagerEventEmitter];
+ if (!eventEmitter) {+ return;+ }
eventEmitter->onPageSelected(RNCViewPagerEventEmitter::OnPageSelected{.position = static_cast<double>(position)});
}
- (void)onPageScrollStateChangedWithState:(enum PageScrollState)state {
const auto eventEmitter = [self pagerEventEmitter];
+ if (!eventEmitter) {+ return;+ }++ if (@available(iOS 16, *)) {+ // UIKit handles touch cancellation on the newer paging implementation.+ } else if (state == PageScrollStateDragging) {+ // SwiftUI's iOS 15 orthogonal scroller does not cancel RN's active press.+ // Reset the ancestor surface touch handlers before a lift can become onPress.+ [PagerTouchHandler cancelTouchesIn:self handlerClass:[RCTSurfaceTouchHandler class]];+ }
RNCViewPagerEventEmitter::OnPageScrollStateChangedPageScrollState scrollState;
@@ -171,6 +185,9 @@
- (void)sendScrollEventsForPosition:(NSInteger)position offset:(CGFloat)offset {
const auto eventEmitter = [self pagerEventEmitter];
+ if (!eventEmitter) {+ return;+ }
eventEmitter->onPageScroll(RNCViewPagerEventEmitter::OnPageScroll{
.position = static_cast<double>(position),
.offset = offset
Activity
prakashpro3 commentedon Apr 3, 2026
After downgrading to 7.0.2 works fine.
ndr commentedon Apr 15, 2026
The same on iphone7 with ios 15.8
manuhook commentedon Apr 24, 2026
Here's a patch for iOS 15 support
react-native-pager-view : 8.0.1
react-native : 0.85.2
react-native-tab-view : 4.3.0
SenglayPann commentedon May 21, 2026
Downgrading to v 7.0.2 will resolve the issue
rissajeanne commentedon Aug 8, 2026
@manuhook Mentioning to hopefully help others... this patch is for
react-native-tab-viewand notreact-native-pager-view.gaodeng commentedon Oct 17, 2026
Confirming this on an iPad running iOS 15.x with react-native-pager-view 9.0.9. We observed three symptoms together and confirmed that the combined native workaround described below resolves all three in our app.
This overlaps #1080. The embedded scroll-delegate change is based on @d-mato's #1179; credit for that fix belongs to that PR. The header and touch-cancellation workarounds below address the additional symptoms we observed. I have not tested each patch component independently, so this is not a claim that all three have one root cause or that #1179 alone fixes all three.
Environment
Reproduction outline
These steps describe our app's setup; I have not extracted a standalone reproduction repository yet.
headerShown: falseand render a customTextInputat the top.onPressnavigates to a detail screen.Actual behavior
headerShown: false, including the route title and native back button. Opening the plain non-pager screen and returning makes this unwanted bar disappear. The plain screen itself does not show the unwanted header.onPressof the item where the gesture started, unexpectedly navigating to its detail screen on release.Native workaround
A PanResponderAdapter fallback on iOS 15 avoided the symptoms, but degraded paging performance in our app. We replaced it with a native patch, retaining the normal PagerViewAdapter.
The combined patch:
The navigation-controller override and touch-handler reset should be treated as compatibility workarounds for review, not as a proposal for a new public API. The iOS-specific paths are gated to iOS 15; no Android change is included.
Validation
Combined native patch against react-native-pager-view 9.0.9
Paths are relative to the library root. This includes the #1179-based event fix plus the additional app-validated compatibility changes described above.