Skip to content

[ScrollView][iOS] Fix contentInsetAdjustmentBehavior for the horizontal safe area (iPhone Duo) - #59194

Open
huntie wants to merge 1 commit into
mainfrom
pr59194
Open

huntie wants to merge 1 commit into
mainfrom
pr59194

Conversation

@huntie

@huntie huntie commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

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.
  • Under 'always', the contentInset passed to native subtracts the safe area across the scrollable axis, so UIKit's content inset there nets to the caller's own and the safe area applies once.
  • 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

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.

@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 19, 2026
@huntie
huntie marked this pull request as ready for review October 19, 2026 15:13
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 19, 2026
@cipolleschi

Copy link
Copy Markdown
Contributor

spm_newapp in release is red. Can you check whether it is a flakyness issue or a real problem?

@huntie

huntie commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

@cipolleschi Reran ✅

@cipolleschi cipolleschi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this, it's a real pain point and the before/after screenshots are great. The native event is well designed: it's deduped, it re-arms on prop set, and the synchronous render avoids a first-frame flash. My concerns are all in how the extra padding gets merged into contentContainerStyle. In a few common setups it drops either the user's padding or the safe-area padding. I left inline comments.

Since this changes layout for every iOS ScrollView using automatic, scrollableAxes or always, and you already flag landscape apps as a possible breakage, could we put it behind a ReactNativeFeatureFlags flag so we can roll it out gradually?

keys.push(edge === 'top' ? 'paddingTop' : 'paddingBottom');
keys.push('paddingVertical');
}
keys.push('padding');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolvePadding doesn't handle paddingInline, paddingInlineStart, paddingInlineEnd, paddingBlock, paddingBlockStart or paddingBlockEnd. Fabric maps those onto the same Yoga edges (YogaLayoutableShadowNode.cpp, around L454–485), so:

  • paddingInline: 16 is overridden by the emitted paddingStart/paddingEnd, and the 16pt is lost.
  • paddingInlineStart/End are applied after paddingStart/End, so the user's value wins and the safe-area inset on that edge is lost.
  • For horizontal scroll views, paddingBlock is overridden, and paddingBlockStart/End only apply when Top/Bottom are undefined, which they never are now.

Could we handle these keys too, or clear them in the override style?

const left = resolvePadding(style, 'left', isRTL) + insets.left;
const right = resolvePadding(style, 'right', isRTL) + insets.right;
return {
paddingStart: isRTL ? right : left,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In RTL with doLeftAndRightSwapInRTL (the default), Fabric's swapLeftAndRightInYogaStyleProps runs after the styles are merged. It copies Edge::Left/Right onto Start/End unconditionally. So with contentContainerStyle={{paddingLeft: 10}}, the emitted paddingStart: 94 gets overwritten with 10 and the inset is lost.

One fix is to have the override also set paddingLeft, paddingRight, paddingHorizontal and the paddingInline* keys to undefined, so the logical keys we emit are the only ones left on that axis.

});
});

test('keeps physical insets on their edge in RTL', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test passes, but it only checks the helper's output, not what Yoga actually lays out (see the RTL comment in getSafeAreaContentPadding.js). Could we add a test of the final merged props, or a Fantom test, so a mismatch between the helper's precedence and Fabric's shows up?

* style already has. Logical edges are used across the row, since they take
* precedence over every other padding key on that edge.
*/
export default function getSafeAreaContentPadding(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the cross-axis insets are 0, which is every iPhone in portrait for a vertical ScrollView, this still returns {paddingStart: 0, paddingEnd: 0}. That spreads the problems above, and percentage padding resolving to 0, to every device using automatic, not just iPhone Duo or landscape. Can we return null when both cross-axis insets are 0, and ideally only override edges with a non-zero inset?

};

_handleSafeAreaInsetsChange = (e: SafeAreaInsetsChangeEvent) => {
this.setState({safeAreaInsets: e.nativeEvent.insets});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this calls setState on any inset change, including top/bottom for a vertical ScrollView, which don't affect the padding. Since the event renders synchronously, it would be worth bailing out when the cross-axis insets haven't changed.

onTouchStart: this._handleTouchStart,
onTouchCancel: this._handleTouchCancel,
onScroll: this._handleScroll,
experimental_onSafeAreaInsetsChange: padsContentForSafeArea

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user passes their own experimental_onSafeAreaInsetsChange, the native prop stays set under 'never', so the native dedupe cache isn't reset. If the insets change while in 'never' and the behavior then switches to 'automatic', nothing is re-emitted and state.safeAreaInsets is stale. Routing through _handleSafeAreaInsetsChange whenever either handler is present would avoid that.

// out clear of the system UI, such as the rail on iPhone Duo. Under
// 'always' UIKit insets that axis as well, which the contentInset passed
// to native cancels out.
const padsContentForSafeArea =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few cases worth a quick check:

  • centerContent + 'always': native ignores the contentInset prop when centerContent is set (RCTScrollViewComponentView.mm:405), so the cancellation doesn't apply and the inset is applied twice.
  • alwaysBounceHorizontal on a vertical ScrollView: I believe UIKit treats that axis as scrollable under automatic/scrollableAxes, which would also apply the inset twice. I haven't verified this.
  • Vertical ScrollView inside a horizontal pager, in landscape: the inner ScrollView's left/right insets may change every frame while swiping. That would mean a synchronous render per frame and could trigger the dev "fired more than 10 times" warning.

@huntie

huntie commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

could we put it behind a ReactNativeFeatureFlags flag so we can roll it out gradually?

@cipolleschi The aim is we ship this for Expo end users ASAP. Additionally we (currently) have no API for overriding individual flags from Expo in order to test them out in our framework. Latter is solvable, but likely not in this timeframe.

The edge case in which this affects users:

  1. <ScrollView contentInsetAdjustmentBehavior="automatic"> is set.
  2. and, the app decides in landscape to draw right to the screen edges, with no inner SafeAreaView.
    • In this case, we will now horizontally pad the inner view away from the device camera cutout.
    • ScrollView > SafeAreaView nesting (which should be close to 100% of users) is backwards compatible and unchanged.

I'm trying to balance the new users compiling in Xcode 27.1 today and suffering from this, vs the theoretically tiny cases where contentInsetAdjustmentBehavior="automatic" has been set and an inner safe area hasn't been used. In fact, I'd wager more apps would find their horizontal functionality fixed.

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 Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants