diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index 55ebda75d1a9..a0221bd821cb 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -386,8 +386,13 @@ export type ScrollViewPropsIOS = Readonly<{ */ zoomScale?: ?number, /** - * This property specifies how the safe area insets are used to modify the - * content area of the scroll view. + * How the safe area insets modify the content area of the scroll view. + * + * Along the scrollable axis, the insets become content insets, so content + * scrolls under the system UI. Across it, every value except `'never'` pads + * the content container by the insets, so content is laid out clear of the + * system UI, such as the rail on iPhone Duo or camera cutout in landscape, + * while the container's background still extends edge to edge. * * @default `'never'` * @platform ios diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js index eeee1a603e72..7b93610f085e 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js @@ -124,6 +124,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = endDraggingSensitivityMultiplier: true, directionalLockEnabled: true, disableIntervalMomentum: true, + horizontal: true, indicatorStyle: true, inverted: true, keyboardDismissMode: true, 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/RCTEnhancedScrollView.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h index 66e2163f37f4..7ee560570b42 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h @@ -59,6 +59,21 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, assign) BOOL inverted; +/* + * The content inset before the correction across the scrollable axis: what the props and the content centering + * give. While `padsContentForSafeArea` is set, `contentInset` carries it less whatever UIKit adds across that axis + * for the safe area. + */ +@property (nonatomic, assign) UIEdgeInsets baseContentInset; +@property (nonatomic, assign) BOOL horizontal; + +/* + * Whether `ScrollView` pads the content container by the safe area across the scrollable axis, which it does when + * its `contentInsetAdjustmentBehavior` prop is not `never`. UIKit's own `contentInsetAdjustmentBehavior` can differ, + * as react-native-screens sets it natively on the first scroll view of a screen. + */ +@property (nonatomic, assign) BOOL padsContentForSafeArea; + /* * Makes `setContentOffset:` method no-op when given `block` is executed. * The block is being executed synchronously. diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm index 979715938ab3..192c685e292e 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm @@ -111,7 +111,69 @@ - (void)centerContentIfNeeded if (contentSize.height < boundsSize.height) { top = (boundsSize.height - contentSize.height) * 0.5f; } - self.contentInset = UIEdgeInsetsMake(top, left, top, left); + self.baseContentInset = UIEdgeInsetsMake(top, left, top, left); +} + +- (void)setBaseContentInset:(UIEdgeInsets)baseContentInset +{ + _baseContentInset = baseContentInset; + [self applyBaseContentInset]; +} + +- (void)setHorizontal:(BOOL)horizontal +{ + _horizontal = horizontal; + [self applyBaseContentInset]; +} + +- (void)setPadsContentForSafeArea:(BOOL)padsContentForSafeArea +{ + _padsContentForSafeArea = padsContentForSafeArea; + [self applyBaseContentInset]; +} + +- (void)adjustedContentInsetDidChange +{ + [super adjustedContentInsetDidChange]; + [self applyBaseContentInset]; +} + +/* + * Applies `baseContentInset`, less what UIKit adds across the scrollable axis for the safe area while the content is + * padded for it. `ScrollView` lays the content out clear of the system UI rather than scrolling it under; the + * content inset would apply the safe area a second time. + */ +- (void)applyBaseContentInset +{ + UIEdgeInsets contentInset = _baseContentInset; + if (_padsContentForSafeArea) { + UIEdgeInsets adjusted = self.adjustedContentInset; + UIEdgeInsets current = self.contentInset; + if (_horizontal) { + contentInset.top -= adjusted.top - current.top; + contentInset.bottom -= adjusted.bottom - current.bottom; + } else { + contentInset.left -= adjusted.left - current.left; + contentInset.right -= adjusted.right - current.right; + } + } + if (UIEdgeInsetsEqualToEdgeInsets(contentInset, self.contentInset)) { + return; + } + // Content pinned to the leading edge across the scrollable axis stays pinned, as UIKit keeps it when it changes the + // adjusted content inset itself. + UIEdgeInsets before = self.adjustedContentInset; + self.contentInset = contentInset; + UIEdgeInsets after = self.adjustedContentInset; + CGPoint contentOffset = self.contentOffset; + if (_horizontal && contentOffset.y <= -before.top) { + contentOffset.y = -after.top; + } else if (!_horizontal && contentOffset.x <= -before.left) { + contentOffset.x = -after.left; + } + if (!CGPointEqualToPoint(contentOffset, self.contentOffset)) { + self.contentOffset = contentOffset; + } } - (void)setContentOffset:(CGPoint)contentOffset 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 3853ea796772..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; @@ -204,7 +205,7 @@ - (void)_keyboardWillChangeFrame:(NSNotification *)notification CGPoint absoluteViewOrigin = [self convertPoint:self.bounds.origin toView:nil]; CGFloat scrollViewLowerY = isInverted ? absoluteViewOrigin.y : absoluteViewOrigin.y + self.bounds.size.height; - UIEdgeInsets newEdgeInsets = _scrollView.contentInset; + UIEdgeInsets newEdgeInsets = ((RCTEnhancedScrollView *)_scrollView).baseContentInset; CGFloat inset = MAX(scrollViewLowerY - keyboardEndFrame.origin.y, 0); const auto &props = static_cast(*_props); if (isInverted) { @@ -257,7 +258,7 @@ - (void)_keyboardWillChangeFrame:(NSNotification *)notification delay:0.0 options:animationOptionsWithCurve(curve) animations:^{ - self->_scrollView.contentInset = newEdgeInsets; + ((RCTEnhancedScrollView *)self->_scrollView).baseContentInset = newEdgeInsets; self->_scrollView.verticalScrollIndicatorInsets = newEdgeInsets; [self scrollTo:newContentOffset.x y:newContentOffset.y animated:NO]; } @@ -345,6 +346,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & MAP_SCROLL_VIEW_PROP(bouncesZoom); MAP_SCROLL_VIEW_PROP(canCancelContentTouches); MAP_SCROLL_VIEW_PROP(centerContent); + MAP_SCROLL_VIEW_PROP(horizontal); // MAP_SCROLL_VIEW_PROP(automaticallyAdjustContentInsets); MAP_SCROLL_VIEW_PROP(decelerationRate); MAP_SCROLL_VIEW_PROP(directionalLockEnabled); @@ -396,13 +398,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & // When disabling centerContent, reset inset to prop value // (enabling is handled automatically by the setCenterContent: setter) if (oldScrollViewProps.centerContent && !newScrollViewProps.centerContent) { - _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset); + ((RCTEnhancedScrollView *)_scrollView).baseContentInset = + RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset); } // Only apply contentInset from props if centerContent is disabled // When centerContent is enabled, the inset is calculated by centerContentIfNeeded if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset && !newScrollViewProps.centerContent) { - _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset); + ((RCTEnhancedScrollView *)_scrollView).baseContentInset = + RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset); } if (oldScrollViewProps.contentOffset != newScrollViewProps.contentOffset) { _scrollView.contentOffset = RCTCGPointFromPoint(newScrollViewProps.contentOffset); @@ -444,6 +448,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & } else if (contentInsetAdjustmentBehavior == ContentInsetAdjustmentBehavior::Always) { scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentAlways; } + ((RCTEnhancedScrollView *)_scrollView).padsContentForSafeArea = + contentInsetAdjustmentBehavior != ContentInsetAdjustmentBehavior::Never; _shouldUpdateContentInsetAdjustmentBehavior = NO; } @@ -458,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 @@ -557,6 +564,7 @@ - (void)mountChildComponentView:(UIView *)childCompone [_containerView insertSubview:childComponentView atIndex:index]; if (![childComponentView conformsToProtocol:@protocol(RCTCustomPullToRefreshViewProtocol)]) { _contentView = childComponentView; + [self _updateContentViewSafeAreaPadding]; } } @@ -596,7 +604,7 @@ - (BOOL)_shouldDisableScrollInteraction auto metrics = ScrollViewEventEmitter::Metrics{}; metrics.contentSize = RCTSizeFromCGSize(_scrollView.contentSize); metrics.contentOffset = RCTPointFromCGPoint(_scrollView.contentOffset); - metrics.contentInset = RCTEdgeInsetsFromUIEdgeInsets(_scrollView.contentInset); + metrics.contentInset = RCTEdgeInsetsFromUIEdgeInsets(((RCTEnhancedScrollView *)_scrollView).baseContentInset); metrics.containerSize = RCTSizeFromCGSize(_scrollView.bounds.size); metrics.zoomScale = _scrollView.zoomScale; metrics.timestamp = CACurrentMediaTime(); @@ -700,11 +708,12 @@ - (void)prepareForRecycle // Invalidate cached content size so that updateState: recalculates the // container frame after zoomScale reset (which may have mutated it in RTL). _contentSize = CGSizeZero; - _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(props.contentInset); + ((RCTEnhancedScrollView *)_scrollView).baseContentInset = RCTUIEdgeInsetsFromEdgeInsets(props.contentInset); // We set the default behavior to "never" so that iOS // doesn't do weird things to UIScrollView insets automatically // and keeps it as an opt-in behavior. _scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + ((RCTEnhancedScrollView *)_scrollView).padsContentForSafeArea = NO; _shouldUpdateContentInsetAdjustmentBehavior = YES; _automaticallyAdjustKeyboardInsets = NO; _isUserTriggeredScrolling = NO; @@ -857,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/rn-tester/js/examples/ScrollView/ScrollViewExample.js b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js index 72580d9c0485..aa13bc65fd62 100644 --- a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js +++ b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js @@ -16,6 +16,7 @@ import nullthrows from 'nullthrows'; import * as React from 'react'; import {cloneElement, useCallback, useRef, useState} from 'react'; import { + Modal, Platform, RefreshControl, ScrollView, @@ -317,6 +318,15 @@ const examples: Array = [ return ; }, }, + { + title: ' Content Inset Adjustment Behavior\n', + description: + 'How the safe area modifies the content area of a full screen ScrollView: along the scrollable axis it becomes content insets, across it the content container is padded.', + platform: 'ios', + render(): React.Node { + return ; + }, + }, { title: ' Deceleration Rate\n', description: @@ -1485,6 +1495,64 @@ const ContentExample = () => { ); }; +const CONTENT_INSET_ADJUSTMENT_BEHAVIORS = [ + 'never', + 'automatic', + 'scrollableAxes', + 'always', +] as const; + +const FULL_SCREEN_ITEMS = [...Array(40)].map((_, i) => `Item ${i}`); + +const ContentInsetAdjustmentBehaviorExample = () => { + const [visible, setVisible] = useState(false); + const [horizontal, setHorizontal] = useState(false); + const [behavior, setBehavior] = + useState<(typeof CONTENT_INSET_ADJUSTMENT_BEHAVIORS)[number]>('automatic'); + return ( + + + Opens a full screen ScrollView that extends under the system UI, + including the rail on iPhone Duo and the notch in landscape. + +