diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index 5c7974f2ac30..d5ef499b85ed 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,9 @@ import type {KeyboardEvent, KeyboardMetrics} from '../Keyboard/Keyboard'; import type {ViewProps} from '../View/ViewPropTypes'; import type {ScrollViewStickyHeaderProps} from './ScrollViewStickyHeader'; +import getSafeAreaContentPadding, { + getSafeAreaContentInset, +} from '../../../src/private/components/scrollview/getSafeAreaContentPadding'; import { HScrollContentViewNativeComponent, HScrollViewNativeComponent, @@ -386,8 +391,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 @@ -792,6 +802,7 @@ export type ScrollViewProps = Readonly<{ type ScrollViewState = { layoutHeight: ?number, + safeAreaInsets: ?SafeAreaInsets, }; const IS_ANIMATING_TOUCH_START_THRESHOLD_MS = 16; @@ -881,6 +892,7 @@ class ScrollView extends React.Component { state: ScrollViewState = { layoutHeight: null, + safeAreaInsets: null, }; componentDidMount() { @@ -1247,6 +1259,11 @@ class ScrollView extends React.Component { this.props.onScroll && this.props.onScroll(e); }; + _handleSafeAreaInsetsChange = (e: SafeAreaInsetsChangeEvent) => { + this.setState({safeAreaInsets: e.nativeEvent.insets}); + this.props.experimental_onSafeAreaInsetsChange?.(e); + }; + _handleLayout = (e: LayoutChangeEvent) => { if (this.props.invertStickyHeaders === true) { this.setState({layoutHeight: e.nativeEvent.layout.height}); @@ -1747,9 +1764,38 @@ 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. Under + // 'always' UIKit insets that axis as well, which the contentInset passed + // to native cancels out. + const padsContentForSafeArea = + Platform.OS === 'ios' && + this.props.contentInsetAdjustmentBehavior != null && + this.props.contentInsetAdjustmentBehavior !== 'never'; + const {safeAreaInsets} = this.state; + const contentInset = + padsContentForSafeArea && + safeAreaInsets != null && + this.props.contentInsetAdjustmentBehavior === 'always' + ? getSafeAreaContentInset( + this.props.contentInset, + safeAreaInsets, + horizontal, + ) + : this.props.contentInset; + 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] @@ -1864,6 +1910,7 @@ class ScrollView extends React.Component { ...otherProps, alwaysBounceHorizontal, alwaysBounceVertical, + contentInset, style: StyleSheet.compose(baseStyle, this.props.style), // Override the onContentSizeChange from props, since this event can // bubble up from TextInputs @@ -1886,6 +1933,9 @@ class ScrollView extends React.Component { onTouchStart: this._handleTouchStart, onTouchCancel: this._handleTouchCancel, onScroll: this._handleScroll, + experimental_onSafeAreaInsetsChange: padsContentForSafeArea + ? this._handleSafeAreaInsetsChange + : this.props.experimental_onSafeAreaInsetsChange, endDraggingSensitivityMultiplier: experimental_endDraggingSensitivityMultiplier, scrollEventThrottle: hasStickyHeaders 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..66c219b9804b --- /dev/null +++ b/packages/react-native/src/private/components/scrollview/__tests__/getSafeAreaContentPadding-test.js @@ -0,0 +1,107 @@ +/** + * 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, { + getSafeAreaContentInset, +} from '../getSafeAreaContentPadding'; + +const INSETS = {top: 44, right: 84, bottom: 34, left: 0}; + +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: 0, + paddingEnd: 84, + }); + }); + + test('pads across the scrollable axis of a horizontal scroll view', () => { + expect(getSafeAreaContentPadding(null, INSETS, true)).toEqual({ + paddingTop: 44, + paddingBottom: 34, + }); + }); + + 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}); + expect( + getSafeAreaContentPadding({padding: 10, paddingTop: 5}, INSETS, true), + ).toEqual({paddingTop: 49, paddingBottom: 44}); + }); + + test('prefers the logical edge the style sets', () => { + expect( + getSafeAreaContentPadding( + {paddingLeft: 10, paddingStart: 30}, + INSETS, + false, + ), + ).toEqual({paddingStart: 30, paddingEnd: 84}); + }); + + test('resolves values that are not numbers to 0', () => { + expect(getSafeAreaContentPadding({padding: '5%'}, INSETS, false)).toEqual({ + paddingStart: 0, + paddingEnd: 84, + }); + }); + + 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. + expect(getSafeAreaContentPadding({paddingLeft: 10}, INSETS, false)).toEqual( + {paddingStart: 94, paddingEnd: 0}, + ); + }); +}); + +describe('getSafeAreaContentInset', () => { + test('cancels the insets across the scrollable axis of a vertical scroll view', () => { + expect(getSafeAreaContentInset(null, INSETS, false)).toEqual({ + left: 0, + right: -84, + }); + }); + + test('cancels the insets across the scrollable axis of a horizontal scroll view', () => { + expect(getSafeAreaContentInset(null, INSETS, true)).toEqual({ + top: -44, + bottom: -34, + }); + }); + + test('keeps the caller contentInset on every edge', () => { + expect( + getSafeAreaContentInset( + {top: 10, right: 20, bottom: 30, left: 40}, + INSETS, + false, + ), + ).toEqual({top: 10, right: -64, bottom: 30, left: 40}); + }); +}); 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..43adc4a1a918 --- /dev/null +++ b/packages/react-native/src/private/components/scrollview/getSafeAreaContentPadding.js @@ -0,0 +1,112 @@ +/** + * 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 {EdgeInsetsProp} from '../../../../Libraries/StyleSheet/EdgeInsetsPropType'; +import type {ViewStyleProp} from '../../../../Libraries/StyleSheet/StyleSheet'; +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'; + +/** + * The padding a style resolves to on one physical edge, following Yoga's + * precedence: the logical edge (start or end), then the physical edge, then + * the axis, then `padding`. Values that are not plain numbers resolve to 0. + */ +function resolvePadding( + style: ?{readonly [key: string]: unknown, ...}, + edge: PhysicalEdge, + isRTL: boolean, +): number { + if (style == null) { + return 0; + } + const keys: Array = []; + if (edge === 'left' || edge === 'right') { + const {doLeftAndRightSwapInRTL} = I18nManager.getConstants(); + const swap = isRTL && doLeftAndRightSwapInRTL; + const leading = (edge === 'left') !== isRTL; + keys.push(leading ? 'paddingStart' : 'paddingEnd'); + let physical = edge; + if (swap) { + physical = edge === 'left' ? 'right' : 'left'; + } + keys.push(physical === 'left' ? 'paddingLeft' : 'paddingRight'); + keys.push('paddingHorizontal'); + } else { + keys.push(edge === 'top' ? 'paddingTop' : 'paddingBottom'); + keys.push('paddingVertical'); + } + keys.push('padding'); + for (const key of keys) { + const value = style[key]; + if (value != null) { + return typeof value === 'number' ? value : 0; + } + } + return 0; +} + +/** + * 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. Logical edges are used across the row, since they take + * precedence over every other padding key on that edge. + */ +export default function getSafeAreaContentPadding( + contentContainerStyle: ViewStyleProp, + insets: SafeAreaInsets, + horizontal: boolean, +): ViewStyleProp { + const style: ?{readonly [key: string]: unknown, ...} = flattenStyle( + contentContainerStyle, + ); + if (horizontal) { + return { + paddingTop: resolvePadding(style, 'top', false) + insets.top, + paddingBottom: resolvePadding(style, 'bottom', false) + insets.bottom, + }; + } + const isRTL = I18nManager.getConstants().isRTL; + const left = resolvePadding(style, 'left', isRTL) + insets.left; + const right = resolvePadding(style, 'right', isRTL) + insets.right; + return { + paddingStart: isRTL ? right : left, + paddingEnd: isRTL ? left : right, + }; +} + +/** + * The `contentInset` for a scroll view whose content container is padded by + * the safe area across the scrollable axis while UIKit also applies the safe + * area on that axis as a content inset, as `'always'` does. Subtracting the + * insets there leaves the adjusted content inset at the caller's own + * `contentInset` across the scrollable axis, so the safe area applies once. + */ +export function getSafeAreaContentInset( + contentInset: ?EdgeInsetsProp, + insets: SafeAreaInsets, + horizontal: boolean, +): EdgeInsetsProp { + if (horizontal) { + return { + ...contentInset, + top: (contentInset?.top ?? 0) - insets.top, + bottom: (contentInset?.bottom ?? 0) - insets.bottom, + }; + } + return { + ...contentInset, + left: (contentInset?.left ?? 0) - insets.left, + right: (contentInset?.right ?? 0) - insets.right, + }; +} 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. + +