From 8c729716604c69de625b9378085d12dbac2bad49 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Thu, 12 Nov 2026 17:16:41 +0000 Subject: [PATCH] [ScrollView][iOS] Fix 'scrollableAxes' mode to differentiate from 'automatic' --- .../Components/ScrollView/ScrollView.js | 38 ++++++++++++++----- .../ScrollView/RCTEnhancedScrollView.h | 4 +- .../RCTScrollContentViewComponentView.h | 4 +- .../ScrollView/RCTScrollViewComponentView.mm | 38 +++++++++++++------ .../scrollview/ScrollContentViewState.h | 2 +- .../examples/ScrollView/ScrollViewExample.js | 2 +- 6 files changed, 62 insertions(+), 26 deletions(-) diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index b422c317292e..a9445922d19f 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -385,16 +385,36 @@ export type ScrollViewPropsIOS = Readonly<{ * @platform ios */ zoomScale?: ?number, + /** - * How the safe area insets modify the content area of the scroll view. + * Controls whether and how the ScrollView keeps its content clear of the + * system safe areas (iOS only). + * + * `'automatic'` is strongly recommended anywhere a ScrollView or FlatList + * is the root element of a given screen. + * + * Along the scrollable axis, `'always'` applies the safe area insets. + * `'automatic'` and `'scrollableAxes'` apply them while the content is + * scrollable, or while `alwaysBounce*` is true. + * + * Across the scrollable axis, `'automatic'` and `'always'` also pad the + * content container by the insets, such as side insets for an iPhone in + * landscape, or the vertical bar on iPhone Duo. + * + * - **'never'** (default): Applies no insets. Content is laid out and + * scrolls under the system UI. + * - **'automatic'**: Applies insets along the scrollable axis, plus padding + * across it. + * - **'scrollableAxes'**: Uses UIKit's `.scrollableAxes` insets along the + * scrollable axis only. + * - **'always'**: Applies insets on both axes. Unlike `'automatic'`, + * scrollable-axis insets remain when content fits and `alwaysBounce*` is + * false. * - * Along the scrollable axis, the insets become content insets, so content - * scrolls under the system UI. Across it, every value except `'never'` pads - * the content container by the insets, so content is laid out clear of the - * system UI, such as the rail on iPhone Duo or camera cutout in landscape, - * while the container's background still extends edge to edge. On every - * edge, the `contentContainerStyle` padding counts toward the inset, so - * content sits at the larger of the two. + * Note: With any value other than `'never'`, point-valued + * `contentContainerStyle` padding on edges where an inset applies is + * absorbed by the inset, instead of adding to it. See + * `contentContainerStyle`. * * @default `'never'` * @platform ios @@ -509,7 +529,7 @@ type ScrollViewBaseProps = Readonly<{ * If setting `contentInsetAdjustmentBehavior` (recommended), `padding` is * applied slightly differently than with a regular ``: * - * - Pixel values: If a system safe area inset applies on a given edge, the + * - Point values: If a system safe area inset applies on a given edge, the * **greater of** the `padding` or inset length will be applied. An edge * without an inset, such as the top of a ScrollView below a header, keeps * its full `padding`. diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h index 7ee560570b42..3d364946c009 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h @@ -69,8 +69,8 @@ NS_ASSUME_NONNULL_BEGIN /* * Whether `ScrollView` pads the content container by the safe area across the scrollable axis, which it does when - * its `contentInsetAdjustmentBehavior` prop is not `never`. UIKit's own `contentInsetAdjustmentBehavior` can differ, - * as react-native-screens sets it natively on the first scroll view of a screen. + * its `contentInsetAdjustmentBehavior` prop is `automatic` or `always`. UIKit's own `contentInsetAdjustmentBehavior` + * can differ, as react-native-screens sets it natively on the first scroll view of a screen. */ @property (nonatomic, assign) BOOL padsContentForSafeArea; diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollContentViewComponentView.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollContentViewComponentView.h index 1d418aa1afc1..87ae79434e04 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollContentViewComponentView.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollContentViewComponentView.h @@ -18,8 +18,8 @@ NS_ASSUME_NONNULL_BEGIN /* * The safe area the content is laid out clear of, as a floor on the content container's own padding. The enclosing - * scroll view sets the pair of insets across its scrollable axis, and zero while its `contentInsetAdjustmentBehavior` - * is `never`. + * scroll view sets the pair of insets across its scrollable axis while its `contentInsetAdjustmentBehavior` is + * `automatic` or `always`, and zero otherwise. */ @property (nonatomic, readonly) UIEdgeInsets safeAreaPadding; diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 39def82fb6d4..86d80594f71c 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -84,6 +84,16 @@ static UIScrollViewIndicatorStyle RCTUIScrollViewIndicatorStyleFromProps(const S userInfo:userInfo]; } +/* + * Whether React Native pads the content container by the safe area across the scrollable axis. Along-axis insets + * still come from UIKit for every value except `never`. + */ +static BOOL RCTPadsContentForSafeArea(ContentInsetAdjustmentBehavior contentInsetAdjustmentBehavior) +{ + return contentInsetAdjustmentBehavior == ContentInsetAdjustmentBehavior::Automatic || + contentInsetAdjustmentBehavior == ContentInsetAdjustmentBehavior::Always; +} + @interface RCTScrollViewComponentView () < UIScrollViewDelegate, RCTScrollViewProtocol, @@ -451,7 +461,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentAlways; } ((RCTEnhancedScrollView *)_scrollView).padsContentForSafeArea = - contentInsetAdjustmentBehavior != ContentInsetAdjustmentBehavior::Never; + RCTPadsContentForSafeArea(contentInsetAdjustmentBehavior); _shouldUpdateContentInsetAdjustmentBehavior = NO; } @@ -894,10 +904,10 @@ - (void)scrollViewDidChangeAdjustedContentInset:(UIScrollView *)scrollView } /* - * Hands the content container the safe area to lay its content out clear of, unless `contentInsetAdjustmentBehavior` - * is `never`: the pair of insets across the scrollable axis, which it pads by, and along the axis the content inset - * UIKit adds for the safe area, which its own padding counts toward. Content still scrolls under the system UI along - * the axis. + * Hands the content container the safe area to lay its content out clear of: along the scrollable axis, the content + * inset UIKit adds for the safe area, which point-valued padding counts toward, unless + * `contentInsetAdjustmentBehavior` is `never`; and across the axis, the pair of insets it pads by, for `automatic` + * and `always`. Content still scrolls under the system UI along the axis. */ - (void)_updateContentViewSafeAreaPadding { @@ -905,22 +915,28 @@ - (void)_updateContentViewSafeAreaPadding return; } const auto &props = static_cast(*_props); + auto *scrollView = (RCTEnhancedScrollView *)_scrollView; UIEdgeInsets padding = UIEdgeInsetsZero; UIEdgeInsets contentInset = UIEdgeInsetsZero; if (props.contentInsetAdjustmentBehavior != ContentInsetAdjustmentBehavior::Never) { + UIEdgeInsets adjusted = scrollView.adjustedContentInset; + UIEdgeInsets current = scrollView.contentInset; + if (props.horizontal) { + contentInset.left = adjusted.left - current.left; + contentInset.right = adjusted.right - current.right; + } else { + contentInset.top = adjusted.top - current.top; + contentInset.bottom = adjusted.bottom - current.bottom; + } + } + if (scrollView.padsContentForSafeArea) { UIEdgeInsets insets = self.safeAreaInsets; - UIEdgeInsets adjusted = _scrollView.adjustedContentInset; - UIEdgeInsets current = _scrollView.contentInset; if (props.horizontal) { padding.top = insets.top; padding.bottom = insets.bottom; - contentInset.left = adjusted.left - current.left; - contentInset.right = adjusted.right - current.right; } else { padding.left = insets.left; padding.right = insets.right; - contentInset.top = adjusted.top - current.top; - contentInset.bottom = adjusted.bottom - current.bottom; } } [(RCTScrollContentViewComponentView *)_contentView setSafeAreaPadding:padding contentInsetAdjustment:contentInset]; diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewState.h b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewState.h index b067d81333e8..1dd8dac59175 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewState.h +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewState.h @@ -27,7 +27,7 @@ class ScrollContentViewState final { /* * The safe area the content is laid out clear of, as a floor on the padding * the style sets. The enclosing scroll view sets the pair of edges across - * its scrollable axis. + * its scrollable axis for `automatic` and `always`. */ EdgeInsets safeAreaPadding{}; diff --git a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js index 0ece3b0ca0be..cf4e2d58158c 100644 --- a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js +++ b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js @@ -360,7 +360,7 @@ const examples: Array = [ { title: ' Content Inset Adjustment Behavior\n', description: - 'How the safe area modifies the content area of a full screen ScrollView: along the scrollable axis it becomes content insets, across it the content container is padded, and on every edge the container padding counts toward it.', + 'How the safe area modifies the content area of a full screen ScrollView: scrollableAxes uses UIKit .scrollableAxes for along-axis insets, automatic and always also pad across the axis, and point-valued container padding counts toward active inset edges.', platform: 'ios', render(): React.Node { return ;