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/ViewPropTypes.js b/packages/react-native/Libraries/Components/View/ViewPropTypes.js index 5afdaf3f415d..20e4231b0cb7 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,26 @@ 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, ...) or the + * position of this view in the window 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. + * + * 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 + */ + 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..33c2c2a715e1 --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js @@ -0,0 +1,120 @@ +/** + * 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_INTERNAL_DO_NOT_USE'; + +const INSETS = {top: 44, right: 0, bottom: 34, left: 0}; +const FRAME = {x: 0, y: 0, width: 390, height: 844}; + +describe('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: ['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: ['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, + frame: FRAME, + }); + + expect( + root + .getRenderedOutput({ + props: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'], + }) + .toJSX(), + ).toEqual( + , + ); + }); +}); 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..06569cab0d4c 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_INTERNAL_DO_NOT_USE'; 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 82a662152af9..4cdf7db240e0 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 0f93c41709c3..a5b4fa3cc134 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: 'onSafeAreaInsetsChange', + }, }; const validAttributesForNonEventProps = { @@ -402,6 +405,7 @@ const validAttributesForNonEventProps = { // Props for bubbling and direct events const validAttributesForEventProps = { onLayout: true, + 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..acabd7911c5a 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: 'onSafeAreaInsetsChange', + }, onGestureHandlerEvent: DynamicallyInjectedByGestureHandler({ registrationName: 'onGestureHandlerEvent', }), @@ -380,6 +383,7 @@ const validAttributesForNonEventProps = { // Props for bubbling and direct events const validAttributesForEventProps = ConditionallyIgnoredEventHandlers({ onLayout: true, + 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..5d05b804b49f 100644 --- a/packages/react-native/Libraries/Types/CoreEventTypes.js +++ b/packages/react-native/Libraries/Types/CoreEventTypes.js @@ -76,6 +76,27 @@ 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 in window coordinates. + */ + 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..fb86412115a2 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,21 @@ class Dimensions { let {screen, window} = dims; const {windowPhysicalPixels} = dims; if (windowPhysicalPixels) { + const {scale, 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, + 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..513b925dd639 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, + safeAreaInsets: {top: 96, right: 0, bottom: 48, left: 0}, + }, + }); + + expect(Dimensions.get('window').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..7d2c7c5215e7 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), + @"safeAreaInsets" : @{ + @"top" : @(safeAreaInsets.top), + @"right" : @(safeAreaInsets.right), + @"bottom" : @(safeAreaInsets.bottom), + @"left" : @(safeAreaInsets.left) + } }; NSDictionary *dimsScreen = @{ 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 b46c5e6cd334..bdc82807d6a3 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,10 @@ @implementation RCTViewComponentView { NSMutableSet *_accessibilityOrderNativeIDs; RCTSwiftUIContainerViewWrapper *_swiftUIWrapper; BOOL _focusable; + BOOL _observesSafeAreaInsets; + BOOL _safeAreaInsetsWereSent; + UIEdgeInsets _lastSafeAreaInsets; + CGRect _lastSafeAreaFrame; } #ifdef RCT_DYNAMIC_FRAMEWORKS @@ -438,6 +443,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & -newViewProps.hitSlop.right}; } + // `onSafeAreaInsetsChange` + if (oldViewProps.onSafeAreaInsetsChange != newViewProps.onSafeAreaInsetsChange) { + [self _setObservesSafeAreaInsets:newViewProps.onSafeAreaInsetsChange]; + } + // `overflow` if (oldViewProps.getClipsContentToBounds() != newViewProps.getClipsContentToBounds()) { self.currentContainerView.clipsToBounds = newViewProps.getClipsContentToBounds(); @@ -720,6 +730,143 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics } } +#pragma mark - Safe area insets + +#if !TARGET_OS_TV +static NSArray *RCTSafeAreaInsetsNotificationNames(void) +{ + // The keyboard is part of the safe area on iOS, so its appearance changes the + // insets without the view hierarchy itself changing. + return @[ + UIKeyboardDidShowNotification, + UIKeyboardDidHideNotification, + UIKeyboardDidChangeFrameNotification, + ]; +} +#endif + +// 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; +} + +- (void)_setObservesSafeAreaInsets:(BOOL)observesSafeAreaInsets +{ + if (_observesSafeAreaInsets == observesSafeAreaInsets) { + return; + } + + _observesSafeAreaInsets = observesSafeAreaInsets; + _safeAreaInsetsWereSent = NO; + +#if !TARGET_OS_TV + for (NSNotificationName name in RCTSafeAreaInsetsNotificationNames()) { + if (observesSafeAreaInsets) { + [NSNotificationCenter.defaultCenter addObserver:self + selector:@selector(_scheduleSafeAreaInsetsCheck) + name:name + object:nil]; + } else { + [NSNotificationCenter.defaultCenter removeObserver:self name:name object:nil]; + } + } +#endif + + if (observesSafeAreaInsets) { + [self _scheduleSafeAreaInsetsCheck]; + } +} + +// The event is only ever emitted from `layoutSubviews`; everything that might +// have changed the insets funnels through here and 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)_scheduleSafeAreaInsetsCheck +{ + [self setNeedsLayout]; +} + +- (void)_safeAreaInsetsMayHaveChanged +{ + if (!_observesSafeAreaInsets || !_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; + } + + UIView *referenceView = RCTParentViewControllerOfView(self).view ?: self.window; + UIEdgeInsets insets = self.safeAreaInsets; + CGRect frame = [self convertRect:self.bounds toView:referenceView]; + + if (_safeAreaInsetsWereSent && CGRectEqualToRect(frame, _lastSafeAreaFrame) && + RCTEdgeInsetsEqualWithThreshold(insets, _lastSafeAreaInsets, 1.0 / RCTScreenScale())) { + return; + } + + _safeAreaInsetsWereSent = YES; + _lastSafeAreaInsets = insets; + _lastSafeAreaFrame = frame; + + static_cast(*_eventEmitter) + .onSafeAreaInsetsChange( + EdgeInsets{ + .left = (Float)insets.left, + .top = (Float)insets.top, + .right = (Float)insets.right, + .bottom = (Float)insets.bottom}, + RCTRectFromCGRect(frame)); +} + +- (void)safeAreaInsetsDidChange +{ + [super safeAreaInsetsDidChange]; + // The ivar is checked here rather than inside the helpers so that views which + // do not use the prop only pay for a branch. + if (_observesSafeAreaInsets) { + [self _scheduleSafeAreaInsetsCheck]; + } +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + if (_observesSafeAreaInsets) { + [self _scheduleSafeAreaInsetsCheck]; + } +} + +- (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 (_observesSafeAreaInsets) { + [self _safeAreaInsetsMayHaveChanged]; + } +} + - (BOOL)isJSResponder { return _isJSResponder; @@ -775,6 +922,8 @@ - (void)prepareForRecycle _filterLayer = nil; [self clearExistingBackgroundImageLayers]; + [self _setObservesSafeAreaInsets:NO]; + _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 4d0e582d3534..276e4ddb49d5 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -3242,6 +3242,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 @@ -4579,6 +4580,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 5f5f5a4308ef..ca401d761ee3 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. */ @@ -71,7 +72,9 @@ internal class DeviceInfoModule(reactContext: ReactApplicationContext) : WritableNativeMap().apply { putMap( "windowPhysicalPixels", - getPhysicalPixelsWritableMap(getWindowDisplayMetrics()), + getPhysicalPixelsWritableMap(getWindowDisplayMetrics()).apply { + getWindowSafeAreaInsetsWritableMap()?.let { putMap("safeAreaInsets", it) } + }, ) putMap( "screenPhysicalPixels", @@ -79,6 +82,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..660b2784ff09 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; @@ -293,6 +294,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 07d7e132e7d6..2fd42a68b38f 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 @@ -152,6 +152,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 = "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..d85ffdaa2fbf --- /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.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 { + + private var lastInsets: Insets? = null + private var lastFrame: Rect? = null + private var isListening = false + + private fun start() { + view.addOnAttachStateChangeListener(this) + if (view.isAttachedToWindow) { + onViewAttachedToWindow(view) + } + } + + private fun stop() { + view.removeOnAttachStateChangeListener(this) + stopListening() + lastInsets = null + lastFrame = null + } + + 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() { + val insets = getSafeAreaInsets(view) ?: return + val frame = getFrame(view) ?: return + if (insets == lastInsets && frame == lastFrame) { + return + } + lastInsets = insets + lastFrame = frame + + val eventDispatcher = + UIManagerHelper.getEventDispatcher(UIManagerHelper.getReactContext(view)) ?: return + 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 { + /** + * 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? { + // The view has not been laid out yet. + if (view.width == 0 || view.height == 0) { + return null + } + val rootView = view.rootView + val windowInsets = + ViewCompat.getRootWindowInsets(rootView)?.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), + ) ?: return null + + val visibleRect = Rect() + view.getGlobalVisibleRect(visibleRect) + return Insets.of( + max(windowInsets.left - visibleRect.left, 0), + max(windowInsets.top - visibleRect.top, 0), + max(min(visibleRect.left + view.width - rootView.width, 0) + windowInsets.right, 0), + max(min(visibleRect.top + view.height - rootView.height, 0) + windowInsets.bottom, 0), + ) + } + + /** The frame of [view] in the coordinate space of the window. */ + private fun getFrame(view: View): Rect? { + val rootView = view.rootView as? ViewGroup ?: return null + if (view.parent == null) { + return null + } + val frame = Rect() + view.getDrawingRect(frame) + try { + rootView.offsetDescendantRectToMyCoords(view, frame) + } 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 frame + } + } +} 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..0fc4e6f8ab8d 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,48 @@ 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. `immediate` processes the queue at the call site + // instead of at the next beat: the emit happens during the layout pass of + // the frame, and waiting for the beat would land the resulting mount one + // frame late on a freshly mounted view. + constexpr bool immediate = true; + 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); + }, + immediate); +} + #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 e556d1e97547..818992e83c1c 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.h @@ -12,6 +12,7 @@ #include #include +#include #include "TouchEventEmitter.h" @@ -32,6 +33,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 in window coordinates. + * + * 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 4f8e4265d895..a313de1a6186 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp @@ -288,6 +288,12 @@ BaseViewProps::BaseViewProps( "onLayout", sourceProps.onLayout, {})), + onSafeAreaInsetsChange(convertRawProp( + context, + rawProps, + "onSafeAreaInsetsChange", + sourceProps.onSafeAreaInsetsChange, + {})), events(convertRawProp(context, rawProps, sourceProps.events, {})), collapsable(convertRawProp( context, @@ -355,6 +361,7 @@ 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_BASIC(onSafeAreaInsetsChange); RAW_SET_PROP_SWITCH_CASE_BASIC(collapsable); RAW_SET_PROP_SWITCH_CASE_BASIC(collapsableChildren); RAW_SET_PROP_SWITCH_CASE_BASIC(removeClippedSubviews); @@ -591,6 +598,10 @@ SharedDebugStringConvertibleList BaseViewProps::getDebugProps() const { "backgroundImage", backgroundImage, defaultBaseViewProps.backgroundImage), + debugStringConvertibleItem( + "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 7554ba7cad86..5759c582c37d 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h @@ -101,6 +101,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/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..3611166c6830 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["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.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/core/EventDispatcher.cpp b/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.cpp index 5fa5e6821a51..7ab74311d94a 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.cpp +++ b/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.cpp @@ -37,8 +37,8 @@ void EventDispatcher::dispatchEvent(RawEvent&& rawEvent) const { eventQueue_.enqueueEvent(std::move(rawEvent)); } -void EventDispatcher::experimental_flushSync() const { - eventQueue_.experimental_flushSync(); +void EventDispatcher::experimental_flushSync(bool immediate) const { + eventQueue_.experimental_flushSync(immediate); } void EventDispatcher::dispatchStateUpdate( diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.h b/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.h index 88a9a953e423..dc53bb47e5e0 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.h +++ b/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.h @@ -44,7 +44,7 @@ class EventDispatcher { /* * Experimental API exposed to support EventEmitter::experimental_flushSync. */ - void experimental_flushSync() const; + void experimental_flushSync(bool immediate = false) const; /* * Dispatches a raw event with asynchronous batched priority. Before the diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventEmitter.h b/packages/react-native/ReactCommon/react/renderer/core/EventEmitter.h index f3e9a4c334e7..16f0305e4249 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventEmitter.h +++ b/packages/react-native/ReactCommon/react/renderer/core/EventEmitter.h @@ -65,9 +65,12 @@ class EventEmitter { /* * Experimental API that will change in the future. + * When `immediate` is true the queue is processed at the call site instead + * of at the next beat, so the effects are mounted before the current frame + * is presented. */ template - void experimental_flushSync(Lambda syncFunc) const + void experimental_flushSync(Lambda syncFunc, bool immediate = false) const { auto eventDispatcher = eventDispatcher_.lock(); if (!eventDispatcher) { @@ -75,7 +78,7 @@ class EventEmitter { } syncFunc(); - eventDispatcher->experimental_flushSync(); + eventDispatcher->experimental_flushSync(immediate); } /* diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventQueue.cpp b/packages/react-native/ReactCommon/react/renderer/core/EventQueue.cpp index 6e99fd71bb7a..e966637b6e5b 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventQueue.cpp +++ b/packages/react-native/ReactCommon/react/renderer/core/EventQueue.cpp @@ -86,8 +86,11 @@ void EventQueue::onEnqueue() const { eventBeat_->request(); } -void EventQueue::experimental_flushSync() const { +void EventQueue::experimental_flushSync(bool immediate) const { eventBeat_->requestSynchronous(); + if (immediate) { + eventBeat_->induce(); + } } void EventQueue::onBeat(jsi::Runtime& runtime) const { diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventQueue.h b/packages/react-native/ReactCommon/react/renderer/core/EventQueue.h index f525767478aa..a531709024ce 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventQueue.h +++ b/packages/react-native/ReactCommon/react/renderer/core/EventQueue.h @@ -60,8 +60,11 @@ class EventQueue { /* * Experimental API exposed to support EventEmitter::experimental_flushSync. + * When `immediate` is true the queue is processed at the call site instead + * of at the next beat, so the effects are mounted before the current frame + * is presented. */ - void experimental_flushSync() const; + void experimental_flushSync(bool immediate = false) const; protected: /* 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/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 5220c72e26e6..67d4adf4968e 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<<4406ddb15814262a7ccc2b56e9625d67>> + * @generated SignedSource<<20b74fddca6689a0e7e2e62f61554644>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -1867,10 +1867,14 @@ declare type DirectEventProps = { readonly onAccessibilityTap?: () => unknown readonly onLayout?: (event: LayoutChangeEvent) => unknown readonly onMagicTap?: () => unknown + readonly onSafeAreaInsetsChange?: ( + event: SafeAreaInsetsChangeEvent, + ) => unknown } declare type DisplayMetrics = { fontScale: number height: number + readonly safeAreaInsets?: WindowSafeAreaInsets scale: number width: number } @@ -1878,6 +1882,7 @@ declare type DisplayMetricsAndroid = { densityDpi: number fontScale: number height: number + readonly safeAreaInsets?: WindowSafeAreaInsets scale: number width: number } @@ -4208,6 +4213,16 @@ declare type Runnable = ( declare type Runnables = { [appKey: string]: Runnable } +declare type SafeAreaInsets = { + readonly bottom: number + readonly left: number + readonly right: number + readonly top: number +} +declare type SafeAreaInsetsChangeEvent = NativeSyntheticEvent<{ + readonly frame: LayoutRectangle + readonly insets: SafeAreaInsets +}> declare type SafeAreaView = typeof SafeAreaView declare type SafeAreaViewInstance = HostInstance declare type ScaledSize = DisplayMetrics @@ -5650,6 +5665,12 @@ declare type WebPlatform = { get isTV(): boolean get Version(): string } +declare type WindowSafeAreaInsets = { + bottom: number + left: number + right: number + top: number +} declare type WindowsPlatform = { OS: "windows" select: (spec: PlatformSelectSpec) => T @@ -5716,16 +5737,16 @@ export { AccessibilityValue, // cf8bcb74 ActionSheetIOS, // b558559e ActionSheetIOSOptions, // 1756eb5a - ActivityIndicator, // e43c68bb + ActivityIndicator, // cb3baebf ActivityIndicatorInstance, // a82dd4e7 - ActivityIndicatorProps, // 619842eb + ActivityIndicatorProps, // c9fb2776 Alert, // a398a509 AlertButton, // bf1a3b60 AlertButtonStyle, // ec9fb242 AlertOptions, // 8a116d2a AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // d74ec583 + Animated, // ef96069a AppConfig, // 35c0ca70 AppRegistry, // 1e8c5a00 AppState, // 12012be5 @@ -5752,16 +5773,16 @@ export { DevMenu, // 21b8b7a9 DevSettings, // e55b91dc DeviceEventEmitter, // bdbdd014 - DeviceInfo, // 521bfb71 - DeviceInfoConstants, // 279e7858 + DeviceInfo, // db26f5e4 + DeviceInfoConstants, // 148c17ed DimensionValue, // b163a381 - Dimensions, // 980ef68c - DimensionsPayload, // 653bc26c - DisplayMetrics, // 1dc35cef - DisplayMetricsAndroid, // 872e62eb - DrawerLayoutAndroid, // 7843b7b5 + Dimensions, // 6e9b417f + DimensionsPayload, // 8251f7f2 + DisplayMetrics, // 5801c350 + DisplayMetricsAndroid, // 5ad0b971 + DrawerLayoutAndroid, // 9789fb75 DrawerLayoutAndroidInstance, // c0694352 - DrawerLayoutAndroidProps, // 0c1d6155 + DrawerLayoutAndroidProps, // 2a3b9a3e DrawerSlideEvent, // c4ab8fba DropShadowValue, // e9df2606 DynamicColorIOS, // d96c228c @@ -5777,9 +5798,9 @@ export { EventSubscription, // b8d084aa ExtendedExceptionData, // 5a6ccf5a FilterFunction, // bf24c0e3 - FlatList, // e1b005c7 - FlatListInstance, // 2d1d8e45 - FlatListProps, // 94fa2dc7 + FlatList, // f7dfb7bf + FlatListInstance, // 4cde6870 + FlatListProps, // eb05aa31 FocusEvent, // 850f1517 FontVariant, // 7c7558bb GestureResponderEvent, // 14d3e77a @@ -5790,17 +5811,17 @@ export { IEventEmitter, // fbef6131 IOSKeyboardEvent, // e67bfe3a IgnorePattern, // ec6f6ece - Image, // 87b793a4 - ImageBackground, // abfd6c1d - ImageBackgroundInstance, // 50bc4cb3 - ImageBackgroundProps, // e04f173a + Image, // 4845ad61 + ImageBackground, // 601e01e7 + ImageBackgroundInstance, // 3bb44053 + ImageBackgroundProps, // 46039e37 ImageErrorEvent, // 978933f4 ImageInstance, // 9a100753 ImageLoadEvent, // 77f0b718 ImageProgressEventIOS, // 445331a4 - ImageProps, // c50cfe0d + ImageProps, // b95ac16f ImagePropsAndroid, // ee00e1d5 - ImagePropsBase, // fa3f8e21 + ImagePropsBase, // 54b7d251 ImagePropsIOS, // 9e19c85d ImageRequireSource, // 681d683b ImageResizeMode, // d51106e2 @@ -5818,9 +5839,9 @@ export { KeyEvent, // 20fa4267 KeyUpEvent, // 57f832c5 Keyboard, // 49414c97 - KeyboardAvoidingView, // fbf69b90 - KeyboardAvoidingViewInstance, // f6c63fc9 - KeyboardAvoidingViewProps, // 8609524c + KeyboardAvoidingView, // 3f0523c3 + KeyboardAvoidingViewInstance, // 668649df + KeyboardAvoidingViewProps, // b3f0f986 KeyboardEvent, // c3f895d4 KeyboardEventEasing, // af4091c8 KeyboardEventName, // 59299ad6 @@ -5845,10 +5866,10 @@ export { MeasureInWindowOnSuccessCallback, // a285f598 MeasureLayoutOnSuccessCallback, // 3592502a MeasureOnSuccessCallback, // 82824e59 - Modal, // 252d448a + Modal, // 1fcfee47 ModalBaseProps, // c294cc46 ModalInstance, // d466ce77 - ModalProps, // 757e7cec + ModalProps, // 84f2e72f ModalPropsAndroid, // 515fb173 ModalPropsIOS, // 664ecb7e ModeChangeEvent, // f64bf69d @@ -5884,15 +5905,15 @@ export { PointerEvent, // ff599afe PressabilityConfig, // fea539a5 PressabilityEventHandlers, // c222648b - Pressable, // ed5391ac + Pressable, // 771ba8f0 PressableAndroidRippleConfig, // ee32eaca PressableInstance, // eebfe911 - PressableProps, // d6bfa55b + PressableProps, // b16dba85 PressableStateCallbackType, // 9af36561 ProcessedColorValue, // 33f74304 - ProgressBarAndroid, // 2dd6dbfd + ProgressBarAndroid, // 7e4aae87 ProgressBarAndroidInstance, // ab545ef1 - ProgressBarAndroidProps, // 07fa83a1 + ProgressBarAndroidProps, // 4a37f451 PublicRootInstance, // 8040afd7 PublicTextInstance, // 6937c7bf PushNotificationEventName, // 84e7e150 @@ -5900,9 +5921,9 @@ export { PushNotificationPermissions, // c2e7ae4f Rationale, // 5df1b1c1 ReactNativeVersion, // abd76827 - RefreshControl, // 947cb880 - RefreshControlInstance, // 55f1814a - RefreshControlProps, // eb2b1cbe + RefreshControl, // f51d7fa6 + RefreshControlInstance, // e3123c95 + RefreshControlProps, // 3b735108 RefreshControlPropsAndroid, // 8ac931ca RefreshControlPropsIOS, // 72a36381 Registry, // 6c39216d @@ -5914,24 +5935,24 @@ export { RootViewStyleProvider, // 8792d506 Runnable, // 594dd93a Runnables, // 4367c557 - SafeAreaView, // 9ce03f75 + SafeAreaView, // 12e47c48 SafeAreaViewInstance, // 21dba39c - ScaledSize, // 07e417c7 + ScaledSize, // 5e5527f8 ScrollEvent, // d7abdd0a - ScrollResponderType, // ba188eae + ScrollResponderType, // 6ffba565 ScrollToLocationParamsType, // d7ecdad1 - ScrollView, // 066a8597 - ScrollViewImperativeMethods, // 904c66fd - ScrollViewInstance, // ccf4f341 - ScrollViewProps, // b62913d1 + ScrollView, // ab8c8f76 + ScrollViewImperativeMethods, // 440145ad + ScrollViewInstance, // 17fa4b20 + ScrollViewProps, // 48286a63 ScrollViewPropsAndroid, // 02f3df2e ScrollViewPropsIOS, // 807cb4f6 ScrollViewScrollToOptions, // 3313411e SectionBase, // 9f13db00 - SectionList, // ee3e7972 + SectionList, // f865a7b9 SectionListData, // 1a4de01a - SectionListInstance, // c9b991fe - SectionListProps, // 0e933318 + SectionListInstance, // e953047b + SectionListProps, // 2866c531 SectionListRenderItem, // 715b2086 SectionListRenderItemInfo, // 4a48a922 Separators, // 6a45f7e3 @@ -5950,17 +5971,17 @@ export { StyleProp, // fa0e9b4a StyleSheet, // 3c21ec63 SubmitBehavior, // c4ddf490 - Switch, // f495bab3 + Switch, // d891ccdc SwitchChangeEvent, // 899635b1 SwitchInstance, // 3c50eec5 - SwitchProps, // cadfd0c4 + SwitchProps, // c4f737fb Systrace, // 626d178c TVViewPropsIOS, // 330ce7b5 TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 Text, // 09e783b9 TextContentType, // 239b3ecc - TextInput, // a2cc82d1 + TextInput, // 169cba98 TextInputAndroidProps, // 9ebbc103 TextInputBlurEvent, // b77af40e TextInputChangeEvent, // f55eef98 @@ -5970,7 +5991,7 @@ export { TextInputIOSProps, // fb3c9327 TextInputInstance, // 5a0c0e0d TextInputKeyPressEvent, // 546c5d07 - TextInputProps, // dd91ebaa + TextInputProps, // 5cbc9768 TextInputSelectionChangeEvent, // e58f2abc TextInputSubmitEditingEvent, // 6bcb2aa5 TextInstance, // 05463a96 @@ -5995,19 +6016,19 @@ export { UIManager, // afbcdf05 UTFSequence, // ad625158 Vibration, // 31e4bbf8 - View, // 3e72139a + View, // 54df4cf8 ViewInstance, // ffde5573 - ViewProps, // 763271bb + ViewProps, // 16a288a1 ViewPropsAndroid, // 55e81851 ViewPropsIOS, // 58ee19bf ViewStyle, // e45056b1 VirtualViewMode, // 6be59722 VirtualizedList, // 68c7345e VirtualizedListInstance, // 423ee7c0 - VirtualizedListProps, // f51f4a42 + VirtualizedListProps, // 214bc0cd VirtualizedSectionList, // 9fd9cd61 VirtualizedSectionListInstance, // 12b706d5 - VirtualizedSectionListProps, // 8210f30a + VirtualizedSectionListProps, // fd63943b WrapperComponentProvider, // 9ef54e61 codegenNativeCommands, // 628a7c0a codegenNativeComponent, // 32a1bca6 @@ -6020,5 +6041,5 @@ export { useAnimatedValueXY, // 04c70878 useColorScheme, // d585efdb usePressability, // 782138ed - useWindowDimensions, // bb4b683f + useWindowDimensions, // 63544292 } 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..631da1e73ff8 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, 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').safeAreaInsets, + ); + + const handleSafeAreaInsetsChange = useCallback( + (event: SafeAreaInsetsChangeEvent) => { + setInsets(event.nativeEvent.insets); + onSafeAreaInsetsChange?.(event); + }, + [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/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..8f53910fcaeb 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,20 @@ 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, + readonly safeAreaInsets?: WindowSafeAreaInsets, }; export type DisplayMetrics = { @@ -25,6 +33,7 @@ export type DisplayMetrics = { height: number, scale: number, fontScale: number, + readonly safeAreaInsets?: WindowSafeAreaInsets, }; export type DimensionsPayload = { diff --git a/packages/rn-tester/Podfile.lock b/packages/rn-tester/Podfile.lock index 030d0acba633..de7fb3acae6b 100644 --- a/packages/rn-tester/Podfile.lock +++ b/packages/rn-tester/Podfile.lock @@ -21,6 +21,7 @@ PODS: - RCTTypeSafety - React-bridging - React-Core + - React-cxxstableapi - React-debug - React-Fabric - React-featureflags @@ -49,6 +50,7 @@ PODS: - RCTTypeSafety - React-bridging - React-Core + - React-cxxstableapi - React-debug - React-Fabric - React-featureflags @@ -2159,6 +2161,7 @@ PODS: - React-RCTFabric - React-RCTFBReactNativeSpec - React-RCTImage + - React-RCTLinking - React-RCTNetwork - React-RCTRuntime - React-rendererdebug @@ -2658,6 +2661,7 @@ PODS: - RCTTypeSafety - React-bridging - React-Core + - React-cxxstableapi - React-debug - React-Fabric - React-featureflags @@ -2957,93 +2961,93 @@ SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 - FBLazyVector: af92c6d1747ebb2387d117d9dd73d7e4903841a8 + FBLazyVector: 8d6300cc6b62b342a5e0bc80b270b1ef86d640f1 fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac glog: e56ede4028c4b7418e6b1195a36b1656bb35e225 - hermes-engine: c80355e32d44390d09935e3e31d569b19ff4b205 - MyNativeView: 02b46285748cdd4731b86f6d91840d6ba2e5741f - NativeCxxModuleExample: 3dee4ec2dece1caf6a820efde7f01efc5a034880 + hermes-engine: 4286c95931498cdf677eb49780ae8472f789fe69 + MyNativeView: 76ad67d331a2511f761380be28f6eff6f367e5f9 + NativeCxxModuleExample: 46d816ef02757e3b002f2f095c1b80254b4b8f80 OCMock: 589f2c84dacb1f5aaf6e4cec1f292551fe748e74 RCT-Folly: 36c4f904fb6cd0219dcb76b94e9502d2a72fab0b - RCTDeprecation: 3808e36294137f9ee5668f4df2e73dc079cd1dcf - RCTRequired: 25edbba9d281a349b2c6ea96164b24ddbaab9682 - RCTSwiftUI: ddb3e80be1d459ffbf69250f96ac110ba16936a1 - RCTSwiftUIWrapper: 80ba3daf3dce113d442e53822e32c14e290eb6a7 - RCTTypeSafety: 656f2b1b5978bedc692d17c142117cf77e27a965 - React: 9fcf4514ef577f653607ef701030619c4d446b8a - React-bridging: e5ca72ed0c4c42237c89d47395caae032324a85d - React-callinvoker: 9dbc5bf0c1770b852c286f27a8ff3ac2c41d2bae - React-Core: 07dd2589d382d42ebea21c94b63ae9b2b9d1c0db - React-CoreModules: 3fff481436a5bf1fe2107c77306b1b22060e6d86 - React-cxxreact: f73e41399049552c9008973772a8a9e71af4ca57 - React-cxxstableapi: 717808de4bba60c59802263e36a93da2167a7a79 - React-debug: c136ab6a9df808ec43092cf683d27035ebb48a26 - React-defaultsnativemodule: ddd0b272cf22ca179066f737c41af2b91f826271 - React-domnativemodule: 91aa634005208e9e068871b5161548faddb76d35 - React-Fabric: f94d3624837f799c26b15b4b014c667fc641e8ad - React-FabricComponents: aca79528e806729b41c4020b722febdd63385194 - React-FabricImage: 803f4a7988bbdad297bff982684cea9aeea3c901 - React-featureflags: 1cd13109ac3f2bdda0145f7c9bce6e70e237b661 - React-featureflagsnativemodule: a8591c1cece2cb28efd7a98632a035f52c344bb9 - React-graphics: bfe5977ad2800e2169a82a0afdea595bb04dd9ba - React-hermes: 52e0641c625bcdd88591dcad239515e7da86170d - React-idlecallbacksnativemodule: a76ec7bbeabde1b997eee3082a8986477effb352 - React-ImageManager: dbb3cba90eb68cd3f406354fcbc9d1a974d12346 - React-intersectionobservernativemodule: 1486d1730e131e8004a70d68bf1a725b79ef5c45 - React-jserrorhandler: 2178b775435a2d46765c1a95cff896f51d6ae297 - React-jsi: eab4393bbf978faa0d9d93ae5d13c20937fab320 - React-jsiexecutor: d07aaba9188314c08751a4f60ee863ef8bee1ecb - React-jsinspector: 085d6437ce83b5f0ad095909ee26d03da661e127 - React-jsinspectorcdp: 17244d8639ca280967956ca1c7da0dd1a4fc6454 - React-jsinspectornetwork: abcd141858a9228dd9fb5b7c2e5395c23ee9bbb4 - React-jsinspectortracing: ce38fda4fed441cc148ba7777bf357261a813ba3 - React-jsitooling: 54447648b4229d55f65dabba53518e56c8bf1a06 - React-jsitracing: c270fb95d74e14e264d33b3a2cf8ca78901cc66b - React-logger: d20ed7b124ed68131054d598cd1ea0bca74e68ee - React-Mapbuffer: 1bda74e2bfd17505fcd7ce21c95842f5d5cdfc78 - React-microtasksnativemodule: cec2334ddab49a8e38dcfb6967860b6002629233 - React-mutationobservernativemodule: dbbdf506d21c3c69653d837498e9d068147054e1 - React-NativeModulesApple: 15fbbad6cff9bef2da812843ba31b743b0f8f8bf - React-networking: 65716f04019b6a5f6ca8013307c3eefea47b6463 - React-oscompat: d4c113e294eaa6bedf7c18d03df8ea503a98129d - React-perflogger: c12ba5379b631513afe6ad4a87c7d64549668c76 - React-performancecdpmetrics: b150de1bd90ca5d536ec187ef9df340a88b9e2be - React-performancetimeline: 4ef48781f3fcafa44bdecab7c119e454bffdae3c - React-RCTActionSheet: 413cbb93858a32b5c18405e792262caec6cf158e - React-RCTAnimatedModuleProvider: dcbb65d677cf6ac1cda7912650ad33c4253916e6 - React-RCTAnimation: 164e50bb4cc07bbc5a5ec1f19b8bc9392268b39f - React-RCTAppDelegate: 3981a52f920a199573a1450764417236043a6717 - React-RCTBlob: 11c79b6e5447c2c1cc1b7633d7ee4d712a465cce - React-RCTFabric: 691f78aeca063781ac816cdd50594a02715f2bc0 - React-RCTFBReactNativeSpec: 329d23f7222c216da3b07dd02ff00b3b98fda1cd - React-RCTImage: bb0eb46db1e24ad906cb31c21e21b514db5b8a55 - React-RCTLinking: 6fb1ec3188dbf07f87b29afe172064a46d331446 - React-RCTNetwork: f424e280b3f9edd4d6692296c69d3dd5de92f78a - React-RCTPushNotification: 49c1acca24d107235c15a61af9ed856ef9e4c956 - React-RCTRuntime: feb1d8054fd1128af1622a6ec477ed5585dd01cc - React-RCTSettings: a932ad87f8dc6601deb45220a9bfa7da4a44df03 - React-RCTTest: 35876bf3e89cbbb357d52292f0e525996e5ce60c - React-RCTText: 16b0e933027bd04a5fc594461cb7691cd7eaaa56 - React-RCTVibration: fa7447c8f2435d18eb07e507e088c77cc6dbf61c - React-rendererconsistency: 7953edf876fa2626cfe7513f8a96dcb87e4be3ae - React-renderercss: 2ff59d46f74a73403fc90b7f70c24031b79173b4 - React-rendererdebug: f18285ef1772f907eb15f83134d33ef98916c261 - React-RuntimeApple: 412d7771e11d3ee5ad48e3d73ad4ab184e14f31b - React-RuntimeCore: c424d1a4f7354dfe5c9a06997865ccf78f22c244 - React-runtimeexecutor: 47ac7048f643a0bef6a0a86a5e136867d66a0768 - React-RuntimeHermes: 17ab25d968387190328b1e337024cf46fa3e5eb4 - React-runtimescheduler: 57f201dffa80893b709accd1722ab5efbedf7699 - React-timing: 208133eb7843c1b4292ab9b3c0c55ad2eedc709c - React-utils: 702979802d8aa217752fd73e95b96bad2646f5d9 - React-viewtransitionnativemodule: 2e1b09e22fe19f3e4286a5768d75570086d46baf - React-webperformancenativemodule: 86b068ca5c8c69583974ab63853b7acc7b5af653 - ReactAppDependencyProvider: 56ca45ab1d086e3093f7ab9c19a8dc8eac5c099b + RCTDeprecation: aa7b5fda9f4dc8176dfbffde52fc399b7cb0d66d + RCTRequired: 908b88af9e6024695eedf4db54f10ed5efed80fb + RCTSwiftUI: ac68aa1d7b4d28d46e8d00c0fc7b171799cd1f51 + RCTSwiftUIWrapper: ee3cf1ea59387cec93af878d48323738eefd078e + RCTTypeSafety: 9d41540f920eb9f178c604436f16a5bb094eee01 + React: cb92ef0fcb72f5480ae2052717d6d22c9bcf791d + React-bridging: cb19f912dbc728f815c563804ab0061d2b7dc7c9 + React-callinvoker: d017f62a093a1c9d47b3c3a65b8bca2fdc57deaa + React-Core: d62bf0f15abc82de8834e89fb175f3e95f221cb0 + React-CoreModules: afb62986d49857902e0b4648797e9796984359ef + React-cxxreact: 3e55cf0a127352c4167a63bd15b3619270030942 + React-cxxstableapi: f4be87fc3dbbe2663f4940baa998478aa5c138d1 + React-debug: 9fa7e9f26c4d6dbafadfae14c1d51e0b2fcaf818 + React-defaultsnativemodule: 302f2fed6b71913cd2c4b28da438848c38e4288b + React-domnativemodule: 3561457a5ca1d65cdbeb342b6ea27da023685c6d + React-Fabric: 7e255acd2deae8422efaa5abaada381a8d6922dc + React-FabricComponents: 799d471bc14673fe0b3ba6d7640b4e315f5725c4 + React-FabricImage: 7e04f4aa4837bbed4ffb5ff833165c400e079261 + React-featureflags: df315955996e39e3aa42e8767ae6db3bc441f964 + React-featureflagsnativemodule: 996d429c83f6c4948c43e11482098873beed3fce + React-graphics: a4185241fd07727b6125bb44609a529038dfff92 + React-hermes: c3c2f08920066e47c023e38d1e181ab7274f09bd + React-idlecallbacksnativemodule: 5fe35990c3d85199008482d655826850cfb530be + React-ImageManager: 973d05ef1a1bd26d948c38449d0c437646f33448 + React-intersectionobservernativemodule: a399b75594139cb345ad499e4016bd6df9aa6263 + React-jserrorhandler: 2b1b682203525d40d58f4923c3a386e126f9bbdc + React-jsi: 1cbf1a84da1b5dd27b4769c66ea4cd17694a7aa2 + React-jsiexecutor: 1906d191073119178d3f8c0c362a4aeadee75e7f + React-jsinspector: d4a3f70bfbb107fd7dd35584514e22eefbceae9f + React-jsinspectorcdp: bc515afeb865573ebe93cd91192a9aad8b0173b3 + React-jsinspectornetwork: 6705e4dee1a1985775365f4caa693c8bbb13065f + React-jsinspectortracing: 5206d500a195c0c842fce4a06c5975e4af9b2fe1 + React-jsitooling: f4b2dfdf0520b2cabe896403ade9764fe4f11d7e + React-jsitracing: 388aa80fdc2ed7c676faf5e81178c7bfd0b7fc65 + React-logger: 25843b3c06ecd66d73a5ac2a867f8e1a5c67667f + React-Mapbuffer: 04872224049c3590c97b1e2ae7c71ab9003b740c + React-microtasksnativemodule: 3ff547be77fc6adabdb0e136d502b1fb2f0bc976 + React-mutationobservernativemodule: dd50c70b4ce32df658dd84b1d93656f4a2fcd323 + React-NativeModulesApple: c1a91dd2bce69b8738fd7ff65c41988c653d4b3a + React-networking: 4da2fb33af97234759f12809cb5662d26f75517a + React-oscompat: 4a8903bdcad429bb06c16117baa75119124e733a + React-perflogger: b9f988ac7409b843ae08753e0deecd9f4fd875b5 + React-performancecdpmetrics: fc889bbeb4d5ad79c53d1cd699ba6fde81c67312 + React-performancetimeline: db0042d44c0e0626e14657d7acab083c39e17201 + React-RCTActionSheet: fc682f96358f6cd719ad7e903b3414643ae58065 + React-RCTAnimatedModuleProvider: 4272096836b4294a0b7778615bc54baa003f99bd + React-RCTAnimation: f195b847767971e8cde56ec8e8f9f68d59cdc04f + React-RCTAppDelegate: 8122020c7c9947d4a5b4df018cad70f977633f1a + React-RCTBlob: b9c504817385c29c03c2ac185312bc1aa137e11f + React-RCTFabric: cb7e3ea76b094e5fc26593f746fdadd1010a791f + React-RCTFBReactNativeSpec: f06270ae49a5278e554b64c62dc4fa3729aa363d + React-RCTImage: 6800513bc9b32db8000678885380052ac8ac6072 + React-RCTLinking: 41e30928506fbe3a73696eb95eb6b2e82bbba3b5 + React-RCTNetwork: 37853e977a1f8968043a23b3d5e486b30bbf4e7e + React-RCTPushNotification: 6e4b9dccbcb6dd0b44a0b7bcf0e940bfef1b8890 + React-RCTRuntime: 8fc36167874c6a8abdaa9b9a18e9abad99721bd0 + React-RCTSettings: 3f4a231c2c16d0a9e62558e0cdc77376755eb060 + React-RCTTest: 946fabedc767f0e77968356d029b8e5441285fd9 + React-RCTText: e60444543bcc19c6a6d93d2950724b6a6cde5563 + React-RCTVibration: e06f29952d420281864957f713f0043d0f9a7c65 + React-rendererconsistency: f57f5e27a6d59206ba81e30d778508853ba13413 + React-renderercss: bd60dcb98385c35c99ce2418def6ac8aa9f32278 + React-rendererdebug: c291bb57cc45c09045f10a70b51d7e19a8eb3dd3 + React-RuntimeApple: 58754061667f6ab2c02b8423908f4d3c239e7715 + React-RuntimeCore: 288433cc447eac534c161bf78743558a7af01eb4 + React-runtimeexecutor: 0d2700e276abcdbdd837fb40e927c2af027cdbbe + React-RuntimeHermes: 4452edab032116ebc64099a8136cb1f9b5612ba6 + React-runtimescheduler: 2fe10373f5a62e98a2a2a235a8fd17a5cea4eaa3 + React-timing: 5c532f6bb6f4fcad906841498a60f77bad8d19c8 + React-utils: 0f9b6619b66a30726819d0bea4ace7d161905d74 + React-viewtransitionnativemodule: d587d49a4597082af3394d7fe39816859f0b4ee4 + React-webperformancenativemodule: 1c4b67938492a055f26b58553bcea63d48fb0103 + ReactAppDependencyProvider: f9167aed120201fa8cc6ee69a48a1b99768e0203 ReactCodegen: 19447115b4e0b8eec131ae256e7000a9a0910dc4 - ReactCommon: 1376b66d6ef10d128c76c2d59fef49e706832ca6 - ReactCommon-Samples: b74ec150a8210d62be6cc899d28a24e7b795edd3 - ScreenshotManager: c005d764f11c09fb4a5640a3157024dddce859e9 + ReactCommon: bb0c52719d59dba1666a5c1a7d4341dc92396ceb + ReactCommon-Samples: 8e155793c7e1e68d9474bc985a95997a2480ff18 + ScreenshotManager: bfc4f9d013f4f8c209cf153eb6ce7f331472b352 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - Yoga: a9afa834491f5b80bc3b10d2d91e8bfb15ab1100 + Yoga: 6f450a7968bc14bc4f200e8d400b1b89d54b6fae PODFILE CHECKSUM: 59f1e7d58cffbfafbeb3df31151743dd4c9a52e8 diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index 8248d57b2b2a..f23b821e256d 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -508,14 +508,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RNTester/Pods-RNTester-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RNTester/Pods-RNTester-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RNTester/Pods-RNTester-frameworks.sh\"\n"; @@ -551,14 +547,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RNTesterIntegrationTests/Pods-RNTesterIntegrationTests-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RNTesterIntegrationTests/Pods-RNTesterIntegrationTests-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RNTesterIntegrationTests/Pods-RNTesterIntegrationTests-resources.sh\"\n"; @@ -572,14 +564,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RNTesterUnitTests/Pods-RNTesterUnitTests-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RNTesterUnitTests/Pods-RNTesterUnitTests-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RNTesterUnitTests/Pods-RNTesterUnitTests-resources.sh\"\n"; @@ -627,14 +615,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RNTesterIntegrationTests/Pods-RNTesterIntegrationTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RNTesterIntegrationTests/Pods-RNTesterIntegrationTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RNTesterIntegrationTests/Pods-RNTesterIntegrationTests-frameworks.sh\"\n"; @@ -670,14 +654,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RNTester/Pods-RNTester-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RNTester/Pods-RNTester-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RNTester/Pods-RNTester-resources.sh\"\n"; @@ -713,14 +693,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RNTesterUnitTests/Pods-RNTesterUnitTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RNTesterUnitTests/Pods-RNTesterUnitTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RNTesterUnitTests/Pods-RNTesterUnitTests-frameworks.sh\"\n"; @@ -953,7 +929,10 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.1; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_CFLAGS = "$(inherited)"; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); OTHER_CPLUSPLUSFLAGS = ( "$(OTHER_CFLAGS)", "-DFOLLY_NO_CONFIG", @@ -961,11 +940,13 @@ "-DFOLLY_USE_LIBCPP=1", "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", + "-DRCT_REMOVE_LEGACY_ARCH=1", ); OTHER_LDFLAGS = ( "-ObjC", "-lc++", ); + PODFILE_DIR = "$(SRCROOT)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -1046,7 +1027,10 @@ ); IPHONEOS_DEPLOYMENT_TARGET = 15.1; MTL_ENABLE_DEBUG_INFO = NO; - OTHER_CFLAGS = "$(inherited)"; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); OTHER_CPLUSPLUSFLAGS = ( "$(OTHER_CFLAGS)", "-DFOLLY_NO_CONFIG", @@ -1054,11 +1038,13 @@ "-DFOLLY_USE_LIBCPP=1", "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", + "-DRCT_REMOVE_LEGACY_ARCH=1", ); OTHER_LDFLAGS = ( "-ObjC", "-lc++", ); + PODFILE_DIR = "$(SRCROOT)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../react-native"; SDKROOT = iphoneos; USE_HERMES = true; 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..24f969d0b4e1 --- /dev/null +++ b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js @@ -0,0 +1,204 @@ +/** + * 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, + StyleSheet, + 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 ? ( +