diff --git a/packages/eslint-plugin-react-native/utils.js b/packages/eslint-plugin-react-native/utils.js index be51ad8d14cf..4b6164d64826 100644 --- a/packages/eslint-plugin-react-native/utils.js +++ b/packages/eslint-plugin-react-native/utils.js @@ -143,10 +143,6 @@ const publicAPIMapping = { 'RefreshControlPropsIOS', ], }, - 'Libraries/Components/SafeAreaView/SafeAreaView': { - default: 'SafeAreaView', - types: ['SafeAreaViewInstance'], - }, 'Libraries/Components/ScrollView/ScrollView': { default: 'ScrollView', types: [ diff --git a/packages/react-native-babel-preset/src/configs/lazy-imports.js b/packages/react-native-babel-preset/src/configs/lazy-imports.js index 95cb75e262c2..23833aa2c14d 100644 --- a/packages/react-native-babel-preset/src/configs/lazy-imports.js +++ b/packages/react-native-babel-preset/src/configs/lazy-imports.js @@ -29,7 +29,6 @@ module.exports = new Set([ 'Pressable', 'ProgressBarAndroid', 'ProgressViewIOS', - 'SafeAreaView', 'ScrollView', 'SectionList', 'Slider', diff --git a/packages/react-native/Libraries/Components/SafeAreaView/RCTSafeAreaViewNativeComponent.js b/packages/react-native/Libraries/Components/SafeAreaView/RCTSafeAreaViewNativeComponent.js deleted file mode 100644 index feb40f24ecef..000000000000 --- a/packages/react-native/Libraries/Components/SafeAreaView/RCTSafeAreaViewNativeComponent.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * 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 - */ - -export * from '../../../src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent'; -export {default} from '../../../src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent'; diff --git a/packages/react-native/Libraries/Components/SafeAreaView/SafeAreaView.js b/packages/react-native/Libraries/Components/SafeAreaView/SafeAreaView.js deleted file mode 100644 index 2b141c8c8494..000000000000 --- a/packages/react-native/Libraries/Components/SafeAreaView/SafeAreaView.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * 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 {HostInstance} from '../../../src/private/types/HostInstance'; -import type {ViewProps} from '../View/ViewPropTypes'; - -import Platform from '../../Utilities/Platform'; -import View from '../View/View'; -import * as React from 'react'; - -export type SafeAreaViewInstance = HostInstance; - -/** - * Renders content within the safe area boundaries of a device. Currently only applicable to iOS devices with iOS version 11 or later. Automatically applies padding to reflect the portion of the view not covered by navigation bars, tab bars, toolbars, and other ancestor views. - * - * @see https://reactnative.dev/docs/safeareaview - * @deprecated Use `react-native-safe-area-context` instead. - * @platform ios - */ -const SafeAreaView: component( - ref?: React.RefSetter, - ...props: ViewProps -) = Platform.select({ - ios: require('./RCTSafeAreaViewNativeComponent').default, - default: View, -}); - -export default SafeAreaView; diff --git a/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js b/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js index e00db0a1fa35..c11bd828f049 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'; 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..0ea92650f248 --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js @@ -0,0 +1,157 @@ +/** + * 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'; +import SafeAreaView from 'react-native/src/private/components/safeareaview/SafeAreaView'; + +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(); + }); +}); + +describe('', () => { + it('applies the insets it receives as padding', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + + Fantom.runTask(() => { + root.render(); + }); + + expect( + root + .getRenderedOutput({ + props: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'], + }) + .toJSX(), + ).toEqual(); + + Fantom.dispatchNativeEvent(nodeRef, 'safeAreaInsetsChange', { + insets: INSETS, + }); + + expect( + root + .getRenderedOutput({ + props: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'], + }) + .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/LogBoxNotificationContainer.js b/packages/react-native/Libraries/LogBox/LogBoxNotificationContainer.js index 33593b2a2af2..9f7d3b6aa9b0 100644 --- a/packages/react-native/Libraries/LogBox/LogBoxNotificationContainer.js +++ b/packages/react-native/Libraries/LogBox/LogBoxNotificationContainer.js @@ -8,7 +8,7 @@ * @format */ -import SafeAreaView from '../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; +import SafeAreaView from '../../src/private/components/safeareaview/SafeAreaView'; import View from '../Components/View/View'; import StyleSheet from '../StyleSheet/StyleSheet'; import BackHandler from '../Utilities/BackHandler'; diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js index 3db512ed3d43..a896030ab1c1 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'; 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..aebec6d63fac 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorHeader.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorHeader.js @@ -8,10 +8,9 @@ * @format */ -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'; import View from '../../Components/View/View'; import StyleSheet from '../../StyleSheet/StyleSheet'; import Text from '../../Text/Text'; @@ -27,13 +26,10 @@ type Props = Readonly<{ level: LogLevel, }>; -const LogBoxInspectorHeaderSafeArea: React.ComponentType = - Platform.OS === 'android' ? View : SafeAreaView; - export default function LogBoxInspectorHeader(props: Props): React.Node { if (props.level === 'syntax') { return ( - + - + ); } @@ -56,7 +52,7 @@ export default function LogBoxInspectorHeader(props: Props): React.Node { const titleText = `Log ${props.selectedIndex + 1} of ${props.total}`; return ( - + props.onSelectIndex(nextIndex)} /> - + ); } 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/Package.swift b/packages/react-native/Package.swift index 386ca6958b7f..db2efa739b02 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -480,7 +480,6 @@ let reactFabric = RNTarget( "components/inputaccessory", "components/modal", "components/rncore", - "components/safeareaview", "components/switch", "components/text", "components/textinput", @@ -518,12 +517,6 @@ let reactFabricSwitch = RNTarget( dependencies: [.reactNativeDependencies, .reactCore, .reactJsiExecutor, .reactTurboModuleCore, .jsi, .logger, .reactDebug, .reactFeatureFlags, .reactUtils, .reactRuntimeScheduler, .reactCxxReact, .yoga, .reactRendererDebug, .reactGraphics, .reactFabric, .reactTurboModuleBridging] ) -let reactFabricSafeAreaView = RNTarget( - name: .reactFabricSafeAreaView, - path: "ReactCommon/react/renderer/components/safeareaview", - dependencies: [.reactNativeDependencies, .reactCore, .reactJsiExecutor, .reactTurboModuleCore, .jsi, .logger, .reactDebug, .reactFeatureFlags, .reactUtils, .reactRuntimeScheduler, .reactCxxReact, .yoga, .reactRendererDebug, .reactGraphics, .reactFabric, .reactTurboModuleBridging] -) - let reactFabricTextLayoutManager = RNTarget( name: .reactFabricTextLayoutManager, path: "ReactCommon/react/renderer/textlayoutmanager", @@ -576,7 +569,7 @@ let reactRCTFabric = RNTarget( name: .reactRCTFabric, path: "React/Fabric", searchPaths: [virtualViewPath], - dependencies: [.reactNativeDependencies, .reactCore, .reactRCTImage, .yoga, .reactRCTText, .jsi, .reactFabricInputAccessory, .reactFabricModal, .reactFabricSafeAreaView, .reactFabricSwitch, .reactFabricText, .reactFabricTextInput, .reactFabricUnimplementedView, .reactFabricTextLayoutManager, .reactGraphics, .reactImageManager, .reactDebug, .reactUtils, .reactPerformanceTimeline, .reactRendererDebug, .reactRendererConsistency, .reactRuntimeScheduler, .reactRCTAnimation, .reactJsInspector, .reactJsInspectorNetwork, .reactJsInspectorTracing, .reactFabric, .reactFabricImage, .rctSwiftUIWrapper] + dependencies: [.reactNativeDependencies, .reactCore, .reactRCTImage, .yoga, .reactRCTText, .jsi, .reactFabricInputAccessory, .reactFabricModal, .reactFabricSwitch, .reactFabricText, .reactFabricTextInput, .reactFabricUnimplementedView, .reactFabricTextLayoutManager, .reactGraphics, .reactImageManager, .reactDebug, .reactUtils, .reactPerformanceTimeline, .reactRendererDebug, .reactRendererConsistency, .reactRuntimeScheduler, .reactRCTAnimation, .reactJsInspector, .reactJsInspectorNetwork, .reactJsInspectorTracing, .reactFabric, .reactFabricImage, .rctSwiftUIWrapper] ) /// React-ImageManagerApple.podspec @@ -699,7 +692,6 @@ let targets = [ reactFabricImage, reactFabricInputAccessory, reactFabricModal, - reactFabricSafeAreaView, reactFabricSwitch, reactFabricTextLayoutManager, reactFabricText, @@ -885,7 +877,6 @@ extension String { static let reactFabricImage = "React-FabricImage" static let reactFabricInputAccessory = "React-FabricInputAccessory" static let reactFabricModal = "React-FabricModal" - static let reactFabricSafeAreaView = "React-FabricSafeAreaView" static let reactFabricSwitch = "React-FabricSwitch" static let reactFabricTextLayoutManager = "React-FabricTextLayoutManager" static let reactFabricText = "React-FabricText" diff --git a/packages/react-native/React/Base/RCTBridge.mm b/packages/react-native/React/Base/RCTBridge.mm index b2258a62c0b9..c9b6ef636ce4 100644 --- a/packages/react-native/React/Base/RCTBridge.mm +++ b/packages/react-native/React/Base/RCTBridge.mm @@ -55,7 +55,6 @@ @"RCTDebuggingOverlayManager", @"RCTModalHostViewManager", @"RCTRefreshControlManager", - @"RCTSafeAreaViewManager", @"RCTScrollContentViewManager", @"RCTScrollViewManager", @"RCTSwitchManager", 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/RCTFabricComponentsPlugins.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.h index 6b8a6ff6d6dc..d03b01686f51 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.h @@ -34,7 +34,6 @@ Class RCTDebuggingOverlayCls(void) __attribute__((used Class RCTInputAccessoryCls(void) __attribute__((used)); Class RCTParagraphCls(void) __attribute__((used)); Class RCTPullToRefreshViewCls(void) __attribute__((used)); -Class RCTSafeAreaViewCls(void) __attribute__((used)); Class RCTScrollViewCls(void) __attribute__((used)); Class RCTSwitchCls(void) __attribute__((used)); Class RCTTextInputCls(void) __attribute__((used)); diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.mm index 442c2e6e3962..fe9746644671 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.mm @@ -23,7 +23,6 @@ {"InputAccessoryView", RCTInputAccessoryCls}, {"Paragraph", RCTParagraphCls}, {"PullToRefreshView", RCTPullToRefreshViewCls}, - {"SafeAreaView", RCTSafeAreaViewCls}, {"ScrollView", RCTScrollViewCls}, {"Switch", RCTSwitchCls}, {"TextInput", RCTTextInputCls}, diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/SafeAreaView/RCTSafeAreaViewComponentView.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/SafeAreaView/RCTSafeAreaViewComponentView.h deleted file mode 100644 index 542b88cf14fa..000000000000 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/SafeAreaView/RCTSafeAreaViewComponentView.h +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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. - */ - -#import - -#import - -NS_ASSUME_NONNULL_BEGIN - -/** - * UIView class for root component. - */ -@interface RCTSafeAreaViewComponentView : RCTViewComponentView - -@end - -NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/SafeAreaView/RCTSafeAreaViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/SafeAreaView/RCTSafeAreaViewComponentView.mm deleted file mode 100644 index 4c05633d8cbb..000000000000 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/SafeAreaView/RCTSafeAreaViewComponentView.mm +++ /dev/null @@ -1,100 +0,0 @@ -/* - * 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. - */ - -#import "RCTSafeAreaViewComponentView.h" - -#import -#import -#import -#import "RCTConversions.h" -#import "RCTFabricComponentsPlugins.h" - -using namespace facebook::react; - -@implementation RCTSafeAreaViewComponentView { - SafeAreaViewShadowNode::ConcreteState::Shared _state; -} - -- (instancetype)initWithFrame:(CGRect)frame -{ - if (self = [super initWithFrame:frame]) { - _props = SafeAreaViewShadowNode::defaultSharedProps(); - } - - return self; -} - -- (void)safeAreaInsetsDidChange -{ - [super safeAreaInsetsDidChange]; - - [self _updateStateIfNecessary]; -} - -- (void)_updateStateIfNecessary -{ - if (!_state) { - return; - } - - UIEdgeInsets insets = self.safeAreaInsets; - insets.left = RCTRoundPixelValue(insets.left); - insets.top = RCTRoundPixelValue(insets.top); - insets.right = RCTRoundPixelValue(insets.right); - insets.bottom = RCTRoundPixelValue(insets.bottom); - - auto newPadding = RCTEdgeInsetsFromUIEdgeInsets(insets); - auto threshold = 1.0 / RCTScreenScale() + 0.01; // Size of a pixel plus some small threshold. - - _state->updateState( - [=](const SafeAreaViewShadowNode::ConcreteState::Data &oldData) - -> SafeAreaViewShadowNode::ConcreteState::SharedData { - auto oldPadding = oldData.padding; - auto deltaPadding = newPadding - oldPadding; - - if (std::abs(deltaPadding.left) < threshold && std::abs(deltaPadding.top) < threshold && - std::abs(deltaPadding.right) < threshold && std::abs(deltaPadding.bottom) < threshold) { - return nullptr; - } - - auto newData = oldData; - newData.padding = newPadding; - return std::make_shared(newData); - }); -} - -#pragma mark - RCTComponentViewProtocol - -+ (ComponentDescriptorProvider)componentDescriptorProvider -{ - return concreteComponentDescriptorProvider(); -} - -- (void)updateState:(const facebook::react::State::Shared &)state - oldState:(const facebook::react::State::Shared &)oldState -{ - _state = std::static_pointer_cast(state); -} - -- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask -{ - [super finalizeUpdates:updateMask]; - [self _updateStateIfNecessary]; -} - -- (void)prepareForRecycle -{ - [super prepareForRecycle]; - _state.reset(); -} - -@end - -Class RCTSafeAreaViewCls(void) -{ - return RCTSafeAreaViewComponentView.class; -} 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..2ea866e6d5af 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; @@ -5114,14 +5116,6 @@ public abstract interface class com/facebook/react/viewmanagers/ModalHostViewMan public abstract fun setVisible (Landroid/view/View;Z)V } -public class com/facebook/react/viewmanagers/SafeAreaViewManagerDelegate : com/facebook/react/uimanager/BaseViewManagerDelegate { - public fun (Lcom/facebook/react/uimanager/BaseViewManager;)V - public fun setProperty (Landroid/view/View;Ljava/lang/String;Ljava/lang/Object;)V -} - -public abstract interface class com/facebook/react/viewmanagers/SafeAreaViewManagerInterface : com/facebook/react/uimanager/ViewManagerWithGeneratedInterface { -} - public class com/facebook/react/viewmanagers/UnimplementedNativeViewManagerDelegate : com/facebook/react/uimanager/BaseViewManagerDelegate { public fun (Lcom/facebook/react/uimanager/BaseViewManager;)V public fun setProperty (Landroid/view/View;Ljava/lang/String;Ljava/lang/Object;)V 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/shell/MainReactPackage.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt index 14b053b4196f..535ffb3a8fbf 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt @@ -50,7 +50,6 @@ import com.facebook.react.views.drawer.ReactDrawerLayoutManager import com.facebook.react.views.image.ReactImageManager import com.facebook.react.views.modal.ReactModalHostManager import com.facebook.react.views.progressbar.ReactProgressBarViewManager -import com.facebook.react.views.safeareaview.ReactSafeAreaViewManager import com.facebook.react.views.scroll.ReactHorizontalScrollContainerViewManager import com.facebook.react.views.scroll.ReactHorizontalScrollViewManager import com.facebook.react.views.scroll.ReactNestedScrollViewManager @@ -148,7 +147,6 @@ constructor(private val config: MainPackageConfig? = null) : if (ReactNativeFeatureFlags.useNestedScrollViewAndroid()) ReactNestedScrollViewManager() else ReactScrollViewManager(), ReactSwitchManager(), - ReactSafeAreaViewManager(), SwipeRefreshLayoutManager(), // Native equivalents ReactImageManager(), @@ -178,8 +176,6 @@ constructor(private val config: MainPackageConfig? = null) : ModuleSpec.viewManagerSpec { ReactHorizontalScrollContainerViewManager() }, ReactProgressBarViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactProgressBarViewManager() }, - ReactSafeAreaViewManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { ReactSafeAreaViewManager() }, ReactScrollViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec { if (ReactNativeFeatureFlags.useNestedScrollViewAndroid()) 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/java/com/facebook/react/views/safeareaview/ReactSafeAreaView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaview/ReactSafeAreaView.kt deleted file mode 100644 index 034879ca96e7..000000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaview/ReactSafeAreaView.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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.views.safeareaview - -import android.view.ViewGroup -import androidx.annotation.UiThread -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsCompat.CONSUMED -import com.facebook.react.bridge.GuardedRunnable -import com.facebook.react.bridge.WritableNativeMap -import com.facebook.react.common.build.ReactBuildConfig -import com.facebook.react.uimanager.PixelUtil.pxToDp -import com.facebook.react.uimanager.StateWrapper -import com.facebook.react.uimanager.ThemedReactContext - -internal class ReactSafeAreaView(val reactContext: ThemedReactContext) : ViewGroup(reactContext) { - internal var stateWrapper: StateWrapper? = null - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - - ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsets -> - val insets = - windowInsets.getInsets( - WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), - ) - updateState(insets) - CONSUMED - } - requestApplyInsets() - } - - override fun onLayout(p0: Boolean, p1: Int, p2: Int, p3: Int, p4: Int): Unit = Unit - - override fun shouldDelayChildPressedState(): Boolean = false - - @Suppress("DEPRECATION") - @UiThread - private fun updateState(insets: Insets) { - val sw = stateWrapper - if (sw != null) { - WritableNativeMap().apply { - putDouble("left", insets.left.toFloat().pxToDp().toDouble()) - putDouble("top", insets.top.toFloat().pxToDp().toDouble()) - putDouble("bottom", insets.bottom.toFloat().pxToDp().toDouble()) - putDouble("right", insets.right.toFloat().pxToDp().toDouble()) - - sw.updateState(this) - } - } else if (!ReactBuildConfig.UNSTABLE_ENABLE_MINIFY_LEGACY_ARCHITECTURE) { - // paper - reactContext.runOnNativeModulesQueueThread( - object : GuardedRunnable(reactContext) { - override fun runGuarded() { - this@ReactSafeAreaView.reactContext.reactApplicationContext - .getNativeModule(com.facebook.react.uimanager.UIManagerModule::class.java) - ?.updateInsetsPadding(id, insets.top, insets.left, insets.bottom, insets.right) - } - }, - ) - } - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaview/ReactSafeAreaViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaview/ReactSafeAreaViewManager.kt deleted file mode 100644 index 4e0c7154b88e..000000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaview/ReactSafeAreaViewManager.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * 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.views.safeareaview - -import com.facebook.react.module.annotations.ReactModule -import com.facebook.react.uimanager.ReactStylesDiffMap -import com.facebook.react.uimanager.StateWrapper -import com.facebook.react.uimanager.ThemedReactContext -import com.facebook.react.uimanager.ViewGroupManager -import com.facebook.react.uimanager.ViewManagerDelegate -import com.facebook.react.viewmanagers.SafeAreaViewManagerDelegate -import com.facebook.react.viewmanagers.SafeAreaViewManagerInterface - -/** View manager for [ReactSafeAreaView] components. */ -@ReactModule(name = ReactSafeAreaViewManager.REACT_CLASS) -internal class ReactSafeAreaViewManager : - ViewGroupManager(), SafeAreaViewManagerInterface { - - private val delegate: ViewManagerDelegate = SafeAreaViewManagerDelegate(this) - - override fun getDelegate(): ViewManagerDelegate = delegate - - override fun createViewInstance(context: ThemedReactContext): ReactSafeAreaView = - ReactSafeAreaView(context) - - override fun getName(): String = REACT_CLASS - - @Suppress("DEPRECATION") - override fun createShadowNodeInstance(): com.facebook.react.uimanager.LayoutShadowNode = - com.facebook.react.uimanager.LayoutShadowNode() - - @Suppress("DEPRECATION") - override fun getShadowNodeClass(): Class = - com.facebook.react.uimanager.LayoutShadowNode::class.java - - override fun updateState( - view: ReactSafeAreaView, - props: ReactStylesDiffMap, - stateWrapper: StateWrapper, - ): Any? { - view.stateWrapper = stateWrapper - return null - } - - internal companion object { - const val REACT_CLASS: String = "RCTSafeAreaView" - } -} diff --git a/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt b/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt index 4c5be342c0bd..d2963cd779f6 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt +++ b/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt @@ -121,7 +121,6 @@ add_react_common_subdir(react/renderer/components/text) add_react_common_subdir(react/renderer/components/unimplementedview) add_react_common_subdir(react/renderer/components/modal) add_react_common_subdir(react/renderer/components/scrollview) -add_react_common_subdir(react/renderer/components/safeareaview) add_react_common_subdir(react/renderer/leakchecker) add_react_common_subdir(react/renderer/observers/events) add_react_common_subdir(react/renderer/observers/intersection) @@ -258,7 +257,6 @@ add_library(reactnative $ $ $ - $ $ $ $ @@ -362,7 +360,6 @@ target_include_directories(reactnative $ $ $ - $ $ $ $ diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/CMakeLists.txt b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/CMakeLists.txt index bdef71c0d4a6..09d419ed1449 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/CMakeLists.txt +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/CMakeLists.txt @@ -54,7 +54,6 @@ target_link_libraries( rrc_modal rrc_progressbar rrc_root - rrc_safeareaview rrc_scrollview rrc_switch rrc_text diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/CoreComponentsRegistry.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/CoreComponentsRegistry.cpp index 57c1e0e8d86e..5275fab0e175 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/CoreComponentsRegistry.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/CoreComponentsRegistry.cpp @@ -14,7 +14,6 @@ #include #include #include -#include #include #include #include @@ -58,8 +57,6 @@ void addCoreComponents( concreteComponentDescriptorProvider()); providerRegistry->add( concreteComponentDescriptorProvider()); - providerRegistry->add( - concreteComponentDescriptorProvider()); providerRegistry->add( concreteComponentDescriptorProvider()); providerRegistry->add( 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-FabricComponents.podspec b/packages/react-native/ReactCommon/React-FabricComponents.podspec index 6f550a031b57..3f1b2519e251 100644 --- a/packages/react-native/ReactCommon/React-FabricComponents.podspec +++ b/packages/react-native/ReactCommon/React-FabricComponents.podspec @@ -95,13 +95,6 @@ Pod::Spec.new do |s| sss.header_dir = "react/renderer/components/modal" end - ss.subspec "safeareaview" do |sss| - sss.source_files = podspec_sources("react/renderer/components/safeareaview/**/*.{m,mm,cpp,h}", "react/renderer/components/safeareaview/**/*.h") - # Exclude tests to avoid conflicts with the react-native-safe-area-context package - sss.exclude_files = "react/renderer/components/safeareaview/tests" - sss.header_dir = "react/renderer/components/safeareaview" - end - ss.subspec "text" do |sss| sss.source_files = podspec_sources(["react/renderer/components/text/*.{m,mm,cpp,h}", "react/renderer/components/text/platform/cxx/**/*.{m,mm,cpp,h}"], diff --git a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/CMakeLists.txt b/packages/react-native/ReactCommon/react/renderer/components/safeareaview/CMakeLists.txt deleted file mode 100644 index 733778153df9..000000000000 --- a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/CMakeLists.txt +++ /dev/null @@ -1,41 +0,0 @@ -# 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. - -cmake_minimum_required(VERSION 3.13) -set(CMAKE_VERBOSE_MAKEFILE on) - -include(${REACT_COMMON_DIR}/cmake-utils/react-native-flags.cmake) - -file(GLOB rrc_safeareaview_SRCS CONFIGURE_DEPENDS *.cpp) - -add_library( - rrc_safeareaview - STATIC - ${rrc_safeareaview_SRCS} -) - -target_include_directories(rrc_safeareaview PUBLIC .) - -target_link_libraries( - rrc_safeareaview - glog - fbjni - folly_runtime - glog_init - react_codegen_rncore - react_cxxstableapi - react_debug - react_renderer_componentregistry - react_renderer_core - react_renderer_debug - react_renderer_graphics - react_renderer_uimanager - reactnativejni - rrc_view - yoga -) - -target_compile_reactnative_options(rrc_safeareaview PRIVATE) -target_compile_options(rrc_safeareaview PRIVATE -Wpedantic) diff --git a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewComponentDescriptor.h b/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewComponentDescriptor.h deleted file mode 100644 index 42ece0a34f95..000000000000 --- a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewComponentDescriptor.h +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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. - */ - -#pragma once - -#include - -#include -#include - -namespace facebook::react { - -/* - * Descriptor for component. - */ -class SafeAreaViewComponentDescriptor final : public ConcreteComponentDescriptor { - using ConcreteComponentDescriptor::ConcreteComponentDescriptor; - void adopt(ShadowNode &shadowNode) const override - { - auto &layoutableShadowNode = static_cast(shadowNode); - auto &stateData = static_cast(*shadowNode.getState()).getData(); - layoutableShadowNode.setPadding(stateData.padding); - - ConcreteComponentDescriptor::adopt(shadowNode); - } -}; - -} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewShadowNode.cpp deleted file mode 100644 index 52a347fba9ef..000000000000 --- a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewShadowNode.cpp +++ /dev/null @@ -1,15 +0,0 @@ -/* - * 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. - */ - -#include "SafeAreaViewShadowNode.h" - -namespace facebook::react { - -// NOLINTNEXTLINE(modernize-avoid-c-arrays) -const char SafeAreaViewComponentName[] = "SafeAreaView"; - -} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewShadowNode.h b/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewShadowNode.h deleted file mode 100644 index ef9c2b668ec2..000000000000 --- a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewShadowNode.h +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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. - */ - -#pragma once - -#include - -#include -#include -#include -#include - -namespace facebook::react { - -// NOLINTNEXTLINE(modernize-avoid-c-arrays) -extern const char SafeAreaViewComponentName[]; - -/* - * `ShadowNode` for component. - */ -class SafeAreaViewShadowNode final - : public ConcreteViewShadowNode { - using ConcreteViewShadowNode::ConcreteViewShadowNode; -}; - -} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewState.cpp b/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewState.cpp deleted file mode 100644 index 44b7721404b7..000000000000 --- a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewState.cpp +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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. - */ - -#include "SafeAreaViewState.h" - -namespace facebook::react { - -#ifdef ANDROID -folly::dynamic SafeAreaViewState::getDynamic() const { - return folly::dynamic::object("left", padding.left)("top", padding.top)( - "right", padding.right)("bottom", padding.bottom); -} -#endif - -} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewState.h b/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewState.h deleted file mode 100644 index 1d51d12d328a..000000000000 --- a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewState.h +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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. - */ - -#pragma once - -#include - -#include - -#ifdef ANDROID -#include -#endif - -namespace facebook::react { - -/* - * State for component. - */ -class SafeAreaViewState final { - public: -#ifdef ANDROID - SafeAreaViewState() = default; - - SafeAreaViewState(const SafeAreaViewState & /*previousState*/, folly::dynamic data) - : padding( - EdgeInsets{ - (Float)data["left"].getDouble(), - (Float)data["top"].getDouble(), - (Float)data["right"].getDouble(), - (Float)data["bottom"].getDouble(), - }) {}; - - folly::dynamic getDynamic() const; -#endif - - EdgeInsets padding{}; -}; - -} // namespace facebook::react 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/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/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index bcdc17c90d99..73cb18865932 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<739253e7b90afea5959c264e1ac3d91a>> + * @generated SignedSource<<97f61dd02c998d4076a1cb6d640b2894>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -357,12 +357,6 @@ declare const requireNativeComponent_default: ( uiViewClassName: string, ) => HostComponent declare const RootTagContext: React.Context -declare const SafeAreaView: typeof SafeAreaView_default -declare const SafeAreaView_default: ( - props: ViewProps & { - ref?: React.Ref - }, -) => React.ReactNode declare const ScrollView: typeof ScrollViewWrapper & ScrollViewComponentStatics declare const ScrollViewContext_default: React.Context declare const ScrollViewWrapper: ( @@ -4236,8 +4230,6 @@ declare type Runnable = ( declare type Runnables = { [appKey: string]: Runnable } -declare type SafeAreaView = typeof SafeAreaView -declare type SafeAreaViewInstance = HostInstance declare type ScaledSize = DisplayMetrics declare type ScheduleLocalNotificationDetails = PresentLocalNotificationDetails & { @@ -5960,8 +5952,6 @@ export { RootViewStyleProvider, // a29fccf7 Runnable, // 594dd93a Runnables, // 4367c557 - SafeAreaView, // 2a5620cd - SafeAreaViewInstance, // 21dba39c ScaledSize, // 07e417c7 ScrollEvent, // d7abdd0a ScrollResponderType, // 4fb54e25 diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 54ea8b7694ba..c18254841f49 100644 --- a/packages/react-native/__typetests__/index.tsx +++ b/packages/react-native/__typetests__/index.tsx @@ -115,7 +115,6 @@ import { UIManager, View, ViewStyle, - SafeAreaView, VirtualizedList, findNodeHandle, requireNativeComponent, @@ -1576,31 +1575,6 @@ class BridgedComponentTest extends React.Component { } } -const SafeAreaViewTest = () => { - const viewRef = React.createRef>(); - - return ( - <> - ; - ; - { - ref?.focus(); - ref?.blur(); - ref?.measure( - (x, y, width, height, pageX, pageY): number => - x + y + width + height + pageX + pageY, - ); - ref?.measureInWindow( - (x, y, width, height): number => x + y + width + height, - ); - ref?.setNativeProps({focusable: false}); - }} - /> - - ); -}; - const SwitchRefTest = () => { const switchRef = React.createRef>(); diff --git a/packages/react-native/index.js b/packages/react-native/index.js index 80fb9723e4f9..82484615e120 100644 --- a/packages/react-native/index.js +++ b/packages/react-native/index.js @@ -118,20 +118,6 @@ module.exports = { return require('./Libraries/Components/RefreshControl/RefreshControl') .default; }, - /** - * @deprecated SafeAreaView has been deprecated and will be removed in a future release. - * Please use 'react-native-safe-area-context' instead. - * See https://github.com/AppAndFlow/react-native-safe-area-context - */ - get SafeAreaView() { - warnOnce( - 'safe-area-view-deprecated', - 'SafeAreaView has been deprecated and will be removed in a future release. ' + - "Please use 'react-native-safe-area-context' instead. " + - 'See https://github.com/AppAndFlow/react-native-safe-area-context', - ); - return require('./Libraries/Components/SafeAreaView/SafeAreaView').default; - }, get ScrollView() { return require('./Libraries/Components/ScrollView/ScrollView').default; }, diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow index 26bdee2353d7..ed851fa5d1aa 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -108,9 +108,6 @@ export type { } from './Libraries/Components/RefreshControl/RefreshControl'; export {default as RefreshControl} from './Libraries/Components/RefreshControl/RefreshControl'; -export type {SafeAreaViewInstance} from './Libraries/Components/SafeAreaView/SafeAreaView'; -export {default as SafeAreaView} from './Libraries/Components/SafeAreaView/SafeAreaView'; - export type { ScrollViewImperativeMethods, ScrollViewInstance, diff --git a/packages/react-native/scripts/ios-prebuild/headers-config.js b/packages/react-native/scripts/ios-prebuild/headers-config.js index 3ee0f7e54245..8403f0784d8c 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-config.js +++ b/packages/react-native/scripts/ios-prebuild/headers-config.js @@ -395,13 +395,6 @@ const PodspecExceptions /*: {[key: string]: PodSpecConfiguration} */ = { headerDir: 'react/renderer/components/modal', }, - { - name: 'safeareaview', - headerPatterns: ['react/renderer/components/safeareaview/**/*.h'], - excludePatterns: ['react/renderer/components/safeareaview/tests'], - headerDir: 'react/renderer/components/safeareaview', - }, - { name: 'scrollview', headerPatterns: [ diff --git a/packages/react-native/src/private/components/safeareaview/SafeAreaView.js b/packages/react-native/src/private/components/safeareaview/SafeAreaView.js new file mode 100644 index 000000000000..5dff2a970af1 --- /dev/null +++ b/packages/react-native/src/private/components/safeareaview/SafeAreaView.js @@ -0,0 +1,81 @@ +/** + * 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 {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 I18nManager from '../../../../Libraries/ReactNative/I18nManager'; +import Dimensions from '../../../../Libraries/Utilities/Dimensions'; +import * as React from 'react'; +import {useCallback, useMemo, useState} from 'react'; + +/** + * 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 SafeAreaView; 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 deleted file mode 100644 index e6b5cdd5f522..000000000000 --- a/packages/react-native/src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * 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 {ViewProps} from '../../../../Libraries/Components/View/ViewPropTypes'; - -import View from '../../../../Libraries/Components/View/View'; -import UIManager from '../../../../Libraries/ReactNative/UIManager'; -import Platform from '../../../../Libraries/Utilities/Platform'; -import * as React 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, -}); - -export default exported; diff --git a/packages/react-native/Libraries/Components/SafeAreaView/__tests__/SafeAreaView-itest.js b/packages/react-native/src/private/components/safeareaview/__tests__/SafeAreaView-itest.js similarity index 89% rename from packages/react-native/Libraries/Components/SafeAreaView/__tests__/SafeAreaView-itest.js rename to packages/react-native/src/private/components/safeareaview/__tests__/SafeAreaView-itest.js index 0fcae9f2e282..78faafedd0ab 100644 --- a/packages/react-native/Libraries/Components/SafeAreaView/__tests__/SafeAreaView-itest.js +++ b/packages/react-native/src/private/components/safeareaview/__tests__/SafeAreaView-itest.js @@ -12,7 +12,8 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import * as Fantom from '@react-native/fantom'; import * as React from 'react'; -import {SafeAreaView, Text} from 'react-native'; +import {Text} from 'react-native'; +import SafeAreaView from 'react-native/src/private/components/safeareaview/SafeAreaView'; describe('', () => { it('renders with children', () => { diff --git a/packages/react-native/src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent.js b/packages/react-native/src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent.js deleted file mode 100644 index 0e5ef68ef9ed..000000000000 --- a/packages/react-native/src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 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 {ViewProps} from '../../../../../Libraries/Components/View/ViewPropTypes'; -import type {HostComponent} from '../../../types/HostComponent'; - -import codegenNativeComponent from '../../../../../Libraries/Utilities/codegenNativeComponent'; - -type RCTSafeAreaViewNativeProps = Readonly<{ - ...ViewProps, - - // No props -}>; - -export default codegenNativeComponent( - 'SafeAreaView', - { - interfaceOnly: true, - }, -) as HostComponent; 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/react-native/src/private/devsupport/devmenu/elementinspector/Inspector.js b/packages/react-native/src/private/devsupport/devmenu/elementinspector/Inspector.js index 752147347e1f..e1ac77a97be3 100644 --- a/packages/react-native/src/private/devsupport/devmenu/elementinspector/Inspector.js +++ b/packages/react-native/src/private/devsupport/devmenu/elementinspector/Inspector.js @@ -18,7 +18,7 @@ import type { import type {ViewStyleProp} from '../../../../../Libraries/StyleSheet/StyleSheet'; import type {ReactDevToolsAgent} from '../../../../../Libraries/Types/ReactDevToolsTypes'; -import SafeAreaView from '../../../components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; +import SafeAreaView from '../../../components/safeareaview/SafeAreaView'; import * as React from 'react'; const View = require('../../../../../Libraries/Components/View/View').default; 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..d5756ac7badc 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'; 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/react-native/types_DEPRECATED/Libraries/Components/SafeAreaView/SafeAreaView.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Components/SafeAreaView/SafeAreaView.d.ts deleted file mode 100644 index 590928373daf..000000000000 --- a/packages/react-native/types_DEPRECATED/Libraries/Components/SafeAreaView/SafeAreaView.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * 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. - * - * @format - */ - -import type * as React from 'react'; -import {Constructor} from '../../../private/Utilities'; -import {HostInstance} from '../../../public/ReactNativeTypes'; -import {ViewProps} from '../View/ViewPropTypes'; - -/** - * @deprecated - * Use `react-native-safe-area-context` instead. This component is deprecated and will be removed in a future release. - * - * Renders nested content and automatically applies paddings reflect the portion of the view - * that is not covered by navigation bars, tab bars, toolbars, and other ancestor views. - * Moreover, and most importantly, Safe Area's paddings reflect physical limitation of the screen, - * such as rounded corners or camera notches (aka sensor housing area on iPhone X). - */ -declare class SafeAreaViewComponent extends React.Component {} - -declare const SafeAreaViewBase: Constructor & - typeof SafeAreaViewComponent; - -/** - * @deprecated - * Use `react-native-safe-area-context` instead. This component is deprecated and will be removed in a future release. - */ -export class SafeAreaView extends SafeAreaViewBase {} diff --git a/packages/react-native/types_DEPRECATED/index.d.ts b/packages/react-native/types_DEPRECATED/index.d.ts index b5541260cb62..219f5ae9152d 100644 --- a/packages/react-native/types_DEPRECATED/index.d.ts +++ b/packages/react-native/types_DEPRECATED/index.d.ts @@ -89,7 +89,6 @@ export * from './Libraries/Components/LayoutConformance/LayoutConformance'; export * from './Libraries/Components/Pressable/Pressable'; export * from './Libraries/Components/ProgressBarAndroid/ProgressBarAndroid'; export * from './Libraries/Components/RefreshControl/RefreshControl'; -export * from './Libraries/Components/SafeAreaView/SafeAreaView'; export * from './Libraries/Components/ScrollView/ScrollView'; export * from './Libraries/Components/StatusBar/StatusBar'; export * from './Libraries/Components/Switch/Switch'; diff --git a/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestResultView.js b/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestResultView.js index b71ce8beb50e..559ddecd63c9 100644 --- a/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestResultView.js +++ b/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestResultView.js @@ -25,7 +25,6 @@ import { KeyboardAvoidingView, Modal, Platform, - SafeAreaView, StyleSheet, Switch, Text, @@ -33,6 +32,7 @@ import { TouchableOpacity, View, } from 'react-native'; +import SafeAreaView from 'react-native/src/private/components/safeareaview/SafeAreaView'; const DISPLAY_STATUS_MAPPING: {[PlatformTestResultStatus]: string} = { PASS: 'Pass', 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 ? ( +