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.
  • Under 'always', the contentInset passed to native subtracts the safe area across the scrollable axis, so UIKit's content inset there nets to the caller's own and the safe area applies once.
  • 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.

  • Therefore I'd like to land this in 0.89 with a prominent user migration note.
  • At the same time, if anyone was handling this before with a nested SafeAreaView, they play nicely and this is backwards compatible.

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

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.

1 participant