Skip to content

[ScrollView][iOS] Pad the content container for the safe area natively - #59555

Draft
huntie wants to merge 1 commit into
pr59194from
pr59555
Draft

huntie wants to merge 1 commit into
pr59194from
pr59555

Conversation

@huntie

@huntie huntie commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

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:

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

✅ Run locally against the real headers with a gtest stand-in: 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.

✅ Flow, ESLint, Jest under Libraries/Components, and clang-format.

RNTester on the folded iPhone Duo simulator (iOS 27.1, Xcode 27.1), ScrollView > Content Inset Adjustment Behavior, full screen. Window 466×678 pt, safe area {top 0, left 0, bottom 34, right 84}; the 84 is the rail.

State Result
vertical, never ✅ Rows run under the rail: Item 0 row is 456 wide
vertical, automatic ✅ Rows stop at the rail: Item 0 row is (5 99; 372 27) and the controls row is 382 wide, while the container's background still runs under the rail
vertical, always ✅ Same as automatic, with no sideways offset
horizontal, never Items run under the home indicator
horizontal, automatic ✅ Items stop above the home indicator; adjustedContentInset={0, 0, 0, 84}, the rail along the scrollable axis only
horizontal, always ✅ Same, with no vertical offset
vertical, automatic, scrolling ✅ The scroll indicator stays at the device edge

✅ Layout identical to the JS version. For vertical never, automatic and always, each from a fresh launch, the window rendered in-app (drawViewHierarchyInRect:) is pixel-identical between this commit and #59194, and every frame matches:

never automatic always
RCTEnhancedScrollView frame (0 0; 466 678) (0 0; 466 678) (0 0; 466 678)
adjustedContentInset {0, 0, 0, 0} {0, 0, 34, 0} {0, 0, 34, 0}, from contentInset.right = -84
Content container frame (0 0; 466 1574) (0 0; 466 1574) (0 0; 466 1574)
Content container LayoutMetrics.contentInsets.right 0 84 84

✅ The padding no longer comes from JS. Under automatic, on #59194 the content container's props carry the Yoga end padding of 84 that JS merges into contentContainerStyle. With this commit its props carry no padding, and the 84 comes from the native safeAreaPadding of {0, 0, 0, 84} in ScrollContentViewState, with the same LayoutMetrics. The JS inset subscription and re-render path are removed, so an inset change reaches layout through a state update; render counts were not measured.

✅ Xcode's view debugger (Debug > Attach to Process, View Debugging > Capture View Hierarchy) shows the class change and nothing else:

never
RCTScrollViewComponentView frame=(0 0; 466 678)
  RCTEnhancedScrollView frame=(0 0; 466 678) adjustedContentInset={0, 0, 0, 0}
    RCTScrollContentViewComponentView frame=(0 0; 466 1574)
      RCTViewComponentView frame=(0 0; 466 94)   the controls row

automatic
RCTScrollViewComponentView frame=(0 0; 466 678)
  RCTEnhancedScrollView frame=(0 0; 466 678) adjustedContentInset={0, 0, 34, 0}
    RCTScrollContentViewComponentView frame=(0 0; 466 1574)
      RCTViewComponentView frame=(0 0; 382 94)   the controls row, 84 clear of the rail

Nesting in native tabs (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.


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

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

✅ Run locally against the real headers with a gtest stand-in: 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.

✅ Flow, ESLint, Jest under `Libraries/Components`, and clang-format.

**RNTester on the folded iPhone Duo simulator** (iOS 27.1, Xcode 27.1), ScrollView > Content Inset Adjustment Behavior, full screen. Window 466×678 pt, safe area `{top 0, left 0, bottom 34, right 84}`; the 84 is the rail.

| State | Result |
|---|---|
| vertical, never | ✅ Rows run under the rail: Item 0 row is 456 wide |
| vertical, automatic | ✅ Rows stop at the rail: Item 0 row is `(5 99; 372 27)` and the controls row is 382 wide, while the container's background still runs under the rail |
| vertical, always | ✅ Same as automatic, with no sideways offset |
| horizontal, never | Items run under the home indicator |
| horizontal, automatic | ✅ Items stop above the home indicator; `adjustedContentInset={0, 0, 0, 84}`, the rail along the scrollable axis only |
| horizontal, always | ✅ Same, with no vertical offset |
| vertical, automatic, scrolling | ✅ The scroll indicator stays at the device edge |

✅ **Layout identical to the JS version.** For vertical `never`, `automatic` and `always`, each from a fresh launch, the window rendered in-app (`drawViewHierarchyInRect:`) is pixel-identical between this commit and #59194, and every frame matches:

| | never | automatic | always |
|---|---|---|---|
| `RCTEnhancedScrollView` frame | `(0 0; 466 678)` | `(0 0; 466 678)` | `(0 0; 466 678)` |
| `adjustedContentInset` | `{0, 0, 0, 0}` | `{0, 0, 34, 0}` | `{0, 0, 34, 0}`, from `contentInset.right = -84` |
| Content container frame | `(0 0; 466 1574)` | `(0 0; 466 1574)` | `(0 0; 466 1574)` |
| Content container `LayoutMetrics.contentInsets.right` | 0 | 84 | 84 |

✅ **The padding no longer comes from JS.** Under `automatic`, on #59194 the content container's props carry the Yoga end padding of 84 that JS merges into `contentContainerStyle`. With this commit its props carry no padding, and the 84 comes from the native `safeAreaPadding` of `{0, 0, 0, 84}` in `ScrollContentViewState`, with the same `LayoutMetrics`. The JS inset subscription and re-render path are removed, so an inset change reaches layout through a state update; render counts were not measured.

✅ **Xcode's view debugger** (Debug > Attach to Process, View Debugging > Capture View Hierarchy) shows the class change and nothing else:

- The content container is `RCTScrollContentViewComponentView`, a subclass of `RCTViewComponentView`, where #59194 shows a plain `RCTViewComponentView`. Filtering the navigator by `RCTScrollContent` finds it directly under `RCTEnhancedScrollView`, and finds nothing on #59194.
- The Size inspector frames match the table above in both builds, and the 3D canvas shows the full-width container behind the inset rows in both.

```
never
RCTScrollViewComponentView frame=(0 0; 466 678)
  RCTEnhancedScrollView frame=(0 0; 466 678) adjustedContentInset={0, 0, 0, 0}
    RCTScrollContentViewComponentView frame=(0 0; 466 1574)
      RCTViewComponentView frame=(0 0; 466 94)   the controls row

automatic
RCTScrollViewComponentView frame=(0 0; 466 678)
  RCTEnhancedScrollView frame=(0 0; 466 678) adjustedContentInset={0, 0, 34, 0}
    RCTScrollContentViewComponentView frame=(0 0; 466 1574)
      RCTViewComponentView frame=(0 0; 382 94)   the controls row, 84 clear of the rail
```

**Nesting in native tabs (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.
@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

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