From 9d886e4947b2025af770830706cf56ecc101c4d7 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Thu, 29 Oct 2026 13:13:54 +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 | 176 ++++++++++++++++++ .../scrollview/HScrollViewNativeComponents.js | 5 +- .../scrollview/VScrollViewNativeComponents.js | 2 +- .../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 + 24 files changed, 720 insertions(+), 649 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 74bf4c3cc73b..37ec57828cc3 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -23,6 +23,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..323824327a5d --- /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(); + auto previousStyle = yogaNode_.style(); + auto wasDirty = yogaNode_.isDirty(); + updateYogaProps(); + auto style = yogaNode_.style(); + padForSafeArea(style, getStateData()); + yogaNode_.setStyle(style); + yogaNode_.setDirty(wasDirty || style != previousStyle); +} + +/*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..7a621cb1ceb0 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/tests/ScrollContentViewTest.cpp @@ -0,0 +1,176 @@ +/* + * 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 + +#include + +#include +#include +#include +#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)); +} + +TEST(ScrollContentViewShadowNodeTest, cloningForChildCommitMustNotDirty) { + ComponentDescriptorProviderRegistry providerRegistry{}; + auto registry = providerRegistry.createComponentDescriptorRegistry( + ComponentDescriptorParameters{}); + providerRegistry.add( + concreteComponentDescriptorProvider()); + providerRegistry.add( + concreteComponentDescriptorProvider()); + providerRegistry.add( + concreteComponentDescriptorProvider< + ScrollContentViewComponentDescriptor>()); + ComponentBuilder builder{registry}; + + std::shared_ptr rootShadowNode; + std::shared_ptr childShadowNode; + // clang-format off + builder.build( + Element() + .reference(rootShadowNode) + .tag(1) + .children({ + Element() + .tag(2) + .stateData([](ScrollContentViewState &state) { + state.safeAreaPadding = {.left = 0, .top = 0, .right = 84, .bottom = 0}; + }) + .children({ + Element() + .tag(3) + .reference(childShadowNode) + }) + })); + // clang-format on + rootShadowNode->layoutIfNeeded(); + + auto newRootShadowNode = rootShadowNode->cloneTree( + childShadowNode->getFamily(), [](const ShadowNode& oldShadowNode) { + auto viewProps = std::make_shared(); + viewProps->backgroundColor = blackColor(); + return oldShadowNode.clone({.props = viewProps}); + }); + + EXPECT_FALSE( + static_cast(*newRootShadowNode).layoutIfNeeded()); +} + +} // namespace facebook::react diff --git a/packages/react-native/src/private/components/scrollview/HScrollViewNativeComponents.js b/packages/react-native/src/private/components/scrollview/HScrollViewNativeComponents.js index a12dcb5e8d3f..a435540a0e6d 100644 --- a/packages/react-native/src/private/components/scrollview/HScrollViewNativeComponents.js +++ b/packages/react-native/src/private/components/scrollview/HScrollViewNativeComponents.js @@ -15,6 +15,7 @@ import type {HostComponent} from '../../types/HostComponent'; import AndroidHorizontalScrollViewNativeComponent from '../../../../Libraries/Components/ScrollView/AndroidHorizontalScrollViewNativeComponent'; import ScrollContentViewNativeComponent from '../../../../Libraries/Components/ScrollView/ScrollContentViewNativeComponent'; import ScrollViewNativeComponent from '../../../../Libraries/Components/ScrollView/ScrollViewNativeComponent'; +import View from '../../../../Libraries/Components/View/View'; import Platform from '../../../../Libraries/Utilities/Platform'; import AndroidHorizontalScrollContentViewNativeComponent from '../../components/scrollview/specs/AndroidHorizontalScrollContentViewNativeComponent'; @@ -26,4 +27,6 @@ export const HScrollViewNativeComponent: HostComponent = export const HScrollContentViewNativeComponent: HostComponent = Platform.OS === 'android' ? AndroidHorizontalScrollContentViewNativeComponent - : ScrollContentViewNativeComponent; + : Platform.OS === 'ios' + ? ScrollContentViewNativeComponent + : View; diff --git a/packages/react-native/src/private/components/scrollview/VScrollViewNativeComponents.js b/packages/react-native/src/private/components/scrollview/VScrollViewNativeComponents.js index 67e12fe79044..48a865afb997 100644 --- a/packages/react-native/src/private/components/scrollview/VScrollViewNativeComponents.js +++ b/packages/react-native/src/private/components/scrollview/VScrollViewNativeComponents.js @@ -21,4 +21,4 @@ export const VScrollViewNativeComponent: HostComponent = ScrollViewNativeComponent; export const VScrollContentViewNativeComponent: HostComponent = - Platform.OS === 'android' ? View : ScrollContentViewNativeComponent; + Platform.OS === 'ios' ? ScrollContentViewNativeComponent : View; 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 cb68d6c58bd7..3313e2e920e9 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -1638,6 +1638,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 3fb5f6906e26..02c12bc7a20a 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -1681,6 +1681,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 497a3a43a3ee..b06805375d28 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -1637,6 +1637,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 4f7774be13cd..74cd67ce7ec4 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -1638,6 +1638,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); }