Skip to content

Commit 46e0941

Browse files
committed
[ScrollView][iOS] Fix contentInsetAdjustmentBehavior for the horizontal safe area (iPhone Duo)
1 parent 9de6471 commit 46e0941

6 files changed

Lines changed: 799 additions & 12 deletions

File tree

‎packages/react-native/Libraries/Components/ScrollView/ScrollView.js‎

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,18 @@ import type {ColorValue} from '../../StyleSheet/StyleSheet';
1616
import type {
1717
GestureResponderEvent,
1818
LayoutChangeEvent,
19+
SafeAreaInsets,
20+
SafeAreaInsetsChangeEvent,
1921
ScrollEvent,
2022
} from '../../Types/CoreEventTypes';
2123
import type {EventSubscription} from '../../vendor/emitter/EventEmitter';
2224
import type {KeyboardEvent, KeyboardMetrics} from '../Keyboard/Keyboard';
2325
import type {ViewProps} from '../View/ViewPropTypes';
2426
import type {ScrollViewStickyHeaderProps} from './ScrollViewStickyHeader';
2527

28+
import getSafeAreaContentPadding, {
29+
getSafeAreaContentInset,
30+
} from '../../../src/private/components/scrollview/getSafeAreaContentPadding';
2631
import {
2732
HScrollContentViewNativeComponent,
2833
HScrollViewNativeComponent,
@@ -386,8 +391,13 @@ export type ScrollViewPropsIOS = Readonly<{
386391
*/
387392
zoomScale?: ?number,
388393
/**
389-
* This property specifies how the safe area insets are used to modify the
390-
* content area of the scroll view.
394+
* How the safe area insets modify the content area of the scroll view.
395+
*
396+
* Along the scrollable axis, the insets become content insets, so content
397+
* scrolls under the system UI. Across it, every value except `'never'` pads
398+
* the content container by the insets, so content is laid out clear of the
399+
* system UI, such as the rail on iPhone Duo or camera cutout in landscape,
400+
* while the container's background still extends edge to edge.
391401
*
392402
* @default `'never'`
393403
* @platform ios
@@ -883,6 +893,11 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
883893
layoutHeight: null,
884894
};
885895

896+
// The latest safe area insets of the native view. Only the pair across the
897+
// scrollable axis affects the content container, so a change to the other
898+
// pair is kept here without a render.
899+
_safeAreaInsets: ?SafeAreaInsets = null;
900+
886901
componentDidMount() {
887902
this._keyboardMetrics = Keyboard.metrics();
888903
this._additionalScrollOffset = 0;
@@ -1247,6 +1262,21 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
12471262
this.props.onScroll && this.props.onScroll(e);
12481263
};
12491264

1265+
_handleSafeAreaInsetsChange = (e: SafeAreaInsetsChangeEvent) => {
1266+
const {insets} = e.nativeEvent;
1267+
const previous = this._safeAreaInsets;
1268+
this._safeAreaInsets = insets;
1269+
const changedAcrossScrollableAxis =
1270+
previous == null ||
1271+
(this.props.horizontal === true
1272+
? previous.top !== insets.top || previous.bottom !== insets.bottom
1273+
: previous.left !== insets.left || previous.right !== insets.right);
1274+
if (changedAcrossScrollableAxis) {
1275+
this.forceUpdate();
1276+
}
1277+
this.props.experimental_onSafeAreaInsetsChange?.(e);
1278+
};
1279+
12501280
_handleLayout = (e: LayoutChangeEvent) => {
12511281
if (this.props.invertStickyHeaders === true) {
12521282
this.setState({layoutHeight: e.nativeEvent.layout.height});
@@ -1747,9 +1777,55 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
17471777
? HScrollContentViewNativeComponent
17481778
: VScrollContentViewNativeComponent;
17491779

1780+
const alwaysBounceHorizontal =
1781+
this.props.alwaysBounceHorizontal !== undefined
1782+
? this.props.alwaysBounceHorizontal
1783+
: this.props.horizontal;
1784+
1785+
const alwaysBounceVertical =
1786+
this.props.alwaysBounceVertical !== undefined
1787+
? this.props.alwaysBounceVertical
1788+
: !this.props.horizontal;
1789+
1790+
// Along the scrollable axis UIKit turns the safe area into content insets,
1791+
// so content scrolls under the system UI. Across it there is nothing to
1792+
// scroll, so the content container is padded instead and content is laid
1793+
// out clear of the system UI, such as the rail on iPhone Duo. UIKit insets
1794+
// that axis as well under 'always', and whenever the axis always bounces,
1795+
// which the contentInset passed to native cancels out. Native ignores that
1796+
// contentInset while it centers the content itself, so the safe area is
1797+
// left to UIKit there.
1798+
const behavior = this.props.contentInsetAdjustmentBehavior;
1799+
const cancelsInsetAcrossScrollableAxis =
1800+
behavior === 'always' ||
1801+
(horizontal ? alwaysBounceVertical : alwaysBounceHorizontal) === true;
1802+
const padsContentForSafeArea =
1803+
Platform.OS === 'ios' &&
1804+
behavior != null &&
1805+
behavior !== 'never' &&
1806+
!(cancelsInsetAcrossScrollableAxis && this.props.centerContent === true);
1807+
const safeAreaInsets = this._safeAreaInsets;
1808+
const contentInset =
1809+
padsContentForSafeArea &&
1810+
safeAreaInsets != null &&
1811+
cancelsInsetAcrossScrollableAxis
1812+
? getSafeAreaContentInset(
1813+
this.props.contentInset,
1814+
safeAreaInsets,
1815+
horizontal,
1816+
)
1817+
: this.props.contentInset;
1818+
17501819
const contentContainerStyle = [
17511820
horizontal && styles.contentContainerHorizontal,
17521821
this.props.contentContainerStyle,
1822+
padsContentForSafeArea && safeAreaInsets != null
1823+
? getSafeAreaContentPadding(
1824+
this.props.contentContainerStyle,
1825+
safeAreaInsets,
1826+
horizontal,
1827+
)
1828+
: null,
17531829
];
17541830
if (__DEV__ && this.props.style !== undefined) {
17551831
// $FlowFixMe[underconstrained-implicit-instantiation]
@@ -1846,16 +1922,6 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
18461922
</NativeScrollContentView>
18471923
);
18481924

1849-
const alwaysBounceHorizontal =
1850-
this.props.alwaysBounceHorizontal !== undefined
1851-
? this.props.alwaysBounceHorizontal
1852-
: this.props.horizontal;
1853-
1854-
const alwaysBounceVertical =
1855-
this.props.alwaysBounceVertical !== undefined
1856-
? this.props.alwaysBounceVertical
1857-
: !this.props.horizontal;
1858-
18591925
const baseStyle = horizontal ? styles.baseHorizontal : styles.baseVertical;
18601926

18611927
const {experimental_endDraggingSensitivityMultiplier, ...otherProps} =
@@ -1864,6 +1930,7 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
18641930
...otherProps,
18651931
alwaysBounceHorizontal,
18661932
alwaysBounceVertical,
1933+
contentInset,
18671934
style: StyleSheet.compose(baseStyle, this.props.style),
18681935
// Override the onContentSizeChange from props, since this event can
18691936
// bubble up from TextInputs
@@ -1886,6 +1953,14 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
18861953
onTouchStart: this._handleTouchStart,
18871954
onTouchCancel: this._handleTouchCancel,
18881955
onScroll: this._handleScroll,
1956+
// Routed through the handler whenever the event is observed at all, so
1957+
// the insets it keeps are current when the behavior changes from
1958+
// 'never' and the content container starts padding for them.
1959+
experimental_onSafeAreaInsetsChange:
1960+
padsContentForSafeArea ||
1961+
this.props.experimental_onSafeAreaInsetsChange != null
1962+
? this._handleSafeAreaInsetsChange
1963+
: undefined,
18891964
endDraggingSensitivityMultiplier:
18901965
experimental_endDraggingSensitivityMultiplier,
18911966
scrollEventThrottle: hasStickyHeaders
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import typeof ScrollViewType from '../ScrollView';
12+
import type {ReactTestRenderer} from 'react-test-renderer';
13+
14+
import flattenStyle from '../../../StyleSheet/flattenStyle';
15+
import {create} from '@react-native/jest-preset/jest/renderer';
16+
import * as React from 'react';
17+
import {act} from 'react-test-renderer';
18+
19+
// The real component, in place of the mock the preset installs.
20+
const ScrollView: ScrollViewType = jest.requireActual<{
21+
default: ScrollViewType,
22+
}>('../ScrollView').default;
23+
24+
const INSETS = {top: 44, right: 84, bottom: 34, left: 0};
25+
26+
function contentContainerStyle(renderer: ReactTestRenderer) {
27+
return flattenStyle(
28+
renderer.root.findByType('RCTScrollContentView').props.style,
29+
);
30+
}
31+
32+
async function dispatchSafeAreaInsetsChange(
33+
renderer: ReactTestRenderer,
34+
insets: typeof INSETS,
35+
) {
36+
const handler =
37+
renderer.root.findByType('RCTScrollView').props
38+
.experimental_onSafeAreaInsetsChange;
39+
await act(async () => {
40+
handler?.({nativeEvent: {insets}});
41+
});
42+
}
43+
44+
describe('<ScrollView> safe area content padding', () => {
45+
test('pads the content container across the scrollable axis', async () => {
46+
const renderer = await create(
47+
<ScrollView
48+
contentInsetAdjustmentBehavior="automatic"
49+
contentContainerStyle={{paddingHorizontal: 12, paddingLeft: 10}}
50+
/>,
51+
);
52+
expect(contentContainerStyle(renderer)).toEqual({
53+
paddingHorizontal: 12,
54+
paddingLeft: 10,
55+
});
56+
57+
await dispatchSafeAreaInsetsChange(renderer, INSETS);
58+
expect(contentContainerStyle(renderer)).toEqual({
59+
paddingStart: 10,
60+
paddingEnd: 96,
61+
paddingLeft: undefined,
62+
paddingRight: undefined,
63+
paddingHorizontal: undefined,
64+
paddingInline: undefined,
65+
paddingInlineStart: undefined,
66+
paddingInlineEnd: undefined,
67+
});
68+
});
69+
70+
test('leaves the style alone while the axis has no safe area', async () => {
71+
const renderer = await create(
72+
<ScrollView
73+
contentInsetAdjustmentBehavior="automatic"
74+
contentContainerStyle={{paddingHorizontal: '5%'}}
75+
/>,
76+
);
77+
await dispatchSafeAreaInsetsChange(renderer, {...INSETS, right: 0});
78+
expect(contentContainerStyle(renderer)).toEqual({paddingHorizontal: '5%'});
79+
});
80+
81+
test('cancels the content inset UIKit applies under always', async () => {
82+
const renderer = await create(
83+
<ScrollView
84+
contentInsetAdjustmentBehavior="always"
85+
contentInset={{top: 10}}
86+
/>,
87+
);
88+
await dispatchSafeAreaInsetsChange(renderer, INSETS);
89+
const scrollView = renderer.root.findByType('RCTScrollView');
90+
expect(scrollView.props.contentInset).toEqual({
91+
top: 10,
92+
left: 0,
93+
right: -84,
94+
});
95+
expect(contentContainerStyle(renderer)).toMatchObject({paddingEnd: 84});
96+
});
97+
98+
test('cancels the content inset UIKit applies to an axis that always bounces', async () => {
99+
const renderer = await create(
100+
<ScrollView
101+
contentInsetAdjustmentBehavior="automatic"
102+
alwaysBounceHorizontal={true}
103+
/>,
104+
);
105+
await dispatchSafeAreaInsetsChange(renderer, INSETS);
106+
const scrollView = renderer.root.findByType('RCTScrollView');
107+
expect(scrollView.props.contentInset).toEqual({left: 0, right: -84});
108+
expect(contentContainerStyle(renderer)).toMatchObject({paddingEnd: 84});
109+
});
110+
111+
test('leaves the safe area to UIKit while centering content under always', async () => {
112+
const renderer = await create(
113+
<ScrollView
114+
contentInsetAdjustmentBehavior="always"
115+
centerContent={true}
116+
/>,
117+
);
118+
const scrollView = renderer.root.findByType('RCTScrollView');
119+
expect(scrollView.props.experimental_onSafeAreaInsetsChange).toBe(
120+
undefined,
121+
);
122+
expect(scrollView.props.contentInset).toBe(undefined);
123+
});
124+
125+
test('renders only when the insets across the scrollable axis change', async () => {
126+
const renderer = await create(
127+
<ScrollView contentInsetAdjustmentBehavior="automatic" />,
128+
);
129+
const styleOf = () =>
130+
renderer.root.findByType('RCTScrollContentView').props.style;
131+
132+
await dispatchSafeAreaInsetsChange(renderer, INSETS);
133+
const padded = styleOf();
134+
expect(flattenStyle(padded)).toMatchObject({paddingEnd: 84});
135+
136+
// A change along the scrollable axis leaves the content container alone.
137+
await dispatchSafeAreaInsetsChange(renderer, {...INSETS, top: 0});
138+
expect(styleOf()).toBe(padded);
139+
140+
await dispatchSafeAreaInsetsChange(renderer, {...INSETS, right: 0});
141+
expect(styleOf()).not.toBe(padded);
142+
expect(contentContainerStyle(renderer)).toEqual({});
143+
});
144+
145+
test('does not observe the safe area under never', async () => {
146+
const renderer = await create(
147+
<ScrollView contentInsetAdjustmentBehavior="never" />,
148+
);
149+
const scrollView = renderer.root.findByType('RCTScrollView');
150+
expect(scrollView.props.experimental_onSafeAreaInsetsChange).toBe(
151+
undefined,
152+
);
153+
});
154+
155+
test('keeps the insets current for a caller handler under never', async () => {
156+
const onSafeAreaInsetsChange = jest.fn();
157+
const renderer = await create(
158+
<ScrollView
159+
contentInsetAdjustmentBehavior="never"
160+
experimental_onSafeAreaInsetsChange={onSafeAreaInsetsChange}
161+
/>,
162+
);
163+
await dispatchSafeAreaInsetsChange(renderer, INSETS);
164+
expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1);
165+
expect(contentContainerStyle(renderer)).toEqual({});
166+
167+
// The insets delivered under 'never' apply as soon as the behavior
168+
// changes, without another event.
169+
await act(async () => {
170+
renderer.update(
171+
<ScrollView
172+
contentInsetAdjustmentBehavior="automatic"
173+
experimental_onSafeAreaInsetsChange={onSafeAreaInsetsChange}
174+
/>,
175+
);
176+
});
177+
expect(contentContainerStyle(renderer)).toMatchObject({paddingEnd: 84});
178+
});
179+
});

0 commit comments

Comments
 (0)