From 8b9a3a821789164ddcb93417b31bbd7620db06b0 Mon Sep 17 00:00:00 2001 From: xiarunmin Date: Thu, 3 Sep 2026 18:29:57 +0800 Subject: [PATCH 1/4] fix(ios): keep pages out of the safe area again #1085 dropped `ignoreSafeArea: true` from the hosting controller when it added `propagateSafeArea()`. The two solve different problems: the flag keeps SwiftUI from laying the pages out inside the safe area, while `propagateSafeArea()` hands child UIKit views their insets back. With the flag gone, `PagerView`'s `GeometryReader` is measured inside the safe area and every page is framed to that measurement, so the pages shrink and shift while React Native's layout still has them at full size. Co-authored-by: Cursor --- ios/PagerViewProvider.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ios/PagerViewProvider.swift b/ios/PagerViewProvider.swift index aa9c374f..452962d6 100644 --- a/ios/PagerViewProvider.swift +++ b/ios/PagerViewProvider.swift @@ -222,8 +222,14 @@ import UIKit return } + // The hosting view must not carry a safe area: `PagerView`'s `GeometryReader` + // is measured inside it, and every page is framed to that measurement, so the + // pages end up inset by the safe area while React Native's own layout still + // has them at full size. `propagateSafeArea()` on `PageChildViewController` + // is what gives child UIKit views their insets back; the two are independent. let hostingController = UIHostingController( - rootView: PagerView(props: props, delegate: delegate) + rootView: PagerView(props: props, delegate: delegate), + ignoreSafeArea: true ) self.hostingController = hostingController From de29679c2cd1346039f43690d97542ab1dc6dc8e Mon Sep 17 00:00:00 2001 From: xiarunmin Date: Tue, 8 Sep 2026 23:09:39 +0800 Subject: [PATCH 2/4] fix(ios): inject the pager's own safe area, not an ancestor's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restoring ignoreSafeArea: true keeps GeometryReader from shrinking the pages, but nearestNonZeroSafeAreaInsets() then stops at an inner SwiftUI host or the window. Those sources have the home-indicator inset and miss a UINavigationController search bar, which is why #1142's bottom recovered and the top did not. Read PagerViewProvider.safeAreaInsets instead — that view is still under the screen view controller. Co-authored-by: Cursor --- ios/Extensions.swift | 31 ++++++++++++++++++++++++------- ios/PagerViewProvider.swift | 13 +++++++++++-- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/ios/Extensions.swift b/ios/Extensions.swift index b7130d84..7207185e 100644 --- a/ios/Extensions.swift +++ b/ios/Extensions.swift @@ -40,10 +40,21 @@ class PageChildViewController: UIViewController { propagateSafeArea() } - /// Re-applies safe area insets from a stable UIKit source, since SwiftUI's - /// .ignoresSafeArea() causes embedded UIKit views to report .zero. + /// Re-applies the pager's safe area to child UIKit views. SwiftUI's + /// `.ignoresSafeArea()` and `UIHostingController(ignoreSafeArea:)` both make + /// this controller report `.zero`, which breaks + /// `contentInsetAdjustmentBehavior` on embedded scroll views. + /// + /// Do not walk to the nearest ancestor with a non-zero inset. That source is + /// in the ancestor's own coordinate space: an inner `_UIHostingView` often + /// has only the home-indicator edge (#1142), and a `UIScrollView` content + /// view's inset tracks scroll offset (#1099). + /// + /// `PagerViewProvider` sits in the React Native hierarchy under the screen + /// view controller, so its `safeAreaInsets` include a native search bar and + /// are zero when this pager does not overlap the unsafe region. private func propagateSafeArea() { - let insets = nearestNonZeroSafeAreaInsets() ?? view.window?.safeAreaInsets ?? .zero + let insets = targetSafeAreaInsets() if abs(additionalSafeAreaInsets.top - insets.top) > 0.5 || abs(additionalSafeAreaInsets.left - insets.left) > 0.5 || abs(additionalSafeAreaInsets.bottom - insets.bottom) > 0.5 @@ -52,12 +63,18 @@ class PageChildViewController: UIViewController { } } - private func nearestNonZeroSafeAreaInsets() -> UIEdgeInsets? { + private func targetSafeAreaInsets() -> UIEdgeInsets { + if let pager = enclosingPagerView() { + return pager.safeAreaInsets + } + return view.window?.safeAreaInsets ?? .zero + } + + private func enclosingPagerView() -> UIView? { var current = view.superview while let candidate = current { - let insets = candidate.safeAreaInsets - if insets.top > 0 || insets.left > 0 || insets.bottom > 0 || insets.right > 0 { - return insets + if candidate is PagerViewProvider { + return candidate } current = candidate.superview } diff --git a/ios/PagerViewProvider.swift b/ios/PagerViewProvider.swift index 452962d6..82cf6f1e 100644 --- a/ios/PagerViewProvider.swift +++ b/ios/PagerViewProvider.swift @@ -117,6 +117,14 @@ import UIKit } } + override public func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + // The hosting view is forced to `.zero`, so UIKit will not forward this + // change into `PageChildViewController`. Relayout so it can re-read our + // insets (native search bar, keyboard, stacked header, …). + hostingController?.view.setNeedsLayout() + } + @objc public func goTo(index: Int, animated: Bool) { if animated && hasPresentedViewController() { // A native-stack modal can begin its dismissal in the same JavaScript @@ -225,8 +233,9 @@ import UIKit // The hosting view must not carry a safe area: `PagerView`'s `GeometryReader` // is measured inside it, and every page is framed to that measurement, so the // pages end up inset by the safe area while React Native's own layout still - // has them at full size. `propagateSafeArea()` on `PageChildViewController` - // is what gives child UIKit views their insets back; the two are independent. + // 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 From 721caef6e914631d2d24472b13940c838626f3cf Mon Sep 17 00:00:00 2001 From: xiarunmin Date: Wed, 9 Sep 2026 16:05:32 +0800 Subject: [PATCH 3/4] fix(ios): subtract inherited search-bar inset instead of adding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PageChildViewController is under the hosting controller, which is a child of the RN screen, so it already inherits the nav + search-bar top inset. additionalSafeAreaInsets is additive: copying the pager's own insets (often top 0, because RN already laid the pager below the header) left that inherited extra in place, which is why #1142 still showed a gap after the previous commit. Set additional to target - inherited — negative is required — so the page matches the pager's overlap with the screen safe area. --- ios/Extensions.swift | 78 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 15 deletions(-) diff --git a/ios/Extensions.swift b/ios/Extensions.swift index 7207185e..ad9987aa 100644 --- a/ios/Extensions.swift +++ b/ios/Extensions.swift @@ -40,21 +40,23 @@ class PageChildViewController: UIViewController { propagateSafeArea() } - /// Re-applies the pager's safe area to child UIKit views. SwiftUI's - /// `.ignoresSafeArea()` and `UIHostingController(ignoreSafeArea:)` both make - /// this controller report `.zero`, which breaks - /// `contentInsetAdjustmentBehavior` on embedded scroll views. + /// Makes this page's `safeAreaInsets` match the pager view's overlap with + /// the screen safe area, so `contentInsetAdjustmentBehavior` on an embedded + /// scroll view matches a list that is not inside `PagerView`. /// - /// Do not walk to the nearest ancestor with a non-zero inset. That source is - /// in the ancestor's own coordinate space: an inner `_UIHostingView` often - /// has only the home-indicator edge (#1142), and a `UIScrollView` content - /// view's inset tracks scroll offset (#1099). + /// `additionalSafeAreaInsets` is *added* to what this controller already + /// inherits. The hosting controller is a child of the RN screen, so the + /// inherited top is often the full nav + search-bar inset even when React + /// Native has already laid the pager out below the header. Copying + /// `PagerViewProvider.safeAreaInsets` on top of that (or leaving the + /// inherited value untouched when the pager's own top is 0) is the extra + /// top inset in #1142. Negative values are required to subtract it. /// - /// `PagerViewProvider` sits in the React Native hierarchy under the screen - /// view controller, so its `safeAreaInsets` include a native search bar and - /// are zero when this pager does not overlap the unsafe region. + /// Do not walk to the nearest ancestor with a non-zero inset. That source + /// is in the ancestor's own coordinate space and can track scroll offset + /// (#1099). private func propagateSafeArea() { - let insets = targetSafeAreaInsets() + let insets = additionalInsets(toReach: targetSafeAreaInsets()) if abs(additionalSafeAreaInsets.top - insets.top) > 0.5 || abs(additionalSafeAreaInsets.left - insets.left) > 0.5 || abs(additionalSafeAreaInsets.bottom - insets.bottom) > 0.5 @@ -63,11 +65,27 @@ class PageChildViewController: UIViewController { } } + private func additionalInsets(toReach target: UIEdgeInsets) -> UIEdgeInsets { + let inherited = UIEdgeInsets( + top: view.safeAreaInsets.top - additionalSafeAreaInsets.top, + left: view.safeAreaInsets.left - additionalSafeAreaInsets.left, + bottom: view.safeAreaInsets.bottom - additionalSafeAreaInsets.bottom, + right: view.safeAreaInsets.right - additionalSafeAreaInsets.right + ) + return UIEdgeInsets( + top: target.top - inherited.top, + left: target.left - inherited.left, + bottom: target.bottom - inherited.bottom, + right: target.right - inherited.right + ) + } + private func targetSafeAreaInsets() -> UIEdgeInsets { - if let pager = enclosingPagerView() { - return pager.safeAreaInsets + guard let pager = enclosingPagerView() else { + return view.window.map { overlap(of: view, withSafeAreaIn: $0) } ?? .zero } - return view.window?.safeAreaInsets ?? .zero + let container = screenView(from: pager) ?? pager.window ?? pager + return overlap(of: pager, withSafeAreaIn: container) } private func enclosingPagerView() -> UIView? { @@ -80,6 +98,36 @@ class PageChildViewController: UIViewController { } return nil } + + /// The RN screen view controller — not `PageChildViewController` (this) and + /// not the pager's `UIHostingController`, both of which sit in the SwiftUI + /// subtree and do not own the navigation/search-bar safe area. + private func screenView(from pager: UIView) -> UIView? { + var responder: UIResponder? = pager + while let current = responder { + if let controller = current as? UIViewController, + !(controller is PageChildViewController), + !(controller is UIHostingController) { + return controller.view + } + responder = current.next + } + return nil + } + + private func overlap(of child: UIView, withSafeAreaIn container: UIView) -> UIEdgeInsets { + guard child.bounds.width > 0, child.bounds.height > 0 else { + return .zero + } + let safe = container.safeAreaLayoutGuide.layoutFrame + let frame = child.convert(child.bounds, to: container) + return UIEdgeInsets( + top: max(0, safe.minY - frame.minY), + left: max(0, safe.minX - frame.minX), + bottom: max(0, frame.maxY - safe.maxY), + right: max(0, frame.maxX - safe.maxX) + ) + } } extension Collection { From 9e982c1cd0441507d648920674e0298ef48a0d4f Mon Sep 17 00:00:00 2001 From: xiarunmin Date: Wed, 9 Sep 2026 22:58:28 +0800 Subject: [PATCH 4/4] test: add #1142 search-bar inset example and Maestro flow troZee asked for the repro under ghIssues plus a Maestro check before merge. The example is iliapnmrv's native-stack search-bar case, and the flow asserts the pager list reports safe-area: pass from the first-row / bottom-marker window positions. --- .../issue_1142_search_bar_inset_repro.yaml | 36 +++ ...sue_1142_search_bar_inset_repro_setup.yaml | 25 ++ example/src/App.tsx | 17 ++ .../Issue1142SearchBarInsetRepro.tsx | 287 ++++++++++++++++++ 4 files changed, 365 insertions(+) create mode 100644 .maestro/issues/issue_1142_search_bar_inset_repro.yaml create mode 100644 .maestro/setup/issue_1142_search_bar_inset_repro_setup.yaml create mode 100644 example/src/gh-issues/Issue1142SearchBarInsetRepro.tsx diff --git a/.maestro/issues/issue_1142_search_bar_inset_repro.yaml b/.maestro/issues/issue_1142_search_bar_inset_repro.yaml new file mode 100644 index 00000000..7837459a --- /dev/null +++ b/.maestro/issues/issue_1142_search_bar_inset_repro.yaml @@ -0,0 +1,36 @@ +appId: com.pagerviewexample +tags: + - ios + - regression +--- +- runFlow: ../setup/issue_1142_search_bar_inset_repro_setup.yaml + +# A FlatList inside PagerView on a native-stack screen with a stacked search +# bar must get the same automatic insets as a bare list. The example reports +# the first-row and bottom-marker window positions as "safe-area: pass". +- extendedWaitUntil: + visible: 'safe-area: pass' + timeout: 15000 + +- assertVisible: + id: 'issue-1142-pager-verdict' + +- assertVisible: + id: 'issue-1142-pager-first-row' + +- assertVisible: + id: 'issue-1142-pager-bottom-marker' + +- swipe: + from: + id: 'issue-1142-pager' + start: 90%, 60% + end: 10%, 60% + duration: 500 + +- extendedWaitUntil: + visible: + id: 'issue-1142-second-page' + timeout: 5000 + +- assertVisible: 'Second page' diff --git a/.maestro/setup/issue_1142_search_bar_inset_repro_setup.yaml b/.maestro/setup/issue_1142_search_bar_inset_repro_setup.yaml new file mode 100644 index 00000000..7516ada0 --- /dev/null +++ b/.maestro/setup/issue_1142_search_bar_inset_repro_setup.yaml @@ -0,0 +1,25 @@ +appId: ${APP_ID} +--- +- launchApp + +# The issue examples sit below the fundamental examples on the home screen. +- scrollUntilVisible: + element: + id: 'Issue #1142 Search Bar Inset Repro' + direction: DOWN + +- tapOn: + id: 'Issue #1142 Search Bar Inset Repro' + +- extendedWaitUntil: + visible: + id: 'issue-1142-hub' + timeout: 10000 + +- tapOn: + id: 'issue-1142-open-pager' + +- extendedWaitUntil: + visible: + id: 'issue-1142-pager' + timeout: 10000 diff --git a/example/src/App.tsx b/example/src/App.tsx index 88b31628..1b8fd45a 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -40,6 +40,11 @@ import { Issue1083ModalSetPageExample, ModalSetPageModalScreen, } from './gh-issues/Issue1083ModalSetPageExample'; +import { + Issue1142PlainListScreen, + Issue1142PagerListScreen, + Issue1142SearchBarInsetRepro, +} from './gh-issues/Issue1142SearchBarInsetRepro'; function BasicPagerViewExampleScreen() { return ; @@ -124,6 +129,10 @@ const ghIssues: Example[] = [ component: Issue1099SafeAreaRepro, name: 'Issue #1099 Safe Area Repro', }, + { + component: Issue1142SearchBarInsetRepro, + name: 'Issue #1142 Search Bar Inset Repro', + }, ]; const allExamples = [ @@ -265,6 +274,14 @@ export function Navigation() { animation: 'slide_from_bottom', }} /> + + diff --git a/example/src/gh-issues/Issue1142SearchBarInsetRepro.tsx b/example/src/gh-issues/Issue1142SearchBarInsetRepro.tsx new file mode 100644 index 00000000..3fae8e40 --- /dev/null +++ b/example/src/gh-issues/Issue1142SearchBarInsetRepro.tsx @@ -0,0 +1,287 @@ +/** + * Repro for #1142 / #1140: a FlatList inside PagerView on a native-stack + * screen with `headerSearchBarOptions` must pick up the same top and bottom + * insets as the same list rendered without a pager. + * + * `contentInsetAdjustmentBehavior="automatic"` is the path under test. Do + * not compensate with `useHeaderHeight()` padding — that hid the bug. + * + * https://github.com/iliapnmrv/react-native-pager-view-ios-searchbar-inset + */ +import { useHeaderHeight } from '@react-navigation/elements'; +import { useNavigation } from '@react-navigation/native'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { + Button, + Dimensions, + FlatList, + Platform, + StyleSheet, + Text, + View, + type ListRenderItem, + type View as ViewType, +} from 'react-native'; +import PagerView from 'react-native-pager-view'; + +const ROWS = Array.from({ length: 40 }, (_, index) => `Row ${index + 1}`); + +function useSearchHeader() { + const navigation = useNavigation(); + + useLayoutEffect(() => { + navigation.setOptions({ + title: 'Items', + ...(Platform.OS === 'ios' + ? { + headerSearchBarOptions: { + placeholder: 'Search items', + placement: 'stacked', + hideWhenScrolling: false, + hideNavigationBar: false, + onChangeText: () => {}, + }, + } + : null), + }); + }, [navigation]); +} + +function useWindowY() { + const ref = useRef(null); + const [y, setY] = useState(null); + + const measure = useCallback(() => { + ref.current?.measureInWindow((_x, nextY) => { + if (Number.isFinite(nextY)) { + setY(nextY); + } + }); + }, []); + + return { ref, y, measure }; +} + +const renderRow: ListRenderItem = ({ item }) => ( + + {item} + +); + +function MeasuredList({ testIDPrefix }: { testIDPrefix: string }) { + const headerHeight = useHeaderHeight(); + const firstRow = useWindowY(); + const bottomMarker = useWindowY(); + const measureFirstRow = firstRow.measure; + const measureBottomMarker = bottomMarker.measure; + + useEffect(() => { + const measure = () => { + measureFirstRow(); + measureBottomMarker(); + }; + const frame = requestAnimationFrame(measure); + const later = setTimeout(measure, 400); + const settled = setTimeout(measure, 900); + return () => { + cancelAnimationFrame(frame); + clearTimeout(later); + clearTimeout(settled); + }; + }, [headerHeight, measureBottomMarker, measureFirstRow]); + + const windowHeight = Dimensions.get('window').height; + const firstY = firstRow.y; + const bottomY = bottomMarker.y; + const measuring = firstY == null || bottomY == null || headerHeight <= 0; + const topOk = + !measuring && firstY >= headerHeight - 8 && firstY <= headerHeight + 28; + const bottomOk = + !measuring && bottomY > windowHeight * 0.82 && bottomY < windowHeight + 1; + const verdict = measuring + ? 'safe-area: measuring' + : topOk && bottomOk + ? 'safe-area: pass' + : 'safe-area: fail'; + + return ( + + item} + onLayout={() => { + firstRow.measure(); + bottomMarker.measure(); + }} + renderItem={(info) => + info.index === 0 ? ( + + {info.item} + + ) : ( + renderRow(info) + ) + } + /> + + + {verdict} + + + ); +} + +export function Issue1142SearchBarInsetRepro() { + const navigation = useNavigation(); + + return ( + + Issue #1142: search-bar inset + + Both screens use the same native-stack search bar and the same FlatList + with contentInsetAdjustmentBehavior="automatic". The only difference is + whether the list is inside PagerView. The first row must sit just below + the search bar, and the red marker must sit on the bottom edge of the + page. + +