Skip to content

[ScrollView][iOS] Refactor ScrollView inset handling to lay out natively - #59555

Open
huntie wants to merge 1 commit into
pr59194from
pr59555
Open

huntie wants to merge 1 commit into
pr59194from
pr59555

Conversation

@huntie

@huntie huntie commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Context

Follow-up to #59194, which extended behaviour of <ScrollView contentInsetAdjustmentBehavior="auto"> to pad the content container across the scroll axis, fixing horizontal safe area insets (e.g. iPhone Duo rail).

This diff

A refactor moving padding handling from JS into the content container's native component, so the safe area reaches the layout without a round trip through React.

After this change, <ScrollView> will no longer depend on experimental_onSafeAreaInsetsChange.

Changes

  • Swaps out RCTScrollContentView from a View alias to be a dedicated component of its own. Its shadow node pads the Yoga style by the safe area in its state, on top of the padding the style resolves to on each edge, following the precedence Yoga gives the padding edges for the layout direction.
  • <ScrollView> no longer observes experimental_onSafeAreaInsetsChange, merges padding into contentContainerStyle or re-renders when the insets change.
Preceding PR This PR
RCTViewComponentView, frame (0 0; 466 1574) RCTScrollContentViewComponentView, the same frame

Notes

  • Android is unchanged: it never renders RCTScrollContentView, and the shared view name mapping only loses the iOS alias.

Changelog: [Internal]

Test Plan

Repeat all verification from #59194.

iPhone Duo (iOS 27.1)

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
  • ✅ Unfolding while the modal is open re-lays the rows out for the wider display without a relaunch: the controls row goes from 382 to 867 wide within a few seconds.
  • ✅ Horizontal, automatic and always: items stop above the home indicator, with the rail inset along the scrollable axis only.
  • ✅ While scrolling, the scroll indicator stays at the device edge.

iPhone 18 Pro (iOS 27.0)

iPhone 18 Pro landscape, automatic
✅ Rows stop at both insets, the camera cutout side and the opposite edge; the container's background still runs edge to edge

Nesting the layout with <SafeAreaView> (backwards compatible)

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

Nested in react-native-screens

RNTester's root hosted in a tab bar controller holding a navigation stack, with the first scroll view registered and flipped from never to automatic natively the way react-native-screens' native tabs do, and a horizontal <ScrollView> as the screen:

iPhone Duo folded, native tab, prop unset iPhone Duo folded, native tab, automatic
✅ UIKit's insets are left alone, adjustedContentInset={82, 0, 34, 84}, and the cards rest below the navigation bar ✅ The container pads the top and bottom once and UIKit's cross-axis inset is removed, adjustedContentInset={0, 0, 0, 84}

@huntie
huntie changed the base branch from main to pr59194 October 27, 2026 21:45
@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
@huntie
huntie added this pull request to stack #59556 October 27, 2026 21:46
## Summary:

Follow-up to #59194.

`<ScrollView contentInsetAdjustmentBehavior>` keeps content clear of the safe area across the scrollable axis, such as the rail on iPhone Duo, by padding the content container. This moves that padding from JS into the content container's native component, so the safe area reaches the layout without a round trip through React.

- `RCTScrollContentView`, which `<ScrollView>` already renders as its content container on iOS, becomes a component of its own instead of an alias of `View`. Its shadow node pads the Yoga style by the safe area in its state, on top of the padding the style resolves to on each edge, following the precedence Yoga gives the padding edges for the layout direction.
- The scroll view hands its content container the pair of safe area insets across the scrollable axis whenever its own insets, props or window change, and zero while the behavior is `'never'`.
- `<ScrollView>` no longer observes `experimental_onSafeAreaInsetsChange`, merges padding into `contentContainerStyle` or re-renders when the insets change. The JS padding helper and its tests go with it.

Notes:

- A scroll view moving within the safe area now costs a state update and layout per change rather than a React render.
- Android is unchanged: it never renders `RCTScrollContentView`, and the shared view name mapping only loses the iOS alias.

## Changelog:

[INTERNAL] [CHANGED] - Pad the iOS ScrollView content container for the safe area natively instead of from JS

## Test Plan:

RNTester, ScrollView > Content Inset Adjustment Behavior, which opens a full screen modal. Every state matches the screenshots of the preceding PR.

**Unit tests**

```
ReactCommon/react/renderer/components/scrollview/tests/ScrollContentViewTest.cpp
```

✅ Run locally against the real headers with a gtest stand-in, since no OSS harness runs it: padding on top of `paddingHorizontal`, the logical edge over the physical one, a percentage on an inset edge, RTL with and without the left/right swap, and the column for a horizontal scroll view.

```
yarn jest packages/react-native/Libraries/Components
```

**iPhone Duo** (iOS 27.1)

| iPhone Duo folded, never | iPhone Duo folded, automatic | iPhone Duo folded, always |
|---|---|---|
| <img src="scratch/rn-scrollview-safe-area-evidence/native-duo-folded-never.png" width="300" /> | <img src="scratch/rn-scrollview-safe-area-evidence/native-duo-folded-automatic.png" width="300" /> | <img src="scratch/rn-scrollview-safe-area-evidence/native-duo-folded-always.png" 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 |
|---|
| <img src="scratch/rn-scrollview-safe-area-evidence/native-duo-unfolded-automatic.png" width="400" /> |
| ✅ Rows stop at the rail on the open display |

- ✅ Unfolding while the modal is open re-lays the rows out for the wider display without a relaunch: the controls row goes from 382 to 867 wide within a few seconds.
- ✅ Horizontal, `automatic` and `always`: items stop above the home indicator, with the rail inset along the scrollable axis only.
- ✅ While scrolling, the scroll indicator stays at the device edge.

**iPhone 18 Pro** (iOS 27.0)

| iPhone 18 Pro landscape, automatic |
|---|
| <img src="scratch/rn-scrollview-safe-area-evidence/native-iphone-18-pro-landscape-automatic.png" width="400" /> |
| ✅ Rows stop at both insets, the camera cutout side and the opposite edge; the container's background still runs edge to edge |

**Same layout as the JS version**

On the folded iPhone Duo, each of `never`, `automatic` and `always` renders pixel-identical to the preceding PR, from a fresh launch of each build. The frames match, and only where the padding comes from changes:

| | Preceding PR | This PR |
|---|---|---|
| Content container | `RCTViewComponentView`, 466 wide | `RCTScrollContentViewComponentView`, 466 wide |
| Item rows under `automatic` | 372 wide, 84 clear of the rail | 372 wide, 84 clear of the rail |
| Source of the 84 | `paddingEnd` merged into `contentContainerStyle` by JS | `safeAreaPadding` in the content container's native state; its props carry no padding |

ℹ️ With the JS inset subscription removed, an inset change reaches layout through a state update rather than a React render. Render counts were not measured.

**Xcode view debugger**

Debug > Attach to Process, then Debug > View Debugging > Capture View Hierarchy, with the content container selected under `automatic`:

| Preceding PR | This PR |
|---|---|
| <img src="scratch/rn-scrollview-safe-area-evidence/native-xcode-before-automatic-hierarchy.png" width="400" /> | <img src="scratch/rn-scrollview-safe-area-evidence/native-xcode-after-automatic-hierarchy.png" width="400" /> |
| `RCTViewComponentView`, frame `(0 0; 466 1574)` | ✅ `RCTScrollContentViewComponentView`, the same frame |

| Preceding PR, 3D | This PR, 3D |
|---|---|
| <img src="scratch/rn-scrollview-safe-area-evidence/native-xcode-before-automatic-3d.png" width="400" /> | <img src="scratch/rn-scrollview-safe-area-evidence/native-xcode-after-automatic-3d.png" width="400" /> |
| The full-width container behind the inset rows | ✅ The same layers |

**Nested in react-native-screens**

RNTester's root hosted in a tab bar controller holding a navigation stack, with the first scroll view registered and flipped from `never` to `automatic` natively the way react-native-screens' native tabs do, and a horizontal `<ScrollView>` as the screen:

- ✅ Prop unset: UIKit's insets are left alone, `adjustedContentInset={82, 0, 34, 84}`, and the cards rest below the navigation bar.
- ✅ `automatic`: the container pads the top and bottom once and UIKit's cross-axis inset is removed, `adjustedContentInset={0, 0, 0, 84}`.
- ✅ Switching between the values re-lays the content out in place.

⚠️ Not run: Fantom, which targets Android, and an RTL layout on device; RTL is covered by the C++ cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8fboujvLgRUJCzXcqXPzB
@huntie
huntie marked this pull request as ready for review October 28, 2026 18:49
@huntie huntie changed the title [ScrollView][iOS] Pad the content container for the safe area natively [ScrollView][iOS] Refactor ScrollView inset handling to lay out natively Oct 28, 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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant