Skip to content

Fix contentInsetAdjustmentBehavior for the horizontal safe area (iPhone Duo) (#59194) - #59529

Open
cipolleschi wants to merge 2 commits into
react:mainfrom
cipolleschi:export-D127787712
Open

cipolleschi wants to merge 2 commits into
react:mainfrom
cipolleschi:export-D127787712

Conversation

@cipolleschi

Copy link
Copy Markdown
Contributor

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:
Validation of this split:

  • Ran js1 test --runTestsByPath for ScrollView-safeAreaContentPadding-test.js and getSafeAreaContentPadding-test.js: 20 tests passed. Includes native opt-in coordination, switching to never, and horizontal true-to-undefined/null.
  • Regenerated C++ API snapshots in the native preparation.
  • Independent static review found no remaining code blocker.
  • Not re-run on iOS: old-JS/new-native behaviour, true-to-true recycling, first mount, rotation and behaviour switching. These still need runtime verification.

Upstream visual verification of the combined implementation, supplied with the import and not re-run for this split:

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.

Differential Revision: D127787712

Pulled By: cipolleschi

cipolleschi and others added 2 commits October 27, 2026 04:02
Summary:

Keep the requested content inset separate from UIKit's safe area correction and add native support for `padsContentForSafeArea`, defaulting to false. Existing JavaScript retains the current inset behaviour. The follow-up enables the correction together with content-container padding.

Changelog: [Internal]

Differential Revision: D127790579
…ne Duo) (react#59194)

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 react#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):

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

Without this diff, needs to become:

```jsx
<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 |
|---|---|
| <img src="https://github.com/user-attachments/assets/9252e804-c69d-413b-9439-04df77d949c7" width="300" /> | <img src="https://github.com/user-attachments/assets/cf64c244-682e-48f9-9616-ddea724fa080" width="300" /> |

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:
Validation of this split:
- Ran `js1 test --runTestsByPath` for `ScrollView-safeAreaContentPadding-test.js` and `getSafeAreaContentPadding-test.js`: 20 tests passed. Includes native opt-in coordination, switching to never, and horizontal true-to-undefined/null.
- Regenerated C++ API snapshots in the native preparation.
- Independent static review found no remaining code blocker.
- Not re-run on iOS: old-JS/new-native behaviour, true-to-true recycling, first mount, rotation and behaviour switching. These still need runtime verification.

Upstream visual verification of the combined implementation, supplied with the import and not re-run for this split:

```
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 |
|---|---|---|
| <img src="https://github.com/user-attachments/assets/4dfa447d-d403-4733-a090-76ac8af821bb" width="300" /> | <img src="https://github.com/user-attachments/assets/c16983c4-7858-4a94-bab2-0f826f0f91a7" width="300" /> | <img src="https://github.com/user-attachments/assets/2b508dbf-9adf-4b18-9509-f0bcf15aa8e7" width="300" /> |
| 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 |
|---|---|
| <img src="https://github.com/user-attachments/assets/2e6a0b7a-4c8a-4895-b26a-7069ff84e307" width="400" /> | <img src="https://github.com/user-attachments/assets/1216d844-18fe-43ea-99a9-944b2d66fe97" width="400" /> |
| ✅ Rows stop at the rail on the open display | ✅ Same, with no sideways offset |

| iPhone Duo folded, automatic, while scrolling |
|---|
| <img src="https://github.com/user-attachments/assets/ba785d2a-d635-47d4-98ff-ead21450fd8b" width="300" /> |
| ✅ 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 |
|---|---|
| <img src="https://github.com/user-attachments/assets/c8365985-ba30-4be1-967d-d8cd0b6bb85d" width="400" /> | <img src="https://github.com/user-attachments/assets/27637ab5-acc4-4134-95b9-e6421734340b" width="400" /> |
| 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 |
|---|---|
| <img src="https://github.com/user-attachments/assets/9aab7554-6cdd-49d2-b545-2b5039fb013d" width="400" /> | <img src="https://github.com/user-attachments/assets/662810c2-562f-4530-b423-bc41d5384b33" width="400" /> |
| 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):

```jsx
<ScrollView contentInsetAdjustmentBehavior={behavior}>
  <SafeAreaView edges={['left', 'right']} style={{backgroundColor: '#f5d76e'}}>
    {rows}
  </SafeAreaView>
</ScrollView>
```

| automatic | automatic, inner SafeAreaView | never, inner SafeAreaView |
|---|---|---|
| <img src="https://github.com/user-attachments/assets/2b430401-7488-4fac-83a2-18cf9b2e6b18" width="300" /> | <img src="https://github.com/user-attachments/assets/1ced6214-d9f1-413f-8281-f3f146df7f29" width="300" /> | <img src="https://github.com/user-attachments/assets/40e0aed6-5b88-4a67-9893-eecbed9717f6" width="300" /> |
| 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.

Differential Revision: D127787712

Pulled By: cipolleschi
@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 27, 2026
@meta-codesync

meta-codesync Bot commented Oct 27, 2026

Copy link
Copy Markdown

@cipolleschi has exported this pull request. If you are a Meta employee, you can view the originating Diff in D127787712.

@github-actions

Copy link
Copy Markdown

Visual regression

2 visual difference(s) at 99957f8ec783. Visual differences need maintainer approval. Review the report, then apply visual-change-approved.

ios button/default-styling/default: Changed, 38223 px (1.533%)

diff

Expected and Captured
Expected Captured
expected captured

ios text/last-line-clipping/default: Changed, 1277820 px (92.943%)

diff

Expected and Captured
Expected Captured
expected captured

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. meta-exported p: Facebook Partner: Facebook Partner

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants