Repository navigation
Conversation
## 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
Collaborator
Author
|
Opened by mistake from a resubmit of #59555; that PR has the update. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ofView. 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.'never'.<ScrollView>no longer observesexperimental_onSafeAreaInsetsChange, merges padding intocontentContainerStyleor re-renders when the insets change. The JS padding helper and its tests go with it.Notes:
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
✅ 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.iPhone Duo (iOS 27.1)
automaticandalways: items stop above the home indicator, with the rail inset along the scrollable axis only.iPhone 18 Pro (iOS 27.0)
Same layout as the JS version
On the folded iPhone Duo, each of
never,automaticandalwaysrenders pixel-identical to the preceding PR, from a fresh launch of each build. The frames match, and only where the padding comes from changes:RCTViewComponentView, 466 wideRCTScrollContentViewComponentView, 466 wideautomaticpaddingEndmerged intocontentContainerStyleby JSsafeAreaPaddingin 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:RCTViewComponentView, frame(0 0; 466 1574)RCTScrollContentViewComponentView, the same frameNested 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
nevertoautomaticnatively the way react-native-screens' native tabs do, and a horizontal<ScrollView>as the screen: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}.Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01J8fboujvLgRUJCzXcqXPzB