Skip to content

[ScrollView][iOS] Fix contentInsetAdjustmentBehavior for the horizontal safe area (iPhone Duo) - #59194

Open
huntie wants to merge 1 commit into
mainfrom
pr59194
Open

huntie wants to merge 1 commit into
mainfrom
pr59194

Conversation

@huntie

@huntie huntie commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

<ScrollView>'s contentInsetAdjustmentBehavior prop provides built-in safe area handling today on the vertical axis (e.g. above the home indicator), however is missing safe area handling on the cross axis (i.e. iPhone Duo vertical rail).

Since #58109 (experimental_onSafeAreaInsetsChange), we now have the API to conveniently patch this behaviour in core.

From the user's perspective

This presents a very frequent code authoring issue.

Typical ScrollView code at the top of a layout today (e.g. app screen in Expo Router):

<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={styles.content}>
  {children}
</ScrollView>

Without this diff, needs to become:

<ScrollView contentInsetAdjustmentBehavior="automatic">
  // ['left', 'right'] compensates for ScrollView only handling 'bottom' today
  <SafeAreaView edges={['left', 'right']} style={styles.content}>
    {children}
  </SafeAreaView>
</ScrollView>

⬆️ contentInsetAdjustmentBehavior="automatic" isn't doing its job on the horizontal safe areas on a device such as iPhone Duo.

With this diff the scroll view handles it alone:

Before, iPhone Duo folded (automatic) After

And the code sample becomes resolved:

  • Users can preserve/revert to the original ScrollView-only hierarchy — backwards compatible ✅
  • Apps that update to the next RN version fresh and try to support iPhone Duo don't face this issue at all ✅

Why this is a bug

Remember that:

  • <ScrollView contentInsetAdjustmentBehavior="automatic"> already handles the bottom safe area inset on relevant devices.
  • As with the bottom inset case, the horizontal insets case needs to pad the ScrollView itself (not be wrapped in a page container) — so that the right hand scroll indicator hits the device edge as expected.

And again, with iOS 27.1 and iPhone Duo, a typical React Native screen with a <ScrollView> (e.g. in Expo Router) that handled safe area before, does not work now. This is a combination of:

  • Our choice in Expo to intentionally not SafeArea-wrap every screen — for the sake of apps being able to draw under the rail when needed.
  • And as above, the incorrectness of the SafeAreaView > ScrollView hierarchy (reversed), which breaks the system scrollbar expectation.

"automatic" should be automatic. By fixing this we delete the problem for all RN users.

Changes

  • ScrollView observes its own safe area when the behavior is not 'never' and pads the content container across the scrollable axis, on top of the padding its style resolves to on that edge.
  • The native scroll view removes the content inset UIKit adds across that axis for the safe area, so the safe area applies once: under 'always', on an axis that always bounces, and when a navigation stack registers the scroll view as its content scroll view, as react-native-screens does for the first scroll view of a screen. The contentInset prop stays as given along the scrollable axis.
  • The horizontal prop reaches the native view on iOS, as it already did on Android, so the scroll view knows which axis it scrolls along.
  • Add a full screen RNTester example under ScrollView that switches between the four values, vertical and horizontal.

Impact

⚠️ This is behaviour change fixing both iPhone Duo in portrait (a recent use case) AND phones with camera cutouts/home indicators in landscape.

At the same time, if anyone was handling this before with a nested SafeAreaView, they play nicely and is backwards compatible.

  • The only user-side breakage possible is we now apply a left/right safe area in landscape where the app did want to draw close to the device edges. We should note this clearly in the changelog.

Notes

  • The padding goes on the content container rather than narrowing the scroll view, so the background and the scroll indicator keep the screen edge, matching UIKit's own tables. A percentage padding on the container resolves to 0 before the inset is added.
  • Android is unchanged: the prop is documented iOS only and the native scroll view ignores it.

Changelog:
[iOS][Fixed] - ScrollView: contentInsetAdjustmentBehavior keeps content clear of the safe area across the scrollable axis, such as the rail on iPhone Duo and the notch in landscape

Test Plan

yarn test packages/react-native/src/private/components/scrollview

RNTester on an iPhone Duo simulator (iOS 27.1), ScrollView > Content Inset Adjustment Behavior, which opens a full screen modal:

iPhone Duo folded, never iPhone Duo folded, automatic iPhone Duo folded, always
Rows run under the rail ✅ Rows stop at the rail; the container's background still runs under it ✅ Rows stop at the rail, with no sideways offset from UIKit's content inset
iPhone Duo unfolded, automatic iPhone Duo unfolded, always
✅ Rows stop at the rail on the open display ✅ Same, with no sideways offset
iPhone Duo folded, automatic, while scrolling
✅ The scroll indicator stays at the device edge, since the scroll view itself still spans the screen

iPhone 18 Pro simulator (iOS 27.0) in landscape, the same example. Both sides carry a safe area inset here, the camera cutout side and the opposite edge:

Before, iPhone 18 Pro landscape (automatic) After
Rows run under the cutout and to the far edge, as never does ✅ Rows stop at both insets; the container's background still runs edge to edge
iPhone 18 Pro landscape, never iPhone 18 Pro landscape, always
Rows run under the cutout and to the far edge ✅ Rows stop at both insets, with no sideways offset on either side

Nesting the layout with <SafeAreaView> (backwards compatible)

Build with a temporary toggle added in the example that wraps the rows in react-native-safe-area-context's SafeAreaView, with a yellow background (today's temporarily necessary authoring behaviour):

<ScrollView contentInsetAdjustmentBehavior={behavior}>
  <SafeAreaView edges={['left', 'right']} style={{backgroundColor: '#f5d76e'}}>
    {rows}
  </SafeAreaView>
</ScrollView>
automatic automatic, inner SafeAreaView never, inner SafeAreaView
Rows stop at the rail ✅ Rows stop at the same place: the content container is already clear of the rail, so the inner view's own safe area is zero and it adds no padding ✅ The inner view pads instead, and rows stop at the same place
  • ✅ Switching between the values re-lays the content out in place, and the modal opens clear of the rail with automatic selected.
  • ⚠️ Not run: Fantom, which targets Android, where the behavior is off; the padding helper is covered by Jest instead.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 19, 2026
@huntie
huntie marked this pull request as ready for review October 19, 2026 15:13
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 19, 2026
@cipolleschi

Copy link
Copy Markdown
Contributor

spm_newapp in release is red. Can you check whether it is a flakyness issue or a real problem?

@huntie

huntie commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

@cipolleschi Reran ✅

@cipolleschi cipolleschi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for tackling this, it's a real pain point and the before/after screenshots are great. The native event is well designed: it's deduped, it re-arms on prop set, and the synchronous render avoids a first-frame flash. My concerns are all in how the extra padding gets merged into contentContainerStyle. In a few common setups it drops either the user's padding or the safe-area padding. I left inline comments.

Since this changes layout for every iOS ScrollView using automatic, scrollableAxes or always, and you already flag landscape apps as a possible breakage, could we put it behind a ReactNativeFeatureFlags flag so we can roll it out gradually?

Comment thread packages/react-native/Libraries/Components/ScrollView/ScrollView.js Outdated
Comment thread packages/react-native/Libraries/Components/ScrollView/ScrollView.js Outdated
Comment thread packages/react-native/Libraries/Components/ScrollView/ScrollView.js Outdated
@huntie

huntie commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

could we put it behind a ReactNativeFeatureFlags flag so we can roll it out gradually?

@cipolleschi The aim is we ship this for Expo end users ASAP. Additionally we (currently) have no API for overriding individual flags from Expo in order to test them out in our framework. Latter is solvable, but likely not in this timeframe.

The edge case in which this affects users:

  1. <ScrollView contentInsetAdjustmentBehavior="automatic"> is set.
  2. and, the app decides in landscape to draw right to the screen edges, with no inner SafeAreaView.
    • In this case, we will now horizontally pad the inner view away from the device camera cutout.
    • ScrollView > SafeAreaView nesting (which should be close to 100% of users) is backwards compatible and unchanged.

I'm trying to balance the new users compiling in Xcode 27.1 today and suffering from this, vs the theoretically tiny cases where contentInsetAdjustmentBehavior="automatic" has been set and an inner safe area hasn't been used. In fact, I'd wager more apps would find their horizontal functionality fixed.

@cipolleschi

Copy link
Copy Markdown
Contributor

@huntie Fair enough, I'm fine shipping this without a feature flag given the Expo timeline and the lack of a per-flag override.

The inline comments still need to be addressed before we merge, though. They're not about the new landscape behavior; they're cases where the padding merge regresses apps that never opted into anything:

  • paddingInline* / paddingBlock* in contentContainerStyle are dropped, or they cancel out the safe-area padding.
  • Percentage padding resolves to 0.
  • In RTL, paddingLeft/paddingRight overwrite the safe-area padding because of Fabric's left/right swap.

Because the override is emitted even when the cross-axis insets are 0, these hit every iPhone in portrait using automatic, not just iPhone Duo or landscape. Returning null for zero insets would shrink the impact a lot. Clearing the conflicting keys in the override, plus a test of the final merged props, would cover the rest.

The other inline comments (re-renders, stale insets with a user handler, edge cases) are lower priority. If they'd slow this PR down, could you open them as a follow-up?

@huntie

huntie commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

@cipolleschi Thanks, addressed and re-tested(!) in the latest push:

  • Padding resolution extended: now follows Fabric's order, including the paddingInline*/paddingBlock* aliases.
  • RTL: the override unsets the physical and alias keys, so only the logical keys reach the left/right swap.
  • Zero cross-axis insets emit no override at all; an uninset edge keeps the user's value as is, percentages included.
  • Tests on the rendered <ScrollView>'s merged props, plus a Fantom test laid out under Yoga.

The padding and RTL additions are sizeable, but thankfully this base contentInsetAdjustmentBehaviour + contentContainerStyle behaviour is now solved for all of ScrollView, FlatList, SectionList etc 💪🏻.

Lower-pri comments are also fixed in the same push.

@cipolleschi

Copy link
Copy Markdown
Contributor

Thanks for the quick turnaround, most of the previous round is addressed. One blocker remains:

The undefined keys in the override don't clear anything on device. The first insets event takes the override from null to an object, and ReactNativeAttributePayload's addNestedProperty skips undefined keys unless the same payload already set them. Fabric then keeps the previous native values. I diffed the payloads on this branch:

  • {paddingInlineStart: 8} → payload {paddingStart: 67, paddingEnd: 59}. paddingInlineStart: 8 stays native and overrides Edge::Start, so the inset is lost even in LTR in landscape.
  • {paddingLeft: 10} → payload {paddingStart: 69, paddingEnd: 59}. In RTL the swap copies the stale paddingLeft onto Start again.

The current tests miss this: the Jest test checks the flattened style, and the Fantom test only covers the first render in LTR, where undefined does clear. Using null instead of undefined fixes adding, removing (the user's values are restored) and changing the override. Could you switch to null and add a test of the render → insets event transition (the diffed payload, or a Fantom rerender)?

Question: under automatic, UIKit always adjusts content vertically when the scroll view is a view controller's content in a navigation or tab bar controller. Could you check that a horizontal ScrollView at the root of a stack screen doesn't get a double top/bottom inset? The test plan only covers a full-screen modal.

Non-blocking follow-ups: these two can land in a separate PR, could you own them?

  • The per-frame render in a horizontal pager in landscape is still there (only the duplicate updates were removed).
  • Under never with a caller handler, we still forceUpdate() without padding anything.

@huntie
huntie force-pushed the pr59194 branch 2 times, most recently from d5d4ff6 to 8eb2473 Compare October 26, 2026 14:17
@huntie

huntie commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

@cipolleschi Both addressed in the latest push.

1/ undefined → null: The override resets the other row keys with null, and the Fantom test now walks render → insets → changed insets → no insets with a paddingInlineEnd.

2/ Navigation stack nesting

Yup, this was a problem, and this went further than automatic's own heuristic.

On its own, UIKit leaves a horizontal scroll view in a navigation stack alone. react-native-screens, however, registers the first scroll view of each screen as the view controller's content scroll view (for the scroll edge effect), and from then on UIKit insets its top and bottom under automatic too. JS cannot observe that inset, so it needs to be cancelled natively.

The scroll view now removes whatever UIKit adds across the scrollable axis itself, and keeps content pinned to the leading edge. This replaces the always, bounce and centerContent special cases from the previous push with one rule.

Verified in RNTester with its root hosted in a UINavigationController and the ScrollView registered to match RN Screens:

Previous push, iPhone Duo folded, automatic This push

Follow-ups

  • never: Done here. The handler only forces a render while the content is padded.
  • Per-frame render — will stack.

@cipolleschi

Copy link
Copy Markdown
Contributor

Thanks, both are addressed. The null reset plus the Fantom walk through render → insets → changed insets → no insets cover the update path, and the native cancellation is a nicer single rule than the special cases. The failing prebuild_react_native_core jobs are a Hermes tarball 404, unrelated to this PR.

One interaction to check before we merge: applyBaseContentInset decides from self.contentInsetAdjustmentBehavior, but JS decides from the prop. react-native-screens' native tabs flip the first descendant ScrollView from never to automatic natively by default (overrideScrollViewContentInsetAdjustmentBehavior, RNSScrollViewHelper). For a horizontal ScrollView as the first scroll view in a native tab, UIKit adds the bar insets on top/bottom, native now removes them, and JS doesn't pad because its behavior is still unset. So the content would end up under the tab and navigation bars, where today it's inset. Could we gate the native correction on the contentInsetAdjustmentBehavior prop, so it only runs when JS pads the content? That would also keep never scroll views untouched.

Minor: scroll events report _scrollView.contentInset (RCTScrollViewComponentView.mm:602), so where the correction applies, onScroll now gets the corrected values instead of the prop. Reporting baseContentInset there would keep the payload unchanged.

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Visual regression

No visual differences at 5c2a61f61744.

ios: no capture

@huntie

huntie commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

@cipolleschi Feedback addressed. Sorry this makes the overall logic quite heavy now, but all states are tested.

I'll see in follow up PRs what we could do to refactor maybe.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Expo Partner: Expo Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants