diff --git a/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js b/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js index e00db0a1fa35..e79cbbb457ab 100644 --- a/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js +++ b/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js @@ -8,7 +8,7 @@ * @format */ -import SafeAreaView from '../../Components/SafeAreaView/SafeAreaView'; +import SafeAreaView from '../../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; import StyleSheet, { type ColorValue, type ViewStyleProp, diff --git a/packages/react-native/Libraries/Components/View/View.js b/packages/react-native/Libraries/Components/View/View.js index 461f7707c7fa..dcf8dacd0d88 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,15 @@ import ViewNativeComponent from './ViewNativeComponent'; import * as React from 'react'; import {use} from 'react'; +// Only development builds check for a view reporting its insets in a loop; the +// production branch keeps the handler as it is, and the module out of the bundle. +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 +125,18 @@ component View(ref?: React.RefSetter, ...props: ViewProps) { }; } + if (__DEV__) { + // Views are the only place the prop is used in practice, so the check for a + // view reporting its insets in a loop lives here rather than on every host + // component that inherits the prop. + 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 5afdaf3f415d..095332267a06 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,32 @@ 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}, frame: {x, y, width, height}}}` + * + * `insets` are relative to this view: an inset is only non-zero for the part + * of the view that actually overlaps the system UI. `frame` is the position + * of the view at the time of the event, relative to its enclosing view + * controller on iOS and to the window on Android; it does not trigger the + * event on its own, so it can be stale while the view moves without its + * insets changing. + * + * 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..e361d265330e --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js @@ -0,0 +1,111 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native/src/private/types/HostInstance'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {createRef} from 'react'; +import {View} from 'react-native'; + +const INSETS = {top: 44, right: 0, bottom: 34, left: 0}; +const FRAME = {x: 0, y: 0, width: 390, height: 844}; + +describe('experimental_onSafeAreaInsetsChange', () => { + it('delivers the insets and the frame 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, + frame: FRAME, + }); + + expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1); + const [event] = onSafeAreaInsetsChange.mock.lastCall; + expect(event.insets).toEqual(INSETS); + expect(event.frame).toEqual(FRAME); + }); + + it('is not delivered to views that did not opt in', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + + Fantom.runTask(() => { + 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(); + + Fantom.runTask(() => { + root.render( + // A layout-only view would ordinarily be flattened away; observing the + // safe area requires a host view to observe with. + {}}> + + , + ); + }); + + expect( + root + .getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']}) + .toJSX(), + ).toEqual( + + + , + ); + }); + + it('is reflected in the props of the view when set', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + {}} + />, + ); + }); + + expect( + root + .getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']}) + .toJSX(), + ).toEqual(); + }); +}); diff --git a/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsetsWarning-itest.js b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsetsWarning-itest.js new file mode 100644 index 000000000000..0735e7473717 --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsetsWarning-itest.js @@ -0,0 +1,132 @@ +/** + * 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}; +const FRAME = {x: 0, y: 0, width: 390, height: 844}; + +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, + frame: FRAME, + }); +} + +describe('experimental_onSafeAreaInsetsChange warning', () => { + const originalConsoleWarn = console.warn; + let mockConsoleWarn: JestMockFn, void>; + let mockClock: ?HighResTimeStampMock; + + beforeEach(() => { + mockConsoleWarn = jest.fn(); + // $FlowFixMe[cannot-write] + console.warn = mockConsoleWarn; + mockClock = Fantom.installHighResTimeStampMock(); + }); + + afterEach(() => { + // $FlowFixMe[cannot-write] + console.warn = originalConsoleWarn; + mockClock?.uninstall(); + mockClock = null; + }); + + it('stays silent while the insets change at a plausible rate', () => { + const nodeRef = renderObservingView(); + + // A rotation, a keyboard, a split view: a handful of changes, spread out. + for (let i = 0; i < 20; i++) { + dispatchInsetsChange(nodeRef); + mockClock?.advanceTimeBy(200); + } + + expect(mockConsoleWarn).not.toHaveBeenCalled(); + }); + + it('warns once when a single view loops within the window', () => { + const nodeRef = renderObservingView(); + + for (let i = 0; i < 11; i++) { + dispatchInsetsChange(nodeRef); + mockClock?.advanceTimeBy(16); + } + + expect(mockConsoleWarn).toHaveBeenCalledTimes(1); + expect(mockConsoleWarn.mock.lastCall[0]).toContain( + '`experimental_onSafeAreaInsetsChange` fired more than 10 times in 1000ms', + ); + + // The loop keeps running; the warning does not. + for (let i = 0; i < 50; i++) { + dispatchInsetsChange(nodeRef); + mockClock?.advanceTimeBy(16); + } + + expect(mockConsoleWarn).toHaveBeenCalledTimes(1); + }); + + it('counts each view separately', () => { + const nodeRefA = renderObservingView(); + const nodeRefB = renderObservingView(); + + for (let i = 0; i < 10; i++) { + dispatchInsetsChange(nodeRefA); + dispatchInsetsChange(nodeRefB); + mockClock?.advanceTimeBy(16); + } + + expect(mockConsoleWarn).not.toHaveBeenCalled(); + + dispatchInsetsChange(nodeRefA); + + expect(mockConsoleWarn).toHaveBeenCalledTimes(1); + }); + + it('still delivers the event to the handler', () => { + const nodeRef = createRef(); + const onSafeAreaInsetsChange = jest.fn(); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + { + onSafeAreaInsetsChange(event.nativeEvent); + }} + />, + ); + }); + + dispatchInsetsChange(nodeRef); + + expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1); + expect(onSafeAreaInsetsChange.mock.lastCall[0].insets).toEqual(INSETS); + }); +}); diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js index 3db512ed3d43..3a3d5da97792 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js @@ -8,7 +8,7 @@ * @format */ -import SafeAreaView from '../../Components/SafeAreaView/SafeAreaView'; +import SafeAreaView from '../../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; import View from '../../Components/View/View'; import StyleSheet from '../../StyleSheet/StyleSheet'; import Text from '../../Text/Text'; diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorHeader.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorHeader.js index a5b5329d440e..8657f427f681 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorHeader.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorHeader.js @@ -11,7 +11,7 @@ import type {ViewProps} from '../../Components/View/ViewPropTypes'; import type {LogLevel} from '../Data/LogBoxLog'; -import SafeAreaView from '../../Components/SafeAreaView/SafeAreaView'; +import SafeAreaView from '../../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; import View from '../../Components/View/View'; import StyleSheet from '../../StyleSheet/StyleSheet'; import Text from '../../Text/Text'; diff --git a/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxNotificationContainer-test.js.snap b/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxNotificationContainer-test.js.snap index 82a662152af9..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..15aff5e610c3 100644 --- a/packages/react-native/Libraries/Types/CoreEventTypes.js +++ b/packages/react-native/Libraries/Types/CoreEventTypes.js @@ -76,6 +76,29 @@ 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, + /** + * The frame of the view at the time of the event. Relative to the + * enclosing view controller on iOS and to the window on Android; only + * updated when the insets change. + */ + frame: LayoutRectangle, + }>, +>; + /** * @deprecated Use `TextLayoutEvent` instead. */ diff --git a/packages/react-native/Libraries/Utilities/Dimensions.js b/packages/react-native/Libraries/Utilities/Dimensions.js index 85a6fc5dc494..f22856ef23c2 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/React/CoreModules/RCTDeviceInfo.mm b/packages/react-native/React/CoreModules/RCTDeviceInfo.mm index 1761214fc3bd..0f8877646bdf 100644 --- a/packages/react-native/React/CoreModules/RCTDeviceInfo.mm +++ b/packages/react-native/React/CoreModules/RCTDeviceInfo.mm @@ -201,12 +201,19 @@ static BOOL RCTIsIPhoneNotched() // We fallback to screen size if a key window is not found. CGSize windowSize = mainWindow != nil ? mainWindow.bounds.size : screenSize; + UIEdgeInsets safeAreaInsets = mainWindow != nil ? mainWindow.safeAreaInsets : UIEdgeInsetsZero; - NSDictionary *dimsWindow = @{ + NSDictionary *dimsWindow = @{ @"width" : @(windowSize.width), @"height" : @(windowSize.height), @"scale" : @(screen.scale), - @"fontScale" : @(fontScale) + @"fontScale" : @(fontScale), + @"experimental_safeAreaInsets" : @{ + @"top" : @(safeAreaInsets.top), + @"right" : @(safeAreaInsets.right), + @"bottom" : @(safeAreaInsets.bottom), + @"left" : @(safeAreaInsets.left) + } }; NSDictionary *dimsScreen = @{ diff --git a/packages/react-native/React/Fabric/AppleEventBeat.cpp b/packages/react-native/React/Fabric/AppleEventBeat.cpp deleted file mode 100644 index 4a3d533a0cd9..000000000000 --- a/packages/react-native/React/Fabric/AppleEventBeat.cpp +++ /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. - */ - -#include "AppleEventBeat.h" - -#include - -namespace facebook::react { - -AppleEventBeat::AppleEventBeat( - std::shared_ptr ownerBox, - std::unique_ptr uiRunLoopObserver, - RuntimeScheduler& runtimeScheduler) - : EventBeat(std::move(ownerBox), runtimeScheduler), - uiRunLoopObserver_(std::move(uiRunLoopObserver)) { - uiRunLoopObserver_->setDelegate(this); - uiRunLoopObserver_->enable(); -} - -void AppleEventBeat::activityDidChange( - const RunLoopObserver::Delegate* delegate, - RunLoopObserver::Activity /*activity*/) const noexcept { - react_native_assert(delegate == this); - induce(); -} - -} // namespace facebook::react diff --git a/packages/react-native/React/Fabric/AppleEventBeat.h b/packages/react-native/React/Fabric/AppleEventBeat.h index 256e0f0983ad..528145e23797 100644 --- a/packages/react-native/React/Fabric/AppleEventBeat.h +++ b/packages/react-native/React/Fabric/AppleEventBeat.h @@ -7,6 +7,8 @@ #pragma once +#include + #include #include #include @@ -19,6 +21,11 @@ class RuntimeScheduler; * Event beat associated with JavaScript runtime. * The beat is called on `RuntimeExecutor`'s thread induced by the UI thread * event loop. + * + * A synchronous request made while Core Animation is laying out the current + * frame (the run loop observer that induces the beat has already run at that + * point) is additionally induced from the display phase of the same commit + * cycle, so that its effects are mounted before the frame is presented. */ class AppleEventBeat : public EventBeat, public RunLoopObserver::Delegate { public: @@ -27,13 +34,20 @@ class AppleEventBeat : public EventBeat, public RunLoopObserver::Delegate { std::unique_ptr uiRunLoopObserver, RuntimeScheduler &RuntimeScheduler); + ~AppleEventBeat() override; + + void requestSynchronous() const override; + #pragma mark - RunLoopObserver::Delegate void activityDidChange(const RunLoopObserver::Delegate *delegate, RunLoopObserver::Activity activity) const noexcept override; private: + class DisplayPhaseFlusher; + std::unique_ptr uiRunLoopObserver_; + std::unique_ptr displayPhaseFlusher_; }; } // namespace facebook::react diff --git a/packages/react-native/React/Fabric/AppleEventBeat.mm b/packages/react-native/React/Fabric/AppleEventBeat.mm new file mode 100644 index 000000000000..b91adb43c2d6 --- /dev/null +++ b/packages/react-native/React/Fabric/AppleEventBeat.mm @@ -0,0 +1,182 @@ +/* + * 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 "AppleEventBeat.h" + +#import +#import + +#include + +/* + * A zero-sized layer whose only purpose is to run a callback during the + * display phase of a Core Animation commit. Core Animation processes a commit + * as layout → display → (repeat until stable) → commit, so a layer marked as + * needing display during the layout phase has its `display` called after the + * whole layout pass but before the transaction is committed. + */ +@interface RCTEventBeatFlusherLayer : CALayer +@property (nonatomic, copy, nullable) void (^onDisplay)(void); +@end + +@implementation RCTEventBeatFlusherLayer + +- (void)display +{ + if (self.onDisplay != nil) { + self.onDisplay(); + } +} + +// The layer is not a visual element; never participate in animations. +- (id)actionForKey:(NSString *)event +{ + return nil; +} + +@end + +/* + * The windows that can commit a Core Animation transaction: the visible ones + * of every foreground scene. + */ +static NSArray *RCTFlushableWindows(void) +{ + NSMutableArray *windows = [NSMutableArray new]; + for (UIScene *scene in RCTSharedApplication().connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) { + continue; + } + if (scene.activationState != UISceneActivationStateForegroundActive && + scene.activationState != UISceneActivationStateForegroundInactive) { + continue; + } + for (UIWindow *window in ((UIWindowScene *)scene).windows) { + if (!window.hidden) { + [windows addObject:window]; + } + } + } + if (windows.count == 0) { + // Apps on the legacy UIApplicationDelegate lifecycle own their window + // outside of any scene, so the enumeration above finds nothing. + UIWindow *keyWindow = RCTKeyWindow(); + if (keyWindow != nil) { + [windows addObject:keyWindow]; + } + } + return windows; +} + +namespace facebook::react { + +/* + * Owns the flusher layers and keeps one attached to every window's layer so + * that whichever layer tree is being committed contains one of them. + */ +class AppleEventBeat::DisplayPhaseFlusher { + public: + DisplayPhaseFlusher(std::function callback, std::weak_ptr weakOwner) + { + // Weak keys: a window that goes away takes its own layer with it. + layers_ = [NSMapTable weakToStrongObjectsMapTable]; + auto sharedCallback = std::make_shared>(std::move(callback)); + onDisplay_ = ^{ + // The owner (indirectly) retains the event beat; if it is gone, so is + // the beat the callback points into. + auto owner = weakOwner.lock(); + if (!owner) { + return; + } + (*sharedCallback)(); + }; + } + + ~DisplayPhaseFlusher() + { + // The beat can be destroyed on any thread; layer mutations belong on the + // main thread. The block only retains the layers, and a display happening + // before this executes is made safe by the owner check above. + NSMapTable *layers = layers_; + RCTExecuteOnMainQueue(^{ + for (RCTEventBeatFlusherLayer *layer in layers.objectEnumerator) { + layer.onDisplay = nil; + [layer removeFromSuperlayer]; + } + [layers removeAllObjects]; + }); + } + + /* + * Schedules the callback to run in the display phase of the current (or + * next) Core Animation commit cycle. Main thread only. + * + * Every window gets a layer rather than only the key window: the request can + * come from any of them — a modal and the LogBox are windows of their own — + * and only a layer in a tree that is committed is displayed in this cycle. + * The induce the display triggers is coalescing, so the extra layers cost a + * dirty zero-sized layer each, not extra beats. + */ + void schedule() const + { + for (UIWindow *window in RCTFlushableWindows()) { + RCTEventBeatFlusherLayer *layer = [layers_ objectForKey:window]; + if (layer == nil) { + layer = [RCTEventBeatFlusherLayer new]; + layer.frame = CGRectZero; + layer.onDisplay = onDisplay_; + [layers_ setObject:layer forKey:window]; + } + if (layer.superlayer != window.layer) { + [window.layer addSublayer:layer]; + } + [layer setNeedsDisplay]; + } + } + + private: + NSMapTable *layers_; + void (^onDisplay_)(void); +}; + +AppleEventBeat::AppleEventBeat(std::shared_ptr ownerBox, + std::unique_ptr uiRunLoopObserver, + RuntimeScheduler &runtimeScheduler) + : EventBeat(std::move(ownerBox), runtimeScheduler), + uiRunLoopObserver_(std::move(uiRunLoopObserver)), + displayPhaseFlusher_(std::make_unique([this]() { induce(); }, ownerBox_->owner)) +{ + uiRunLoopObserver_->setDelegate(this); + uiRunLoopObserver_->enable(); +} + +AppleEventBeat::~AppleEventBeat() = default; + +void AppleEventBeat::requestSynchronous() const +{ + EventBeat::requestSynchronous(); + + // The run loop observer that ordinarily induces the beat runs before Core + // Animation commits the frame. A synchronous request made while Core + // Animation is already laying out (e.g. an event emitted from + // `layoutSubviews`) would therefore only be processed on the next frame. + // Scheduling an induce in the display phase of the current commit cycle + // processes it before this frame is presented. Multiple requests within one + // cycle coalesce into a single induce. + if (RCTIsMainQueue()) { + displayPhaseFlusher_->schedule(); + } +} + +void AppleEventBeat::activityDidChange(const RunLoopObserver::Delegate *delegate, + RunLoopObserver::Activity /*activity*/) const noexcept +{ + react_native_assert(delegate == this); + induce(); +} + +} // namespace facebook::react 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 0912c50fec13..427e927ee635 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 @@ -122,6 +123,11 @@ @implementation RCTViewComponentView { NSMutableSet *_accessibilityOrderNativeIDs; RCTSwiftUIContainerViewWrapper *_swiftUIWrapper; BOOL _focusable; + // The insets sent with the last `onSafeAreaInsetsChange` event, or nil if + // none was sent yet. A pointer because almost no view observes the safe + // area: the views that do pay for a small box, every other view only for + // the pointer. + NSValue *_lastSentSafeAreaInsets; } #ifdef RCT_DYNAMIC_FRAMEWORKS @@ -438,6 +444,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & -newViewProps.hitSlop.right}; } + // `onSafeAreaInsetsChange`. Scheduled whenever the prop is set, not only on + // its transitions: recycled views keep their last props, so `oldViewProps` + // of a freshly reused view is not a reliable baseline. + if (newViewProps.onSafeAreaInsetsChange) { + [self setNeedsLayout]; + } else if (oldViewProps.onSafeAreaInsetsChange) { + _lastSentSafeAreaInsets = nil; + } + // `overflow` if (oldViewProps.getClipsContentToBounds() != newViewProps.getClipsContentToBounds()) { self.currentContainerView.clipsToBounds = newViewProps.getClipsContentToBounds(); @@ -720,6 +735,105 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics } } +#pragma mark - Safe area insets + +// The view controller the view is hosted in, which is the coordinate space +// `frame` is reported in. Modals and other view controllers are positioned +// independently of the window, so the window is not a usable reference. +static UIViewController *RCTParentViewControllerOfView(UIView *view) +{ + UIResponder *responder = view.nextResponder; + while (responder != nil) { + if ([responder isKindOfClass:[UIViewController class]]) { + return (UIViewController *)responder; + } + responder = responder.nextResponder; + } + return nil; +} + +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; + } + + // The view has not been mounted or laid out yet, so the insets we would + // compute are not the ones the view ends up with. + if (self.window == nil || CGSizeEqualToSize(self.bounds.size, CGSizeZero)) { + return; + } + + // Only a change of the insets triggers an event. The frame is part of the + // payload but not of the trigger: a view that moves without its overlap with + // the system UI changing stays silent, which is what makes observing views + // safe to place inside scroll views. + UIEdgeInsets insets = self.safeAreaInsets; + if (_lastSentSafeAreaInsets != nil && + RCTEdgeInsetsEqualWithThreshold(insets, _lastSentSafeAreaInsets.UIEdgeInsetsValue, 1.0 / RCTScreenScale())) { + return; + } + + UIView *referenceView = RCTParentViewControllerOfView(self).view ?: self.window; + CGRect frame = [self convertRect:self.bounds toView:referenceView]; + + _lastSentSafeAreaInsets = [NSValue valueWithUIEdgeInsets:insets]; + + static_cast(*_eventEmitter) + .onSafeAreaInsetsChange( + EdgeInsets{ + .left = (Float)insets.left, + .top = (Float)insets.top, + .right = (Float)insets.right, + .bottom = (Float)insets.bottom}, + RCTRectFromCGRect(frame)); +} + +// The prop is checked here rather than inside the helper so that views which +// do not use it only pay for a branch on a prop they already have in hand. +- (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]; + // Both the insets and the frame depend on where the view sits in the window, + // so moving or resizing it changes them without UIKit notifying us. + if ([self _observesSafeAreaInsets]) { + [self _safeAreaInsetsMayHaveChanged]; + } +} + - (BOOL)isJSResponder { return _isJSResponder; @@ -775,6 +889,7 @@ - (void)prepareForRecycle _filterLayer = nil; [self clearExistingBackgroundImageLayers]; + _lastSentSafeAreaInsets = nil; _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 a78d8fbd869e..7834ef7392a0 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -3264,6 +3264,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 @@ -4604,6 +4605,7 @@ public final class com/facebook/react/uimanager/ViewProps { public static final field NONE Ljava/lang/String; public static final field NUMBER_OF_LINES Ljava/lang/String; public static final field ON Ljava/lang/String; + public static final field ON_SAFE_AREA_INSETS_CHANGE Ljava/lang/String; public static final field OPACITY Ljava/lang/String; public static final field OUTLINE_COLOR Ljava/lang/String; public static final field OUTLINE_OFFSET Ljava/lang/String; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/deviceinfo/DeviceInfoModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/deviceinfo/DeviceInfoModule.kt index 2d20ced6fb33..b0fe8ebb44f2 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,9 @@ internal class DeviceInfoModule(reactContext: ReactApplicationContext) : WritableNativeMap().apply { putMap( "windowPhysicalPixels", - getPhysicalPixelsWritableMap(getWindowDisplayMetrics()), + getPhysicalPixelsWritableMap(getWindowDisplayMetrics()).apply { + getWindowSafeAreaInsetsWritableMap()?.let { putMap("experimental_safeAreaInsets", it) } + }, ) putMap( "screenPhysicalPixels", @@ -91,6 +94,21 @@ internal class DeviceInfoModule(reactContext: ReactApplicationContext) : ) } + /** + * The part of the window that is covered by the system UI, in physical pixels. Uses the same + * computation as the `onSafeAreaInsetsChange` view prop, applied to the window's decor view. + */ + private fun getWindowSafeAreaInsetsWritableMap(): WritableMap? { + val decorView = reactApplicationContext.currentActivity?.window?.decorView ?: return null + val insets = SafeAreaInsetsObserver.getSafeAreaInsets(decorView) ?: return null + return WritableNativeMap().apply { + putInt("top", insets.top) + putInt("right", insets.right) + putInt("bottom", insets.bottom) + putInt("left", insets.left) + } + } + private fun getPhysicalPixelsWritableMap( displayMetrics: DisplayMetrics, ): WritableMap = diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index d2747ebda577..e3c937145c35 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,7 @@ 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.internal.SafeAreaInsetsObserver; import com.facebook.react.uimanager.style.OutlineStyle; import com.facebook.react.uimanager.util.ReactFindViewUtil; import java.util.ArrayList; @@ -74,6 +75,10 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) { @Override protected @Nullable T prepareToRecycleView(@NonNull ThemedReactContext reactContext, T view) { + // Stops safe area observation and clears its tag; the next user of the + // view re-enables it through the prop if needed. + SafeAreaInsetsObserver.setEnabled(view, false); + // Reset tags view.setTag(null); view.setTag(R.id.pointer_events, null); @@ -293,6 +298,15 @@ public void setRenderToHardwareTexture(@NonNull T view, boolean useHWTexture) { view.setTag(R.id.use_hardware_layer, useHWTexture); } + /** + * Views only observe safe area insets while a JavaScript handler is attached, so views that do + * not use the prop are not affected. + */ + @ReactProp(name = ViewProps.ON_SAFE_AREA_INSETS_CHANGE, defaultBoolean = false) + public void setOnSafeAreaInsetsChange(@NonNull T view, boolean onSafeAreaInsetsChange) { + SafeAreaInsetsObserver.setEnabled(view, onSafeAreaInsetsChange); + } + @ReactProp(name = ViewProps.TEST_ID) public void setTestId(@NonNull T view, @Nullable String testId) { view.setTag(R.id.react_test_id, testId); 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 8aeb06848370..cca374268438 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..fdfa4329633f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.PixelUtil.pxToDp + +/** + * Emitted when the part of a view that is covered by the system UI, or the position of that view in + * the window, changes. + * + * Dispatched synchronously so that the layout depending on the insets is mounted in the frame the + * insets changed in, rather than the one after it. + */ +internal class SafeAreaInsetsChangeEvent( + surfaceId: Int, + viewTag: Int, + private val insetTop: Int, + private val insetRight: Int, + private val insetBottom: Int, + private val insetLeft: Int, + private val frameX: Int, + private val frameY: Int, + private val frameWidth: Int, + private val frameHeight: 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()) + }, + ) + putMap( + "frame", + Arguments.createMap().apply { + putDouble("x", frameX.toDp()) + putDouble("y", frameY.toDp()) + putDouble("width", frameWidth.toDp()) + putDouble("height", frameHeight.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..5512d2e7c5dc --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt @@ -0,0 +1,212 @@ +/* + * 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.ViewGroup +import android.view.ViewTreeObserver +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.facebook.react.R +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.SafeAreaInsetsChangeEvent +import kotlin.math.max +import kotlin.math.min + +/** + * Observes the part of a view that is covered by the system UI, and emits + * [SafeAreaInsetsChangeEvent] whenever it, or the position of the view in the window, changes. + * + * One observer is attached per view that sets the `onSafeAreaInsetsChange` prop. Views without the + * prop never get an observer, and so pay nothing for this. + */ +internal class SafeAreaInsetsObserver private constructor(private val view: View) : + ViewTreeObserver.OnPreDrawListener, View.OnAttachStateChangeListener { + + // Scratch state, reused so that observing a view allocates nothing per frame. + private val visibleRect = Rect() + private val frameRect = 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 and the frame both depend on where the view ends up in the window, which is only + // known once it has been laid out. A pre-draw listener is the cheapest hook that catches every + // change: window insets, layout, and scrolling ancestors alike. + startListening() + maybeEmit() + } + + override fun onViewDetachedFromWindow(v: View) { + stopListening() + } + + override fun onPreDraw(): Boolean { + maybeEmit() + return true + } + + private fun maybeEmit() { + // Only a change of the insets triggers an event. The frame is part of the + // payload but not of the trigger: a view that moves (scrolling, layout) + // without its overlap with the system UI changing stays silent. This is + // what makes observing views safe to place inside scroll views — and it + // prevents feedback loops, since the synchronous render caused by an event + // produces a new frame, which runs this pre-draw listener again. + if (!computeSafeAreaInsets(view, visibleRect, insets)) { + return + } + if (hasLastInsets && insets.contentEquals(lastInsets)) { + return + } + val frame = getFrame(view, frameRect) ?: 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], + frameX = frame.left, + frameY = frame.top, + frameWidth = frame.width(), + frameHeight = frame.height(), + ), + ) + } + + companion object { + private const val TOP = 0 + private const val RIGHT = 1 + private const val BOTTOM = 2 + private const val LEFT = 3 + + /** + * Starts or stops observing safe area insets for [view]. Safe to call repeatedly with the same + * value. + */ + @JvmStatic + fun setEnabled(view: View, enabled: Boolean) { + val existing = view.getTag(R.id.safe_area_insets_observer) as? SafeAreaInsetsObserver + if (enabled == (existing != null)) { + return + } + if (enabled) { + val observer = SafeAreaInsetsObserver(view) + view.setTag(R.id.safe_area_insets_observer, observer) + observer.start() + } else { + view.setTag(R.id.safe_area_insets_observer, null) + existing?.stop() + } + } + + /** + * The insets of the window that overlap [view], in the view's own coordinate space. A view that + * does not reach under the system UI has no insets. + * + * Also used with the window's decor view to report window-level safe area insets through the + * `Dimensions` module. + */ + @JvmStatic + fun getSafeAreaInsets(view: View): Insets? { + val insets = IntArray(4) + if (!computeSafeAreaInsets(view, Rect(), insets)) { + return null + } + return Insets.of(insets[LEFT], insets[TOP], insets[RIGHT], insets[BOTTOM]) + } + + /** + * Writes the insets of [view] into [out], ordered [TOP], [RIGHT], [BOTTOM], [LEFT], using + * [visibleRect] as scratch space. Returns false when they cannot be computed, leaving [out] + * untouched. + */ + private fun computeSafeAreaInsets(view: View, visibleRect: Rect, out: IntArray): Boolean { + // The view has not been laid out yet. + 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 + } + + /** The frame of [view] in the coordinate space of the window, written into [out]. */ + private fun getFrame(view: View, out: Rect): Rect? { + val rootView = view.rootView as? ViewGroup ?: return null + if (view.parent == null) { + return null + } + view.getDrawingRect(out) + try { + rootView.offsetDescendantRectToMyCoords(view, out) + } catch (e: IllegalArgumentException) { + // Thrown when the view is not a descendant of its own root view, which can happen while it + // is being unmounted. + return null + } + return out + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index 0e51a358eb77..a4820e5d8da1 100644 --- a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -82,4 +82,7 @@ + + + diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.cpp index 4e981efd3f80..5f3b261ae664 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,42 @@ void BaseViewEventEmitter::onAccessibilityEscape() const { dispatchEvent("accessibilityEscape"); } +#pragma mark - Safe area + +void BaseViewEventEmitter::onSafeAreaInsetsChange( + const EdgeInsets& insets, + const Rect& frame) const { + // Dispatched synchronously and as a discrete event so that React processes it + // before the current frame is presented. Both the thread this is called from + // (the UI thread) and the JavaScript thread are blocked until React has + // finished rendering. + experimental_flushSync([this, insets, frame]() { + dispatchEvent( + "safeAreaInsetsChange", + [insets, frame](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); + } + { + auto framePayload = jsi::Object(runtime); + framePayload.setProperty(runtime, "x", frame.origin.x); + framePayload.setProperty(runtime, "y", frame.origin.y); + framePayload.setProperty(runtime, "width", frame.size.width); + framePayload.setProperty(runtime, "height", frame.size.height); + payload.setProperty(runtime, "frame", framePayload); + } + 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..01bef6388b7c 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,19 @@ 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, ...) and the + * frame of the view at the time of the event. + * + * The event is dispatched synchronously, blocking the thread it is called + * from until React has re-rendered, so that the layout that depends on the + * insets is mounted in the same frame the insets changed in. + */ + void onSafeAreaInsetsChange(const EdgeInsets &insets, const Rect &frame) 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..9567c79c8178 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp @@ -55,6 +55,8 @@ void ViewShadowNode::initialize() noexcept { viewProps.accessibilityViewIsModal || viewProps.importantForAccessibility != ImportantForAccessibility::Auto || viewProps.removeClippedSubviews || viewProps.cursor != Cursor::Auto || + // Observing the safe area requires a host view to observe with. + viewProps.onSafeAreaInsetsChange || !viewProps.filter.empty() || viewProps.mixBlendMode != BlendMode::Normal || viewProps.isolation == Isolation::Isolate || 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 32289a2c43f1..0c7d05b7d33a 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 @@ -563,6 +563,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/ReactCommon/react/renderer/core/EventBeat.cpp b/packages/react-native/ReactCommon/react/renderer/core/EventBeat.cpp index cdb05f4719fd..839e2f19f90b 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventBeat.cpp +++ b/packages/react-native/ReactCommon/react/renderer/core/EventBeat.cpp @@ -53,7 +53,14 @@ void EventBeat::induce() const { isEventBeatRequested_ = false; if (isBeatCallbackScheduled_) { - return; + // An asynchronous beat is already scheduled but has not run yet. A + // synchronous request must not be stranded behind it (it would silently + // lose its this-frame guarantee, and the leftover flag would make an + // unrelated later beat blocking), so it proceeds and processes the queue + // now; the already scheduled beat will simply find an empty queue. + if (!isSynchronousRequested_) { + return; + } } isBeatCallbackScheduled_ = true; diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventBeat.h b/packages/react-native/ReactCommon/react/renderer/core/EventBeat.h index 3740b457e785..b8da33cc9ac2 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventBeat.h +++ b/packages/react-native/ReactCommon/react/renderer/core/EventBeat.h @@ -111,6 +111,16 @@ class EventBeat { */ virtual void requestSynchronous() const; + /* + * Induces the next beat to happen as soon as possible. + * Receiver might ignore the call if a beat was not requested. + * + * Ordinarily called by the platform once per frame; also callable by a + * consumer right after `requestSynchronous` to process the queue immediately + * at the call site instead of at the next frame boundary. + */ + void induce() const; + /* * The callback will be executed once a consumer (for example EventQueue) * calls either `EventBeat::request` or `EventBeat::requestSynchronous`. The @@ -128,12 +138,6 @@ class EventBeat { void unstable_setInduceCallback(std::function callback); protected: - /* - * Induces the next beat to happen as soon as possible. - * Receiver might ignore the call if a beat was not requested. - */ - void induce() const; - BeatCallback beatCallback_; std::function induceCallback_; std::shared_ptr ownerBox_; diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/EventBeatTest.cpp b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/EventBeatTest.cpp new file mode 100644 index 000000000000..1dda2fd47fd6 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/EventBeatTest.cpp @@ -0,0 +1,168 @@ +/* + * 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 +#include +#include +#include +#include +#include +#include +#include + +#include "StubQueue.h" + +namespace facebook::react { + +class EventBeatTestFeatureFlags : public ReactNativeFeatureFlagsDefaults { + public: + bool enableBridgelessArchitecture() override { + return true; + } +}; + +class EventBeatTest : public testing::Test { + protected: + void SetUp() override { + ReactNativeFeatureFlags::dangerouslyReset(); + ReactNativeFeatureFlags::override( + std::make_unique()); + + runtime_ = facebook::hermes::makeHermesRuntime( + ::hermes::vm::RuntimeConfig::Builder().build()); + stubQueue_ = std::make_unique(); + + RuntimeExecutor runtimeExecutor = + [this]( + std::function&& callback) { + stubQueue_->runOnQueue([this, callback = std::move(callback)]() { + callback(*runtime_); + }); + }; + + runtimeScheduler_ = std::make_unique(runtimeExecutor); + + ownerBox_ = std::make_shared(); + owner_ = std::make_shared(0); + ownerBox_->owner = owner_; + eventBeat_ = std::make_unique(ownerBox_, *runtimeScheduler_); + } + + void TearDown() override { + ReactNativeFeatureFlags::dangerouslyReset(); + } + + std::unique_ptr runtime_; + std::unique_ptr stubQueue_; + std::unique_ptr runtimeScheduler_; + std::shared_ptr ownerBox_; + std::shared_ptr owner_; + std::unique_ptr eventBeat_; +}; + +TEST_F(EventBeatTest, induceWithoutRequestIsNoop) { + int beatCount = 0; + eventBeat_->setBeatCallback([&beatCount](jsi::Runtime& /*runtime*/) { + beatCount++; + }); + + eventBeat_->induce(); + + EXPECT_EQ(beatCount, 0); + EXPECT_EQ(stubQueue_->size(), 0); +} + +TEST_F(EventBeatTest, synchronousRequestIsProcessedAtInduce) { + int beatCount = 0; + eventBeat_->setBeatCallback([&beatCount](jsi::Runtime& /*runtime*/) { + beatCount++; + }); + + eventBeat_->requestSynchronous(); + EXPECT_EQ(beatCount, 0); + + // Platform implementations induce the beat at a point where the effects of + // synchronous events can still make the current frame (the display phase on + // Apple, before the draw on Android). The beat callback runs synchronously + // before `induce` returns, with both threads blocked. + std::thread driver([this]() { + stubQueue_->waitForTask(); + stubQueue_->tick(); + }); + eventBeat_->induce(); + driver.join(); + + EXPECT_EQ(beatCount, 1); + + // The request was consumed: another induce does nothing. + eventBeat_->induce(); + EXPECT_EQ(beatCount, 1); + EXPECT_EQ(stubQueue_->size(), 0); +} + +TEST_F(EventBeatTest, requestMadeDuringBeatIsProcessedByASubsequentInduce) { + int beatCount = 0; + eventBeat_->setBeatCallback([&](jsi::Runtime& /*runtime*/) { + beatCount++; + if (beatCount == 1) { + // A synchronous request made from within the beat (e.g. an event whose + // handler causes another synchronous event). Platform implementations + // defer the induce for it (the display phase flusher on Apple, the next + // pre-draw on Android) rather than inducing from within the beat. + eventBeat_->requestSynchronous(); + } + }); + + eventBeat_->requestSynchronous(); + std::thread driver([this]() { + stubQueue_->waitForTask(); + stubQueue_->tick(); + }); + eventBeat_->induce(); + driver.join(); + + EXPECT_EQ(beatCount, 1); + + // The request made during the beat is not lost: the next induce processes + // it. + std::thread driver2([this]() { + stubQueue_->waitForTask(); + stubQueue_->tick(); + }); + eventBeat_->induce(); + driver2.join(); + + EXPECT_EQ(beatCount, 2); +} + +TEST_F(EventBeatTest, synchronousRequestIsNotStrandedBehindScheduledBeat) { + int beatCount = 0; + eventBeat_->setBeatCallback([&beatCount](jsi::Runtime& /*runtime*/) { + beatCount++; + }); + + // An asynchronous beat is scheduled but has not run yet. + eventBeat_->request(); + eventBeat_->induce(); + EXPECT_EQ(beatCount, 0); + + // A synchronous request arriving now must still be processed by its induce + // instead of being silently deferred behind the scheduled beat. + eventBeat_->requestSynchronous(); + std::thread driver([this]() { + stubQueue_->waitForTask(); + stubQueue_->tick(); + stubQueue_->waitForTask(); + stubQueue_->tick(); + }); + eventBeat_->induce(); + driver.join(); + + EXPECT_EQ(beatCount, 2); +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCxxPlatform/react/coremodules/DeviceInfoModule.h b/packages/react-native/ReactCxxPlatform/react/coremodules/DeviceInfoModule.h index c750cf971521..05cbf8e73709 100644 --- a/packages/react-native/ReactCxxPlatform/react/coremodules/DeviceInfoModule.h +++ b/packages/react-native/ReactCxxPlatform/react/coremodules/DeviceInfoModule.h @@ -12,9 +12,18 @@ namespace facebook::react { -using DisplayMetrics = NativeDeviceInfoDisplayMetrics; +using WindowSafeAreaInsets = NativeDeviceInfoWindowSafeAreaInsets; -using DisplayMetricsAndroid = NativeDeviceInfoDisplayMetricsAndroid; +using DisplayMetrics = + NativeDeviceInfoDisplayMetrics>; + +using DisplayMetricsAndroid = NativeDeviceInfoDisplayMetricsAndroid< + double, + double, + double, + double, + double, + std::optional>; using DimensionsPayload = NativeDeviceInfoDimensionsPayload< std::optional, @@ -25,6 +34,9 @@ using DimensionsPayload = NativeDeviceInfoDimensionsPayload< using DeviceInfoConstants = NativeDeviceInfoDeviceInfoConstants, std::optional>; +template <> +struct Bridging : NativeDeviceInfoWindowSafeAreaInsetsBridging {}; + template <> struct Bridging : NativeDeviceInfoDisplayMetricsBridging {}; diff --git a/packages/react-native/src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE.js b/packages/react-native/src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE.js index e6b5cdd5f522..8f5d978ee48e 100644 --- a/packages/react-native/src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE.js +++ b/packages/react-native/src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE.js @@ -9,23 +9,67 @@ */ import type {ViewProps} from '../../../../Libraries/Components/View/ViewPropTypes'; +import type { + SafeAreaInsets, + SafeAreaInsetsChangeEvent, +} from '../../../../Libraries/Types/CoreEventTypes'; +import type {HostInstance} from '../../types/HostInstance'; import View from '../../../../Libraries/Components/View/View'; -import UIManager from '../../../../Libraries/ReactNative/UIManager'; -import Platform from '../../../../Libraries/Utilities/Platform'; +import Dimensions from '../../../../Libraries/Utilities/Dimensions'; import * as React from 'react'; +import {useCallback, useMemo, useState} from 'react'; -const exported: component( - ref?: React.RefSetter>, - ...ViewProps -) = Platform.select({ - ios: require('../../../../src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent') - .default, - android: UIManager.hasViewManagerConfig('RCTSafeAreaView') - ? require('../../../../src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent') - .default - : View, - default: View, -}); +/** + * Renders its children within the safe area of the device, by applying the part + * of the view that is covered by the system UI as padding. + * + * This is the internal counterpart of `react-native-safe-area-context`, for the + * few surfaces React Native renders itself (LogBox, the element inspector, ...) + * which cannot take a dependency on it. Everything else should use the library. + */ +component SafeAreaView( + ref?: React.RefSetter, + ...props: ViewProps +) { + const {style, experimental_onSafeAreaInsetsChange, ...otherProps} = props; + // Seeded with the window insets so the first frame is already padded; the + // synchronous event then keeps them correct relative to this view. The seed + // is only exact for views aligned with the window edges, which the internal + // surfaces using this component (LogBox, the element inspector) are. + const [insets, setInsets] = useState( + () => Dimensions.get('window').experimental_safeAreaInsets, + ); + + const handleSafeAreaInsetsChange = useCallback( + (event: SafeAreaInsetsChangeEvent) => { + setInsets(event.nativeEvent.insets); + experimental_onSafeAreaInsetsChange?.(event); + }, + [experimental_onSafeAreaInsetsChange], + ); + + const paddingStyle = useMemo( + () => + insets == null + ? null + : { + paddingTop: insets.top, + paddingRight: insets.right, + paddingBottom: insets.bottom, + paddingLeft: insets.left, + }, + [insets], + ); + + return ( + + ); +} -export default exported; +export default SafeAreaView; diff --git a/packages/react-native/src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges.js b/packages/react-native/src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges.js new file mode 100644 index 000000000000..e03553610b89 --- /dev/null +++ b/packages/react-native/src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges.js @@ -0,0 +1,78 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {SafeAreaInsetsChangeEvent} from '../../../../Libraries/Types/CoreEventTypes'; + +const DISPATCH_WINDOW_MS = 1000; +const MAX_DISPATCHES_PER_WINDOW = 10; + +type DispatchRate = { + count: number, + windowStart: number, + warned: boolean, +}; + +const dispatchRates: WeakMap = new WeakMap(); + +/** + * Wraps an `experimental_onSafeAreaInsetsChange` handler with a development + * check for a view that reports insets over and over. + * + * The system UI does not move many times a second, so a sustained stream of + * events means the layout is feeding the insets back into the position of the + * observed view: it pads itself by the insets it reports, which moves it, which + * changes its insets. Every one of those events renders synchronously, blocking + * the UI thread, so the loop is paid for in frames. + */ +export default function warnOnRepeatedSafeAreaInsetsChanges( + onSafeAreaInsetsChange: (event: SafeAreaInsetsChangeEvent) => unknown, +): (event: SafeAreaInsetsChangeEvent) => unknown { + return event => { + // The target identifies the view without keeping it alive; events dispatched + // without one are simply not counted. + const target = event.target; + if (target != null && typeof target === 'object') { + warnIfDispatchingTooOften(target); + } + return onSafeAreaInsetsChange(event); + }; +} + +function warnIfDispatchingTooOften(target: interface {}): void { + const now = performance.now(); + let dispatchRate: ?DispatchRate = dispatchRates.get(target); + if (dispatchRate == null) { + const newDispatchRate: DispatchRate = { + count: 0, + windowStart: now, + warned: false, + }; + dispatchRates.set(target, newDispatchRate); + dispatchRate = newDispatchRate; + } + if (dispatchRate.warned) { + return; + } + if (now - dispatchRate.windowStart > DISPATCH_WINDOW_MS) { + dispatchRate.windowStart = now; + dispatchRate.count = 0; + } + dispatchRate.count++; + if (dispatchRate.count > MAX_DISPATCHES_PER_WINDOW) { + dispatchRate.warned = true; + console.warn( + `\`experimental_onSafeAreaInsetsChange\` fired more than ${MAX_DISPATCHES_PER_WINDOW} ` + + `times in ${DISPATCH_WINDOW_MS}ms on a single view. The safe area insets of a view ` + + 'only change when the system UI moves or the view does, so this is usually a loop: ' + + 'the view is laid out from the insets it reports, which moves it, which changes its ' + + 'insets. Each event renders synchronously, so the loop costs frames.', + ); + } +} diff --git a/packages/react-native/src/private/devsupport/devmenu/elementinspector/InspectorPanel.js b/packages/react-native/src/private/devsupport/devmenu/elementinspector/InspectorPanel.js index 9e5d04f30038..b4195f431486 100644 --- a/packages/react-native/src/private/devsupport/devmenu/elementinspector/InspectorPanel.js +++ b/packages/react-native/src/private/devsupport/devmenu/elementinspector/InspectorPanel.js @@ -12,7 +12,7 @@ import type {ElementsHierarchy, InspectedElement} from './Inspector'; -import SafeAreaView from '../../../../../Libraries/Components/SafeAreaView/SafeAreaView'; +import SafeAreaView from '../../../components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; import * as React from 'react'; const ScrollView = diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceInfo.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceInfo.js index df2f70c62def..103bfe4f6109 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceInfo.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceInfo.js @@ -12,12 +12,26 @@ import type {TurboModule} from '../../../../Libraries/TurboModule/RCTExport'; import * as TurboModuleRegistry from '../../../../Libraries/TurboModule/TurboModuleRegistry'; +export type WindowSafeAreaInsets = { + top: number, + right: number, + bottom: number, + left: number, +}; + export type DisplayMetricsAndroid = { width: number, height: number, scale: number, fontScale: number, densityDpi: number, + /** + * The part of the window that is covered by the system UI, in physical + * pixels. Absent on platforms and versions that cannot report it. + * + * @experimental + */ + readonly experimental_safeAreaInsets?: WindowSafeAreaInsets, }; export type DisplayMetrics = { @@ -25,6 +39,13 @@ export type DisplayMetrics = { height: number, scale: number, fontScale: number, + /** + * The part of the window that is covered by the system UI, in physical + * pixels. Absent on platforms and versions that cannot report it. + * + * @experimental + */ + readonly experimental_safeAreaInsets?: WindowSafeAreaInsets, }; export type DimensionsPayload = { diff --git a/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js new file mode 100644 index 000000000000..55b842a6c578 --- /dev/null +++ b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js @@ -0,0 +1,398 @@ +/** + * 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']; +type Frame = SafeAreaInsetsChangeEvent['nativeEvent']['frame']; + +function useSafeAreaInsets(): [ + ?Insets, + ?Frame, + (SafeAreaInsetsChangeEvent) => void, +] { + const [state, setState] = useState(null); + const onSafeAreaInsetsChange = useCallback( + (event: SafeAreaInsetsChangeEvent) => { + setState({ + insets: event.nativeEvent.insets, + frame: event.nativeEvent.frame, + }); + }, + [], + ); + return [state?.insets, state?.frame, onSafeAreaInsetsChange]; +} + +function InsetsReadoutExample(): React.Node { + const [insets, frame, onSafeAreaInsetsChange] = useSafeAreaInsets(); + + return ( + + + {insets == null + ? 'Waiting for insets…' + : `insets: {top: ${insets.top}, right: ${insets.right}, bottom: ${insets.bottom}, left: ${insets.left}}`} + + + {frame == null + ? '' + : `frame: {x: ${frame.x}, y: ${frame.y}, width: ${frame.width}, height: ${frame.height}}`} + + + 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 ? ( +