From 66912fe710499d80cd5c6e346ef19f8b7d1923aa Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Thu, 9 Jul 2026 10:22:25 -0700 Subject: [PATCH] SafeAreaProvider, safe-area hooks, and SafeAreaView edges/mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Brings React Native core's safe-area API to functional parity with `react-native-safe-area-context`. This change adds the shared JS API and shared C++ layout logic, and wires up the Android native implementation (iOS follows in a subsequent change). New public API (exported from `react-native`): - `SafeAreaProvider` — measures the window safe-area insets and frame and provides them via context. - `useSafeAreaInsets()` / `useSafeAreaFrame()` hooks, plus `SafeAreaInsetsContext` / `SafeAreaFrameContext`, `withSafeAreaInsets` HOC, and `SafeAreaListener`. - `initialWindowMetrics` for synchronously seeding insets/frame to avoid a first-frame jump. `SafeAreaView` gains `edges` (per-edge `off` / `additive` / `maximum`) and `mode` (`padding` | `margin`) props. The inset-to-padding/margin conversion lives in the shared C++ shadow node (`SafeAreaViewShadowNode::adjustLayoutWithState`) driven from the component descriptor's `adopt`, so iOS and Android share one implementation. On Android, `SafeAreaView` now reports insets into Fabric state without consuming them, so nested providers/views compute correctly, and a new native `SafeAreaProvider` component reports insets and frame to JS via an `onInsetsChange` event. This targets the new architecture (Fabric). Changelog: [Android][Added] - Add `SafeAreaProvider`, `useSafeAreaInsets`/`useSafeAreaFrame`, and `edges`/`mode` props on `SafeAreaView` Differential Revision: D111273335 --- .../Components/SafeAreaView/InitialWindow.js | 27 +++ .../SafeAreaView/SafeAreaContext.js | 200 ++++++++++++++++++ .../Components/SafeAreaView/SafeAreaView.js | 71 ++++++- .../SafeAreaView/SafeAreaViewTypes.js | 36 ++++ .../__tests__/SafeAreaView-itest.js | 59 +++++- .../facebook/react/shell/MainReactPackage.kt | 4 + .../safeareaprovider/InsetsChangeEvent.kt | 32 +++ .../safeareaprovider/ReactSafeAreaProvider.kt | 60 ++++++ .../ReactSafeAreaProviderManager.kt | 60 ++++++ .../views/safeareaprovider/SafeAreaUtils.kt | 114 ++++++++++ .../views/safeareaview/ReactSafeAreaView.kt | 11 +- .../safeareaview/ReactSafeAreaViewManager.kt | 8 + .../SafeAreaViewComponentDescriptor.h | 5 +- .../safeareaview/SafeAreaViewShadowNode.cpp | 125 +++++++++++ .../safeareaview/SafeAreaViewShadowNode.h | 8 + .../safeareaview/tests/SafeAreaViewTest.cpp | 144 +++++++++++++ packages/react-native/index.js | 43 +++- packages/react-native/index.js.flow | 28 ++- .../specs/SafeAreaProviderNativeComponent.js | 45 ++++ .../specs/RCTSafeAreaViewNativeComponent.js | 21 +- .../modules/NativeSafeAreaContext.js | 37 ++++ 21 files changed, 1101 insertions(+), 37 deletions(-) create mode 100644 packages/react-native/Libraries/Components/SafeAreaView/InitialWindow.js create mode 100644 packages/react-native/Libraries/Components/SafeAreaView/SafeAreaContext.js create mode 100644 packages/react-native/Libraries/Components/SafeAreaView/SafeAreaViewTypes.js create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/InsetsChangeEvent.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/ReactSafeAreaProvider.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/ReactSafeAreaProviderManager.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/SafeAreaUtils.kt create mode 100644 packages/react-native/ReactCommon/react/renderer/components/safeareaview/tests/SafeAreaViewTest.cpp create mode 100644 packages/react-native/src/private/components/safeareaprovider/specs/SafeAreaProviderNativeComponent.js create mode 100644 packages/react-native/src/private/specs_DEPRECATED/modules/NativeSafeAreaContext.js diff --git a/packages/react-native/Libraries/Components/SafeAreaView/InitialWindow.js b/packages/react-native/Libraries/Components/SafeAreaView/InitialWindow.js new file mode 100644 index 000000000000..c1ddd831644b --- /dev/null +++ b/packages/react-native/Libraries/Components/SafeAreaView/InitialWindow.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {EdgeInsets, Metrics} from './SafeAreaViewTypes'; + +import NativeSafeAreaContext from '../../../src/private/specs_DEPRECATED/modules/NativeSafeAreaContext'; + +/** + * Safe area metrics available synchronously at startup, read from a native + * constant. Pass to `SafeAreaProvider`'s `initialMetrics` prop to avoid a + * first-frame layout jump. `null` when the native module is unavailable. + */ +export const initialWindowMetrics: Metrics | null = + NativeSafeAreaContext?.getConstants().initialWindowMetrics ?? null; + +/** + * @deprecated Use `initialWindowMetrics` instead. + */ +export const initialWindowSafeAreaInsets: EdgeInsets | void = + initialWindowMetrics?.insets; diff --git a/packages/react-native/Libraries/Components/SafeAreaView/SafeAreaContext.js b/packages/react-native/Libraries/Components/SafeAreaView/SafeAreaContext.js new file mode 100644 index 000000000000..048aab56bcd1 --- /dev/null +++ b/packages/react-native/Libraries/Components/SafeAreaView/SafeAreaContext.js @@ -0,0 +1,200 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {HostInstance} from '../../../src/private/types/HostInstance'; +import type {NativeSyntheticEvent} from '../../Types/CoreEventTypes'; +import type {ViewProps} from '../View/ViewPropTypes'; +import type {EdgeInsets, Metrics, Rect} from './SafeAreaViewTypes'; + +import NativeSafeAreaProvider from '../../../src/private/components/safeareaprovider/specs/SafeAreaProviderNativeComponent'; +import StyleSheet from '../../StyleSheet/StyleSheet'; +import Dimensions from '../../Utilities/Dimensions'; +import * as React from 'react'; + +export type InsetChangedEvent = NativeSyntheticEvent; + +export type SafeAreaProviderProps = Readonly<{ + ...ViewProps, + children?: React.Node, + /** + * Seed insets and frame synchronously so the first frame does not jump. + * Typically `initialWindowMetrics` from `./InitialWindow`. + */ + initialMetrics?: ?Metrics, +}>; + +export const SafeAreaInsetsContext: React.Context = + React.createContext(null); + +export const SafeAreaFrameContext: React.Context = + React.createContext(null); + +if (__DEV__) { + SafeAreaInsetsContext.displayName = 'SafeAreaInsetsContext'; + SafeAreaFrameContext.displayName = 'SafeAreaFrameContext'; +} + +export const SafeAreaProvider: component( + ref?: React.RefSetter, + ...props: SafeAreaProviderProps +) = React.forwardRef( + function SafeAreaProvider(props, forwardedRef) { + const {children, initialMetrics, style, ...others} = props; + + // Inherit from a parent provider so nested providers keep working. + const parentInsets = React.useContext(SafeAreaInsetsContext); + const parentFrame = React.useContext(SafeAreaFrameContext); + + const [insets, setInsets] = React.useState( + initialMetrics?.insets ?? parentInsets ?? null, + ); + const [frame, setFrame] = React.useState( + initialMetrics?.frame ?? + parentFrame ?? { + x: 0, + y: 0, + width: Dimensions.get('window').width, + height: Dimensions.get('window').height, + }, + ); + + const onInsetsChange = React.useCallback((event: InsetChangedEvent) => { + const { + nativeEvent: {frame: nextFrame, insets: nextInsets}, + } = event; + + setFrame(curFrame => { + if ( + nextFrame != null && + (nextFrame.height !== curFrame.height || + nextFrame.width !== curFrame.width || + nextFrame.x !== curFrame.x || + nextFrame.y !== curFrame.y) + ) { + return nextFrame; + } + return curFrame; + }); + + setInsets(curInsets => { + if ( + curInsets == null || + nextInsets.bottom !== curInsets.bottom || + nextInsets.left !== curInsets.left || + nextInsets.right !== curInsets.right || + nextInsets.top !== curInsets.top + ) { + return nextInsets; + } + return curInsets; + }); + }, []); + + return ( + + {insets != null ? ( + + + {children} + + + ) : null} + + ); + }, +); + +export type SafeAreaListenerProps = Readonly<{ + ...ViewProps, + children?: React.Node, + onChange: (data: {insets: EdgeInsets, frame: Rect}) => void, +}>; + +/** + * Observe safe area changes without providing a React context. Useful for + * imperative consumers (animations, measurements) that do not want to re-render + * on every inset change. + */ +export function SafeAreaListener(props: SafeAreaListenerProps): React.Node { + const {onChange, style, children, ...others} = props; + return ( + { + onChange({ + insets: event.nativeEvent.insets, + frame: event.nativeEvent.frame, + }); + }}> + {children} + + ); +} + +const NO_INSETS_ERROR = + 'No safe area value available. Make sure you are rendering `` at the top of your app.'; + +export function useSafeAreaInsets(): EdgeInsets { + const insets = React.useContext(SafeAreaInsetsContext); + if (insets == null) { + throw new Error(NO_INSETS_ERROR); + } + return insets; +} + +export function useSafeAreaFrame(): Rect { + const frame = React.useContext(SafeAreaFrameContext); + if (frame == null) { + throw new Error(NO_INSETS_ERROR); + } + return frame; +} + +export type WithSafeAreaInsetsProps = { + insets: EdgeInsets, +}; + +export function withSafeAreaInsets( + WrappedComponent: React.ComponentType<{...Props, +insets: EdgeInsets}>, +): React.ComponentType { + return function WithSafeAreaInsets(props: Props): React.Node { + const insets = useSafeAreaInsets(); + return ; + }; +} + +/** + * @deprecated Use `useSafeAreaInsets` instead. + */ +export function useSafeArea(): EdgeInsets { + return useSafeAreaInsets(); +} + +/** + * @deprecated Use `SafeAreaInsetsContext.Consumer` instead. + */ +export const SafeAreaConsumer: React.ComponentType< + (value: EdgeInsets | null) => React.Node, +> = SafeAreaInsetsContext.Consumer; + +/** + * @deprecated Use `SafeAreaInsetsContext` instead. + */ +export const SafeAreaContext: React.Context = + SafeAreaInsetsContext; + +const styles = StyleSheet.create({ + fill: {flex: 1}, +}); diff --git a/packages/react-native/Libraries/Components/SafeAreaView/SafeAreaView.js b/packages/react-native/Libraries/Components/SafeAreaView/SafeAreaView.js index 2b141c8c8494..78131587823f 100644 --- a/packages/react-native/Libraries/Components/SafeAreaView/SafeAreaView.js +++ b/packages/react-native/Libraries/Components/SafeAreaView/SafeAreaView.js @@ -10,26 +10,77 @@ import type {HostInstance} from '../../../src/private/types/HostInstance'; import type {ViewProps} from '../View/ViewPropTypes'; +import type {Edge, EdgeMode, EdgeRecord, Edges} from './SafeAreaViewTypes'; -import Platform from '../../Utilities/Platform'; -import View from '../View/View'; +import SafeAreaViewNativeComponent from '../../../src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent'; import * as React from 'react'; export type SafeAreaViewInstance = HostInstance; +export type SafeAreaViewProps = Readonly<{ + ...ViewProps, + /** + * Apply the safe area insets as `padding` (default) or `margin`. + */ + mode?: 'padding' | 'margin', + /** + * Which edges to inset. Either a list of edges (each applied `additive`) or a + * per-edge record of `'off' | 'additive' | 'maximum'`. Defaults to all four + * edges `additive`. + */ + edges?: Edges, +}>; + +const defaultEdges: {[Edge]: EdgeMode} = { + top: 'additive', + right: 'additive', + bottom: 'additive', + left: 'additive', +}; + /** - * Renders content within the safe area boundaries of a device. Currently only applicable to iOS devices with iOS version 11 or later. Automatically applies padding to reflect the portion of the view not covered by navigation bars, tab bars, toolbars, and other ancestor views. + * Renders content within the safe area boundaries of a device, applying padding + * (or margin) that reflects the portion of the view covered by system bars, + * notches, and other ancestor views. * * @see https://reactnative.dev/docs/safeareaview - * @deprecated Use `react-native-safe-area-context` instead. - * @platform ios */ const SafeAreaView: component( ref?: React.RefSetter, - ...props: ViewProps -) = Platform.select({ - ios: require('./RCTSafeAreaViewNativeComponent').default, - default: View, -}); + ...props: SafeAreaViewProps +) = React.forwardRef( + ({edges, mode, ...props}, ref) => { + const nativeEdges = React.useMemo(() => { + if (edges == null) { + return defaultEdges; + } + const edgesObj: EdgeRecord = Array.isArray(edges) + ? edges.reduce( + (acc, edge) => { + acc[edge] = 'additive'; + return acc; + }, + ({}: {[Edge]: EdgeMode}), + ) + : edges; + // Fabric requires every edge to be present. + return { + top: edgesObj.top ?? 'off', + right: edgesObj.right ?? 'off', + bottom: edgesObj.bottom ?? 'off', + left: edgesObj.left ?? 'off', + }; + }, [edges]); + + return ( + + ); + }, +); export default SafeAreaView; diff --git a/packages/react-native/Libraries/Components/SafeAreaView/SafeAreaViewTypes.js b/packages/react-native/Libraries/Components/SafeAreaView/SafeAreaViewTypes.js new file mode 100644 index 000000000000..9e86ddaf60f4 --- /dev/null +++ b/packages/react-native/Libraries/Components/SafeAreaView/SafeAreaViewTypes.js @@ -0,0 +1,36 @@ +/** + * 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 + * @format + */ + +export type EdgeInsets = { + top: number, + right: number, + bottom: number, + left: number, +}; + +export type Rect = { + x: number, + y: number, + width: number, + height: number, +}; + +export type Metrics = { + insets: EdgeInsets, + frame: Rect, +}; + +export type Edge = 'top' | 'right' | 'bottom' | 'left'; + +export type EdgeMode = 'off' | 'additive' | 'maximum'; + +export type EdgeRecord = Partial<{[edge: Edge]: EdgeMode}>; + +export type Edges = ReadonlyArray | Readonly; diff --git a/packages/react-native/Libraries/Components/SafeAreaView/__tests__/SafeAreaView-itest.js b/packages/react-native/Libraries/Components/SafeAreaView/__tests__/SafeAreaView-itest.js index 0fcae9f2e282..c0776ab0c77d 100644 --- a/packages/react-native/Libraries/Components/SafeAreaView/__tests__/SafeAreaView-itest.js +++ b/packages/react-native/Libraries/Components/SafeAreaView/__tests__/SafeAreaView-itest.js @@ -12,27 +12,68 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import * as Fantom from '@react-native/fantom'; import * as React from 'react'; -import {SafeAreaView, Text} from 'react-native'; +import { + SafeAreaProvider, + SafeAreaView, + Text, + useSafeAreaFrame, + useSafeAreaInsets, +} from 'react-native'; describe('', () => { - it('renders with children', () => { + it('renders as a native component with children', () => { const root = Fantom.createRoot(); Fantom.runTask(() => { root.render( - // collapsable={false} is needed to prevent the View (SafeAreaView falls - // back to View in non-iOS environments) from being optimized away in - // the Fantom output. - + Hello World! , ); }); - expect(root.getRenderedOutput({props: []}).toJSX()).toEqual( - + expect(root.getRenderedOutput().toJSX()).toEqual( + Hello World! - , + , + ); + }); +}); + +describe('', () => { + it('delivers insets and frame to useSafeAreaInsets/useSafeAreaFrame', () => { + const root = Fantom.createRoot(); + const providerRef = React.createRef(); + + function Consumer(): React.Node { + const insets = useSafeAreaInsets(); + const frame = useSafeAreaFrame(); + return ( + {`insets:${insets.top},${insets.right},${insets.bottom},${insets.left} frame:${frame.width}x${frame.height}`} + ); + } + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + // Before any insets are reported, the provider renders no children, so the + // consumer is not mounted and `useSafeAreaInsets` never throws. + expect(root.getRenderedOutput().toJSX()).toEqual(); + + Fantom.dispatchNativeEvent(providerRef.current, 'onInsetsChange', { + insets: {top: 44, right: 2, bottom: 34, left: 1}, + frame: {x: 0, y: 0, width: 390, height: 844}, + }); + + expect(root.getRenderedOutput().toJSX()).toEqual( + + insets:44,2,34,1 frame:390x844 + , ); }); }); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt index be24b4d62097..aeda080a1a7d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt @@ -50,6 +50,7 @@ import com.facebook.react.views.drawer.ReactDrawerLayoutManager import com.facebook.react.views.image.ReactImageManager import com.facebook.react.views.modal.ReactModalHostManager import com.facebook.react.views.progressbar.ReactProgressBarViewManager +import com.facebook.react.views.safeareaprovider.ReactSafeAreaProviderManager import com.facebook.react.views.safeareaview.ReactSafeAreaViewManager import com.facebook.react.views.scroll.ReactHorizontalScrollContainerViewManager import com.facebook.react.views.scroll.ReactHorizontalScrollViewManager @@ -147,6 +148,7 @@ constructor(private val config: MainPackageConfig? = null) : else ReactScrollViewManager(), ReactSwitchManager(), ReactSafeAreaViewManager(), + ReactSafeAreaProviderManager(), SwipeRefreshLayoutManager(), // Native equivalents ReactImageManager(), @@ -178,6 +180,8 @@ constructor(private val config: MainPackageConfig? = null) : ModuleSpec.viewManagerSpec { ReactProgressBarViewManager() }, ReactSafeAreaViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactSafeAreaViewManager() }, + ReactSafeAreaProviderManager.REACT_CLASS to + ModuleSpec.viewManagerSpec { ReactSafeAreaProviderManager() }, ReactScrollViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec { if (ReactNativeFeatureFlags.useNestedScrollViewAndroid()) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/InsetsChangeEvent.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/InsetsChangeEvent.kt new file mode 100644 index 000000000000..a4ed8600ba51 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/InsetsChangeEvent.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.safeareaprovider + +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +/** Event emitted by [ReactSafeAreaProvider] when its insets or frame change. */ +internal class InsetsChangeEvent( + surfaceId: Int, + viewTag: Int, + private val insets: EdgeInsets, + private val frame: Rect, +) : Event(surfaceId, viewTag) { + + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap = + com.facebook.react.bridge.Arguments.createMap().apply { + putMap("insets", edgeInsetsToJsMap(insets)) + putMap("frame", rectToJsMap(frame)) + } + + internal companion object { + const val EVENT_NAME: String = "topInsetsChange" + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/ReactSafeAreaProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/ReactSafeAreaProvider.kt new file mode 100644 index 000000000000..53c6f75c6646 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/ReactSafeAreaProvider.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.safeareaprovider + +import android.content.Context +import android.view.ViewGroup +import android.view.ViewTreeObserver +import com.facebook.react.views.view.ReactViewGroup + +internal typealias OnInsetsChangeHandler = + (view: ReactSafeAreaProvider, insets: EdgeInsets, frame: Rect) -> Unit + +/** + * Native view backing `SafeAreaProvider`. It measures the safe area insets and its own frame and + * reports them to JS via [InsetsChangeEvent] whenever they change. Measurement is driven off the + * view lifecycle: it is computed on attach and on every pre-draw pass. + */ +internal class ReactSafeAreaProvider(context: Context?) : + ReactViewGroup(context), ViewTreeObserver.OnPreDrawListener { + private var insetsChangeHandler: OnInsetsChangeHandler? = null + private var lastInsets: EdgeInsets? = null + private var lastFrame: Rect? = null + + private fun maybeUpdateInsets() { + val handler = insetsChangeHandler ?: return + val edgeInsets = getSafeAreaInsets(this) ?: return + val frame = getFrame(rootView as ViewGroup, this) ?: return + if (lastInsets != edgeInsets || lastFrame != frame) { + handler(this, edgeInsets, frame) + lastInsets = edgeInsets + lastFrame = frame + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + viewTreeObserver.addOnPreDrawListener(this) + maybeUpdateInsets() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + viewTreeObserver.removeOnPreDrawListener(this) + } + + override fun onPreDraw(): Boolean { + maybeUpdateInsets() + return true + } + + fun setOnInsetsChangeHandler(handler: OnInsetsChangeHandler?) { + insetsChangeHandler = handler + maybeUpdateInsets() + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/ReactSafeAreaProviderManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/ReactSafeAreaProviderManager.kt new file mode 100644 index 000000000000..65966614492f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/ReactSafeAreaProviderManager.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.safeareaprovider + +import com.facebook.react.bridge.ReactContext +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.viewmanagers.SafeAreaProviderManagerDelegate +import com.facebook.react.viewmanagers.SafeAreaProviderManagerInterface + +/** View manager for [ReactSafeAreaProvider] components. */ +@ReactModule(name = ReactSafeAreaProviderManager.REACT_CLASS) +internal class ReactSafeAreaProviderManager : + ViewGroupManager(), + SafeAreaProviderManagerInterface { + + private val delegate: ViewManagerDelegate = + SafeAreaProviderManagerDelegate(this) + + override fun getDelegate(): ViewManagerDelegate = delegate + + override fun getName(): String = REACT_CLASS + + override fun createViewInstance(context: ThemedReactContext): ReactSafeAreaProvider = + ReactSafeAreaProvider(context) + + override fun getExportedCustomDirectEventTypeConstants(): MutableMap = + mutableMapOf( + InsetsChangeEvent.EVENT_NAME to mutableMapOf("registrationName" to "onInsetsChange") + ) + + override fun addEventEmitters(reactContext: ThemedReactContext, view: ReactSafeAreaProvider) { + super.addEventEmitters(reactContext, view) + view.setOnInsetsChangeHandler(::dispatchInsetsChange) + } + + private fun dispatchInsetsChange( + view: ReactSafeAreaProvider, + insets: EdgeInsets, + frame: Rect, + ) { + val reactContext = view.context as ReactContext + val reactTag = view.id + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + UIManagerHelper.getEventDispatcherForReactTag(reactContext, reactTag) + ?.dispatchEvent(InsetsChangeEvent(surfaceId, reactTag, insets, frame)) + } + + internal companion object { + const val REACT_CLASS: String = "RCTSafeAreaProvider" + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/SafeAreaUtils.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/SafeAreaUtils.kt new file mode 100644 index 000000000000..cc9856f7942c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaprovider/SafeAreaUtils.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.safeareaprovider + +import android.graphics.Rect as AndroidRect +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.PixelUtil +import kotlin.math.max +import kotlin.math.min + +/** The safe area insets, in pixels, on each edge of a view. */ +internal data class EdgeInsets( + val top: Float, + val right: Float, + val bottom: Float, + val left: Float, +) + +/** The frame (position and size), in pixels, of a view within its provider. */ +internal data class Rect(val x: Float, val y: Float, val width: Float, val height: Float) + +private fun getRootWindowInsets(rootView: View): EdgeInsets? { + val insets = ViewCompat.getRootWindowInsets(rootView) ?: return null + // Intentionally excludes `ime()` so the soft keyboard never contributes to the + // bottom inset, matching iOS behavior. + val systemInsets = + insets.getInsets( + WindowInsetsCompat.Type.statusBars() or + WindowInsetsCompat.Type.displayCutout() or + WindowInsetsCompat.Type.navigationBars() or + WindowInsetsCompat.Type.captionBar() + ) + return EdgeInsets( + top = systemInsets.top.toFloat(), + right = systemInsets.right.toFloat(), + bottom = systemInsets.bottom.toFloat(), + left = systemInsets.left.toFloat(), + ) +} + +/** + * Computes the safe area insets that overlap [view], relative to its position in the window. This + * makes nested providers/views subtract only the portion of the system insets that actually covers + * the view. + */ +internal fun getSafeAreaInsets(view: View): EdgeInsets? { + // The view has not been laid out yet. + if (view.height == 0) { + return null + } + val rootView = view.rootView + val windowInsets = getRootWindowInsets(rootView) ?: return null + + val windowWidth = rootView.width.toFloat() + val windowHeight = rootView.height.toFloat() + val visibleRect = AndroidRect() + view.getGlobalVisibleRect(visibleRect) + + return EdgeInsets( + top = max(windowInsets.top - visibleRect.top, 0f), + right = max(min(visibleRect.left + view.width - windowWidth, 0f) + windowInsets.right, 0f), + bottom = max(min(visibleRect.top + view.height - windowHeight, 0f) + windowInsets.bottom, 0f), + left = max(windowInsets.left - visibleRect.left, 0f), + ) +} + +/** Computes the frame of [view] relative to [rootView]. */ +internal fun getFrame(rootView: ViewGroup, view: View): Rect? { + // This can happen while the view gets unmounted. + if (view.parent == null) { + return null + } + val offset = AndroidRect() + view.getDrawingRect(offset) + try { + rootView.offsetDescendantRectToMyCoords(view, offset) + } catch (ex: IllegalArgumentException) { + // Thrown if the view is not a descendant of rootView. Should not happen, but + // avoid crashing. + return null + } + return Rect( + x = offset.left.toFloat(), + y = offset.top.toFloat(), + width = view.width.toFloat(), + height = view.height.toFloat(), + ) +} + +internal fun edgeInsetsToJsMap(insets: EdgeInsets): WritableMap = + Arguments.createMap().apply { + putDouble("top", PixelUtil.toDIPFromPixel(insets.top).toDouble()) + putDouble("right", PixelUtil.toDIPFromPixel(insets.right).toDouble()) + putDouble("bottom", PixelUtil.toDIPFromPixel(insets.bottom).toDouble()) + putDouble("left", PixelUtil.toDIPFromPixel(insets.left).toDouble()) + } + +internal fun rectToJsMap(rect: Rect): WritableMap = + Arguments.createMap().apply { + putDouble("x", PixelUtil.toDIPFromPixel(rect.x).toDouble()) + putDouble("y", PixelUtil.toDIPFromPixel(rect.y).toDouble()) + putDouble("width", PixelUtil.toDIPFromPixel(rect.width).toDouble()) + putDouble("height", PixelUtil.toDIPFromPixel(rect.height).toDouble()) + } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaview/ReactSafeAreaView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaview/ReactSafeAreaView.kt index 6a7792c8d5b4..77b0ff1041ad 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaview/ReactSafeAreaView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaview/ReactSafeAreaView.kt @@ -12,7 +12,6 @@ import androidx.annotation.UiThread import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsCompat.CONSUMED import com.facebook.react.bridge.GuardedRunnable import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.common.build.ReactBuildConfig @@ -20,6 +19,12 @@ import com.facebook.react.uimanager.PixelUtil.pxToDp import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext +/** + * Native view backing `SafeAreaView`. It reports the system window insets that overlap it into the + * component's Fabric state on attach and whenever the insets change; the shared C++ shadow node + * then turns those insets into per-edge padding or margin according to the `edges` and `mode` + * props. + */ internal class ReactSafeAreaView(val reactContext: ThemedReactContext) : ViewGroup(reactContext) { internal var stateWrapper: StateWrapper? = null @@ -32,7 +37,9 @@ internal class ReactSafeAreaView(val reactContext: ThemedReactContext) : ViewGro WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() ) updateState(insets) - CONSUMED + // Do NOT consume the insets: nested SafeAreaViews and SafeAreaProviders below this view must + // still receive them. + windowInsets } requestApplyInsets() } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaview/ReactSafeAreaViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaview/ReactSafeAreaViewManager.kt index 4e0c7154b88e..f487942b8e30 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaview/ReactSafeAreaViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/safeareaview/ReactSafeAreaViewManager.kt @@ -7,6 +7,7 @@ package com.facebook.react.views.safeareaview +import com.facebook.react.bridge.ReadableMap import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.ReactStylesDiffMap import com.facebook.react.uimanager.StateWrapper @@ -47,6 +48,13 @@ internal class ReactSafeAreaViewManager : return null } + // `edges` and `mode` are consumed by the shared C++ shadow node (which converts the state insets + // into per-edge padding/margin), so the Android view needs no behavior here. The overrides exist + // only to satisfy the codegen-generated manager interface. + override fun setEdges(view: ReactSafeAreaView, value: ReadableMap?): Unit = Unit + + override fun setMode(view: ReactSafeAreaView, value: String?): Unit = Unit + internal companion object { const val REACT_CLASS: String = "RCTSafeAreaView" } diff --git a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewComponentDescriptor.h b/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewComponentDescriptor.h index a03c7c968d2e..c9574780fb84 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewComponentDescriptor.h +++ b/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewComponentDescriptor.h @@ -19,9 +19,8 @@ class SafeAreaViewComponentDescriptor final : public ConcreteComponentDescriptor using ConcreteComponentDescriptor::ConcreteComponentDescriptor; void adopt(ShadowNode &shadowNode) const override { - auto &layoutableShadowNode = static_cast(shadowNode); - auto &stateData = static_cast(*shadowNode.getState()).getData(); - layoutableShadowNode.setPadding(stateData.padding); + auto &safeAreaViewShadowNode = static_cast(shadowNode); + safeAreaViewShadowNode.adjustLayoutWithState(); ConcreteComponentDescriptor::adopt(shadowNode); } diff --git a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewShadowNode.cpp index 52a347fba9ef..b70d610884a0 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewShadowNode.cpp @@ -7,9 +7,134 @@ #include "SafeAreaViewShadowNode.h" +#include +#include + namespace facebook::react { +using namespace yoga; + // NOLINTNEXTLINE(modernize-avoid-c-arrays) const char SafeAreaViewComponentName[] = "SafeAreaView"; +namespace { + +Style::Length valueFromEdges( + Style::Length edge, + Style::Length axis, + Style::Length defaultValue) { + if (edge.isDefined()) { + return edge; + } + if (axis.isDefined()) { + return axis; + } + return defaultValue; +} + +float getEdgeValue( + const std::string& edgeMode, + float insetValue, + float edgeValue) { + if (edgeMode == "off") { + return edgeValue; + } + if (edgeMode == "maximum") { + return std::fmax(insetValue, edgeValue); + } + // "additive" (default) + return insetValue + edgeValue; +} + +} // namespace + +void SafeAreaViewShadowNode::adjustLayoutWithState() { + ensureUnsealed(); + + const auto& props = getConcreteProps(); + const auto& stateData = + static_cast(*getState()) + .getData(); + const auto& edges = props.edges; + // State carries the raw window insets, reported by the native view. + const auto& insets = stateData.padding; + + // Read the base padding/margin already set on the node, so `additive` and + // `maximum` edge modes can build on top of author-supplied values. + Style::Length top, left, right, bottom; + if (props.mode == SafeAreaViewMode::Padding) { + auto defaultPadding = props.yogaStyle.padding(Edge::All); + top = valueFromEdges( + props.yogaStyle.padding(Edge::Top), + props.yogaStyle.padding(Edge::Vertical), + defaultPadding); + left = valueFromEdges( + props.yogaStyle.padding(Edge::Left), + props.yogaStyle.padding(Edge::Horizontal), + defaultPadding); + bottom = valueFromEdges( + props.yogaStyle.padding(Edge::Bottom), + props.yogaStyle.padding(Edge::Vertical), + defaultPadding); + right = valueFromEdges( + props.yogaStyle.padding(Edge::Right), + props.yogaStyle.padding(Edge::Horizontal), + defaultPadding); + } else { + auto defaultMargin = props.yogaStyle.margin(Edge::All); + top = valueFromEdges( + props.yogaStyle.margin(Edge::Top), + props.yogaStyle.margin(Edge::Vertical), + defaultMargin); + left = valueFromEdges( + props.yogaStyle.margin(Edge::Left), + props.yogaStyle.margin(Edge::Horizontal), + defaultMargin); + bottom = valueFromEdges( + props.yogaStyle.margin(Edge::Bottom), + props.yogaStyle.margin(Edge::Vertical), + defaultMargin); + right = valueFromEdges( + props.yogaStyle.margin(Edge::Right), + props.yogaStyle.margin(Edge::Horizontal), + defaultMargin); + } + + top = Style::Length::points( + getEdgeValue(edges.top, insets.top, top.value().unwrapOrDefault(0))); + left = Style::Length::points( + getEdgeValue(edges.left, insets.left, left.value().unwrapOrDefault(0))); + right = Style::Length::points(getEdgeValue( + edges.right, insets.right, right.value().unwrapOrDefault(0))); + bottom = Style::Length::points(getEdgeValue( + edges.bottom, insets.bottom, bottom.value().unwrapOrDefault(0))); + + yoga::Style adjustedStyle = props.yogaStyle; + if (props.mode == SafeAreaViewMode::Padding) { + adjustedStyle.setPadding(Edge::Top, top); + adjustedStyle.setPadding(Edge::Left, left); + adjustedStyle.setPadding(Edge::Right, right); + adjustedStyle.setPadding(Edge::Bottom, bottom); + } else { + adjustedStyle.setMargin(Edge::Top, top); + adjustedStyle.setMargin(Edge::Left, left); + adjustedStyle.setMargin(Edge::Right, right); + adjustedStyle.setMargin(Edge::Bottom, bottom); + } + + const auto& currentStyle = yogaNode_.style(); + if (adjustedStyle.padding(Edge::Top) != currentStyle.padding(Edge::Top) || + adjustedStyle.padding(Edge::Left) != currentStyle.padding(Edge::Left) || + adjustedStyle.padding(Edge::Right) != currentStyle.padding(Edge::Right) || + adjustedStyle.padding(Edge::Bottom) != + currentStyle.padding(Edge::Bottom) || + adjustedStyle.margin(Edge::Top) != currentStyle.margin(Edge::Top) || + adjustedStyle.margin(Edge::Left) != currentStyle.margin(Edge::Left) || + adjustedStyle.margin(Edge::Right) != currentStyle.margin(Edge::Right) || + adjustedStyle.margin(Edge::Bottom) != currentStyle.margin(Edge::Bottom)) { + yogaNode_.setStyle(adjustedStyle); + yogaNode_.setDirty(true); + } +} + } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewShadowNode.h b/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewShadowNode.h index ed073709833f..feab2446de1d 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewShadowNode.h +++ b/packages/react-native/ReactCommon/react/renderer/components/safeareaview/SafeAreaViewShadowNode.h @@ -23,6 +23,14 @@ extern const char SafeAreaViewComponentName[]; class SafeAreaViewShadowNode final : public ConcreteViewShadowNode { using ConcreteViewShadowNode::ConcreteViewShadowNode; + + public: + /* + * Applies the safe area insets carried in the component's state as padding or + * margin, per the `edges` and `mode` props, by mutating the underlying Yoga + * style. Called from the component descriptor's `adopt`. + */ + void adjustLayoutWithState(); }; } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/safeareaview/tests/SafeAreaViewTest.cpp b/packages/react-native/ReactCommon/react/renderer/components/safeareaview/tests/SafeAreaViewTest.cpp new file mode 100644 index 000000000000..e930e887578b --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/safeareaview/tests/SafeAreaViewTest.cpp @@ -0,0 +1,144 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +namespace { + +ComponentBuilder safeAreaComponentBuilder() { + ComponentDescriptorProviderRegistry registry{}; + auto eventDispatcher = EventDispatcher::Shared{}; + auto componentDescriptorRegistry = registry.createComponentDescriptorRegistry( + ComponentDescriptorParameters{ + .eventDispatcher = eventDispatcher, + .contextContainer = nullptr, + .flavor = nullptr}); + + registry.add(concreteComponentDescriptorProvider()); + registry.add(concreteComponentDescriptorProvider()); + registry.add( + concreteComponentDescriptorProvider()); + + return ComponentBuilder{componentDescriptorRegistry}; +} + +// Builds a 200x200 root containing a single 200x200 with the +// given edges/mode props and the given insets seeded into its state, lays it +// out, and returns the SafeAreaView's resolved content insets (== padding when +// there is no border). +EdgeInsets layoutSafeAreaView( + const std::function& configureProps, + EdgeInsets insets) { + auto builder = safeAreaComponentBuilder(); + std::shared_ptr rootShadowNode; + std::shared_ptr safeAreaViewShadowNode; + + // clang-format off + auto element = + Element() + .reference(rootShadowNode) + .tag(1) + .props([] { + auto sharedProps = std::make_shared(); + auto& props = *sharedProps; + props.layoutConstraints = LayoutConstraints{ + .minimumSize = {.width = 0, .height = 0}, + .maximumSize = {.width = 200, .height = 200}}; + auto& yogaStyle = props.yogaStyle; + yogaStyle.setDimension( + yoga::Dimension::Width, yoga::StyleSizeLength::points(200)); + yogaStyle.setDimension( + yoga::Dimension::Height, yoga::StyleSizeLength::points(200)); + return sharedProps; + }) + .children({ + Element() + .reference(safeAreaViewShadowNode) + .tag(2) + .props([&configureProps] { + auto sharedProps = std::make_shared(); + auto& props = *sharedProps; + props.edges.top = "additive"; + props.edges.right = "additive"; + props.edges.bottom = "additive"; + props.edges.left = "additive"; + props.mode = SafeAreaViewMode::Padding; + auto& yogaStyle = props.yogaStyle; + yogaStyle.setDimension( + yoga::Dimension::Width, yoga::StyleSizeLength::points(200)); + yogaStyle.setDimension( + yoga::Dimension::Height, yoga::StyleSizeLength::points(200)); + configureProps(props); + return sharedProps; + }) + .stateData([insets](SafeAreaViewState& data) { + data.padding = insets; + }) + }); + // clang-format on + + builder.build(element); + + rootShadowNode->layoutIfNeeded(); + rootShadowNode->sealRecursive(); + + return safeAreaViewShadowNode->getLayoutMetrics().contentInsets; +} + +} // namespace + +TEST(SafeAreaViewTest, additiveEdgesApplyInsetsAsPadding) { + auto contentInsets = layoutSafeAreaView( + [](SafeAreaViewProps&) {}, + EdgeInsets{.left = 1, .top = 44, .right = 2, .bottom = 34}); + + EXPECT_EQ(contentInsets.top, 44); + EXPECT_EQ(contentInsets.right, 2); + EXPECT_EQ(contentInsets.bottom, 34); + EXPECT_EQ(contentInsets.left, 1); +} + +TEST(SafeAreaViewTest, offEdgeIsNotInset) { + auto contentInsets = layoutSafeAreaView( + [](SafeAreaViewProps& props) { props.edges.top = "off"; }, + EdgeInsets{.left = 1, .top = 44, .right = 2, .bottom = 34}); + + // `top` is off, so it keeps the (zero) base padding; the rest are additive. + EXPECT_EQ(contentInsets.top, 0); + EXPECT_EQ(contentInsets.right, 2); + EXPECT_EQ(contentInsets.bottom, 34); + EXPECT_EQ(contentInsets.left, 1); +} + +TEST(SafeAreaViewTest, maximumEdgeUsesLargerOfInsetAndBasePadding) { + auto contentInsets = layoutSafeAreaView( + [](SafeAreaViewProps& props) { + props.edges.top = "maximum"; + props.edges.bottom = "maximum"; + // Base padding larger than the inset on top, smaller on bottom. + props.yogaStyle.setPadding( + yoga::Edge::Top, yoga::StyleLength::points(100)); + props.yogaStyle.setPadding( + yoga::Edge::Bottom, yoga::StyleLength::points(0)); + }, + EdgeInsets{.left = 0, .top = 44, .right = 0, .bottom = 34}); + + EXPECT_EQ(contentInsets.top, 100); // max(44, 100) + EXPECT_EQ(contentInsets.bottom, 34); // max(34, 0) +} + +} // namespace facebook::react diff --git a/packages/react-native/index.js b/packages/react-native/index.js index 0fb660e9aaf7..822772096231 100644 --- a/packages/react-native/index.js +++ b/packages/react-native/index.js @@ -114,20 +114,29 @@ module.exports = { return require('./Libraries/Components/RefreshControl/RefreshControl') .default; }, - /** - * @deprecated SafeAreaView has been deprecated and will be removed in a future release. - * Please use 'react-native-safe-area-context' instead. - * See https://github.com/AppAndFlow/react-native-safe-area-context - */ + get SafeAreaFrameContext() { + return require('./Libraries/Components/SafeAreaView/SafeAreaContext') + .SafeAreaFrameContext; + }, + get SafeAreaInsetsContext() { + return require('./Libraries/Components/SafeAreaView/SafeAreaContext') + .SafeAreaInsetsContext; + }, + get SafeAreaListener() { + return require('./Libraries/Components/SafeAreaView/SafeAreaContext') + .SafeAreaListener; + }, + get SafeAreaProvider() { + return require('./Libraries/Components/SafeAreaView/SafeAreaContext') + .SafeAreaProvider; + }, get SafeAreaView() { - warnOnce( - 'safe-area-view-deprecated', - 'SafeAreaView has been deprecated and will be removed in a future release. ' + - "Please use 'react-native-safe-area-context' instead. " + - 'See https://github.com/AppAndFlow/react-native-safe-area-context', - ); return require('./Libraries/Components/SafeAreaView/SafeAreaView').default; }, + get withSafeAreaInsets() { + return require('./Libraries/Components/SafeAreaView/SafeAreaContext') + .withSafeAreaInsets; + }, get ScrollView() { return require('./Libraries/Components/ScrollView/ScrollView').default; }, @@ -386,6 +395,18 @@ module.exports = { get usePressability() { return require('./Libraries/Pressability/usePressability').default; }, + get useSafeAreaFrame() { + return require('./Libraries/Components/SafeAreaView/SafeAreaContext') + .useSafeAreaFrame; + }, + get useSafeAreaInsets() { + return require('./Libraries/Components/SafeAreaView/SafeAreaContext') + .useSafeAreaInsets; + }, + get initialWindowMetrics() { + return require('./Libraries/Components/SafeAreaView/InitialWindow') + .initialWindowMetrics; + }, get useWindowDimensions() { return require('./Libraries/Utilities/useWindowDimensions').default; }, diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow index 51e4a54e0552..cc1134c0eb9f 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -108,8 +108,34 @@ export type { } from './Libraries/Components/RefreshControl/RefreshControl'; export {default as RefreshControl} from './Libraries/Components/RefreshControl/RefreshControl'; -export type {SafeAreaViewInstance} from './Libraries/Components/SafeAreaView/SafeAreaView'; +export type { + SafeAreaViewInstance, + SafeAreaViewProps, +} from './Libraries/Components/SafeAreaView/SafeAreaView'; export {default as SafeAreaView} from './Libraries/Components/SafeAreaView/SafeAreaView'; +export type { + Edge, + EdgeInsets, + EdgeMode, + Edges, + Metrics, + Rect, +} from './Libraries/Components/SafeAreaView/SafeAreaViewTypes'; +export type { + SafeAreaListenerProps, + SafeAreaProviderProps, + WithSafeAreaInsetsProps, +} from './Libraries/Components/SafeAreaView/SafeAreaContext'; +export { + SafeAreaFrameContext, + SafeAreaInsetsContext, + SafeAreaListener, + SafeAreaProvider, + useSafeAreaFrame, + useSafeAreaInsets, + withSafeAreaInsets, +} from './Libraries/Components/SafeAreaView/SafeAreaContext'; +export {initialWindowMetrics} from './Libraries/Components/SafeAreaView/InitialWindow'; export type { ScrollViewImperativeMethods, diff --git a/packages/react-native/src/private/components/safeareaprovider/specs/SafeAreaProviderNativeComponent.js b/packages/react-native/src/private/components/safeareaprovider/specs/SafeAreaProviderNativeComponent.js new file mode 100644 index 000000000000..d8870f6e654d --- /dev/null +++ b/packages/react-native/src/private/components/safeareaprovider/specs/SafeAreaProviderNativeComponent.js @@ -0,0 +1,45 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {ViewProps} from '../../../../../Libraries/Components/View/ViewPropTypes'; +import type { + DirectEventHandler, + Double, +} from '../../../../../Libraries/Types/CodegenTypes'; +import type {HostComponent} from '../../../types/HostComponent'; + +import codegenNativeComponent from '../../../../../Libraries/Utilities/codegenNativeComponent'; + +export type SafeAreaProviderInsetsChangeEvent = Readonly<{ + insets: Readonly<{ + top: Double, + right: Double, + bottom: Double, + left: Double, + }>, + frame: Readonly<{ + x: Double, + y: Double, + width: Double, + height: Double, + }>, +}>; + +type SafeAreaProviderNativeProps = Readonly<{ + ...ViewProps, + onInsetsChange?: ?DirectEventHandler, +}>; + +export default codegenNativeComponent( + 'SafeAreaProvider', + { + paperComponentName: 'RCTSafeAreaProvider', + }, +) as HostComponent; diff --git a/packages/react-native/src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent.js b/packages/react-native/src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent.js index 444937011090..0a1e1efb2a1d 100644 --- a/packages/react-native/src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent.js +++ b/packages/react-native/src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent.js @@ -9,6 +9,7 @@ */ import type {ViewProps} from '../../../../../Libraries/Components/View/ViewPropTypes'; +import type {WithDefault} from '../../../../../Libraries/Types/CodegenTypes'; import type {HostComponent} from '../../../types/HostComponent'; import codegenNativeComponent from '../../../../../Libraries/Utilities/codegenNativeComponent'; @@ -16,7 +17,25 @@ import codegenNativeComponent from '../../../../../Libraries/Utilities/codegenNa type RCTSafeAreaViewNativeProps = Readonly<{ ...ViewProps, - // No props + /** + * Whether the safe area insets are applied as `padding` (default) or + * `margin`. Applied per-edge according to `edges`. + */ + mode?: WithDefault<'padding' | 'margin', 'padding'>, + + /** + * Which edges to apply the safe area insets to, and how. Each edge is one of + * `'off'` (ignore), `'additive'` (add the inset to any existing + * padding/margin), or `'maximum'` (use the larger of the inset and the + * existing padding/margin). The JS `SafeAreaView` normalizes the public + * `edges` prop into this fully-specified object before it reaches native. + */ + edges?: Readonly<{ + top: string, + right: string, + bottom: string, + left: string, + }>, }>; export default codegenNativeComponent( diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeSafeAreaContext.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeSafeAreaContext.js new file mode 100644 index 000000000000..60d4f8f1752b --- /dev/null +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeSafeAreaContext.js @@ -0,0 +1,37 @@ +/** + * 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 + * @format + */ + +import type {TurboModule} from '../../../../Libraries/TurboModule/RCTExport'; +import type {Double} from '../../../../Libraries/Types/CodegenTypes'; + +import * as TurboModuleRegistry from '../../../../Libraries/TurboModule/TurboModuleRegistry'; + +export type SafeAreaContextConstants = { + initialWindowMetrics?: { + insets: { + top: Double, + right: Double, + bottom: Double, + left: Double, + }, + frame: { + x: Double, + y: Double, + width: Double, + height: Double, + }, + }, +}; + +export interface Spec extends TurboModule { + readonly getConstants: () => SafeAreaContextConstants; +} + +export default (TurboModuleRegistry.get('SafeAreaContext'): ?Spec);