Skip to content

Commit dc6df95

Browse files
Add an experimental_onSafeAreaInsetsChange view prop
Reports the part of a view that is covered by the system UI, as a view prop: ```jsx <View experimental_onSafeAreaInsetsChange={({nativeEvent: {insets, frame}}) => { // insets: {top, right, bottom, left}, frame: {x, y, width, height} }} /> ``` `SafeAreaView` is deprecated in favour of `react-native-safe-area-context`, but core surfaces like LogBox and the element inspector cannot depend on the library, so core keeps a private copy of the deprecated component alive. The smallest primitive that lets both sides go away is native code reporting inset values to JavaScript — today the library's own `RNCSafeAreaProvider` component. This adds that primitive, with the payload the library already uses, so `SafeAreaProvider` can swap its native component for a plain `View`. Insets are relative to the view: one laid out inside the safe area reports zeros. That is what makes the prop composable and stops nested providers from double-padding. **Cost when unused.** The prop is a `bool` in `BaseViewProps`, like `onLayout`; native only observes the safe area when it is set. On iOS the flag is read from the props the view already holds and the last-sent insets live behind a single pointer ivar that stays nil unless the view observes; the only unconditional cost is a branch in `layoutSubviews`, `didMoveToWindow` and `safeAreaInsetsDidChange`. **Cost when used.** Events fire only when the *insets* change — the frame is in the payload but not in the trigger — so a view moving inside a scroll view emits nothing, and 50 observing rows scroll at the same frame times as zero. An observing view allocates nothing per frame on Android in the steady state. Benchmarked with the "Scroll benchmark" section of the new RNTester example. **Synchronous dispatch.** The event goes out through `EventEmitter::experimental_flushSync` as a `Discrete` event, so inset-driven layout is mounted in the frame the insets changed in — first mount included, and on rotation the padding animates with the transition instead of jumping after it. Edge cases covered: view flattening (the prop forms a stacking context so the host view cannot be optimized away), view recycling on both platforms, Android views fully clipped by an ancestor, and multi-window iPad.
1 parent ebee670 commit dc6df95

30 files changed

Lines changed: 972 additions & 0 deletions

File tree

packages/react-native/Libraries/Components/View/ViewPropTypes.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
LayoutRectangle,
2424
MouseEvent,
2525
PointerEvent,
26+
SafeAreaInsetsChangeEvent,
2627
} from '../../Types/CoreEventTypes';
2728
import type {
2829
AccessibilityActionEvent,
@@ -63,6 +64,32 @@ type DirectEventProps = Readonly<{
6364
*/
6465
onLayout?: ?(event: LayoutChangeEvent) => unknown,
6566

67+
/**
68+
* Invoked when the part of this view that is covered by the system UI
69+
* (status bar, navigation bar, home indicator, display cutouts, ...)
70+
* changes, with:
71+
*
72+
* `{nativeEvent: {insets: {top, right, bottom, left}, frame: {x, y, width, height}}}`
73+
*
74+
* `insets` are relative to this view: an inset is only non-zero for the part
75+
* of the view that actually overlaps the system UI. `frame` is the position
76+
* of the view at the time of the event, relative to its enclosing view
77+
* controller on iOS and to the window on Android; it does not trigger the
78+
* event on its own, so it can be stale while the view moves without its
79+
* insets changing.
80+
*
81+
* The event is dispatched synchronously, so the rendering it schedules is
82+
* applied in the same frame the insets changed in.
83+
*
84+
* Setting this prop makes the view observe safe area changes; views without
85+
* it are unaffected.
86+
*
87+
* @experimental
88+
*/
89+
experimental_onSafeAreaInsetsChange?: ?(
90+
event: SafeAreaInsetsChangeEvent,
91+
) => unknown,
92+
6693
/**
6794
* When `accessible` is `true`, the system will invoke this function when the
6895
* user performs the magic tap gesture.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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 '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
12+
13+
import type {HostInstance} from 'react-native/src/private/types/HostInstance';
14+
15+
import * as Fantom from '@react-native/fantom';
16+
import * as React from 'react';
17+
import {createRef} from 'react';
18+
import {View} from 'react-native';
19+
20+
const INSETS = {top: 44, right: 0, bottom: 34, left: 0};
21+
const FRAME = {x: 0, y: 0, width: 390, height: 844};
22+
23+
describe('experimental_onSafeAreaInsetsChange', () => {
24+
it('delivers the insets and the frame of the view', () => {
25+
const root = Fantom.createRoot();
26+
const nodeRef = createRef<HostInstance>();
27+
const onSafeAreaInsetsChange = jest.fn();
28+
29+
Fantom.runTask(() => {
30+
root.render(
31+
<View
32+
collapsable={false}
33+
ref={nodeRef}
34+
experimental_onSafeAreaInsetsChange={event => {
35+
onSafeAreaInsetsChange(event.nativeEvent);
36+
}}
37+
/>,
38+
);
39+
});
40+
41+
Fantom.dispatchNativeEvent(nodeRef, 'safeAreaInsetsChange', {
42+
insets: INSETS,
43+
frame: FRAME,
44+
});
45+
46+
expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1);
47+
const [event] = onSafeAreaInsetsChange.mock.lastCall;
48+
expect(event.insets).toEqual(INSETS);
49+
expect(event.frame).toEqual(FRAME);
50+
});
51+
52+
it('is not delivered to views that did not opt in', () => {
53+
const root = Fantom.createRoot();
54+
const nodeRef = createRef<HostInstance>();
55+
56+
Fantom.runTask(() => {
57+
root.render(<View collapsable={false} ref={nodeRef} />);
58+
});
59+
60+
// The prop is what makes the view observe the safe area, so a view without
61+
// it is never the target of the event.
62+
expect(
63+
root
64+
.getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']})
65+
.toJSX(),
66+
).toEqual(<rn-view />);
67+
});
68+
69+
it('prevents the view from being flattened', () => {
70+
const root = Fantom.createRoot();
71+
72+
Fantom.runTask(() => {
73+
root.render(
74+
// A layout-only view would ordinarily be flattened away; observing the
75+
// safe area requires a host view to observe with.
76+
<View experimental_onSafeAreaInsetsChange={() => {}}>
77+
<View collapsable={false} />
78+
</View>,
79+
);
80+
});
81+
82+
expect(
83+
root
84+
.getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']})
85+
.toJSX(),
86+
).toEqual(
87+
<rn-view experimental_onSafeAreaInsetsChange="true">
88+
<rn-view />
89+
</rn-view>,
90+
);
91+
});
92+
93+
it('is reflected in the props of the view when set', () => {
94+
const root = Fantom.createRoot();
95+
96+
Fantom.runTask(() => {
97+
root.render(
98+
<View
99+
collapsable={false}
100+
experimental_onSafeAreaInsetsChange={() => {}}
101+
/>,
102+
);
103+
});
104+
105+
expect(
106+
root
107+
.getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']})
108+
.toJSX(),
109+
).toEqual(<rn-view experimental_onSafeAreaInsetsChange="true" />);
110+
});
111+
});

packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,9 @@ const directEventTypes = {
204204
topLayout: {
205205
registrationName: 'onLayout',
206206
},
207+
topSafeAreaInsetsChange: {
208+
registrationName: 'experimental_onSafeAreaInsetsChange',
209+
},
207210
};
208211

209212
const validAttributesForNonEventProps = {
@@ -405,6 +408,7 @@ const validAttributesForNonEventProps = {
405408
// Props for bubbling and direct events
406409
const validAttributesForEventProps = {
407410
onLayout: true,
411+
experimental_onSafeAreaInsetsChange: true,
408412

409413
// PanResponder handlers
410414
onMoveShouldSetResponder: true,

packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ const directEventTypes = {
179179
topLayout: {
180180
registrationName: 'onLayout',
181181
},
182+
topSafeAreaInsetsChange: {
183+
registrationName: 'experimental_onSafeAreaInsetsChange',
184+
},
182185
onGestureHandlerEvent: DynamicallyInjectedByGestureHandler({
183186
registrationName: 'onGestureHandlerEvent',
184187
}),
@@ -380,6 +383,7 @@ const validAttributesForNonEventProps = {
380383
// Props for bubbling and direct events
381384
const validAttributesForEventProps = ConditionallyIgnoredEventHandlers({
382385
onLayout: true,
386+
experimental_onSafeAreaInsetsChange: true,
383387
onMagicTap: true,
384388

385389
// Accessibility

packages/react-native/Libraries/Types/CoreEventTypes.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,29 @@ export type LayoutChangeEvent = NativeSyntheticEvent<
7676
}>,
7777
>;
7878

79+
export type SafeAreaInsets = Readonly<{
80+
top: number,
81+
right: number,
82+
bottom: number,
83+
left: number,
84+
}>;
85+
86+
export type SafeAreaInsetsChangeEvent = NativeSyntheticEvent<
87+
Readonly<{
88+
/**
89+
* The part of the view that is covered by the system UI, in the view's own
90+
* coordinate space.
91+
*/
92+
insets: SafeAreaInsets,
93+
/**
94+
* The frame of the view at the time of the event. Relative to the
95+
* enclosing view controller on iOS and to the window on Android; only
96+
* updated when the insets change.
97+
*/
98+
frame: LayoutRectangle,
99+
}>,
100+
>;
101+
79102
/**
80103
* @deprecated Use `TextLayoutEvent` instead.
81104
*/

packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
#import <React/RCTLocalizedString.h>
2626
#import <React/RCTLog.h>
2727
#import <React/RCTRadialGradient.h>
28+
#import <React/RCTUtils.h>
2829
#import <react/featureflags/ReactNativeFeatureFlags.h>
2930
#import <react/renderer/components/view/ViewComponentDescriptor.h>
3031
#import <react/renderer/components/view/ViewEventEmitter.h>
@@ -122,6 +123,11 @@ @implementation RCTViewComponentView {
122123
NSMutableSet<NSString *> *_accessibilityOrderNativeIDs;
123124
RCTSwiftUIContainerViewWrapper *_swiftUIWrapper;
124125
BOOL _focusable;
126+
// The insets sent with the last `onSafeAreaInsetsChange` event, or nil if
127+
// none was sent yet. A pointer because almost no view observes the safe
128+
// area: the views that do pay for a small box, every other view only for
129+
// the pointer.
130+
NSValue *_lastSentSafeAreaInsets;
125131
}
126132

127133
#ifdef RCT_DYNAMIC_FRAMEWORKS
@@ -438,6 +444,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
438444
-newViewProps.hitSlop.right};
439445
}
440446

447+
// `onSafeAreaInsetsChange`. Scheduled whenever the prop is set, not only on
448+
// its transitions: recycled views keep their last props, so `oldViewProps`
449+
// of a freshly reused view is not a reliable baseline.
450+
if (newViewProps.onSafeAreaInsetsChange) {
451+
[self setNeedsLayout];
452+
} else if (oldViewProps.onSafeAreaInsetsChange) {
453+
_lastSentSafeAreaInsets = nil;
454+
}
455+
441456
// `overflow`
442457
if (oldViewProps.getClipsContentToBounds() != newViewProps.getClipsContentToBounds()) {
443458
self.currentContainerView.clipsToBounds = newViewProps.getClipsContentToBounds();
@@ -720,6 +735,105 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
720735
}
721736
}
722737

738+
#pragma mark - Safe area insets
739+
740+
// The view controller the view is hosted in, which is the coordinate space
741+
// `frame` is reported in. Modals and other view controllers are positioned
742+
// independently of the window, so the window is not a usable reference.
743+
static UIViewController *RCTParentViewControllerOfView(UIView *view)
744+
{
745+
UIResponder *responder = view.nextResponder;
746+
while (responder != nil) {
747+
if ([responder isKindOfClass:[UIViewController class]]) {
748+
return (UIViewController *)responder;
749+
}
750+
responder = responder.nextResponder;
751+
}
752+
return nil;
753+
}
754+
755+
static BOOL RCTEdgeInsetsEqualWithThreshold(UIEdgeInsets lhs, UIEdgeInsets rhs, CGFloat threshold)
756+
{
757+
return ABS(lhs.left - rhs.left) <= threshold && ABS(lhs.top - rhs.top) <= threshold &&
758+
ABS(lhs.right - rhs.right) <= threshold && ABS(lhs.bottom - rhs.bottom) <= threshold;
759+
}
760+
761+
// The event is only ever emitted from `layoutSubviews`; everything that might
762+
// have changed the insets merely marks the view as needing layout. This defers
763+
// the emit out of arbitrary call contexts — in particular out of
764+
// `updateProps`, which runs inside the mounting transaction where
765+
// synchronously re-entering React is not safe — while keeping it in the same
766+
// frame: the layout pass runs before the frame is displayed.
767+
- (void)_safeAreaInsetsMayHaveChanged
768+
{
769+
if (!_eventEmitter) {
770+
return;
771+
}
772+
773+
// The view has not been mounted or laid out yet, so the insets we would
774+
// compute are not the ones the view ends up with.
775+
if (self.window == nil || CGSizeEqualToSize(self.bounds.size, CGSizeZero)) {
776+
return;
777+
}
778+
779+
// Only a change of the insets triggers an event. The frame is part of the
780+
// payload but not of the trigger: a view that moves without its overlap with
781+
// the system UI changing stays silent, which is what makes observing views
782+
// safe to place inside scroll views.
783+
UIEdgeInsets insets = self.safeAreaInsets;
784+
if (_lastSentSafeAreaInsets != nil &&
785+
RCTEdgeInsetsEqualWithThreshold(insets, _lastSentSafeAreaInsets.UIEdgeInsetsValue, 1.0 / RCTScreenScale())) {
786+
return;
787+
}
788+
789+
UIView *referenceView = RCTParentViewControllerOfView(self).view ?: self.window;
790+
CGRect frame = [self convertRect:self.bounds toView:referenceView];
791+
792+
_lastSentSafeAreaInsets = [NSValue valueWithUIEdgeInsets:insets];
793+
794+
static_cast<const ViewEventEmitter &>(*_eventEmitter)
795+
.onSafeAreaInsetsChange(
796+
EdgeInsets{
797+
.left = (Float)insets.left,
798+
.top = (Float)insets.top,
799+
.right = (Float)insets.right,
800+
.bottom = (Float)insets.bottom},
801+
RCTRectFromCGRect(frame));
802+
}
803+
804+
// The prop is checked here rather than inside the helper so that views which
805+
// do not use it only pay for a branch on a prop they already have in hand.
806+
- (BOOL)_observesSafeAreaInsets
807+
{
808+
return static_cast<const ViewProps &>(*_props).onSafeAreaInsetsChange;
809+
}
810+
811+
- (void)safeAreaInsetsDidChange
812+
{
813+
[super safeAreaInsetsDidChange];
814+
if ([self _observesSafeAreaInsets]) {
815+
[self setNeedsLayout];
816+
}
817+
}
818+
819+
- (void)didMoveToWindow
820+
{
821+
[super didMoveToWindow];
822+
if ([self _observesSafeAreaInsets]) {
823+
[self setNeedsLayout];
824+
}
825+
}
826+
827+
- (void)layoutSubviews
828+
{
829+
[super layoutSubviews];
830+
// Both the insets and the frame depend on where the view sits in the window,
831+
// so moving or resizing it changes them without UIKit notifying us.
832+
if ([self _observesSafeAreaInsets]) {
833+
[self _safeAreaInsetsMayHaveChanged];
834+
}
835+
}
836+
723837
- (BOOL)isJSResponder
724838
{
725839
return _isJSResponder;
@@ -775,6 +889,7 @@ - (void)prepareForRecycle
775889
_filterLayer = nil;
776890
[self clearExistingBackgroundImageLayers];
777891

892+
_lastSentSafeAreaInsets = nil;
778893
_propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN = nil;
779894
_eventEmitter.reset();
780895
_isJSResponder = NO;

packages/react-native/ReactAndroid/api/ReactAndroid.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3264,6 +3264,7 @@ public abstract class com/facebook/react/uimanager/BaseViewManager : com/faceboo
32643264
public fun setMoveShouldSetResponder (Landroid/view/View;Z)V
32653265
public fun setMoveShouldSetResponderCapture (Landroid/view/View;Z)V
32663266
public fun setNativeId (Landroid/view/View;Ljava/lang/String;)V
3267+
public fun setOnSafeAreaInsetsChange (Landroid/view/View;Z)V
32673268
public fun setOpacity (Landroid/view/View;F)V
32683269
public fun setOutlineColor (Landroid/view/View;Ljava/lang/Integer;)V
32693270
public fun setOutlineOffset (Landroid/view/View;F)V
@@ -4604,6 +4605,7 @@ public final class com/facebook/react/uimanager/ViewProps {
46044605
public static final field NONE Ljava/lang/String;
46054606
public static final field NUMBER_OF_LINES Ljava/lang/String;
46064607
public static final field ON Ljava/lang/String;
4608+
public static final field ON_SAFE_AREA_INSETS_CHANGE Ljava/lang/String;
46074609
public static final field OPACITY Ljava/lang/String;
46084610
public static final field OUTLINE_COLOR Ljava/lang/String;
46094611
public static final field OUTLINE_OFFSET Ljava/lang/String;

0 commit comments

Comments
 (0)