From 575d3f1297056e358ae5a0c1d90336fdfa98851f Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 14 Aug 2026 11:33:19 -0400 Subject: [PATCH 1/9] Add an onSafeAreaInsetsChange view prop Reports the part of a view that is covered by the system UI, dispatched synchronously so that layout depending on the insets lands in the frame the insets changed in. Replaces every use of the deprecated SafeAreaView inside core (LogBox, the element inspector, InputAccessoryView) with a JS implementation built on the prop. --- .../TextInput/InputAccessoryView.js | 2 +- .../Components/View/ViewPropTypes.js | 21 +++ .../__tests__/ViewSafeAreaInsets-itest.js | 120 ++++++++++++ .../LogBox/UI/LogBoxInspectorFooterButton.js | 2 +- .../LogBox/UI/LogBoxInspectorHeader.js | 14 +- .../LogBoxNotificationContainer-test.js.snap | 60 +++--- .../NativeComponent/BaseViewConfig.android.js | 4 + .../NativeComponent/BaseViewConfig.ios.js | 4 + .../Libraries/Types/CoreEventTypes.js | 21 +++ .../View/RCTViewComponentView.mm | 136 ++++++++++++++ .../ReactAndroid/api/ReactAndroid.api | 2 + .../react/uimanager/BaseViewManager.java | 10 + .../com/facebook/react/uimanager/ViewProps.kt | 1 + .../events/SafeAreaInsetsChangeEvent.kt | 65 +++++++ .../internal/SafeAreaInsetsObserver.kt | 173 ++++++++++++++++++ .../main/res/views/uimanager/values/ids.xml | 3 + .../components/view/BaseViewEventEmitter.cpp | 36 ++++ .../components/view/BaseViewEventEmitter.h | 14 ++ .../components/view/BaseViewProps.cpp | 11 ++ .../renderer/components/view/BaseViewProps.h | 1 + .../components/view/HostPlatformViewProps.cpp | 4 + packages/react-native/ReactNativeApi.d.ts | 101 +++++----- .../SafeAreaView_INTERNAL_DO_NOT_USE.js | 67 +++++-- .../elementinspector/InspectorPanel.js | 2 +- .../SafeAreaInsets/SafeAreaInsetsExample.js | 150 +++++++++++++++ .../js/utils/RNTesterList.android.js | 4 + .../rn-tester/js/utils/RNTesterList.ios.js | 4 + 27 files changed, 937 insertions(+), 95 deletions(-) create mode 100644 packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt create mode 100644 packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js 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/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index b46c5e6cd334..b124a5692b87 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,130 @@ - (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(_safeAreaInsetsMayHaveChanged) + name:name + object:nil]; + } else { + [NSNotificationCenter.defaultCenter removeObserver:self name:name object:nil]; + } + } +#endif + + if (observesSafeAreaInsets) { + [self _safeAreaInsetsMayHaveChanged]; + } +} + +- (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]; + [self _safeAreaInsetsMayHaveChanged]; +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + // The ivar is checked here rather than inside `_safeAreaInsetsMayHaveChanged` + // so that views which do not use the prop only pay for a branch. + if (_observesSafeAreaInsets) { + [self _safeAreaInsetsMayHaveChanged]; + } +} + +- (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 +909,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/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..2ebca265ce66 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt @@ -0,0 +1,173 @@ +/* + * 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. + */ + private 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..f6be372fd336 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 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/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 5220c72e26e6..9a18c0a0f6ca 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<<7a676bde00b5e419e1d0cb515daefc54>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -1867,6 +1867,9 @@ declare type DirectEventProps = { readonly onAccessibilityTap?: () => unknown readonly onLayout?: (event: LayoutChangeEvent) => unknown readonly onMagicTap?: () => unknown + readonly onSafeAreaInsetsChange?: ( + event: SafeAreaInsetsChangeEvent, + ) => unknown } declare type DisplayMetrics = { fontScale: number @@ -4208,6 +4211,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 @@ -5716,16 +5729,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 @@ -5759,9 +5772,9 @@ export { DimensionsPayload, // 653bc26c DisplayMetrics, // 1dc35cef DisplayMetricsAndroid, // 872e62eb - DrawerLayoutAndroid, // 7843b7b5 + DrawerLayoutAndroid, // 9789fb75 DrawerLayoutAndroidInstance, // c0694352 - DrawerLayoutAndroidProps, // 0c1d6155 + DrawerLayoutAndroidProps, // 2a3b9a3e DrawerSlideEvent, // c4ab8fba DropShadowValue, // e9df2606 DynamicColorIOS, // d96c228c @@ -5777,9 +5790,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 +5803,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 +5831,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 +5858,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 +5897,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 +5913,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 +5927,24 @@ export { RootViewStyleProvider, // 8792d506 Runnable, // 594dd93a Runnables, // 4367c557 - SafeAreaView, // 9ce03f75 + SafeAreaView, // 12e47c48 SafeAreaViewInstance, // 21dba39c ScaledSize, // 07e417c7 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 +5963,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 +5983,7 @@ export { TextInputIOSProps, // fb3c9327 TextInputInstance, // 5a0c0e0d TextInputKeyPressEvent, // 546c5d07 - TextInputProps, // dd91ebaa + TextInputProps, // 5cbc9768 TextInputSelectionChangeEvent, // e58f2abc TextInputSubmitEditingEvent, // 6bcb2aa5 TextInstance, // 05463a96 @@ -5995,19 +6008,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 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..469e6a0dab30 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,60 @@ */ 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 * 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; + const [insets, setInsets] = useState(null); + + 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/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js new file mode 100644 index 000000000000..030eda2ab6a8 --- /dev/null +++ b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js @@ -0,0 +1,150 @@ +/** + * 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} 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 FullScreenExample(): React.Node { + const [modalVisible, setModalVisible] = useState(false); + const [insets, , onSafeAreaInsetsChange] = useSafeAreaInsets(); + + return ( + + setModalVisible(false)} + animationType="slide" + supportedOrientations={['portrait', 'landscape']}> + + + + {insets == null + ? 'Waiting for insets…' + : `top: ${insets.top}, right: ${insets.right}, bottom: ${insets.bottom}, left: ${insets.left}`} + + + Rotate the device: the padding follows the insets in the same + frame as the rotation, without the content jumping. + +