Repository navigation
Fix contentInsetAdjustmentBehavior for the horizontal safe area (iPhone Duo) (#59194) - #59529
Open
cipolleschi wants to merge 2 commits into
Open
cipolleschi wants to merge 2 commits into
cipolleschi wants to merge 2 commits into
Conversation
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
|
@cipolleschi has exported this pull request. If you are a Meta employee, you can view the originating Diff in D127787712. |
Visual regression2 visual difference(s) at ios
|
This branch has not been deployed
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:
<ScrollView>'scontentInsetAdjustmentBehaviorprop 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):
Without this diff, needs to become:
⬆️
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:
automatic)And the code sample becomes resolved:
ScrollView-only hierarchy — backwards compatible ✅Why this is a bug
Remember that:
<ScrollView contentInsetAdjustmentBehavior="automatic">already handles the bottom safe area inset on relevant devices.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:"automatic"should be automatic. By fixing this we delete the problem for all RN users.Changes
ScrollViewobserves 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.'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. ThecontentInsetprop stays as given along the scrollable axis.horizontalprop reaches the native view on iOS, as it already did on Android, so the scroll view knows which axis it scrolls along.Impact
At the same time, if anyone was handling this before with a nested SafeAreaView, they play nicely and is backwards compatible.
Notes
Changelog:
[iOS][Fixed] - ScrollView:
contentInsetAdjustmentBehaviorkeeps content clear of the safe area across the scrollable axis, such as the rail on iPhone Duo and the notch in landscapeTest Plan:
Validation of this split:
js1 test --runTestsByPathforScrollView-safeAreaContentPadding-test.jsandgetSafeAreaContentPadding-test.js: 20 tests passed. Includes native opt-in coordination, switching to never, and horizontal true-to-undefined/null.Upstream visual verification of the combined implementation, supplied with the import and not re-run for this split:
RNTester on an iPhone Duo simulator (iOS 27.1), ScrollView > Content Inset Adjustment Behavior, which opens a full screen modal:
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:
automatic)neverdoesNesting 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'sSafeAreaView, with a yellow background (today's temporarily necessary authoring behaviour):automaticselected.Differential Revision: D127787712
Pulled By: cipolleschi