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); }