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,16 @@ 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 from '../../../src/private/components/scrollview/getSafeAreaContentPadding';
import {
HScrollContentViewNativeComponent,
HScrollViewNativeComponent,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -883,6 +891,11 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
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;
Expand Down Expand Up @@ -1247,6 +1260,30 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
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});
Expand Down Expand Up @@ -1747,9 +1784,25 @@ 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. 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]
Expand Down Expand Up @@ -1886,6 +1939,14 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig =
endDraggingSensitivityMultiplier: true,
directionalLockEnabled: true,
disableIntervalMomentum: true,
horizontal: true,
indicatorStyle: true,
inverted: true,
keyboardDismissMode: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -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('<ScrollView> safe area content padding', () => {
test('pads the content container across the scrollable axis', async () => {
const renderer = await create(
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{paddingHorizontal: 12, paddingLeft: 10}}
/>,
);
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(
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{paddingHorizontal: '5%'}}
/>,
);
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(
<ScrollView contentInsetAdjustmentBehavior="automatic" />,
);
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(
<ScrollView contentInsetAdjustmentBehavior="never" />,
);
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(
<ScrollView
contentInsetAdjustmentBehavior="never"
experimental_onSafeAreaInsetsChange={onSafeAreaInsetsChange}
/>,
);
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(
<ScrollView
contentInsetAdjustmentBehavior="automatic"
experimental_onSafeAreaInsetsChange={onSafeAreaInsetsChange}
/>,
);
});
expect(contentContainerStyle(renderer)).toMatchObject({paddingEnd: 84});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading