From f5d8d36eb3ea56a11b49a3cf69e6260c28cc4cd5 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 21 Sep 2026 05:28:16 -0400 Subject: [PATCH 1/4] Add an experimental_onSafeAreaInsetsChange view prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reports the part of a view that is covered by the system UI, as a view prop: ```jsx { // insets: {top, right, bottom, left} }} /> ``` `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 are a plain `UIEdgeInsets` ivar (a negative sentinel marks "none sent yet", since insets are never negative); the only unconditional cost is a branch in `layoutSubviews`, `didMoveToWindow` and `safeAreaInsetsDidChange`. **Cost when used.** Events fire only when the *insets* change, 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. Folded in from review: the prop is forwarded through BaseViewManagerDelegate for components with generated delegates, and the event is exported from the native view config so it maps to the handler when native view configs are in use. Development warning for a view that reports its insets in a loop: The system UI does not move many times a second, so a sustained stream of inset events means the layout is feeding the insets back into the position of the observed view: it is offset by the insets it reports, which moves it out from under the system UI, which changes its insets. Every one of those events renders synchronously, so the loop is paid for in frames. `View` wraps the handler in development builds and warns once per view above ten events in a second. The check lives in the handler `View` passes down rather than in either platform's observer, so it covers iOS and Android with one implementation and surfaces in LogBox with a JavaScript stack. The production branch is the identity function, so the module stays out of the bundle, and the native prop is unaffected either way — function props are normalized to `true` before props are diffed, so wrapping does not produce an update. Counts are kept per view in a `WeakMap` keyed by the event target, so views that do not loop are never charged for it. RNTester grows the mistake it warns about, and a Fantom test with a mocked clock covers the rate, the once-per-view behaviour, per-view counting, and that the handler still receives its event. The payload is the insets alone. A frame is deliberately not included: it would only be current as of the last inset change, since the trigger is inset-only, and reporting it needs a coordinate space that differs between platforms (the enclosing view controller on iOS, the window on Android). A view that needs its own frame has `onLayout` and `measureInWindow`, which stay current; the window's frame is in `Dimensions`. --- .../Libraries/Components/View/View.js | 17 + .../Components/View/ViewPropTypes.js | 23 ++ .../__tests__/ViewSafeAreaInsets-itest.js | 118 ++++++ .../ViewSafeAreaInsetsWarning-itest.js | 130 +++++++ .../NativeComponent/BaseViewConfig.android.js | 4 + .../NativeComponent/BaseViewConfig.ios.js | 4 + .../Libraries/Types/CoreEventTypes.js | 17 + .../View/RCTViewComponentView.mm | 88 +++++ .../ReactAndroid/api/ReactAndroid.api | 2 + .../react/uimanager/BaseViewManager.java | 12 + .../uimanager/BaseViewManagerDelegate.kt | 2 + .../com/facebook/react/uimanager/ViewProps.kt | 1 + .../events/SafeAreaInsetsChangeEvent.kt | 51 +++ .../internal/SafeAreaInsetsObserver.kt | 177 +++++++++ .../main/res/views/uimanager/values/ids.xml | 3 + .../components/view/BaseViewEventEmitter.cpp | 23 ++ .../components/view/BaseViewEventEmitter.h | 9 + .../components/view/BaseViewProps.cpp | 12 + .../renderer/components/view/BaseViewProps.h | 1 + .../components/view/ViewShadowNode.cpp | 2 +- .../components/view/HostPlatformViewProps.cpp | 4 + .../warnOnRepeatedSafeAreaInsetsChanges.js | 72 ++++ .../SafeAreaInsets/SafeAreaInsetsExample.js | 359 ++++++++++++++++++ .../js/utils/RNTesterList.android.js | 4 + .../rn-tester/js/utils/RNTesterList.ios.js | 4 + .../api-snapshots/ReactAndroidDebugCxx.api | 2 + .../api-snapshots/ReactAndroidNewarchCxx.api | 2 + .../api-snapshots/ReactAndroidReleaseCxx.api | 2 + .../api-snapshots/ReactAppleDebugCxx.api | 2 + .../api-snapshots/ReactAppleNewarchCxx.api | 2 + .../api-snapshots/ReactAppleReleaseCxx.api | 2 + .../api-snapshots/ReactCommonDebugCxx.api | 2 + .../api-snapshots/ReactCommonNewarchCxx.api | 2 + .../api-snapshots/ReactCommonReleaseCxx.api | 2 + 34 files changed, 1156 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js create mode 100644 packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsetsWarning-itest.js create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt create mode 100644 packages/react-native/src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges.js create mode 100644 packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js diff --git a/packages/react-native/Libraries/Components/View/View.js b/packages/react-native/Libraries/Components/View/View.js index 461f7707c7fa..e9d370ffc5b1 100644 --- a/packages/react-native/Libraries/Components/View/View.js +++ b/packages/react-native/Libraries/Components/View/View.js @@ -9,6 +9,7 @@ */ import type {HostInstance} from '../../../src/private/types/HostInstance'; +import type {SafeAreaInsetsChangeEvent} from '../../Types/CoreEventTypes'; import type {ViewProps} from './ViewPropTypes'; import TextAncestorContext from '../../Text/TextAncestorContext'; @@ -16,6 +17,13 @@ import ViewNativeComponent from './ViewNativeComponent'; import * as React from 'react'; import {use} from 'react'; +const warnOnRepeatedSafeAreaInsetsChanges: ( + onSafeAreaInsetsChange: (event: SafeAreaInsetsChangeEvent) => unknown, +) => (event: SafeAreaInsetsChangeEvent) => unknown = __DEV__ + ? require('../../../src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges') + .default + : onSafeAreaInsetsChange => onSafeAreaInsetsChange; + export type ViewInstance = HostInstance; /** @@ -115,6 +123,15 @@ component View(ref?: React.RefSetter, ...props: ViewProps) { }; } + if (__DEV__) { + const onSafeAreaInsetsChange = + resolvedProps.experimental_onSafeAreaInsetsChange; + if (onSafeAreaInsetsChange != null) { + resolvedProps.experimental_onSafeAreaInsetsChange = + warnOnRepeatedSafeAreaInsetsChanges(onSafeAreaInsetsChange); + } + } + const actualView = ref == null ? ( diff --git a/packages/react-native/Libraries/Components/View/ViewPropTypes.js b/packages/react-native/Libraries/Components/View/ViewPropTypes.js index 3d5fdd8db373..3b5959016316 100644 --- a/packages/react-native/Libraries/Components/View/ViewPropTypes.js +++ b/packages/react-native/Libraries/Components/View/ViewPropTypes.js @@ -23,6 +23,7 @@ import type { LayoutRectangle, MouseEvent, PointerEvent, + SafeAreaInsetsChangeEvent, } from '../../Types/CoreEventTypes'; import type { AccessibilityActionEvent, @@ -63,6 +64,28 @@ type DirectEventProps = Readonly<{ */ onLayout?: ?(event: LayoutChangeEvent) => unknown, + /** + * Invoked when the part of this view that is covered by the system UI + * (status bar, navigation bar, home indicator, display cutouts, ...) + * changes, with: + * + * `{nativeEvent: {insets: {top, right, bottom, left}}}` + * + * `insets` are relative to this view: an inset is only non-zero for the part + * of the view that actually overlaps the system UI. + * + * The event is dispatched synchronously, so the rendering it schedules is + * applied in the same frame the insets changed in. + * + * Setting this prop makes the view observe safe area changes; views without + * it are unaffected. + * + * @experimental + */ + experimental_onSafeAreaInsetsChange?: ?( + event: SafeAreaInsetsChangeEvent, + ) => unknown, + /** * When `accessible` is `true`, the system will invoke this function when the * user performs the magic tap gesture. diff --git a/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js new file mode 100644 index 000000000000..e1530b408214 --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js @@ -0,0 +1,118 @@ +/** + * 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 '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native/src/private/types/HostInstance'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {createRef} from 'react'; +import {View} from 'react-native'; + +const INSETS = {top: 44, right: 0, bottom: 34, left: 0}; + +describe('experimental_onSafeAreaInsetsChange', () => { + it('delivers the insets of the view', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onSafeAreaInsetsChange = jest.fn(); + + Fantom.runTask(() => { + root.render( + { + onSafeAreaInsetsChange(event.nativeEvent); + }} + />, + ); + }); + + Fantom.dispatchNativeEvent(nodeRef, 'safeAreaInsetsChange', { + insets: INSETS, + }); + + expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1); + const [event] = onSafeAreaInsetsChange.mock.lastCall; + expect(event.insets).toEqual(INSETS); + }); + + it('is not delivered to views that did not opt in', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + + Fantom.runTask(() => { + // Without the prop nothing keeps a layout-only view from being flattened + // away, so it has to be kept explicitly to have a host view to inspect. + root.render(); + }); + + // The prop is what makes the view observe the safe area, so a view without + // it is never the target of the event. + expect( + root + .getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']}) + .toJSX(), + ).toEqual(); + }); + + it('prevents the view from being flattened', () => { + const root = Fantom.createRoot(); + + // A layout-only view is ordinarily flattened away. The same view is kept + // once it observes the safe area, since observing requires a host view. + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect( + root + .getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']}) + .toJSX(), + ).toEqual(); + + Fantom.runTask(() => { + root.render( + {}}> + + , + ); + }); + + expect( + root + .getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']}) + .toJSX(), + ).toEqual( + + + , + ); + }); + + it('is reflected in the props of the view when set', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( {}} />); + }); + + expect( + root + .getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']}) + .toJSX(), + ).toEqual(); + }); +}); diff --git a/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsetsWarning-itest.js b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsetsWarning-itest.js new file mode 100644 index 000000000000..1904a2fb593c --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsetsWarning-itest.js @@ -0,0 +1,130 @@ +/** + * 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 '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HighResTimeStampMock} from '@react-native/fantom/src/HighResTimeStampMock'; +import type {HostInstance} from 'react-native/src/private/types/HostInstance'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {createRef} from 'react'; +import {View} from 'react-native'; + +const INSETS = {top: 44, right: 0, bottom: 34, left: 0}; + +function renderObservingView(): {current: HostInstance | null} { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + {}} />, + ); + }); + return nodeRef; +} + +function dispatchInsetsChange(nodeRef: {current: HostInstance | null}) { + Fantom.dispatchNativeEvent(nodeRef, 'safeAreaInsetsChange', { + insets: INSETS, + }); +} + +describe('experimental_onSafeAreaInsetsChange warning', () => { + const originalConsoleWarn = console.warn; + let mockConsoleWarn: JestMockFn, void>; + let mockClock: ?HighResTimeStampMock; + + beforeEach(() => { + mockConsoleWarn = jest.fn(); + // $FlowFixMe[cannot-write] + console.warn = mockConsoleWarn; + mockClock = Fantom.installHighResTimeStampMock(); + }); + + afterEach(() => { + // $FlowFixMe[cannot-write] + console.warn = originalConsoleWarn; + mockClock?.uninstall(); + mockClock = null; + }); + + it('stays silent while the insets change at a plausible rate', () => { + const nodeRef = renderObservingView(); + + // A rotation, a keyboard, a split view: a handful of changes, spread out. + for (let i = 0; i < 20; i++) { + dispatchInsetsChange(nodeRef); + mockClock?.advanceTimeBy(200); + } + + expect(mockConsoleWarn).not.toHaveBeenCalled(); + }); + + it('warns once when a single view loops within the window', () => { + const nodeRef = renderObservingView(); + + for (let i = 0; i < 11; i++) { + dispatchInsetsChange(nodeRef); + mockClock?.advanceTimeBy(16); + } + + expect(mockConsoleWarn).toHaveBeenCalledTimes(1); + expect(mockConsoleWarn.mock.lastCall[0]).toContain( + '`experimental_onSafeAreaInsetsChange` fired more than 10 times in 1000ms', + ); + + // The loop keeps running; the warning does not. + for (let i = 0; i < 50; i++) { + dispatchInsetsChange(nodeRef); + mockClock?.advanceTimeBy(16); + } + + expect(mockConsoleWarn).toHaveBeenCalledTimes(1); + }); + + it('counts each view separately', () => { + const nodeRefA = renderObservingView(); + const nodeRefB = renderObservingView(); + + for (let i = 0; i < 10; i++) { + dispatchInsetsChange(nodeRefA); + dispatchInsetsChange(nodeRefB); + mockClock?.advanceTimeBy(16); + } + + expect(mockConsoleWarn).not.toHaveBeenCalled(); + + dispatchInsetsChange(nodeRefA); + + expect(mockConsoleWarn).toHaveBeenCalledTimes(1); + }); + + it('still delivers the event to the handler', () => { + const nodeRef = createRef(); + const onSafeAreaInsetsChange = jest.fn(); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + { + onSafeAreaInsetsChange(event.nativeEvent); + }} + />, + ); + }); + + dispatchInsetsChange(nodeRef); + + expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1); + expect(onSafeAreaInsetsChange.mock.lastCall[0].insets).toEqual(INSETS); + }); +}); diff --git a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js index 6e3ee698720d..c37f44b61888 100644 --- a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js +++ b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js @@ -204,6 +204,9 @@ const directEventTypes = { topLayout: { registrationName: 'onLayout', }, + topSafeAreaInsetsChange: { + registrationName: 'experimental_onSafeAreaInsetsChange', + }, }; const validAttributesForNonEventProps = { @@ -405,6 +408,7 @@ const validAttributesForNonEventProps = { // Props for bubbling and direct events const validAttributesForEventProps = { onLayout: true, + experimental_onSafeAreaInsetsChange: true, // PanResponder handlers onMoveShouldSetResponder: true, diff --git a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js index d22a68642194..80c413a7c1d0 100644 --- a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js +++ b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js @@ -179,6 +179,9 @@ const directEventTypes = { topLayout: { registrationName: 'onLayout', }, + topSafeAreaInsetsChange: { + registrationName: 'experimental_onSafeAreaInsetsChange', + }, onGestureHandlerEvent: DynamicallyInjectedByGestureHandler({ registrationName: 'onGestureHandlerEvent', }), @@ -380,6 +383,7 @@ const validAttributesForNonEventProps = { // Props for bubbling and direct events const validAttributesForEventProps = ConditionallyIgnoredEventHandlers({ onLayout: true, + experimental_onSafeAreaInsetsChange: true, onMagicTap: true, // Accessibility diff --git a/packages/react-native/Libraries/Types/CoreEventTypes.js b/packages/react-native/Libraries/Types/CoreEventTypes.js index dff10cb27609..c1dc36073cd3 100644 --- a/packages/react-native/Libraries/Types/CoreEventTypes.js +++ b/packages/react-native/Libraries/Types/CoreEventTypes.js @@ -76,6 +76,23 @@ export type LayoutChangeEvent = NativeSyntheticEvent< }>, >; +export type SafeAreaInsets = Readonly<{ + top: number, + right: number, + bottom: number, + left: number, +}>; + +export type SafeAreaInsetsChangeEvent = NativeSyntheticEvent< + Readonly<{ + /** + * The part of the view that is covered by the system UI, in the view's own + * coordinate space. + */ + insets: SafeAreaInsets, + }>, +>; + /** * @deprecated Use `TextLayoutEvent` instead. */ diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 37db047e8b25..1aa8f67648ab 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -25,6 +25,7 @@ #import #import #import +#import #import #import #import @@ -104,6 +105,9 @@ static BOOL RCTViewIsInteractiveAccessibilityElement(UIView *view, const ViewPro } #endif +// Sentinel for insets that have not been set yet. +static const UIEdgeInsets RCTNoSafeAreaInsetsSent = {-1, -1, -1, -1}; + @implementation RCTViewComponentView { UIColor *_backgroundColor; CALayer *_backgroundColorLayer; @@ -122,6 +126,7 @@ @implementation RCTViewComponentView { NSMutableSet *_accessibilityOrderNativeIDs; RCTSwiftUIContainerViewWrapper *_swiftUIWrapper; BOOL _focusable; + UIEdgeInsets _lastSentSafeAreaInsets; } #ifdef RCT_DYNAMIC_FRAMEWORKS @@ -141,6 +146,7 @@ - (instancetype)initWithFrame:(CGRect)frame #endif _useCustomContainerView = NO; _removeClippedSubviews = NO; + _lastSentSafeAreaInsets = RCTNoSafeAreaInsetsSent; } return self; } @@ -438,6 +444,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & -newViewProps.hitSlop.right}; } + // `onSafeAreaInsetsChange`. Re-armed whenever the prop is set rather than on + // its transition: `oldViewProps` comes from `_props`, which a recycled view + // keeps from its previous occupant, so `!old && new` would miss a reuse. + if (newViewProps.onSafeAreaInsetsChange) { + [self setNeedsLayout]; + } else if (oldViewProps.onSafeAreaInsetsChange) { + _lastSentSafeAreaInsets = RCTNoSafeAreaInsetsSent; + } + // `overflow` if (oldViewProps.getClipsContentToBounds() != newViewProps.getClipsContentToBounds()) { self.currentContainerView.clipsToBounds = newViewProps.getClipsContentToBounds(); @@ -720,6 +735,78 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics } } +#pragma mark - Safe area insets + +static BOOL RCTEdgeInsetsEqualWithThreshold(UIEdgeInsets lhs, UIEdgeInsets rhs, CGFloat threshold) +{ + return ABS(lhs.left - rhs.left) <= threshold && ABS(lhs.top - rhs.top) <= threshold && + ABS(lhs.right - rhs.right) <= threshold && ABS(lhs.bottom - rhs.bottom) <= threshold; +} + +// The event is only ever emitted from `layoutSubviews`; everything that might +// have changed the insets merely marks the view as needing layout. This defers +// the emit out of arbitrary call contexts — in particular out of +// `updateProps`, which runs inside the mounting transaction where +// synchronously re-entering React is not safe — while keeping it in the same +// frame: the layout pass runs before the frame is displayed. +- (void)_safeAreaInsetsMayHaveChanged +{ + if (!_eventEmitter) { + return; + } + + if (self.window == nil || CGSizeEqualToSize(self.bounds.size, CGSizeZero)) { + return; + } + + UIEdgeInsets insets = self.safeAreaInsets; + if (_lastSentSafeAreaInsets.top >= 0 && + RCTEdgeInsetsEqualWithThreshold(insets, _lastSentSafeAreaInsets, 1.0 / RCTScreenScale())) { + return; + } + + _lastSentSafeAreaInsets = insets; + + static_cast(*_eventEmitter) + .onSafeAreaInsetsChange( + EdgeInsets{ + .left = (Float)insets.left, + .top = (Float)insets.top, + .right = (Float)insets.right, + .bottom = (Float)insets.bottom}); +} + +- (BOOL)_observesSafeAreaInsets +{ + return static_cast(*_props).onSafeAreaInsetsChange; +} + +- (void)safeAreaInsetsDidChange +{ + [super safeAreaInsetsDidChange]; + if ([self _observesSafeAreaInsets]) { + [self setNeedsLayout]; + } +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + if ([self _observesSafeAreaInsets]) { + [self setNeedsLayout]; + } +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + // Moving or resizing the view changes its insets without + // `safeAreaInsetsDidChange` firing; that only reports window-level changes. + if ([self _observesSafeAreaInsets]) { + [self _safeAreaInsetsMayHaveChanged]; + } +} + - (BOOL)isJSResponder { return _isJSResponder; @@ -775,6 +862,7 @@ - (void)prepareForRecycle _filterLayer = nil; [self clearExistingBackgroundImageLayers]; + _lastSentSafeAreaInsets = RCTNoSafeAreaInsetsSent; _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN = nil; _eventEmitter.reset(); _isJSResponder = NO; diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index bd06a3b94d37..771badbbc1db 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -3222,6 +3222,7 @@ public abstract class com/facebook/react/uimanager/BaseViewManager : com/faceboo public fun setMoveShouldSetResponder (Landroid/view/View;Z)V public fun setMoveShouldSetResponderCapture (Landroid/view/View;Z)V public fun setNativeId (Landroid/view/View;Ljava/lang/String;)V + public fun setOnSafeAreaInsetsChange (Landroid/view/View;Z)V public fun setOpacity (Landroid/view/View;F)V public fun setOutlineColor (Landroid/view/View;Ljava/lang/Integer;)V public fun setOutlineOffset (Landroid/view/View;F)V @@ -4562,6 +4563,7 @@ public final class com/facebook/react/uimanager/ViewProps { public static final field NONE Ljava/lang/String; public static final field NUMBER_OF_LINES Ljava/lang/String; public static final field ON Ljava/lang/String; + public static final field ON_SAFE_AREA_INSETS_CHANGE Ljava/lang/String; public static final field OPACITY Ljava/lang/String; public static final field OUTLINE_COLOR Ljava/lang/String; public static final field OUTLINE_OFFSET Ljava/lang/String; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index 9affa257fa5f..a6ba826609f8 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -37,6 +37,8 @@ import com.facebook.react.uimanager.events.EventDispatcher; import com.facebook.react.uimanager.events.FocusEvent; import com.facebook.react.uimanager.events.PointerEventHelper; +import com.facebook.react.uimanager.events.SafeAreaInsetsChangeEvent; +import com.facebook.react.uimanager.internal.SafeAreaInsetsObserver; import com.facebook.react.uimanager.style.OutlineStyle; import com.facebook.react.uimanager.util.ReactFindViewUtil; import java.util.ArrayList; @@ -74,6 +76,8 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) { @Override protected @Nullable T prepareToRecycleView(@NonNull ThemedReactContext reactContext, T view) { + SafeAreaInsetsObserver.setEnabled(view, false); + // Reset tags view.setTag(null); view.setTag(R.id.pointer_events, null); @@ -297,6 +301,11 @@ public void setRenderToHardwareTexture(@NonNull T view, boolean useHWTexture) { view.setTag(R.id.use_hardware_layer, useHWTexture); } + @ReactProp(name = ViewProps.ON_SAFE_AREA_INSETS_CHANGE, defaultBoolean = false) + public void setOnSafeAreaInsetsChange(@NonNull T view, boolean onSafeAreaInsetsChange) { + SafeAreaInsetsObserver.setEnabled(view, onSafeAreaInsetsChange); + } + @ReactProp(name = ViewProps.TEST_ID) public void setTestId(@NonNull T view, @Nullable String testId) { view.setTag(R.id.react_test_id, testId); @@ -823,6 +832,9 @@ protected void onAfterUpdateTransaction(@NonNull T view) { .put( "topAccessibilityAction", MapBuilder.of("registrationName", "onAccessibilityAction")) + .put( + SafeAreaInsetsChangeEvent.EVENT_NAME, + MapBuilder.of("registrationName", ViewProps.ON_SAFE_AREA_INSETS_CHANGE)) .build()); return eventTypeConstants; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt index d2164e77b192..c1d63abe3a7e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt @@ -164,6 +164,8 @@ public abstract class BaseViewManagerDelegate< mViewManager.setPointerMoveCapture(view, value as Boolean? ?: false) ViewProps.ON_CLICK -> mViewManager.setClick(view, value as Boolean? ?: false) ViewProps.ON_CLICK_CAPTURE -> mViewManager.setClickCapture(view, value as Boolean? ?: false) + ViewProps.ON_SAFE_AREA_INSETS_CHANGE -> + mViewManager.setOnSafeAreaInsetsChange(view, value as Boolean? ?: false) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt index 281c390a7578..1d0a305b2f2b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt @@ -158,6 +158,7 @@ public object ViewProps { public const val SHADOW_COLOR: String = "shadowColor" public const val Z_INDEX: String = "zIndex" public const val RENDER_TO_HARDWARE_TEXTURE: String = "renderToHardwareTextureAndroid" + public const val ON_SAFE_AREA_INSETS_CHANGE: String = "experimental_onSafeAreaInsetsChange" public const val ACCESSIBILITY_LABEL: String = "accessibilityLabel" public const val ACCESSIBILITY_COLLECTION: String = "accessibilityCollection" public const val ACCESSIBILITY_COLLECTION_ITEM: String = "accessibilityCollectionItem" diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt new file mode 100644 index 000000000000..97ce2cb4a282 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt @@ -0,0 +1,51 @@ +/* + * 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. + */ + +package com.facebook.react.uimanager.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.PixelUtil.pxToDp + +/** + * Emitted when the part of a view that is covered by the system UI changes. + * + * Dispatched synchronously so that the layout depending on the insets is mounted in the frame the + * insets changed in, rather than the one after it. + */ +internal class SafeAreaInsetsChangeEvent( + surfaceId: Int, + viewTag: Int, + private val insetTop: Int, + private val insetRight: Int, + private val insetBottom: Int, + private val insetLeft: Int, +) : Event(surfaceId, viewTag) { + + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap = + Arguments.createMap().apply { + putMap( + "insets", + Arguments.createMap().apply { + putDouble("top", insetTop.toDp()) + putDouble("right", insetRight.toDp()) + putDouble("bottom", insetBottom.toDp()) + putDouble("left", insetLeft.toDp()) + }, + ) + } + + override fun experimental_isSynchronous(): Boolean = true + + internal companion object { + const val EVENT_NAME: String = "topSafeAreaInsetsChange" + + private fun Int.toDp(): Double = toFloat().pxToDp().toDouble() + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt new file mode 100644 index 000000000000..c25333f59b94 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt @@ -0,0 +1,177 @@ +/* + * 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. + */ + +package com.facebook.react.uimanager.internal + +import android.graphics.Rect +import android.view.View +import android.view.ViewTreeObserver +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.facebook.react.R +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.SafeAreaInsetsChangeEvent +import kotlin.math.max +import kotlin.math.min + +/** + * Observes the part of a view that is covered by the system UI, and emits + * [SafeAreaInsetsChangeEvent] whenever it changes. + */ +internal class SafeAreaInsetsObserver private constructor(private val view: View) : + ViewTreeObserver.OnPreDrawListener, View.OnAttachStateChangeListener { + + private val visibleRect = Rect() + private val insets = IntArray(4) + private val lastInsets = IntArray(4) + + private var hasLastInsets = false + private var isListening = false + + private fun start() { + view.addOnAttachStateChangeListener(this) + if (view.isAttachedToWindow) { + onViewAttachedToWindow(view) + } + } + + private fun stop() { + view.removeOnAttachStateChangeListener(this) + stopListening() + hasLastInsets = false + } + + private fun startListening() { + if (!isListening) { + isListening = true + view.viewTreeObserver.addOnPreDrawListener(this) + } + } + + private fun stopListening() { + if (isListening) { + isListening = false + view.viewTreeObserver.removeOnPreDrawListener(this) + } + } + + override fun onViewAttachedToWindow(v: View) { + // The insets depend on where the view ends up in the window, which is only known once it has + // been laid out. A pre-draw listener is the cheapest hook that catches every + // change: window insets, layout, and scrolling ancestors alike. The first emit waits for it + // too: this can run from the prop setter, inside the mount transaction, where synchronously + // re-entering React is not safe. + startListening() + view.invalidate() + } + + override fun onViewDetachedFromWindow(v: View) { + stopListening() + } + + override fun onPreDraw(): Boolean { + maybeEmit() + return true + } + + private fun maybeEmit() { + // Emitting on anything but an inset change would loop: the synchronous + // render an event causes produces a new frame, which runs this listener + // again. + if (!computeSafeAreaInsets(view, visibleRect, insets)) { + return + } + if (hasLastInsets && insets.contentEquals(lastInsets)) { + return + } + val eventDispatcher = + UIManagerHelper.getEventDispatcher(UIManagerHelper.getReactContext(view)) ?: return + // Recorded only once the event is actually dispatched, so a failed lookup + // above does not permanently swallow this inset value. + insets.copyInto(lastInsets) + hasLastInsets = true + eventDispatcher.dispatchEvent( + SafeAreaInsetsChangeEvent( + surfaceId = UIManagerHelper.getSurfaceId(view), + viewTag = view.id, + insetTop = insets[TOP], + insetRight = insets[RIGHT], + insetBottom = insets[BOTTOM], + insetLeft = insets[LEFT], + ), + ) + } + + companion object { + private const val TOP = 0 + private const val RIGHT = 1 + private const val BOTTOM = 2 + private const val LEFT = 3 + + // One observer per view that sets the prop; views without it pay nothing. + @JvmStatic + fun setEnabled(view: View, enabled: Boolean) { + val existing = view.getTag(R.id.safe_area_insets_observer) as? SafeAreaInsetsObserver + if (enabled == (existing != null)) { + return + } + if (enabled) { + val observer = SafeAreaInsetsObserver(view) + view.setTag(R.id.safe_area_insets_observer, observer) + observer.start() + } else { + view.setTag(R.id.safe_area_insets_observer, null) + existing?.stop() + } + } + + /** + * The insets of the window that overlap [view], in the view's own coordinate space. A view that + * does not reach under the system UI has no insets. + */ + @JvmStatic + fun getSafeAreaInsets(view: View): Insets? { + val insets = IntArray(4) + if (!computeSafeAreaInsets(view, Rect(), insets)) { + return null + } + return Insets.of(insets[LEFT], insets[TOP], insets[RIGHT], insets[BOTTOM]) + } + + /** + * Writes the insets of [view] into [out], ordered [TOP], [RIGHT], [BOTTOM], [LEFT], using + * [visibleRect] as scratch space. Returns false when they cannot be computed, leaving [out] + * untouched. + */ + private fun computeSafeAreaInsets(view: View, visibleRect: Rect, out: IntArray): Boolean { + if (view.width == 0 || view.height == 0) { + return false + } + val rootView = view.rootView + val windowInsets = + ViewCompat.getRootWindowInsets(rootView) + ?.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), + ) ?: return false + + if (!view.getGlobalVisibleRect(visibleRect)) { + // The view is fully clipped by an ancestor (e.g. scrolled out of a + // scroll view); the rect is undefined in that case, and a view that is + // not visible has no meaningful insets. + return false + } + out[TOP] = max(windowInsets.top - visibleRect.top, 0) + out[RIGHT] = + max(min(visibleRect.left + view.width - rootView.width, 0) + windowInsets.right, 0) + out[BOTTOM] = + max(min(visibleRect.top + view.height - rootView.height, 0) + windowInsets.bottom, 0) + out[LEFT] = max(windowInsets.left - visibleRect.left, 0) + return true + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index 0e51a358eb77..a4820e5d8da1 100644 --- a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -82,4 +82,7 @@ + + + diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.cpp index 4e981efd3f80..0aac07bf23c1 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.cpp @@ -32,6 +32,29 @@ void BaseViewEventEmitter::onAccessibilityEscape() const { dispatchEvent("accessibilityEscape"); } +#pragma mark - Safe area + +void BaseViewEventEmitter::onSafeAreaInsetsChange( + const EdgeInsets& insets) const { + experimental_flushSync([this, insets]() { + dispatchEvent( + "safeAreaInsetsChange", + [insets](jsi::Runtime& runtime) { + auto payload = jsi::Object(runtime); + { + auto insetsPayload = jsi::Object(runtime); + insetsPayload.setProperty(runtime, "top", insets.top); + insetsPayload.setProperty(runtime, "right", insets.right); + insetsPayload.setProperty(runtime, "bottom", insets.bottom); + insetsPayload.setProperty(runtime, "left", insets.left); + payload.setProperty(runtime, "insets", insetsPayload); + } + return payload; + }, + RawEvent::Category::Discrete); + }); +} + #pragma mark - Layout void BaseViewEventEmitter::onLayout(const LayoutMetrics& layoutMetrics) const { diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.h b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.h index 8d9978a80fc2..51cd96dcdc17 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.h @@ -14,6 +14,7 @@ #include #include +#include #include "TouchEventEmitter.h" @@ -34,6 +35,14 @@ class BaseViewEventEmitter : public TouchEventEmitter { void onLayout(const LayoutMetrics &layoutMetrics) const; +#pragma mark - Safe area + + /* + * Emits `onSafeAreaInsetsChange` with the portion of the view that is covered + * by the system UI (status bar, home indicator, display cutouts, ...). + */ + void onSafeAreaInsetsChange(const EdgeInsets &insets) const; + #pragma mark - Focus void onFocus() const; void onBlur() const; diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp index 1cb30b0ed6a8..713ab1470fd0 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp @@ -303,6 +303,12 @@ BaseViewProps::BaseViewProps( "onLayout", sourceProps.onLayout, {})), + onSafeAreaInsetsChange(convertRawProp( + context, + rawProps, + "experimental_onSafeAreaInsetsChange", + sourceProps.onSafeAreaInsetsChange, + {})), events(convertRawProp(context, rawProps, sourceProps.events, {})), collapsable(convertRawProp( context, @@ -373,6 +379,8 @@ void BaseViewProps::setProp( RAW_SET_PROP_SWITCH_CASE_BASIC(isolation); RAW_SET_PROP_SWITCH_CASE_BASIC(hitSlop); RAW_SET_PROP_SWITCH_CASE_BASIC(onLayout); + RAW_SET_PROP_SWITCH_CASE( + onSafeAreaInsetsChange, "experimental_onSafeAreaInsetsChange"); RAW_SET_PROP_SWITCH_CASE_BASIC(collapsable); RAW_SET_PROP_SWITCH_CASE_BASIC(collapsableChildren); RAW_SET_PROP_SWITCH_CASE_BASIC(removeClippedSubviews); @@ -609,6 +617,10 @@ SharedDebugStringConvertibleList BaseViewProps::getDebugProps() const { "backgroundImage", backgroundImage, defaultBaseViewProps.backgroundImage), + debugStringConvertibleItem( + "experimental_onSafeAreaInsetsChange", + onSafeAreaInsetsChange, + defaultBaseViewProps.onSafeAreaInsetsChange), }; } #endif diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h index c78c4f38729b..b72c5f944f63 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h @@ -103,6 +103,7 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps { PointerEventsMode pointerEvents{}; EdgeInsets hitSlop{}; bool onLayout{}; + bool onSafeAreaInsetsChange{}; ViewEvents events{}; diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp index a166a90546c6..035a657af1b4 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp @@ -55,7 +55,7 @@ void ViewShadowNode::initialize() noexcept { viewProps.accessibilityViewIsModal || viewProps.importantForAccessibility != ImportantForAccessibility::Auto || viewProps.removeClippedSubviews || viewProps.cursor != Cursor::Auto || - !viewProps.filter.empty() || + viewProps.onSafeAreaInsetsChange || !viewProps.filter.empty() || viewProps.mixBlendMode != BlendMode::Normal || viewProps.isolation == Isolation::Isolate || HostPlatformViewTraitsInitializer::formsStackingContext(viewProps) || diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp index f8eb6df79520..78e0e4950f74 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp @@ -570,6 +570,10 @@ folly::dynamic HostPlatformViewProps::getDiffProps( result["onLayout"] = onLayout; } + if (onSafeAreaInsetsChange != oldProps->onSafeAreaInsetsChange) { + result["experimental_onSafeAreaInsetsChange"] = onSafeAreaInsetsChange; + } + if (zIndex != oldProps->zIndex) { result["zIndex"] = zIndex.has_value() ? zIndex.value() : folly::dynamic(nullptr); diff --git a/packages/react-native/src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges.js b/packages/react-native/src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges.js new file mode 100644 index 000000000000..e0abed49b534 --- /dev/null +++ b/packages/react-native/src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges.js @@ -0,0 +1,72 @@ +/** + * 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 {SafeAreaInsetsChangeEvent} from '../../../../Libraries/Types/CoreEventTypes'; + +const DISPATCH_WINDOW_MS = 1000; +const MAX_DISPATCHES_PER_WINDOW = 10; + +type DispatchRate = { + count: number, + windowStart: number, + warned: boolean, +}; + +const dispatchRates: WeakMap = new WeakMap(); + +/** + * Wraps an `experimental_onSafeAreaInsetsChange` handler with a development + * check for a view that reports insets over and over. + */ +export default function warnOnRepeatedSafeAreaInsetsChanges( + onSafeAreaInsetsChange: (event: SafeAreaInsetsChangeEvent) => unknown, +): (event: SafeAreaInsetsChangeEvent) => unknown { + return event => { + // The target identifies the view without keeping it alive; events dispatched + // without one are simply not counted. + const target = event.target; + if (target != null && typeof target === 'object') { + warnIfDispatchingTooOften(target); + } + return onSafeAreaInsetsChange(event); + }; +} + +function warnIfDispatchingTooOften(target: interface {}): void { + const now = performance.now(); + let dispatchRate: ?DispatchRate = dispatchRates.get(target); + if (dispatchRate == null) { + const newDispatchRate: DispatchRate = { + count: 0, + windowStart: now, + warned: false, + }; + dispatchRates.set(target, newDispatchRate); + dispatchRate = newDispatchRate; + } + if (dispatchRate.warned) { + return; + } + if (now - dispatchRate.windowStart > DISPATCH_WINDOW_MS) { + dispatchRate.windowStart = now; + dispatchRate.count = 0; + } + dispatchRate.count++; + if (dispatchRate.count > MAX_DISPATCHES_PER_WINDOW) { + dispatchRate.warned = true; + console.warn( + `\`experimental_onSafeAreaInsetsChange\` fired more than ${MAX_DISPATCHES_PER_WINDOW} ` + + `times in ${DISPATCH_WINDOW_MS}ms on a single view. The safe area insets of a view ` + + 'only change when the system UI moves or the view does, so this is usually a loop: ' + + 'the view is laid out from the insets it reports, which moves it, which changes its ' + + 'insets. Each event renders synchronously, so the loop costs frames.', + ); + } +} diff --git a/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js new file mode 100644 index 000000000000..0520c4ef20a7 --- /dev/null +++ b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js @@ -0,0 +1,359 @@ +/** + * 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 + */ + +'use strict'; + +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; +import type {SafeAreaInsetsChangeEvent} from 'react-native/Libraries/Types/CoreEventTypes'; + +import RNTesterText from '../../components/RNTesterText'; +import * as React from 'react'; +import {useCallback, useState} from 'react'; +import { + Button, + Modal, + ScrollView, + StyleSheet, + TextInput, + View, +} from 'react-native'; + +type Insets = SafeAreaInsetsChangeEvent['nativeEvent']['insets']; + +function useSafeAreaInsets(): [?Insets, (SafeAreaInsetsChangeEvent) => void] { + const [insets, setInsets] = useState(null); + const onSafeAreaInsetsChange = useCallback( + (event: SafeAreaInsetsChangeEvent) => { + setInsets(event.nativeEvent.insets); + }, + [], + ); + return [insets, onSafeAreaInsetsChange]; +} + +function InsetsReadoutExample(): React.Node { + const [insets, onSafeAreaInsetsChange] = useSafeAreaInsets(); + + return ( + + + {insets == null + ? 'Waiting for insets…' + : `insets: {top: ${insets.top}, right: ${insets.right}, bottom: ${insets.bottom}, left: ${insets.left}}`} + + + This view does not reach under the system UI, so its insets are zero. + + + ); +} + +function FullScreenModalContent({onClose}: {onClose: () => void}): React.Node { + const [insets, onSafeAreaInsetsChange] = useSafeAreaInsets(); + const [applied, setApplied] = useState(false); + + // The view observes the safe area but no event has been received yet. With + // synchronous dispatch this state is committed but never displayed: the + // event fires while this tree is being mounted and the insets are applied + // before the frame is presented. If a frame ever renders in this state, the + // dispatch was not synchronous. + const waitingForInsets = applied && insets == null; + + return ( + + + + {insets != null + ? `top: ${insets.top}, right: ${insets.right}, bottom: ${insets.bottom}, left: ${insets.left}` + : waitingForInsets + ? 'Observing the safe area, inset event not received yet — this state should never be visible.' + : 'Insets not applied: the content extends under the system UI.'} + + + Applying the insets and rotating the device both update the padding in + the same frame, without the content jumping. + + {!applied ? ( +