Skip to content

Commit 8eb2473

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

10 files changed

Lines changed: 799 additions & 8 deletions

File tree

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

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ 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 from '../../../src/private/components/scrollview/getSafeAreaContentPadding';
2629
import {
2730
HScrollContentViewNativeComponent,
2831
HScrollViewNativeComponent,
@@ -386,8 +389,13 @@ export type ScrollViewPropsIOS = Readonly<{
386389
*/
387390
zoomScale?: ?number,
388391
/**
389-
* This property specifies how the safe area insets are used to modify the
390-
* content area of the scroll view.
392+
* How the safe area insets modify the content area of the scroll view.
393+
*
394+
* Along the scrollable axis, the insets become content insets, so content
395+
* scrolls under the system UI. Across it, every value except `'never'` pads
396+
* the content container by the insets, so content is laid out clear of the
397+
* system UI, such as the rail on iPhone Duo or camera cutout in landscape,
398+
* while the container's background still extends edge to edge.
391399
*
392400
* @default `'never'`
393401
* @platform ios
@@ -883,6 +891,11 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
883891
layoutHeight: null,
884892
};
885893

894+
// The latest safe area insets of the native view. Only the pair across the
895+
// scrollable axis affects the content container, so a change to the other
896+
// pair is kept here without a render.
897+
_safeAreaInsets: ?SafeAreaInsets = null;
898+
886899
componentDidMount() {
887900
this._keyboardMetrics = Keyboard.metrics();
888901
this._additionalScrollOffset = 0;
@@ -1247,6 +1260,30 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
12471260
this.props.onScroll && this.props.onScroll(e);
12481261
};
12491262

1263+
_handleSafeAreaInsetsChange = (e: SafeAreaInsetsChangeEvent) => {
1264+
const {insets} = e.nativeEvent;
1265+
const previous = this._safeAreaInsets;
1266+
this._safeAreaInsets = insets;
1267+
const changedAcrossScrollableAxis =
1268+
previous == null ||
1269+
(this.props.horizontal === true
1270+
? previous.top !== insets.top || previous.bottom !== insets.bottom
1271+
: previous.left !== insets.left || previous.right !== insets.right);
1272+
if (changedAcrossScrollableAxis && this._padsContentForSafeArea()) {
1273+
this.forceUpdate();
1274+
}
1275+
this.props.experimental_onSafeAreaInsetsChange?.(e);
1276+
};
1277+
1278+
/**
1279+
* Whether the content container is padded by the safe area across the
1280+
* scrollable axis.
1281+
*/
1282+
_padsContentForSafeArea(): boolean {
1283+
const behavior = this.props.contentInsetAdjustmentBehavior;
1284+
return Platform.OS === 'ios' && behavior != null && behavior !== 'never';
1285+
}
1286+
12501287
_handleLayout = (e: LayoutChangeEvent) => {
12511288
if (this.props.invertStickyHeaders === true) {
12521289
this.setState({layoutHeight: e.nativeEvent.layout.height});
@@ -1747,9 +1784,25 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
17471784
? HScrollContentViewNativeComponent
17481785
: VScrollContentViewNativeComponent;
17491786

1787+
// Along the scrollable axis UIKit turns the safe area into content insets,
1788+
// so content scrolls under the system UI. Across it there is nothing to
1789+
// scroll, so the content container is padded instead and content is laid
1790+
// out clear of the system UI, such as the rail on iPhone Duo. Native
1791+
// removes whatever UIKit insets across that axis, so the safe area applies
1792+
// once.
1793+
const padsContentForSafeArea = this._padsContentForSafeArea();
1794+
const safeAreaInsets = this._safeAreaInsets;
1795+
17501796
const contentContainerStyle = [
17511797
horizontal && styles.contentContainerHorizontal,
17521798
this.props.contentContainerStyle,
1799+
padsContentForSafeArea && safeAreaInsets != null
1800+
? getSafeAreaContentPadding(
1801+
this.props.contentContainerStyle,
1802+
safeAreaInsets,
1803+
horizontal,
1804+
)
1805+
: null,
17531806
];
17541807
if (__DEV__ && this.props.style !== undefined) {
17551808
// $FlowFixMe[underconstrained-implicit-instantiation]
@@ -1886,6 +1939,14 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
18861939
onTouchStart: this._handleTouchStart,
18871940
onTouchCancel: this._handleTouchCancel,
18881941
onScroll: this._handleScroll,
1942+
// Routed through the handler whenever the event is observed at all, so
1943+
// the insets it keeps are current when the behavior changes from
1944+
// 'never' and the content container starts padding for them.
1945+
experimental_onSafeAreaInsetsChange:
1946+
padsContentForSafeArea ||
1947+
this.props.experimental_onSafeAreaInsetsChange != null
1948+
? this._handleSafeAreaInsetsChange
1949+
: undefined,
18891950
endDraggingSensitivityMultiplier:
18901951
experimental_endDraggingSensitivityMultiplier,
18911952
scrollEventThrottle: hasStickyHeaders

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig =
124124
endDraggingSensitivityMultiplier: true,
125125
directionalLockEnabled: true,
126126
disableIntervalMomentum: true,
127+
horizontal: true,
127128
indicatorStyle: true,
128129
inverted: true,
129130
keyboardDismissMode: true,
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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: null,
62+
paddingRight: null,
63+
paddingHorizontal: null,
64+
paddingInline: null,
65+
paddingInlineStart: null,
66+
paddingInlineEnd: null,
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('renders only when the insets across the scrollable axis change', async () => {
82+
const renderer = await create(
83+
<ScrollView contentInsetAdjustmentBehavior="automatic" />,
84+
);
85+
const styleOf = () =>
86+
renderer.root.findByType('RCTScrollContentView').props.style;
87+
88+
await dispatchSafeAreaInsetsChange(renderer, INSETS);
89+
const padded = styleOf();
90+
expect(flattenStyle(padded)).toMatchObject({paddingEnd: 84});
91+
92+
// A change along the scrollable axis leaves the content container alone.
93+
await dispatchSafeAreaInsetsChange(renderer, {...INSETS, top: 0});
94+
expect(styleOf()).toBe(padded);
95+
96+
await dispatchSafeAreaInsetsChange(renderer, {...INSETS, right: 0});
97+
expect(styleOf()).not.toBe(padded);
98+
expect(contentContainerStyle(renderer)).toEqual({});
99+
});
100+
101+
test('does not observe the safe area under never', async () => {
102+
const renderer = await create(
103+
<ScrollView contentInsetAdjustmentBehavior="never" />,
104+
);
105+
const scrollView = renderer.root.findByType('RCTScrollView');
106+
expect(scrollView.props.experimental_onSafeAreaInsetsChange).toBe(
107+
undefined,
108+
);
109+
});
110+
111+
test('keeps the insets current for a caller handler under never', async () => {
112+
const onSafeAreaInsetsChange = jest.fn();
113+
const renderer = await create(
114+
<ScrollView
115+
contentInsetAdjustmentBehavior="never"
116+
experimental_onSafeAreaInsetsChange={onSafeAreaInsetsChange}
117+
/>,
118+
);
119+
const unpadded = renderer.root.findByType('RCTScrollContentView').props
120+
.style;
121+
await dispatchSafeAreaInsetsChange(renderer, INSETS);
122+
expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1);
123+
// Nothing is padded under 'never', so nothing is rendered either.
124+
expect(renderer.root.findByType('RCTScrollContentView').props.style).toBe(
125+
unpadded,
126+
);
127+
128+
// The insets delivered under 'never' apply as soon as the behavior
129+
// changes, without another event.
130+
await act(async () => {
131+
renderer.update(
132+
<ScrollView
133+
contentInsetAdjustmentBehavior="automatic"
134+
experimental_onSafeAreaInsetsChange={onSafeAreaInsetsChange}
135+
/>,
136+
);
137+
});
138+
expect(contentContainerStyle(renderer)).toMatchObject({paddingEnd: 84});
139+
});
140+
});

‎packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ NS_ASSUME_NONNULL_BEGIN
5959
*/
6060
@property (nonatomic, assign) BOOL inverted;
6161

62+
/*
63+
* The content inset before the correction across the scrollable axis: what the props and the content centering
64+
* give. `contentInset` carries it less whatever UIKit adds across that axis for the safe area, which `ScrollView`
65+
* already pads into the content.
66+
*/
67+
@property (nonatomic, assign) UIEdgeInsets baseContentInset;
68+
@property (nonatomic, assign) BOOL horizontal;
69+
6270
/*
6371
* Makes `setContentOffset:` method no-op when given `block` is executed.
6472
* The block is being executed synchronously.

‎packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,63 @@ - (void)centerContentIfNeeded
111111
if (contentSize.height < boundsSize.height) {
112112
top = (boundsSize.height - contentSize.height) * 0.5f;
113113
}
114-
self.contentInset = UIEdgeInsetsMake(top, left, top, left);
114+
self.baseContentInset = UIEdgeInsetsMake(top, left, top, left);
115+
}
116+
117+
- (void)setBaseContentInset:(UIEdgeInsets)baseContentInset
118+
{
119+
_baseContentInset = baseContentInset;
120+
[self applyBaseContentInset];
121+
}
122+
123+
- (void)setHorizontal:(BOOL)horizontal
124+
{
125+
_horizontal = horizontal;
126+
[self applyBaseContentInset];
127+
}
128+
129+
- (void)adjustedContentInsetDidChange
130+
{
131+
[super adjustedContentInsetDidChange];
132+
[self applyBaseContentInset];
133+
}
134+
135+
/*
136+
* Applies `baseContentInset`, less what UIKit adds across the scrollable axis for the safe area. `ScrollView`
137+
* pads the content by that safe area whenever the adjustment behavior is not `never`, so that content is laid out
138+
* clear of the system UI rather than scrolled under it; the content inset would apply it a second time.
139+
*/
140+
- (void)applyBaseContentInset
141+
{
142+
UIEdgeInsets contentInset = _baseContentInset;
143+
if (self.contentInsetAdjustmentBehavior != UIScrollViewContentInsetAdjustmentNever) {
144+
UIEdgeInsets adjusted = self.adjustedContentInset;
145+
UIEdgeInsets current = self.contentInset;
146+
if (_horizontal) {
147+
contentInset.top -= adjusted.top - current.top;
148+
contentInset.bottom -= adjusted.bottom - current.bottom;
149+
} else {
150+
contentInset.left -= adjusted.left - current.left;
151+
contentInset.right -= adjusted.right - current.right;
152+
}
153+
}
154+
if (UIEdgeInsetsEqualToEdgeInsets(contentInset, self.contentInset)) {
155+
return;
156+
}
157+
// Content pinned to the leading edge across the scrollable axis stays pinned, as UIKit keeps it when it changes the
158+
// adjusted content inset itself.
159+
UIEdgeInsets before = self.adjustedContentInset;
160+
self.contentInset = contentInset;
161+
UIEdgeInsets after = self.adjustedContentInset;
162+
CGPoint contentOffset = self.contentOffset;
163+
if (_horizontal && contentOffset.y <= -before.top) {
164+
contentOffset.y = -after.top;
165+
} else if (!_horizontal && contentOffset.x <= -before.left) {
166+
contentOffset.x = -after.left;
167+
}
168+
if (!CGPointEqualToPoint(contentOffset, self.contentOffset)) {
169+
self.contentOffset = contentOffset;
170+
}
115171
}
116172

117173
- (void)setContentOffset:(CGPoint)contentOffset

‎packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ - (void)_keyboardWillChangeFrame:(NSNotification *)notification
205205
CGPoint absoluteViewOrigin = [self convertPoint:self.bounds.origin toView:nil];
206206
CGFloat scrollViewLowerY = isInverted ? absoluteViewOrigin.y : absoluteViewOrigin.y + self.bounds.size.height;
207207

208-
UIEdgeInsets newEdgeInsets = _scrollView.contentInset;
208+
UIEdgeInsets newEdgeInsets = ((RCTEnhancedScrollView *)_scrollView).baseContentInset;
209209
CGFloat inset = MAX(scrollViewLowerY - keyboardEndFrame.origin.y, 0);
210210
const auto &props = static_cast<const ScrollViewProps &>(*_props);
211211
if (isInverted) {
@@ -258,7 +258,7 @@ - (void)_keyboardWillChangeFrame:(NSNotification *)notification
258258
delay:0.0
259259
options:animationOptionsWithCurve(curve)
260260
animations:^{
261-
self->_scrollView.contentInset = newEdgeInsets;
261+
((RCTEnhancedScrollView *)self->_scrollView).baseContentInset = newEdgeInsets;
262262
self->_scrollView.verticalScrollIndicatorInsets = newEdgeInsets;
263263
[self scrollTo:newContentOffset.x y:newContentOffset.y animated:NO];
264264
}
@@ -346,6 +346,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
346346
MAP_SCROLL_VIEW_PROP(bouncesZoom);
347347
MAP_SCROLL_VIEW_PROP(canCancelContentTouches);
348348
MAP_SCROLL_VIEW_PROP(centerContent);
349+
MAP_SCROLL_VIEW_PROP(horizontal);
349350
// MAP_SCROLL_VIEW_PROP(automaticallyAdjustContentInsets);
350351
MAP_SCROLL_VIEW_PROP(decelerationRate);
351352
MAP_SCROLL_VIEW_PROP(directionalLockEnabled);
@@ -397,13 +398,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
397398
// When disabling centerContent, reset inset to prop value
398399
// (enabling is handled automatically by the setCenterContent: setter)
399400
if (oldScrollViewProps.centerContent && !newScrollViewProps.centerContent) {
400-
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
401+
((RCTEnhancedScrollView *)_scrollView).baseContentInset =
402+
RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
401403
}
402404

403405
// Only apply contentInset from props if centerContent is disabled
404406
// When centerContent is enabled, the inset is calculated by centerContentIfNeeded
405407
if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset && !newScrollViewProps.centerContent) {
406-
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
408+
((RCTEnhancedScrollView *)_scrollView).baseContentInset =
409+
RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
407410
}
408411
if (oldScrollViewProps.contentOffset != newScrollViewProps.contentOffset) {
409412
_scrollView.contentOffset = RCTCGPointFromPoint(newScrollViewProps.contentOffset);
@@ -700,7 +703,7 @@ - (void)prepareForRecycle
700703
// Invalidate cached content size so that updateState: recalculates the
701704
// container frame after zoomScale reset (which may have mutated it in RTL).
702705
_contentSize = CGSizeZero;
703-
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(props.contentInset);
706+
((RCTEnhancedScrollView *)_scrollView).baseContentInset = RCTUIEdgeInsetsFromEdgeInsets(props.contentInset);
704707
// We set the default behavior to "never" so that iOS
705708
// doesn't do weird things to UIScrollView insets automatically
706709
// and keeps it as an opt-in behavior.

0 commit comments

Comments
 (0)