Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ import type {ColorValue} from '../../StyleSheet/StyleSheet';
import type {
GestureResponderEvent,
LayoutChangeEvent,
SafeAreaInsets,
SafeAreaInsetsChangeEvent,
ScrollEvent,
} from '../../Types/CoreEventTypes';
import type {EventSubscription} from '../../vendor/emitter/EventEmitter';
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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -792,6 +802,7 @@ export type ScrollViewProps = Readonly<{

type ScrollViewState = {
layoutHeight: ?number,
safeAreaInsets: ?SafeAreaInsets,
};

const IS_ANIMATING_TOUCH_START_THRESHOLD_MS = 16;
Expand Down Expand Up @@ -881,6 +892,7 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {

state: ScrollViewState = {
layoutHeight: null,
safeAreaInsets: null,
};

componentDidMount() {
Expand Down Expand Up @@ -1247,6 +1259,11 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
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});
Expand Down Expand Up @@ -1747,9 +1764,38 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
? 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]
Expand Down Expand Up @@ -1864,6 +1910,7 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
...otherProps,
alwaysBounceHorizontal,
alwaysBounceVertical,
contentInset,
style: StyleSheet.compose(baseStyle, this.props.style),
// Override the onContentSizeChange from props, since this event can
// bubble up from TextInputs
Expand All @@ -1886,6 +1933,9 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
onTouchStart: this._handleTouchStart,
onTouchCancel: this._handleTouchCancel,
onScroll: this._handleScroll,
experimental_onSafeAreaInsetsChange: padsContentForSafeArea
? this._handleSafeAreaInsetsChange
: this.props.experimental_onSafeAreaInsetsChange,
endDraggingSensitivityMultiplier:
experimental_endDraggingSensitivityMultiplier,
scrollEventThrottle: hasStickyHeaders
Expand Down
Original file line number Diff line number Diff line change
@@ -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});
});
});
Original file line number Diff line number Diff line change
@@ -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<string> = [];
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,
};
}
Loading
Loading