diff --git a/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js b/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js index e00db0a1fa35..e79cbbb457ab 100644 --- a/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js +++ b/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js @@ -8,7 +8,7 @@ * @format */ -import SafeAreaView from '../../Components/SafeAreaView/SafeAreaView'; +import SafeAreaView from '../../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; import StyleSheet, { type ColorValue, type ViewStyleProp, 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/LogBox/UI/LogBoxInspectorFooterButton.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js index 3db512ed3d43..3a3d5da97792 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js @@ -8,7 +8,7 @@ * @format */ -import SafeAreaView from '../../Components/SafeAreaView/SafeAreaView'; +import SafeAreaView from '../../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; import View from '../../Components/View/View'; import StyleSheet from '../../StyleSheet/StyleSheet'; import Text from '../../Text/Text'; diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorHeader.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorHeader.js index a5b5329d440e..8657f427f681 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorHeader.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorHeader.js @@ -11,7 +11,7 @@ import type {ViewProps} from '../../Components/View/ViewPropTypes'; import type {LogLevel} from '../Data/LogBoxLog'; -import SafeAreaView from '../../Components/SafeAreaView/SafeAreaView'; +import SafeAreaView from '../../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; import View from '../../Components/View/View'; import StyleSheet from '../../StyleSheet/StyleSheet'; import Text from '../../Text/Text'; diff --git a/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxNotificationContainer-test.js.snap b/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxNotificationContainer-test.js.snap index d341008aa058..cd774f0fdea9 100644 --- a/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxNotificationContainer-test.js.snap +++ b/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxNotificationContainer-test.js.snap @@ -1,14 +1,18 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`LogBoxNotificationContainer should render both an error and warning notification 1`] = ` - - + `; exports[`LogBoxNotificationContainer should render null with no logs 1`] = `null`; @@ -113,14 +117,18 @@ exports[`LogBoxNotificationContainer should render selected fatal error even whe exports[`LogBoxNotificationContainer should render selected syntax error even when disabled 1`] = `null`; exports[`LogBoxNotificationContainer should render the latest error notification 1`] = ` - - + `; exports[`LogBoxNotificationContainer should render the latest warning notification 1`] = ` - - + `; 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/Libraries/Utilities/Dimensions.js b/packages/react-native/Libraries/Utilities/Dimensions.js index 13458dd737db..a2222868cf77 100644 --- a/packages/react-native/Libraries/Utilities/Dimensions.js +++ b/packages/react-native/Libraries/Utilities/Dimensions.js @@ -16,10 +16,16 @@ import NativeDeviceInfo, { type DimensionsPayload, type DisplayMetrics, type DisplayMetricsAndroid, + type WindowSafeAreaInsets, } from './NativeDeviceInfo'; import invariant from 'invariant'; -export type {DimensionsPayload, DisplayMetrics, DisplayMetricsAndroid}; +export type { + DimensionsPayload, + DisplayMetrics, + DisplayMetricsAndroid, + WindowSafeAreaInsets, +}; /** @deprecated Use DisplayMetrics */ export type ScaledSize = DisplayMetrics; @@ -72,11 +78,22 @@ class Dimensions { let {screen, window} = dims; const {windowPhysicalPixels} = dims; if (windowPhysicalPixels) { + const {scale, experimental_safeAreaInsets: safeAreaInsets} = + windowPhysicalPixels; window = { - width: windowPhysicalPixels.width / windowPhysicalPixels.scale, - height: windowPhysicalPixels.height / windowPhysicalPixels.scale, - scale: windowPhysicalPixels.scale, + width: windowPhysicalPixels.width / scale, + height: windowPhysicalPixels.height / scale, + scale, fontScale: windowPhysicalPixels.fontScale, + experimental_safeAreaInsets: + safeAreaInsets == null + ? undefined + : { + top: safeAreaInsets.top / scale, + right: safeAreaInsets.right / scale, + bottom: safeAreaInsets.bottom / scale, + left: safeAreaInsets.left / scale, + }, }; } const {screenPhysicalPixels} = dims; diff --git a/packages/react-native/Libraries/Utilities/__tests__/Dimensions-itest.js b/packages/react-native/Libraries/Utilities/__tests__/Dimensions-itest.js index e4481e8f8be1..4bcb6261d23a 100644 --- a/packages/react-native/Libraries/Utilities/__tests__/Dimensions-itest.js +++ b/packages/react-native/Libraries/Utilities/__tests__/Dimensions-itest.js @@ -30,6 +30,26 @@ describe('Dimensions', () => { expect(Dimensions.get('window').fontScale).toEqual(3); }); + it('should scale window safe area insets from physical pixels', () => { + Dimensions.set({ + windowPhysicalPixels: { + width: 400, + height: 800, + scale: 2, + densityDpi: 2, + fontScale: 3, + experimental_safeAreaInsets: {top: 96, right: 0, bottom: 48, left: 0}, + }, + }); + + expect(Dimensions.get('window').experimental_safeAreaInsets).toEqual({ + top: 48, + right: 0, + bottom: 24, + left: 0, + }); + }); + it('should set screen dimensions on Android', () => { // $FlowFixMe[incompatible-type] - `Platform.OS` needs to be read-only. Platform.OS = 'android'; diff --git a/packages/react-native/Libraries/Utilities/useWindowDimensions.js b/packages/react-native/Libraries/Utilities/useWindowDimensions.js index 02e35b6df780..65fb58d1f20e 100644 --- a/packages/react-native/Libraries/Utilities/useWindowDimensions.js +++ b/packages/react-native/Libraries/Utilities/useWindowDimensions.js @@ -15,6 +15,21 @@ import { } from './NativeDeviceInfo'; import {useEffect, useState} from 'react'; +function safeAreaInsetsAreEqual( + a: DisplayMetrics['experimental_safeAreaInsets'], + b: DisplayMetrics['experimental_safeAreaInsets'], +): boolean { + if (a == null || b == null) { + return a == null && b == null; + } + return ( + a.top === b.top && + a.right === b.right && + a.bottom === b.bottom && + a.left === b.left + ); +} + /** * React hook that provides the application window's width, height, scale, and * font scale. Automatically updates when screen size or font scale changes. @@ -35,7 +50,11 @@ export default function useWindowDimensions(): dimensions.width !== window.width || dimensions.height !== window.height || dimensions.scale !== window.scale || - dimensions.fontScale !== window.fontScale + dimensions.fontScale !== window.fontScale || + !safeAreaInsetsAreEqual( + dimensions.experimental_safeAreaInsets, + window.experimental_safeAreaInsets, + ) ) { setDimensions(window); } diff --git a/packages/react-native/React/CoreModules/RCTDeviceInfo.mm b/packages/react-native/React/CoreModules/RCTDeviceInfo.mm index 1761214fc3bd..a391b9fc50c5 100644 --- a/packages/react-native/React/CoreModules/RCTDeviceInfo.mm +++ b/packages/react-native/React/CoreModules/RCTDeviceInfo.mm @@ -202,12 +202,24 @@ static BOOL RCTIsIPhoneNotched() // We fallback to screen size if a key window is not found. CGSize windowSize = mainWindow != nil ? mainWindow.bounds.size : screenSize; - NSDictionary *dimsWindow = @{ + NSMutableDictionary *dimsWindow = [@{ @"width" : @(windowSize.width), @"height" : @(windowSize.height), @"scale" : @(screen.scale), - @"fontScale" : @(fontScale) - }; + @"fontScale" : @(fontScale), + } mutableCopy]; + // The field is documented as absent when it cannot be measured; without a + // window there are no insets to report, and zero would read as a + // measurement. + if (mainWindow != nil) { + UIEdgeInsets safeAreaInsets = mainWindow.safeAreaInsets; + dimsWindow[@"experimental_safeAreaInsets"] = @{ + @"top" : @(safeAreaInsets.top), + @"right" : @(safeAreaInsets.right), + @"bottom" : @(safeAreaInsets.bottom), + @"left" : @(safeAreaInsets.left) + }; + } NSDictionary *dimsScreen = @{ @"width" : @(screenSize.width), 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..1e34fea1a14a 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,8 @@ static BOOL RCTViewIsInteractiveAccessibilityElement(UIView *view, const ViewPro } #endif +static const UIEdgeInsets RCTNoSafeAreaInsetsSent = {-1, -1, -1, -1}; + @implementation RCTViewComponentView { UIColor *_backgroundColor; CALayer *_backgroundColorLayer; @@ -122,6 +125,8 @@ @implementation RCTViewComponentView { NSMutableSet *_accessibilityOrderNativeIDs; RCTSwiftUIContainerViewWrapper *_swiftUIWrapper; BOOL _focusable; + // Insets are never negative, so a negative top marks "no event sent yet". + 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`. Scheduled whenever the prop is set, not only on + // its transitions: recycled views keep their last props, so `oldViewProps` + // of a freshly reused view is not a reliable baseline. + 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/modules/deviceinfo/DeviceInfoModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/deviceinfo/DeviceInfoModule.kt index e40a8f1221f6..c905f7737cf7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/deviceinfo/DeviceInfoModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/deviceinfo/DeviceInfoModule.kt @@ -23,6 +23,7 @@ import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.DisplayMetricsHolder.getScreenDisplayMetrics import com.facebook.react.uimanager.DisplayMetricsHolder.initDisplayMetricsIfNotInitialized +import com.facebook.react.uimanager.internal.SafeAreaInsetsObserver import com.facebook.react.views.view.isEdgeToEdgeFeatureFlagOn /** Module that exposes Android Constants to JS. */ @@ -83,7 +84,11 @@ internal class DeviceInfoModule(reactContext: ReactApplicationContext) : WritableNativeMap().apply { putMap( "windowPhysicalPixels", - getPhysicalPixelsWritableMap(getWindowDisplayMetrics()), + getPhysicalPixelsWritableMap(getWindowDisplayMetrics()).apply { + getWindowSafeAreaInsetsWritableMap()?.let { + putMap("experimental_safeAreaInsets", it) + } + }, ) putMap( "screenPhysicalPixels", @@ -91,6 +96,21 @@ internal class DeviceInfoModule(reactContext: ReactApplicationContext) : ) } + /** + * The part of the window that is covered by the system UI, in physical pixels. Uses the same + * computation as the `onSafeAreaInsetsChange` view prop, applied to the window's decor view. + */ + private fun getWindowSafeAreaInsetsWritableMap(): WritableMap? { + val decorView = reactApplicationContext.currentActivity?.window?.decorView ?: return null + val insets = SafeAreaInsetsObserver.getSafeAreaInsets(decorView) ?: return null + return WritableNativeMap().apply { + putInt("top", insets.top) + putInt("right", insets.right) + putInt("bottom", insets.bottom) + putInt("left", insets.left) + } + } + private fun getPhysicalPixelsWritableMap( displayMetrics: DisplayMetrics, ): WritableMap = 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..d396f6e97f56 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,10 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) { @Override protected @Nullable T prepareToRecycleView(@NonNull ThemedReactContext reactContext, T view) { + // Stops safe area observation and clears its tag; the next user of the + // view re-enables it through the prop if needed. + SafeAreaInsetsObserver.setEnabled(view, false); + // Reset tags view.setTag(null); view.setTag(R.id.pointer_events, null); @@ -297,6 +303,15 @@ public void setRenderToHardwareTexture(@NonNull T view, boolean useHWTexture) { view.setTag(R.id.use_hardware_layer, useHWTexture); } + /** + * Views only observe safe area insets while a JavaScript handler is attached, so views that do + * not use the prop are not affected. + */ + @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 +838,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..81c3d43a0deb --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt @@ -0,0 +1,52 @@ +/* + * 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, or the position of that view in + * the window, 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..4a9de4144ebf --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt @@ -0,0 +1,184 @@ +/* + * 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, or the position of the view in the window, changes. + * + * One observer is attached per view that sets the `onSafeAreaInsetsChange` prop. Views without the + * prop never get an observer, and so pay nothing for this. + */ +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. + startListening() + maybeEmit() + } + + 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 + + /** + * Starts or stops observing safe area insets for [view]. Safe to call repeatedly with the same + * value. + */ + @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. + * + * Also used with the window's decor view to report window-level safe area insets through the + * `Dimensions` module. + */ + @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..bc972f8497d3 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,33 @@ void BaseViewEventEmitter::onAccessibilityEscape() const { dispatchEvent("accessibilityEscape"); } +#pragma mark - Safe area + +void BaseViewEventEmitter::onSafeAreaInsetsChange( + const EdgeInsets& insets) const { + // Dispatched synchronously and as a discrete event so that React processes it + // before the current frame is presented. Both the thread this is called from + // (the UI thread) and the JavaScript thread are blocked until React has + // finished rendering. + 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..a28e304c4fd1 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,18 @@ 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, ...). + * + * The event is dispatched synchronously, blocking the thread it is called + * from until React has re-rendered, so that the layout that depends on the + * insets is mounted in the same frame the insets changed in. + */ + 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..5a4460301ff6 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,8 @@ void ViewShadowNode::initialize() noexcept { viewProps.accessibilityViewIsModal || viewProps.importantForAccessibility != ImportantForAccessibility::Auto || viewProps.removeClippedSubviews || viewProps.cursor != Cursor::Auto || - !viewProps.filter.empty() || + // Observing the safe area requires a host view to observe with. + 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/ReactCxxPlatform/react/coremodules/DeviceInfoModule.h b/packages/react-native/ReactCxxPlatform/react/coremodules/DeviceInfoModule.h index c750cf971521..8caddbe6a2bc 100644 --- a/packages/react-native/ReactCxxPlatform/react/coremodules/DeviceInfoModule.h +++ b/packages/react-native/ReactCxxPlatform/react/coremodules/DeviceInfoModule.h @@ -12,9 +12,13 @@ namespace facebook::react { -using DisplayMetrics = NativeDeviceInfoDisplayMetrics; +using WindowSafeAreaInsets = NativeDeviceInfoWindowSafeAreaInsets; -using DisplayMetricsAndroid = NativeDeviceInfoDisplayMetricsAndroid; +using DisplayMetrics = + NativeDeviceInfoDisplayMetrics>; + +using DisplayMetricsAndroid = + NativeDeviceInfoDisplayMetricsAndroid>; using DimensionsPayload = NativeDeviceInfoDimensionsPayload< std::optional, @@ -25,6 +29,9 @@ using DimensionsPayload = NativeDeviceInfoDimensionsPayload< using DeviceInfoConstants = NativeDeviceInfoDeviceInfoConstants, std::optional>; +template <> +struct Bridging : NativeDeviceInfoWindowSafeAreaInsetsBridging {}; + template <> struct Bridging : NativeDeviceInfoDisplayMetricsBridging {}; diff --git a/packages/react-native/src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE.js b/packages/react-native/src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE.js index e6b5cdd5f522..5dff2a970af1 100644 --- a/packages/react-native/src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE.js +++ b/packages/react-native/src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE.js @@ -9,23 +9,73 @@ */ import type {ViewProps} from '../../../../Libraries/Components/View/ViewPropTypes'; +import type { + SafeAreaInsets, + SafeAreaInsetsChangeEvent, +} from '../../../../Libraries/Types/CoreEventTypes'; +import type {HostInstance} from '../../types/HostInstance'; import View from '../../../../Libraries/Components/View/View'; -import UIManager from '../../../../Libraries/ReactNative/UIManager'; -import Platform from '../../../../Libraries/Utilities/Platform'; +import I18nManager from '../../../../Libraries/ReactNative/I18nManager'; +import Dimensions from '../../../../Libraries/Utilities/Dimensions'; import * as React from 'react'; +import {useCallback, useMemo, useState} from 'react'; -const exported: component( - ref?: React.RefSetter>, - ...ViewProps -) = Platform.select({ - ios: require('../../../../src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent') - .default, - android: UIManager.hasViewManagerConfig('RCTSafeAreaView') - ? require('../../../../src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent') - .default - : View, - default: View, -}); +/** + * Renders its children within the safe area of the device, by applying the part + * of the view that is covered by the system UI as padding. + * + * This is the internal counterpart of `react-native-safe-area-context`, for the + * few surfaces React Native renders itself (LogBox, the element inspector, ...) + * which cannot take a dependency on it. Everything else should use the library. + */ +component SafeAreaView( + ref?: React.RefSetter, + ...props: ViewProps +) { + const {style, experimental_onSafeAreaInsetsChange, ...otherProps} = props; + // Seeded with the window insets so the first frame is already padded; the + // synchronous event then keeps them correct relative to this view. The seed + // is only exact for views aligned with the window edges, which the internal + // surfaces using this component (LogBox, the element inspector) are. + const [insets, setInsets] = useState( + () => Dimensions.get('window').experimental_safeAreaInsets, + ); + + const handleSafeAreaInsetsChange = useCallback( + (event: SafeAreaInsetsChangeEvent) => { + setInsets(event.nativeEvent.insets); + experimental_onSafeAreaInsetsChange?.(event); + }, + [experimental_onSafeAreaInsetsChange], + ); + + const paddingStyle = useMemo(() => { + if (insets == null) { + return null; + } + // Insets are physical edges, but Yoga remaps paddingLeft/paddingRight to + // start/end when I18nManager's swapLeftAndRightInRTL is on, which would + // pad the mirror-image edge in RTL. Swap the values so the physical edge + // keeps its inset. + const {isRTL, doLeftAndRightSwapInRTL} = I18nManager.getConstants(); + const swap = isRTL && doLeftAndRightSwapInRTL; + return { + paddingTop: insets.top, + paddingRight: swap ? insets.left : insets.right, + paddingBottom: insets.bottom, + paddingLeft: swap ? insets.right : insets.left, + }; + }, [insets]); + + return ( + + ); +} -export default exported; +export default SafeAreaView; 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..e03553610b89 --- /dev/null +++ b/packages/react-native/src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges.js @@ -0,0 +1,78 @@ +/** + * 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. + * + * The system UI does not move many times a second, so a sustained stream of + * events means the layout is feeding the insets back into the position of the + * observed view: it pads itself by the insets it reports, which moves it, which + * changes its insets. Every one of those events renders synchronously, blocking + * the UI thread, so the loop is paid for in frames. + */ +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/react-native/src/private/devsupport/devmenu/elementinspector/InspectorPanel.js b/packages/react-native/src/private/devsupport/devmenu/elementinspector/InspectorPanel.js index 9e5d04f30038..b4195f431486 100644 --- a/packages/react-native/src/private/devsupport/devmenu/elementinspector/InspectorPanel.js +++ b/packages/react-native/src/private/devsupport/devmenu/elementinspector/InspectorPanel.js @@ -12,7 +12,7 @@ import type {ElementsHierarchy, InspectedElement} from './Inspector'; -import SafeAreaView from '../../../../../Libraries/Components/SafeAreaView/SafeAreaView'; +import SafeAreaView from '../../../components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; import * as React from 'react'; const ScrollView = diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceInfo.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceInfo.js index 57d18b6bd3ab..50566f2ffe4b 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceInfo.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceInfo.js @@ -12,12 +12,26 @@ import type {TurboModule} from '../../../../Libraries/TurboModule/RCTExport'; import * as TurboModuleRegistry from '../../../../Libraries/TurboModule/TurboModuleRegistry'; +export type WindowSafeAreaInsets = { + top: number, + right: number, + bottom: number, + left: number, +}; + export type DisplayMetricsAndroid = { width: number, height: number, scale: number, fontScale: number, densityDpi: number, + /** + * The part of the window that is covered by the system UI, in physical + * pixels. Absent on platforms and versions that cannot report it. + * + * @experimental + */ + readonly experimental_safeAreaInsets?: WindowSafeAreaInsets, }; export type DisplayMetrics = { @@ -25,6 +39,13 @@ export type DisplayMetrics = { height: number, scale: number, fontScale: number, + /** + * The part of the window that is covered by the system UI, in physical + * pixels. Absent on platforms and versions that cannot report it. + * + * @experimental + */ + readonly experimental_safeAreaInsets?: WindowSafeAreaInsets, }; export type DimensionsPayload = { 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..bb9f4cf117d0 --- /dev/null +++ b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js @@ -0,0 +1,385 @@ +/** + * 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, + useWindowDimensions, +} 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 ? ( +