From 9ac5e24b55fddaef0293a9be26f701cb467a3816 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Tue, 27 Oct 2026 16:47:07 +0000 Subject: [PATCH] [ScrollView][iOS] Pad the content container for the safe area natively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: Follow-up to https://github.com/react/react-native/pull/59194. `` 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 `` 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'`. - `` 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 | |---|---|---| | | | | | 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 | |---| | | | ✅ 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 | |---| | | | ✅ 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 | |---|---| | | | | `RCTViewComponentView`, frame `(0 0; 466 1574)` | ✅ `RCTScrollContentViewComponentView`, the same frame | | Preceding PR, 3D | This PR, 3D | |---|---| | | | | 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 `` 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 Claude-Session: https://claude.ai/code/session_01J8fboujvLgRUJCzXcqXPzB --- .../Components/ScrollView/ScrollView.js | 56 ------ .../ScrollView-safeAreaContentPadding-test.js | 140 --------------- .../RCTFabricComponentsPlugins.h | 1 + .../RCTFabricComponentsPlugins.mm | 1 + .../RCTScrollContentViewComponentView.h | 28 +++ .../RCTScrollContentViewComponentView.mm | 106 ++++++++++++ .../ScrollView/RCTScrollViewComponentView.mm | 43 +++++ .../componentNameByReactViewName.cpp | 7 - .../ScrollContentViewComponentDescriptor.h | 31 ++++ .../scrollview/ScrollContentViewProps.cpp | 18 ++ .../scrollview/ScrollContentViewProps.h | 29 ++++ .../ScrollContentViewShadowNode.cpp | 144 ++++++++++++++++ .../scrollview/ScrollContentViewShadowNode.h | 58 +++++++ .../scrollview/ScrollContentViewState.h | 64 +++++++ .../tests/ScrollContentViewTest.cpp | 123 ++++++++++++++ .../getSafeAreaContentPadding-itest.js | 143 ---------------- .../getSafeAreaContentPadding-test.js | 159 ------------------ .../scrollview/getSafeAreaContentPadding.js | 142 ---------------- .../api-snapshots/ReactAppleDebugCxx.api | 4 + .../api-snapshots/ReactAppleFrameworksCxx.api | 4 + .../api-snapshots/ReactAppleNewarchCxx.api | 4 + .../api-snapshots/ReactAppleReleaseCxx.api | 4 + 22 files changed, 662 insertions(+), 647 deletions(-) delete mode 100644 packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-safeAreaContentPadding-test.js create mode 100644 packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollContentViewComponentView.h create mode 100644 packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollContentViewComponentView.mm create mode 100644 packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewComponentDescriptor.h create mode 100644 packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewProps.cpp create mode 100644 packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewProps.h create mode 100644 packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewShadowNode.cpp create mode 100644 packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewShadowNode.h create mode 100644 packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewState.h create mode 100644 packages/react-native/ReactCommon/react/renderer/components/scrollview/tests/ScrollContentViewTest.cpp delete mode 100644 packages/react-native/src/private/components/scrollview/__tests__/getSafeAreaContentPadding-itest.js delete mode 100644 packages/react-native/src/private/components/scrollview/__tests__/getSafeAreaContentPadding-test.js delete mode 100644 packages/react-native/src/private/components/scrollview/getSafeAreaContentPadding.js diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index ed41a7faad73..a0221bd821cb 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -16,8 +16,6 @@ import type {ColorValue} from '../../StyleSheet/StyleSheet'; import type { GestureResponderEvent, LayoutChangeEvent, - SafeAreaInsets, - SafeAreaInsetsChangeEvent, ScrollEvent, } from '../../Types/CoreEventTypes'; import type {EventSubscription} from '../../vendor/emitter/EventEmitter'; @@ -25,7 +23,6 @@ import type {KeyboardEvent, KeyboardMetrics} from '../Keyboard/Keyboard'; import type {ViewProps} from '../View/ViewPropTypes'; import type {ScrollViewStickyHeaderProps} from './ScrollViewStickyHeader'; -import getSafeAreaContentPadding from '../../../src/private/components/scrollview/getSafeAreaContentPadding'; import { HScrollContentViewNativeComponent, HScrollViewNativeComponent, @@ -891,11 +888,6 @@ class ScrollView extends React.Component { layoutHeight: null, }; - // The latest safe area insets of the native view. Only the pair across the - // scrollable axis affects the content container, so a change to the other - // pair is kept here without a render. - _safeAreaInsets: ?SafeAreaInsets = null; - componentDidMount() { this._keyboardMetrics = Keyboard.metrics(); this._additionalScrollOffset = 0; @@ -1260,30 +1252,6 @@ class ScrollView extends React.Component { this.props.onScroll && this.props.onScroll(e); }; - _handleSafeAreaInsetsChange = (e: SafeAreaInsetsChangeEvent) => { - const {insets} = e.nativeEvent; - const previous = this._safeAreaInsets; - this._safeAreaInsets = insets; - const changedAcrossScrollableAxis = - previous == null || - (this.props.horizontal === true - ? previous.top !== insets.top || previous.bottom !== insets.bottom - : previous.left !== insets.left || previous.right !== insets.right); - if (changedAcrossScrollableAxis && this._padsContentForSafeArea()) { - this.forceUpdate(); - } - this.props.experimental_onSafeAreaInsetsChange?.(e); - }; - - /** - * Whether the content container is padded by the safe area across the - * scrollable axis. - */ - _padsContentForSafeArea(): boolean { - const behavior = this.props.contentInsetAdjustmentBehavior; - return Platform.OS === 'ios' && behavior != null && behavior !== 'never'; - } - _handleLayout = (e: LayoutChangeEvent) => { if (this.props.invertStickyHeaders === true) { this.setState({layoutHeight: e.nativeEvent.layout.height}); @@ -1784,25 +1752,9 @@ class ScrollView extends React.Component { ? HScrollContentViewNativeComponent : VScrollContentViewNativeComponent; - // Along the scrollable axis UIKit turns the safe area into content insets, - // so content scrolls under the system UI. Across it there is nothing to - // scroll, so the content container is padded instead and content is laid - // out clear of the system UI, such as the rail on iPhone Duo. Native - // removes whatever UIKit insets across that axis, so the safe area applies - // once. - const padsContentForSafeArea = this._padsContentForSafeArea(); - const safeAreaInsets = this._safeAreaInsets; - const contentContainerStyle = [ horizontal && styles.contentContainerHorizontal, this.props.contentContainerStyle, - padsContentForSafeArea && safeAreaInsets != null - ? getSafeAreaContentPadding( - this.props.contentContainerStyle, - safeAreaInsets, - horizontal, - ) - : null, ]; if (__DEV__ && this.props.style !== undefined) { // $FlowFixMe[underconstrained-implicit-instantiation] @@ -1939,14 +1891,6 @@ class ScrollView extends React.Component { onTouchStart: this._handleTouchStart, onTouchCancel: this._handleTouchCancel, onScroll: this._handleScroll, - // Routed through the handler whenever the event is observed at all, so - // the insets it keeps are current when the behavior changes from - // 'never' and the content container starts padding for them. - experimental_onSafeAreaInsetsChange: - padsContentForSafeArea || - this.props.experimental_onSafeAreaInsetsChange != null - ? this._handleSafeAreaInsetsChange - : undefined, endDraggingSensitivityMultiplier: experimental_endDraggingSensitivityMultiplier, scrollEventThrottle: hasStickyHeaders diff --git a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-safeAreaContentPadding-test.js b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-safeAreaContentPadding-test.js deleted file mode 100644 index 0e3c17822d9c..000000000000 --- a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-safeAreaContentPadding-test.js +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - */ - -import typeof ScrollViewType from '../ScrollView'; -import type {ReactTestRenderer} from 'react-test-renderer'; - -import flattenStyle from '../../../StyleSheet/flattenStyle'; -import {create} from '@react-native/jest-preset/jest/renderer'; -import * as React from 'react'; -import {act} from 'react-test-renderer'; - -// The real component, in place of the mock the preset installs. -const ScrollView: ScrollViewType = jest.requireActual<{ - default: ScrollViewType, -}>('../ScrollView').default; - -const INSETS = {top: 44, right: 84, bottom: 34, left: 0}; - -function contentContainerStyle(renderer: ReactTestRenderer) { - return flattenStyle( - renderer.root.findByType('RCTScrollContentView').props.style, - ); -} - -async function dispatchSafeAreaInsetsChange( - renderer: ReactTestRenderer, - insets: typeof INSETS, -) { - const handler = - renderer.root.findByType('RCTScrollView').props - .experimental_onSafeAreaInsetsChange; - await act(async () => { - handler?.({nativeEvent: {insets}}); - }); -} - -describe(' safe area content padding', () => { - test('pads the content container across the scrollable axis', async () => { - const renderer = await create( - , - ); - expect(contentContainerStyle(renderer)).toEqual({ - paddingHorizontal: 12, - paddingLeft: 10, - }); - - await dispatchSafeAreaInsetsChange(renderer, INSETS); - expect(contentContainerStyle(renderer)).toEqual({ - paddingStart: 10, - paddingEnd: 96, - paddingLeft: null, - paddingRight: null, - paddingHorizontal: null, - paddingInline: null, - paddingInlineStart: null, - paddingInlineEnd: null, - }); - }); - - test('leaves the style alone while the axis has no safe area', async () => { - const renderer = await create( - , - ); - await dispatchSafeAreaInsetsChange(renderer, {...INSETS, right: 0}); - expect(contentContainerStyle(renderer)).toEqual({paddingHorizontal: '5%'}); - }); - - test('renders only when the insets across the scrollable axis change', async () => { - const renderer = await create( - , - ); - const styleOf = () => - renderer.root.findByType('RCTScrollContentView').props.style; - - await dispatchSafeAreaInsetsChange(renderer, INSETS); - const padded = styleOf(); - expect(flattenStyle(padded)).toMatchObject({paddingEnd: 84}); - - // A change along the scrollable axis leaves the content container alone. - await dispatchSafeAreaInsetsChange(renderer, {...INSETS, top: 0}); - expect(styleOf()).toBe(padded); - - await dispatchSafeAreaInsetsChange(renderer, {...INSETS, right: 0}); - expect(styleOf()).not.toBe(padded); - expect(contentContainerStyle(renderer)).toEqual({}); - }); - - test('does not observe the safe area under never', async () => { - const renderer = await create( - , - ); - const scrollView = renderer.root.findByType('RCTScrollView'); - expect(scrollView.props.experimental_onSafeAreaInsetsChange).toBe( - undefined, - ); - }); - - test('keeps the insets current for a caller handler under never', async () => { - const onSafeAreaInsetsChange = jest.fn(); - const renderer = await create( - , - ); - const unpadded = renderer.root.findByType('RCTScrollContentView').props - .style; - await dispatchSafeAreaInsetsChange(renderer, INSETS); - expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1); - // Nothing is padded under 'never', so nothing is rendered either. - expect(renderer.root.findByType('RCTScrollContentView').props.style).toBe( - unpadded, - ); - - // The insets delivered under 'never' apply as soon as the behavior - // changes, without another event. - await act(async () => { - renderer.update( - , - ); - }); - expect(contentContainerStyle(renderer)).toMatchObject({paddingEnd: 84}); - }); -}); diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.h index 6b8a6ff6d6dc..943d2383da1e 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.h @@ -35,6 +35,7 @@ Class RCTInputAccessoryCls(void) __attribute__((used)) Class RCTParagraphCls(void) __attribute__((used)); Class RCTPullToRefreshViewCls(void) __attribute__((used)); Class RCTSafeAreaViewCls(void) __attribute__((used)); +Class RCTScrollContentViewCls(void) __attribute__((used)); Class RCTScrollViewCls(void) __attribute__((used)); Class RCTSwitchCls(void) __attribute__((used)); Class RCTTextInputCls(void) __attribute__((used)); diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.mm index 442c2e6e3962..afd45feecc43 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.mm @@ -24,6 +24,7 @@ {"Paragraph", RCTParagraphCls}, {"PullToRefreshView", RCTPullToRefreshViewCls}, {"SafeAreaView", RCTSafeAreaViewCls}, + {"ScrollContentView", RCTScrollContentViewCls}, {"ScrollView", RCTScrollViewCls}, {"Switch", RCTSwitchCls}, {"TextInput", RCTTextInputCls}, diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollContentViewComponentView.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollContentViewComponentView.h new file mode 100644 index 000000000000..2d539ab2ca2a --- /dev/null +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollContentViewComponentView.h @@ -0,0 +1,28 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import + +NS_ASSUME_NONNULL_BEGIN + +/* + * UIView class for the component, the content container of a . + */ +@interface RCTScrollContentViewComponentView : RCTViewComponentView + +/* + * The safe area the content is laid out clear of, as padding on top of the content container's own. The enclosing + * scroll view sets the pair of insets across its scrollable axis, and zero while its `contentInsetAdjustmentBehavior` + * is `never`. + */ +@property (nonatomic, assign) UIEdgeInsets safeAreaPadding; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollContentViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollContentViewComponentView.mm new file mode 100644 index 000000000000..0f75757ebf55 --- /dev/null +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollContentViewComponentView.mm @@ -0,0 +1,106 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RCTScrollContentViewComponentView.h" + +#import +#import +#import +#import +#import "RCTConversions.h" +#import "RCTFabricComponentsPlugins.h" + +using namespace facebook::react; + +@implementation RCTScrollContentViewComponentView { + ScrollContentViewShadowNode::ConcreteState::Shared _state; +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + _props = ScrollContentViewShadowNode::defaultSharedProps(); + } + + return self; +} + +- (void)setSafeAreaPadding:(UIEdgeInsets)safeAreaPadding +{ + _safeAreaPadding = safeAreaPadding; + [self _updateStateIfNecessary]; +} + +- (void)_updateStateIfNecessary +{ + if (!_state) { + return; + } + + UIEdgeInsets insets = _safeAreaPadding; + insets.left = RCTRoundPixelValue(insets.left); + insets.top = RCTRoundPixelValue(insets.top); + insets.right = RCTRoundPixelValue(insets.right); + insets.bottom = RCTRoundPixelValue(insets.bottom); + + auto newPadding = RCTEdgeInsetsFromUIEdgeInsets(insets); + RCTI18nUtil *i18nUtil = [RCTI18nUtil sharedInstance]; + auto layoutDirection = i18nUtil.isRTL ? LayoutDirection::RightToLeft : LayoutDirection::LeftToRight; + bool swapLeftAndRightInRTL = i18nUtil.isRTL && i18nUtil.doLeftAndRightSwapInRTL; + auto threshold = 1.0 / RCTScreenScale() + 0.01; // Size of a pixel plus some small threshold. + + _state->updateState( + [=](const ScrollContentViewShadowNode::ConcreteState::Data &oldData) + -> ScrollContentViewShadowNode::ConcreteState::SharedData { + auto deltaPadding = newPadding - oldData.safeAreaPadding; + + if (std::abs(deltaPadding.left) < threshold && std::abs(deltaPadding.top) < threshold && + std::abs(deltaPadding.right) < threshold && std::abs(deltaPadding.bottom) < threshold && + oldData.layoutDirection == layoutDirection && oldData.swapLeftAndRightInRTL == swapLeftAndRightInRTL) { + return nullptr; + } + + auto newData = oldData; + newData.safeAreaPadding = newPadding; + newData.layoutDirection = layoutDirection; + newData.swapLeftAndRightInRTL = swapLeftAndRightInRTL; + return std::make_shared(newData); + }); +} + +#pragma mark - RCTComponentViewProtocol + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +- (void)updateState:(const facebook::react::State::Shared &)state + oldState:(const facebook::react::State::Shared &)oldState +{ + _state = std::static_pointer_cast(state); +} + +- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask +{ + [super finalizeUpdates:updateMask]; + [self _updateStateIfNecessary]; +} + +- (void)prepareForRecycle +{ + [super prepareForRecycle]; + _state.reset(); + _safeAreaPadding = UIEdgeInsetsZero; +} + +@end + +Class RCTScrollContentViewCls(void) +{ + return RCTScrollContentViewComponentView.class; +} 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 fa8534301003..a0b14910fb75 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -24,6 +24,7 @@ #import "RCTCustomPullToRefreshViewProtocol.h" #import "RCTEnhancedScrollView.h" #import "RCTFabricComponentsPlugins.h" +#import "RCTScrollContentViewComponentView.h" #import "RCTVirtualViewContainerState.h" using namespace facebook::react; @@ -463,6 +464,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & // Must run after `super updateProps:` so it is based on the latest calculated view. ((RCTEnhancedScrollView *)_scrollView).inverted = [self isInverted]; + [self _updateContentViewSafeAreaPadding]; } - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState @@ -562,6 +564,7 @@ - (void)mountChildComponentView:(UIView *)childCompone [_containerView insertSubview:childComponentView atIndex:index]; if (![childComponentView conformsToProtocol:@protocol(RCTCustomPullToRefreshViewProtocol)]) { _contentView = childComponentView; + [self _updateContentViewSafeAreaPadding]; } } @@ -863,6 +866,46 @@ - (void)didMoveToWindow // The view is being removed, ensure that the scroll end event is dispatched [self _handleScrollEndIfNeeded]; } + [self _updateContentViewSafeAreaPadding]; +} + +- (void)safeAreaInsetsDidChange +{ + [super safeAreaInsetsDidChange]; + [self _updateContentViewSafeAreaPadding]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + // Moving or resizing the view changes its insets without `safeAreaInsetsDidChange` firing; that only reports + // window-level changes. + [self _updateContentViewSafeAreaPadding]; +} + +/* + * Hands the content container the safe area to pad its content by: the pair of insets across the scrollable axis, + * unless `contentInsetAdjustmentBehavior` is `never`. Along the scrollable axis UIKit turns the safe area into content + * insets instead, so content scrolls under the system UI. + */ +- (void)_updateContentViewSafeAreaPadding +{ + if (![_contentView isKindOfClass:[RCTScrollContentViewComponentView class]]) { + return; + } + const auto &props = static_cast(*_props); + UIEdgeInsets padding = UIEdgeInsetsZero; + if (props.contentInsetAdjustmentBehavior != ContentInsetAdjustmentBehavior::Never) { + UIEdgeInsets insets = self.safeAreaInsets; + if (props.horizontal) { + padding.top = insets.top; + padding.bottom = insets.bottom; + } else { + padding.left = insets.left; + padding.right = insets.right; + } + } + ((RCTScrollContentViewComponentView *)_contentView).safeAreaPadding = padding; } - (void)_handleScrollEndIfNeeded diff --git a/packages/react-native/ReactCommon/react/renderer/componentregistry/componentNameByReactViewName.cpp b/packages/react-native/ReactCommon/react/renderer/componentregistry/componentNameByReactViewName.cpp index fe63407de827..230d577f6a5b 100644 --- a/packages/react-native/ReactCommon/react/renderer/componentregistry/componentNameByReactViewName.cpp +++ b/packages/react-native/ReactCommon/react/renderer/componentregistry/componentNameByReactViewName.cpp @@ -51,13 +51,6 @@ std::string componentNameByReactViewName(std::string viewName) { return "PullToRefreshView"; } - // We need this temporarily for testing purposes until we have proper - // implementation of core components. - // iOS-only - if (viewName == "ScrollContentView") { - return "View"; - } - // iOS-only if (viewName == "MultilineTextInputView" || viewName == "SinglelineTextInputView") { diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewComponentDescriptor.h b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewComponentDescriptor.h new file mode 100644 index 000000000000..d02cc97738ad --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewComponentDescriptor.h @@ -0,0 +1,31 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +#include +#include + +namespace facebook::react { + +/* + * Descriptor for the component. + */ +class ScrollContentViewComponentDescriptor final : public ConcreteComponentDescriptor { + public: + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + + void adopt(ShadowNode &shadowNode) const override + { + static_cast(shadowNode).applySafeAreaPadding(); + ConcreteComponentDescriptor::adopt(shadowNode); + } +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewProps.cpp new file mode 100644 index 000000000000..45bbd69db088 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewProps.cpp @@ -0,0 +1,18 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "ScrollContentViewProps.h" + +namespace facebook::react { + +ScrollContentViewProps::ScrollContentViewProps( + const PropsParserContext& context, + const ScrollContentViewProps& sourceProps, + const RawProps& rawProps) + : ViewProps(context, sourceProps, rawProps) {} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewProps.h new file mode 100644 index 000000000000..ef7f25899d08 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewProps.h @@ -0,0 +1,29 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +#include + +namespace facebook::react { + +/* + * Props for the component, the content container of a + * . It takes the props of . + */ +class ScrollContentViewProps final : public ViewProps { + public: + ScrollContentViewProps() = default; + ScrollContentViewProps( + const PropsParserContext &context, + const ScrollContentViewProps &sourceProps, + const RawProps &rawProps); +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewShadowNode.cpp new file mode 100644 index 000000000000..ea343b9a165e --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewShadowNode.cpp @@ -0,0 +1,144 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "ScrollContentViewShadowNode.h" + +#include + +namespace facebook::react { + +// NOLINTNEXTLINE(modernize-avoid-c-arrays) +const char ScrollContentViewComponentName[] = "ScrollContentView"; + +namespace { + +using yoga::Edge; +using yoga::StyleLength; + +/* + * The padding edges Yoga reads for one physical edge, most specific first. + * Along the row the logical edges take precedence over the physical ones, and + * when left and right swap in RTL the physical values are copied over the + * logical ones at layout, so they come first. + */ +std::array paddingEdges( + Edge edge, + const ScrollContentViewState& state) { + auto rtl = state.layoutDirection == LayoutDirection::RightToLeft; + auto swap = rtl && state.swapLeftAndRightInRTL; + switch (edge) { + case Edge::Top: + return {Edge::Top, Edge::Vertical, Edge::All, Edge::All}; + case Edge::Bottom: + return {Edge::Bottom, Edge::Vertical, Edge::All, Edge::All}; + case Edge::Left: + if (swap) { + return {Edge::Right, Edge::End, Edge::Horizontal, Edge::All}; + } + return { + rtl ? Edge::End : Edge::Start, + Edge::Left, + Edge::Horizontal, + Edge::All}; + case Edge::Right: + if (swap) { + return {Edge::Left, Edge::Start, Edge::Horizontal, Edge::All}; + } + return { + rtl ? Edge::Start : Edge::End, + Edge::Right, + Edge::Horizontal, + Edge::All}; + default: + return {Edge::All, Edge::All, Edge::All, Edge::All}; + } +} + +StyleLength resolvedPadding( + const yoga::Style& style, + Edge edge, + const ScrollContentViewState& state) { + for (auto candidate : paddingEdges(edge, state)) { + auto value = style.padding(candidate); + if (value.isDefined()) { + return value; + } + } + return StyleLength::undefined(); +} + +StyleLength padded(StyleLength value, Float inset) { + if (inset == 0) { + return value; + } + auto base = value.isPoints() ? value.value().unwrap() : 0.0f; + return StyleLength::points(base + inset); +} + +} // namespace + +ScrollContentViewShadowNode::ScrollContentViewShadowNode( + const ShadowNodeFragment& fragment, + const ShadowNodeFamily::Shared& family, + ShadowNodeTraits traits) + : ConcreteViewShadowNode(fragment, family, traits) { + initialize(); +} + +ScrollContentViewShadowNode::ScrollContentViewShadowNode( + const ShadowNode& sourceShadowNode, + const ShadowNodeFragment& fragment) + : ConcreteViewShadowNode(sourceShadowNode, fragment) { + initialize(); +} + +void ScrollContentViewShadowNode::initialize() noexcept { + if (getConcreteProps().collapsableChildren) { + traits_.unset(ShadowNodeTraits::Trait::ChildrenFormStackingContext); + } else { + traits_.set(ShadowNodeTraits::Trait::ChildrenFormStackingContext); + } +} + +void ScrollContentViewShadowNode::applySafeAreaPadding() { + ensureUnsealed(); + updateYogaProps(); + auto style = yogaNode_.style(); + padForSafeArea(style, getStateData()); + if (style != yogaNode_.style()) { + yogaNode_.setStyle(style); + yogaNode_.setDirty(true); + } +} + +/*static*/ void ScrollContentViewShadowNode::padForSafeArea( + yoga::Style& style, + const ScrollContentViewState& state) { + const auto& insets = state.safeAreaPadding; + if (insets.top != 0 || insets.bottom != 0) { + auto top = padded(resolvedPadding(style, Edge::Top, state), insets.top); + auto bottom = + padded(resolvedPadding(style, Edge::Bottom, state), insets.bottom); + style.setPadding(Edge::Top, top); + style.setPadding(Edge::Bottom, bottom); + } + if (insets.left != 0 || insets.right != 0) { + auto left = padded(resolvedPadding(style, Edge::Left, state), insets.left); + auto right = + padded(resolvedPadding(style, Edge::Right, state), insets.right); + // The logical edges take precedence over every other padding edge along + // the row. The physical edges are cleared, since at layout they are copied + // over the logical ones when left and right swap in RTL. + auto rtl = state.layoutDirection == LayoutDirection::RightToLeft; + style.setPadding(Edge::Start, rtl ? right : left); + style.setPadding(Edge::End, rtl ? left : right); + style.setPadding(Edge::Left, StyleLength::undefined()); + style.setPadding(Edge::Right, StyleLength::undefined()); + } +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewShadowNode.h b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewShadowNode.h new file mode 100644 index 000000000000..c4838447e882 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewShadowNode.h @@ -0,0 +1,58 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +#include +#include +#include +#include + +namespace facebook::react { + +// NOLINTNEXTLINE(modernize-avoid-c-arrays) +extern const char ScrollContentViewComponentName[]; + +/* + * `ShadowNode` for the component, the content container of + * a . It pads its content by the safe area in its state, on top of + * the padding its style sets. + */ +class ScrollContentViewShadowNode final : public ConcreteViewShadowNode< + ScrollContentViewComponentName, + ScrollContentViewProps, + ViewEventEmitter, + ScrollContentViewState> { + public: + ScrollContentViewShadowNode( + const ShadowNodeFragment &fragment, + const ShadowNodeFamily::Shared &family, + ShadowNodeTraits traits); + + ScrollContentViewShadowNode(const ShadowNode &sourceShadowNode, const ShadowNodeFragment &fragment); + + /* + * Rebuilds the Yoga style from the props and pads it by the safe area in the + * state. + */ + void applySafeAreaPadding(); + + /* + * Pads `style` by `state.safeAreaPadding`, on top of the padding `style` + * resolves to on each edge, following the precedence Yoga gives the padding + * edges for the layout direction. A percentage resolves to 0 on an edge with + * an inset. + */ + static void padForSafeArea(yoga::Style &style, const ScrollContentViewState &state); + + private: + void initialize() noexcept; +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewState.h b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewState.h new file mode 100644 index 000000000000..3529addb2689 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollContentViewState.h @@ -0,0 +1,64 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +#include +#include + +#ifdef RN_SERIALIZABLE_STATE +#include +#endif + +namespace facebook::react { + +/* + * State for the component, the content container of a + * . + */ +class ScrollContentViewState final { + public: + /* + * The safe area the content is laid out clear of, as padding on top of the + * padding the style sets. The enclosing scroll view sets the pair of edges + * across its scrollable axis. + */ + EdgeInsets safeAreaPadding{}; + + /* + * The layout direction, and whether left and right swap in RTL, decide the + * padding edges the safe area adds to. + */ + LayoutDirection layoutDirection{LayoutDirection::LeftToRight}; + bool swapLeftAndRightInRTL{false}; + +#ifdef RN_SERIALIZABLE_STATE + ScrollContentViewState() = default; + + ScrollContentViewState(const ScrollContentViewState & /*previousState*/, folly::dynamic data) + : safeAreaPadding( + {(Float)data["left"].getDouble(), + (Float)data["top"].getDouble(), + (Float)data["right"].getDouble(), + (Float)data["bottom"].getDouble()}), + layoutDirection(static_cast(data["layoutDirection"].getInt())), + swapLeftAndRightInRTL(data["swapLeftAndRightInRTL"].getBool()) + { + } + + folly::dynamic getDynamic() const + { + return folly::dynamic::object("left", safeAreaPadding.left)("top", safeAreaPadding.top)( + "right", safeAreaPadding.right)("bottom", safeAreaPadding.bottom)( + "layoutDirection", static_cast(layoutDirection))("swapLeftAndRightInRTL", swapLeftAndRightInRTL); + } +#endif +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/tests/ScrollContentViewTest.cpp b/packages/react-native/ReactCommon/react/renderer/components/scrollview/tests/ScrollContentViewTest.cpp new file mode 100644 index 000000000000..0b62a7da6e2b --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/tests/ScrollContentViewTest.cpp @@ -0,0 +1,123 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include + +namespace facebook::react { + +namespace { + +using yoga::Edge; +using yoga::StyleLength; + +ScrollContentViewState stateWith( + EdgeInsets safeAreaPadding, + LayoutDirection layoutDirection = LayoutDirection::LeftToRight, + bool swapLeftAndRightInRTL = false) { + ScrollContentViewState state; + state.safeAreaPadding = safeAreaPadding; + state.layoutDirection = layoutDirection; + state.swapLeftAndRightInRTL = swapLeftAndRightInRTL; + return state; +} + +} // namespace + +TEST(ScrollContentViewShadowNodeTest, leavesStyleWithoutInsets) { + yoga::Style style; + style.setPadding(Edge::Left, StyleLength::points(8)); + + ScrollContentViewShadowNode::padForSafeArea(style, stateWith({})); + + EXPECT_EQ(style.padding(Edge::Left), StyleLength::points(8)); + EXPECT_FALSE(style.padding(Edge::Start).isDefined()); +} + +TEST(ScrollContentViewShadowNodeTest, padsRowOnTopOfResolvedPadding) { + yoga::Style style; + style.setPadding(Edge::Horizontal, StyleLength::points(16)); + + ScrollContentViewShadowNode::padForSafeArea( + style, stateWith({.left = 0, .top = 0, .right = 84, .bottom = 0})); + + EXPECT_EQ(style.padding(Edge::Start), StyleLength::points(16)); + EXPECT_EQ(style.padding(Edge::End), StyleLength::points(100)); + EXPECT_FALSE(style.padding(Edge::Left).isDefined()); + EXPECT_FALSE(style.padding(Edge::Right).isDefined()); +} + +TEST(ScrollContentViewShadowNodeTest, prefersLogicalOverPhysicalEdge) { + yoga::Style style; + style.setPadding(Edge::Left, StyleLength::points(4)); + style.setPadding(Edge::Start, StyleLength::points(12)); + + ScrollContentViewShadowNode::padForSafeArea( + style, stateWith({.left = 20, .top = 0, .right = 0, .bottom = 0})); + + EXPECT_EQ(style.padding(Edge::Start), StyleLength::points(32)); +} + +TEST(ScrollContentViewShadowNodeTest, resolvesPercentageToZeroOnInsetEdge) { + yoga::Style style; + style.setPadding(Edge::Horizontal, StyleLength::percent(10)); + + ScrollContentViewShadowNode::padForSafeArea( + style, stateWith({.left = 20, .top = 0, .right = 0, .bottom = 0})); + + EXPECT_EQ(style.padding(Edge::Start), StyleLength::points(20)); + EXPECT_EQ(style.padding(Edge::End), StyleLength::percent(10)); +} + +TEST(ScrollContentViewShadowNodeTest, padsPhysicalEdgesInRTL) { + yoga::Style style; + style.setPadding(Edge::Start, StyleLength::points(12)); + + ScrollContentViewShadowNode::padForSafeArea( + style, + stateWith( + {.left = 0, .top = 0, .right = 84, .bottom = 0}, + LayoutDirection::RightToLeft)); + + // In RTL the start edge is on the right, where the inset is. + EXPECT_EQ(style.padding(Edge::Start), StyleLength::points(96)); + EXPECT_FALSE(style.padding(Edge::End).isDefined()); +} + +TEST(ScrollContentViewShadowNodeTest, readsSwappedPhysicalEdgesInRTL) { + yoga::Style style; + style.setPadding(Edge::Left, StyleLength::points(8)); + style.setPadding(Edge::End, StyleLength::points(2)); + + ScrollContentViewShadowNode::padForSafeArea( + style, + stateWith( + {.left = 84, .top = 0, .right = 0, .bottom = 0}, + LayoutDirection::RightToLeft, + true)); + + // Left is copied over start at layout, so it lands on the right, and the + // inset on the left adds to the end edge. + EXPECT_EQ(style.padding(Edge::Start), StyleLength::points(8)); + EXPECT_EQ(style.padding(Edge::End), StyleLength::points(86)); + EXPECT_FALSE(style.padding(Edge::Left).isDefined()); +} + +TEST(ScrollContentViewShadowNodeTest, padsColumnForHorizontalScrollView) { + yoga::Style style; + style.setPadding(Edge::Vertical, StyleLength::points(4)); + + ScrollContentViewShadowNode::padForSafeArea( + style, stateWith({.left = 0, .top = 20, .right = 0, .bottom = 34})); + + EXPECT_EQ(style.padding(Edge::Top), StyleLength::points(24)); + EXPECT_EQ(style.padding(Edge::Bottom), StyleLength::points(38)); + EXPECT_EQ(style.padding(Edge::Vertical), StyleLength::points(4)); +} + +} // namespace facebook::react diff --git a/packages/react-native/src/private/components/scrollview/__tests__/getSafeAreaContentPadding-itest.js b/packages/react-native/src/private/components/scrollview/__tests__/getSafeAreaContentPadding-itest.js deleted file mode 100644 index 75c72f9596c2..000000000000 --- a/packages/react-native/src/private/components/scrollview/__tests__/getSafeAreaContentPadding-itest.js +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - */ - -import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; - -import type {ViewStyleProp} from '../../../../../Libraries/StyleSheet/StyleSheet'; -import type {HostInstance} from 'react-native'; - -import getSafeAreaContentPadding from '../getSafeAreaContentPadding'; -import * as Fantom from '@react-native/fantom'; -import nullthrows from 'nullthrows'; -import * as React from 'react'; -import {createRef} from 'react'; -import {View} from 'react-native'; - -const VIEWPORT_WIDTH = 400; -const VIEWPORT_HEIGHT = 600; -const INSETS = {top: 44, right: 84, bottom: 34, left: 0}; - -/** - * The frame of a child filling a container styled by the padding under test, - * on top of the style it was computed for, as Yoga lays it out. - */ -function layoutContent( - style: ViewStyleProp, - horizontal: boolean, -): {x: number, y: number, width: number, height: number} { - const root = Fantom.createRoot({ - viewportWidth: VIEWPORT_WIDTH, - viewportHeight: VIEWPORT_HEIGHT, - }); - const contentRef = createRef(); - Fantom.runTask(() => { - root.render( - - - , - ); - }); - const {x, y, width, height} = nullthrows( - contentRef.current, - ).getBoundingClientRect(); - return {x, y, width, height}; -} - -describe('getSafeAreaContentPadding under Yoga', () => { - test('applies and lifts the insets across renders', () => { - const root = Fantom.createRoot({ - viewportWidth: VIEWPORT_WIDTH, - viewportHeight: VIEWPORT_HEIGHT, - }); - const contentRef = createRef(); - const style = {paddingInlineStart: 8, paddingInlineEnd: 20}; - const render = (insets: ?typeof INSETS) => { - Fantom.runTask(() => { - root.render( - - - , - ); - }); - const {x, width} = nullthrows(contentRef.current).getBoundingClientRect(); - return {x, width}; - }; - - // The style alone. - expect(render(null)).toEqual({x: 8, width: VIEWPORT_WIDTH - 8 - 20}); - // The insets arrive: the keys the style set are reset, so the padding the - // override emits is what applies. A `paddingInlineEnd` left in place on - // the native side would apply over it. - expect(render(INSETS)).toEqual({x: 8, width: VIEWPORT_WIDTH - 8 - 104}); - // The insets change: the override follows. - expect(render({...INSETS, right: 30})).toEqual({ - x: 8, - width: VIEWPORT_WIDTH - 8 - 50, - }); - // The axis has no safe area any more: the style applies on its own again. - expect(render(null)).toEqual({x: 8, width: VIEWPORT_WIDTH - 8 - 20}); - }); - - test('pads the row by the insets on top of the style', () => { - expect(layoutContent({padding: 10}, false)).toEqual({ - x: 10, - y: 10, - width: VIEWPORT_WIDTH - 10 - 94, - height: VIEWPORT_HEIGHT - 20, - }); - }); - - test('pads the column by the insets on top of the style', () => { - expect(layoutContent({paddingBlockStart: 10}, true)).toEqual({ - x: 0, - y: 54, - width: VIEWPORT_WIDTH, - height: VIEWPORT_HEIGHT - 54 - 34, - }); - }); - - test('applies over the inline aliases', () => { - expect(layoutContent({paddingInline: 16}, false)).toEqual({ - x: 16, - y: 0, - width: VIEWPORT_WIDTH - 16 - 100, - height: VIEWPORT_HEIGHT, - }); - expect(layoutContent({paddingInlineEnd: 20}, false)).toEqual({ - x: 0, - y: 0, - width: VIEWPORT_WIDTH - 104, - height: VIEWPORT_HEIGHT, - }); - }); - - test('applies over the physical keys', () => { - expect(layoutContent({paddingLeft: 10, paddingRight: 20}, false)).toEqual({ - x: 10, - y: 0, - width: VIEWPORT_WIDTH - 10 - 104, - height: VIEWPORT_HEIGHT, - }); - }); -}); diff --git a/packages/react-native/src/private/components/scrollview/__tests__/getSafeAreaContentPadding-test.js b/packages/react-native/src/private/components/scrollview/__tests__/getSafeAreaContentPadding-test.js deleted file mode 100644 index 9cbca9ce0d55..000000000000 --- a/packages/react-native/src/private/components/scrollview/__tests__/getSafeAreaContentPadding-test.js +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - */ - -import getSafeAreaContentPadding from '../getSafeAreaContentPadding'; - -const INSETS = {top: 44, right: 84, bottom: 34, left: 0}; - -// Every other key on the row is reset, so that only the logical keys apply. -const ROW_KEYS_UNSET = { - paddingLeft: null, - paddingRight: null, - paddingHorizontal: null, - paddingInline: null, - paddingInlineStart: null, - paddingInlineEnd: null, -}; - -describe('getSafeAreaContentPadding', () => { - const I18nManager = - require('../../../../../Libraries/ReactNative/I18nManager').default; - - afterEach(() => { - jest.restoreAllMocks(); - }); - - test('pads across the scrollable axis of a vertical scroll view', () => { - expect(getSafeAreaContentPadding(null, INSETS, false)).toEqual({ - paddingStart: undefined, - paddingEnd: 84, - ...ROW_KEYS_UNSET, - }); - }); - - test('pads across the scrollable axis of a horizontal scroll view', () => { - expect(getSafeAreaContentPadding(null, INSETS, true)).toEqual({ - paddingTop: 44, - paddingBottom: 34, - }); - }); - - test('is null when the axis has no safe area', () => { - expect( - getSafeAreaContentPadding( - {padding: 10}, - {top: 44, right: 0, bottom: 34, left: 0}, - false, - ), - ).toBeNull(); - expect( - getSafeAreaContentPadding( - {padding: 10}, - {top: 0, right: 84, bottom: 0, left: 0}, - true, - ), - ).toBeNull(); - }); - - test('adds to the padding the style resolves to on each edge', () => { - expect( - getSafeAreaContentPadding( - [{padding: 10}, {paddingHorizontal: 12}, {paddingRight: 20}], - INSETS, - false, - ), - ).toEqual({paddingStart: 12, paddingEnd: 104, ...ROW_KEYS_UNSET}); - expect( - getSafeAreaContentPadding({padding: 10, paddingTop: 5}, INSETS, true), - ).toEqual({paddingTop: 49, paddingBottom: 44}); - }); - - test('resolves the inline and block aliases as Fabric does', () => { - // `paddingInline` applies over `paddingHorizontal`, and `paddingInlineEnd` - // over `paddingEnd`. - expect( - getSafeAreaContentPadding( - {paddingHorizontal: 12, paddingInline: 16, paddingEnd: 20}, - INSETS, - false, - ), - ).toEqual({paddingStart: 16, paddingEnd: 104, ...ROW_KEYS_UNSET}); - expect( - getSafeAreaContentPadding( - {paddingEnd: 20, paddingInlineEnd: 30}, - INSETS, - false, - ), - ).toEqual({paddingStart: undefined, paddingEnd: 114, ...ROW_KEYS_UNSET}); - // `paddingBlockStart` applies under `paddingTop` and over `paddingBlock`, - // which applies over `paddingVertical`. - expect( - getSafeAreaContentPadding( - { - paddingTop: 5, - paddingBlockStart: 8, - paddingVertical: 2, - paddingBlock: 3, - }, - INSETS, - true, - ), - ).toEqual({paddingTop: 49, paddingBottom: 37}); - }); - - test('prefers the logical edge the style sets', () => { - expect( - getSafeAreaContentPadding( - {paddingLeft: 10, paddingStart: 30}, - INSETS, - false, - ), - ).toEqual({paddingStart: 30, paddingEnd: 84, ...ROW_KEYS_UNSET}); - }); - - test('keeps a value that is not a number on an edge with no inset', () => { - expect( - getSafeAreaContentPadding({paddingHorizontal: '5%'}, INSETS, false), - ).toEqual({paddingStart: '5%', paddingEnd: 84, ...ROW_KEYS_UNSET}); - }); - - test('keeps physical insets on their edge in RTL', () => { - jest.spyOn(I18nManager, 'getConstants').mockReturnValue({ - isRTL: true, - doLeftAndRightSwapInRTL: true, - localeIdentifier: 'ar', - }); - // The right inset lands on the leading edge, which is the start in RTL, - // and a `paddingLeft` the style sets is swapped to the physical right, - // over the `paddingStart` it also sets. - expect( - getSafeAreaContentPadding( - {paddingLeft: 10, paddingStart: 30, paddingEnd: 5}, - INSETS, - false, - ), - ).toEqual({paddingStart: 94, paddingEnd: 5, ...ROW_KEYS_UNSET}); - }); - - test('keeps physical edges in RTL without the left and right swap', () => { - jest.spyOn(I18nManager, 'getConstants').mockReturnValue({ - isRTL: true, - doLeftAndRightSwapInRTL: false, - localeIdentifier: 'ar', - }); - expect( - getSafeAreaContentPadding( - {paddingLeft: 10, paddingRight: 20}, - INSETS, - false, - ), - ).toEqual({paddingStart: 104, paddingEnd: 10, ...ROW_KEYS_UNSET}); - }); -}); diff --git a/packages/react-native/src/private/components/scrollview/getSafeAreaContentPadding.js b/packages/react-native/src/private/components/scrollview/getSafeAreaContentPadding.js deleted file mode 100644 index f3fe463ab052..000000000000 --- a/packages/react-native/src/private/components/scrollview/getSafeAreaContentPadding.js +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - */ - -import type {ViewStyleProp} from '../../../../Libraries/StyleSheet/StyleSheet'; -import type {DimensionValue} from '../../../../Libraries/StyleSheet/StyleSheetTypes'; -import type {SafeAreaInsets} from '../../../../Libraries/Types/CoreEventTypes'; - -import I18nManager from '../../../../Libraries/ReactNative/I18nManager'; -import flattenStyle from '../../../../Libraries/StyleSheet/flattenStyle'; - -type PhysicalEdge = 'top' | 'right' | 'bottom' | 'left'; -type FlatStyle = ?{readonly [key: string]: unknown, ...}; -type PaddingValue = DimensionValue | void; - -/** - * The padding keys that apply to one physical edge, most specific first, as - * Fabric resolves them: the `paddingInline*` and `paddingBlock*` aliases are - * applied over the keys they alias, and in RTL with `doLeftAndRightSwapInRTL` - * the physical left and right keys are copied over the logical start and end - * keys after everything else. - */ -function getPaddingKeys(edge: PhysicalEdge, isRTL: boolean): Array { - if (edge === 'top') { - return [ - 'paddingTop', - 'paddingBlockStart', - 'paddingBlock', - 'paddingVertical', - 'padding', - ]; - } - if (edge === 'bottom') { - return [ - 'paddingBottom', - 'paddingBlockEnd', - 'paddingBlock', - 'paddingVertical', - 'padding', - ]; - } - const {doLeftAndRightSwapInRTL} = I18nManager.getConstants(); - const swap = isRTL && doLeftAndRightSwapInRTL; - const leading = (edge === 'left') !== isRTL; - const logical = leading - ? ['paddingInlineStart', 'paddingStart'] - : ['paddingInlineEnd', 'paddingEnd']; - const physical = (edge === 'left') !== swap ? 'paddingLeft' : 'paddingRight'; - return [ - ...(swap ? [physical, ...logical] : [...logical, physical]), - 'paddingInline', - 'paddingHorizontal', - 'padding', - ]; -} - -/** - * The padding a flattened style sets on one physical edge, or `undefined` when - * it sets none. - */ -function resolvePadding( - style: FlatStyle, - edge: PhysicalEdge, - isRTL: boolean, -): PaddingValue { - if (style == null) { - return undefined; - } - for (const key of getPaddingKeys(edge, isRTL)) { - const value = style[key]; - if (typeof value === 'number' || typeof value === 'string') { - return value; - } - } - return undefined; -} - -/** - * The padding the style resolves to on an edge, grown by that edge's inset. - * A value that is not a plain number, such as a percentage, keeps its meaning - * only where the inset is 0; on an inset edge it resolves to 0. - */ -function padEdge(value: PaddingValue, inset: number): PaddingValue { - if (inset === 0) { - return value; - } - return (typeof value === 'number' ? value : 0) + inset; -} - -/** - * Padding that keeps a scroll view's content container clear of the safe area - * on the axis the scroll view does not scroll along, on top of the padding its - * style already has, or `null` when that axis has no safe area to clear. - * - * Across the row the logical keys carry the result, and the other keys on that - * axis are set to `null`: every one of them can otherwise take precedence over - * the logical keys once Fabric resolves the style, and `null` reaches native as - * a reset where `undefined` is left out of the props diff. The column needs no - * such care, since the physical keys take precedence over everything else - * there. - */ -export default function getSafeAreaContentPadding( - contentContainerStyle: ViewStyleProp, - insets: SafeAreaInsets, - horizontal: boolean, -): ViewStyleProp { - const style: FlatStyle = flattenStyle(contentContainerStyle); - if (horizontal) { - if (insets.top === 0 && insets.bottom === 0) { - return null; - } - return { - paddingTop: padEdge(resolvePadding(style, 'top', false), insets.top), - paddingBottom: padEdge( - resolvePadding(style, 'bottom', false), - insets.bottom, - ), - }; - } - if (insets.left === 0 && insets.right === 0) { - return null; - } - const isRTL = I18nManager.getConstants().isRTL; - const left = padEdge(resolvePadding(style, 'left', isRTL), insets.left); - const right = padEdge(resolvePadding(style, 'right', isRTL), insets.right); - return { - paddingStart: isRTL ? right : left, - paddingEnd: isRTL ? left : right, - paddingLeft: null, - paddingRight: null, - paddingHorizontal: null, - paddingInline: null, - paddingInlineStart: null, - paddingInlineEnd: null, - }; -} diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index c1a09deaf698..58d8e7bc3f08 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -1656,6 +1656,10 @@ interface RCTScheduler : public NSObject { public virtual void unregisterSurface:(const facebook::react::SurfaceHandler& surfaceHandler); } +interface RCTScrollContentViewComponentView : public RCTViewComponentView { + public @property (assign) UIEdgeInsets safeAreaPadding; +} + interface RCTScrollEvent : public NSObject { public virtual instancetype initWithEventName:reactTag:scrollViewContentOffset:scrollViewContentInset:scrollViewContentSize:scrollViewFrame:scrollViewZoomScale:userData:coalescingKey:(NSString* eventName, NSNumber* reactTag, CGPoint scrollViewContentOffset, UIEdgeInsets scrollViewContentInset, CGSize scrollViewContentSize, CGRect scrollViewFrame, CGFloat scrollViewZoomScale, NSDictionary* userData, uint16_t coalescingKey); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index b9ab76e51105..34ae3159fb2d 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -1699,6 +1699,10 @@ interface RCTScheduler : public NSObject { public virtual void unregisterSurface:(const facebook::react::SurfaceHandler& surfaceHandler); } +interface RCTScrollContentViewComponentView : public RCTViewComponentView { + public @property (assign) UIEdgeInsets safeAreaPadding; +} + interface RCTScrollEvent : public NSObject { public virtual instancetype initWithEventName:reactTag:scrollViewContentOffset:scrollViewContentInset:scrollViewContentSize:scrollViewFrame:scrollViewZoomScale:userData:coalescingKey:(NSString* eventName, NSNumber* reactTag, CGPoint scrollViewContentOffset, UIEdgeInsets scrollViewContentInset, CGSize scrollViewContentSize, CGRect scrollViewFrame, CGFloat scrollViewZoomScale, NSDictionary* userData, uint16_t coalescingKey); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index 53aae145d4af..1a08681424a4 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -1655,6 +1655,10 @@ interface RCTScheduler : public NSObject { public virtual void unregisterSurface:(const facebook::react::SurfaceHandler& surfaceHandler); } +interface RCTScrollContentViewComponentView : public RCTViewComponentView { + public @property (assign) UIEdgeInsets safeAreaPadding; +} + interface RCTScrollEvent : public NSObject { public virtual instancetype initWithEventName:reactTag:scrollViewContentOffset:scrollViewContentInset:scrollViewContentSize:scrollViewFrame:scrollViewZoomScale:userData:coalescingKey:(NSString* eventName, NSNumber* reactTag, CGPoint scrollViewContentOffset, UIEdgeInsets scrollViewContentInset, CGSize scrollViewContentSize, CGRect scrollViewFrame, CGFloat scrollViewZoomScale, NSDictionary* userData, uint16_t coalescingKey); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 6d605daf3219..e4c77095b78c 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -1656,6 +1656,10 @@ interface RCTScheduler : public NSObject { public virtual void unregisterSurface:(const facebook::react::SurfaceHandler& surfaceHandler); } +interface RCTScrollContentViewComponentView : public RCTViewComponentView { + public @property (assign) UIEdgeInsets safeAreaPadding; +} + interface RCTScrollEvent : public NSObject { public virtual instancetype initWithEventName:reactTag:scrollViewContentOffset:scrollViewContentInset:scrollViewContentSize:scrollViewFrame:scrollViewZoomScale:userData:coalescingKey:(NSString* eventName, NSNumber* reactTag, CGPoint scrollViewContentOffset, UIEdgeInsets scrollViewContentInset, CGSize scrollViewContentSize, CGRect scrollViewFrame, CGFloat scrollViewZoomScale, NSDictionary* userData, uint16_t coalescingKey); }