diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index 55ebda75d1a9..ed41a7faad73 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -16,6 +16,8 @@ import type {ColorValue} from '../../StyleSheet/StyleSheet'; import type { GestureResponderEvent, LayoutChangeEvent, + SafeAreaInsets, + SafeAreaInsetsChangeEvent, ScrollEvent, } from '../../Types/CoreEventTypes'; import type {EventSubscription} from '../../vendor/emitter/EventEmitter'; @@ -23,6 +25,7 @@ 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, @@ -386,8 +389,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 @@ -883,6 +891,11 @@ 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; @@ -1247,6 +1260,30 @@ 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}); @@ -1747,9 +1784,25 @@ 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] @@ -1886,6 +1939,14 @@ 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/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/Libraries/Components/ScrollView/__tests__/ScrollView-safeAreaContentPadding-test.js b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-safeAreaContentPadding-test.js new file mode 100644 index 000000000000..0e3c17822d9c --- /dev/null +++ b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-safeAreaContentPadding-test.js @@ -0,0 +1,140 @@ +/** + * 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/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/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index d9e3b901824e..fa8534301003 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -205,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) { @@ -258,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]; } @@ -346,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); @@ -397,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 +447,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; } @@ -596,7 +601,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 +705,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/src/private/components/scrollview/__tests__/getSafeAreaContentPadding-itest.js b/packages/react-native/src/private/components/scrollview/__tests__/getSafeAreaContentPadding-itest.js new file mode 100644 index 000000000000..75c72f9596c2 --- /dev/null +++ b/packages/react-native/src/private/components/scrollview/__tests__/getSafeAreaContentPadding-itest.js @@ -0,0 +1,143 @@ +/** + * 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 new file mode 100644 index 000000000000..9cbca9ce0d55 --- /dev/null +++ b/packages/react-native/src/private/components/scrollview/__tests__/getSafeAreaContentPadding-test.js @@ -0,0 +1,159 @@ +/** + * 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 new file mode 100644 index 000000000000..f3fe463ab052 --- /dev/null +++ b/packages/react-native/src/private/components/scrollview/getSafeAreaContentPadding.js @@ -0,0 +1,142 @@ +/** + * 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/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js index ef937f99089c..cd5fd2fe2ffa 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: @@ -1328,6 +1338,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. + +