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..37a3ee2bd251 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,19 @@ 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 JavaScript pads the content container by the safe area across the scrollable axis. Defaults to NO. + */ +@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..9d116bdd74de 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,80 @@ - (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; + if (_padsContentForSafeArea) { + [self applyBaseContentInset]; + } else { + self.contentInset = baseContentInset; + } +} + +- (void)setHorizontal:(BOOL)horizontal +{ + _horizontal = horizontal; + if (_padsContentForSafeArea) { + [self applyBaseContentInset]; + } +} + +- (void)setPadsContentForSafeArea:(BOOL)padsContentForSafeArea +{ + if (_padsContentForSafeArea == padsContentForSafeArea) { + return; + } + _padsContentForSafeArea = padsContentForSafeArea; + [self applyBaseContentInset]; +} + +- (void)adjustedContentInsetDidChange +{ + [super adjustedContentInsetDidChange]; + if (_padsContentForSafeArea) { + [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/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 00653abc9fd0..7c3b78ee0eb2 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -204,7 +204,9 @@ - (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; + auto *enhancedScrollView = (RCTEnhancedScrollView *)_scrollView; + UIEdgeInsets newEdgeInsets = + enhancedScrollView.padsContentForSafeArea ? enhancedScrollView.baseContentInset : enhancedScrollView.contentInset; CGFloat inset = MAX(scrollViewLowerY - keyboardEndFrame.origin.y, 0); const auto &props = static_cast(*_props); if (isInverted) { @@ -257,7 +259,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 +347,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 +399,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); @@ -445,6 +450,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & } _shouldUpdateContentInsetAdjustmentBehavior = NO; } + scrollView.padsContentForSafeArea = newScrollViewProps.padsContentForSafeArea; MAP_SCROLL_VIEW_PROP(disableIntervalMomentum); MAP_SCROLL_VIEW_PROP(snapToInterval); @@ -595,7 +601,9 @@ - (BOOL)_shouldDisableScrollInteraction auto metrics = ScrollViewEventEmitter::Metrics{}; metrics.contentSize = RCTSizeFromCGSize(_scrollView.contentSize); metrics.contentOffset = RCTPointFromCGPoint(_scrollView.contentOffset); - metrics.contentInset = RCTEdgeInsetsFromUIEdgeInsets(_scrollView.contentInset); + auto *scrollView = (RCTEnhancedScrollView *)_scrollView; + metrics.contentInset = RCTEdgeInsetsFromUIEdgeInsets( + scrollView.padsContentForSafeArea ? scrollView.baseContentInset : scrollView.contentInset); metrics.containerSize = RCTSizeFromCGSize(_scrollView.bounds.size); metrics.zoomScale = _scrollView.zoomScale; metrics.timestamp = CACurrentMediaTime(); @@ -699,11 +707,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; diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/BaseScrollViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/scrollview/BaseScrollViewProps.cpp index cb5407ea853e..e80551e1928e 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/BaseScrollViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/BaseScrollViewProps.cpp @@ -171,6 +171,12 @@ BaseScrollViewProps::BaseScrollViewProps( "horizontal", sourceProps.horizontal, true)), + padsContentForSafeArea(convertRawProp( + context, + rawProps, + "padsContentForSafeArea", + sourceProps.padsContentForSafeArea, + false)), scrollEventThrottle(convertRawProp( context, rawProps, @@ -294,6 +300,7 @@ void BaseScrollViewProps::setProp( RAW_SET_PROP_SWITCH_CASE_BASIC(showsVerticalScrollIndicator); RAW_SET_PROP_SWITCH_CASE_BASIC(persistentScrollbar); RAW_SET_PROP_SWITCH_CASE_BASIC(horizontal); + RAW_SET_PROP_SWITCH_CASE_BASIC(padsContentForSafeArea); RAW_SET_PROP_SWITCH_CASE_BASIC(scrollEventThrottle); RAW_SET_PROP_SWITCH_CASE_BASIC(zoomScale); RAW_SET_PROP_SWITCH_CASE_BASIC(contentInset); @@ -405,6 +412,10 @@ SharedDebugStringConvertibleList BaseScrollViewProps::getDebugProps() const { defaultScrollViewProps.persistentScrollbar), debugStringConvertibleItem( "horizontal", horizontal, defaultScrollViewProps.horizontal), + debugStringConvertibleItem( + "padsContentForSafeArea", + padsContentForSafeArea, + defaultScrollViewProps.padsContentForSafeArea), debugStringConvertibleItem( "scrollEventThrottle", scrollEventThrottle, diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/BaseScrollViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/scrollview/BaseScrollViewProps.h index 3a3ebbe84b9b..66efdd5e32e2 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/BaseScrollViewProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/BaseScrollViewProps.h @@ -57,6 +57,7 @@ class BaseScrollViewProps : public ViewProps { bool showsVerticalScrollIndicator{true}; bool persistentScrollbar{false}; bool horizontal{false}; + bool padsContentForSafeArea{false}; Float scrollEventThrottle{}; Float zoomScale{1.0f}; EdgeInsets contentInset{}; diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/tests/ScrollViewTest.cpp b/packages/react-native/ReactCommon/react/renderer/components/scrollview/tests/ScrollViewTest.cpp index 8912b5373bc3..6e11d8130bcb 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/tests/ScrollViewTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/tests/ScrollViewTest.cpp @@ -5,12 +5,57 @@ * LICENSE file in the root directory of this source tree. */ +#include #include +#include #include namespace facebook::react { +namespace { + +BaseScrollViewProps parseScrollViewProps( + const folly::dynamic& value, + const BaseScrollViewProps& sourceProps = BaseScrollViewProps{}) { + ContextContainer contextContainer{}; + PropsParserContext context{-1, contextContainer}; + RawProps raw{value}; + RawPropsParser parser{}; + parser.prepare(); + raw.parse(parser); + return {context, sourceProps, raw}; +} + +} // namespace + +TEST(ScrollViewPropsTest, safeAreaPaddingRequiresExplicitOptIn) { + EXPECT_FALSE(BaseScrollViewProps{}.padsContentForSafeArea); + for (const auto* behavior : + {"never", "automatic", "scrollableAxes", "always"}) { + auto props = parseScrollViewProps( + folly::dynamic::object("contentInsetAdjustmentBehavior", behavior)); + EXPECT_FALSE(props.padsContentForSafeArea); + } +} + +TEST(ScrollViewPropsTest, safeAreaPaddingCanBeEnabledAndDisabled) { + auto enabled = parseScrollViewProps( + folly::dynamic::object("padsContentForSafeArea", true)); + EXPECT_TRUE(enabled.padsContentForSafeArea); + auto disabled = parseScrollViewProps( + folly::dynamic::object("padsContentForSafeArea", false), enabled); + EXPECT_FALSE(disabled.padsContentForSafeArea); +} + +TEST(ScrollViewPropsTest, safeAreaPaddingResetsToDefault) { + auto enabled = parseScrollViewProps( + folly::dynamic::object("padsContentForSafeArea", true)); + auto reset = parseScrollViewProps( + folly::dynamic::object("padsContentForSafeArea", nullptr), enabled); + EXPECT_FALSE(reset.padsContentForSafeArea); +} + TEST(ScrollViewStateTest, defaultConstructor) { ScrollViewState state; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api index 063d23740fac..11ac9b62ed20 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api @@ -1416,6 +1416,7 @@ class facebook::react::BaseScrollViewProps : public facebook::react::HostPlatfor public bool disableIntervalMomentum; public bool horizontal; public bool isInvertedVirtualizedList; + public bool padsContentForSafeArea; public bool pagingEnabled; public bool persistentScrollbar; public bool pinchGestureEnabled; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 1ca098e4b6f9..17eada39ea7e 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -1023,11 +1023,14 @@ interface RCTDivisionAnimatedNode : public RCTValueAnimatedNode { interface RCTEnhancedScrollView : public UIScrollView { public @property (assign) BOOL centerContent; public @property (assign) BOOL disableIntervalMomentum; + public @property (assign) BOOL horizontal; public @property (assign) BOOL inverted; + public @property (assign) BOOL padsContentForSafeArea; public @property (assign) BOOL pinchGestureEnabled; public @property (assign) BOOL snapToEnd; public @property (assign) BOOL snapToStart; public @property (assign) CGFloat snapToInterval; + public @property (assign) UIEdgeInsets baseContentInset; public @property (copy) NSArray* snapToOffsets; public @property (copy) NSString* snapToAlignment; public @property (strong, readonly) RCTGenericDelegateSplitter>* delegateSplitter; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index 4d74084f2445..f892035e9ee5 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -1032,11 +1032,14 @@ interface RCTDivisionAnimatedNode : public RCTValueAnimatedNode { interface RCTEnhancedScrollView : public UIScrollView { public @property (assign) BOOL centerContent; public @property (assign) BOOL disableIntervalMomentum; + public @property (assign) BOOL horizontal; public @property (assign) BOOL inverted; + public @property (assign) BOOL padsContentForSafeArea; public @property (assign) BOOL pinchGestureEnabled; public @property (assign) BOOL snapToEnd; public @property (assign) BOOL snapToStart; public @property (assign) CGFloat snapToInterval; + public @property (assign) UIEdgeInsets baseContentInset; public @property (copy) NSArray* snapToOffsets; public @property (copy) NSString* snapToAlignment; public @property (strong, readonly) RCTGenericDelegateSplitter>* delegateSplitter; @@ -3981,6 +3984,7 @@ class facebook::react::BaseScrollViewProps : public facebook::react::HostPlatfor public bool disableIntervalMomentum; public bool horizontal; public bool isInvertedVirtualizedList; + public bool padsContentForSafeArea; public bool pagingEnabled; public bool persistentScrollbar; public bool pinchGestureEnabled; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index df4b2417b5af..a6dfef9517ab 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -1023,11 +1023,14 @@ interface RCTDivisionAnimatedNode : public RCTValueAnimatedNode { interface RCTEnhancedScrollView : public UIScrollView { public @property (assign) BOOL centerContent; public @property (assign) BOOL disableIntervalMomentum; + public @property (assign) BOOL horizontal; public @property (assign) BOOL inverted; + public @property (assign) BOOL padsContentForSafeArea; public @property (assign) BOOL pinchGestureEnabled; public @property (assign) BOOL snapToEnd; public @property (assign) BOOL snapToStart; public @property (assign) CGFloat snapToInterval; + public @property (assign) UIEdgeInsets baseContentInset; public @property (copy) NSArray* snapToOffsets; public @property (copy) NSString* snapToAlignment; public @property (strong, readonly) RCTGenericDelegateSplitter>* delegateSplitter; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 1c4998b3d189..6c7ad80d4515 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -1023,11 +1023,14 @@ interface RCTDivisionAnimatedNode : public RCTValueAnimatedNode { interface RCTEnhancedScrollView : public UIScrollView { public @property (assign) BOOL centerContent; public @property (assign) BOOL disableIntervalMomentum; + public @property (assign) BOOL horizontal; public @property (assign) BOOL inverted; + public @property (assign) BOOL padsContentForSafeArea; public @property (assign) BOOL pinchGestureEnabled; public @property (assign) BOOL snapToEnd; public @property (assign) BOOL snapToStart; public @property (assign) CGFloat snapToInterval; + public @property (assign) UIEdgeInsets baseContentInset; public @property (copy) NSArray* snapToOffsets; public @property (copy) NSString* snapToAlignment; public @property (strong, readonly) RCTGenericDelegateSplitter>* delegateSplitter; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api index 40d47068ebce..47ba94697318 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api @@ -764,6 +764,7 @@ class facebook::react::BaseScrollViewProps : public facebook::react::HostPlatfor public bool disableIntervalMomentum; public bool horizontal; public bool isInvertedVirtualizedList; + public bool padsContentForSafeArea; public bool pagingEnabled; public bool persistentScrollbar; public bool pinchGestureEnabled;