diff --git a/src/InitialWindow.native.ts b/src/InitialWindow.native.ts index 5ff96845..65f4e792 100644 --- a/src/InitialWindow.native.ts +++ b/src/InitialWindow.native.ts @@ -1,8 +1,33 @@ -import type { Metrics } from './SafeArea.types'; +import { Dimensions, type ScaledSize } from 'react-native'; +import type { EdgeInsets, Metrics } from './SafeArea.types'; +import { SUPPORTS_CORE_SAFE_AREA_INSETS } from './coreSafeAreaInsets'; import NativeSafeAreaContext from './specs/NativeSafeAreaContext'; -export const initialWindowMetrics = (NativeSafeAreaContext?.getConstants?.() - ?.initialWindowMetrics ?? null) as Metrics | null; +// Drop once the installed react-native types declare the field. +type ScaledSizeWithInsets = ScaledSize & { + safeAreaInsets?: EdgeInsets; +}; + +function getInitialWindowMetrics(): Metrics | null { + if (SUPPORTS_CORE_SAFE_AREA_INSETS) { + // Core reports window safe area insets through the Dimensions module, + // using the same native code as the `onSafeAreaInsetsChange` view prop. + const { width, height, safeAreaInsets } = Dimensions.get( + 'window', + ) as ScaledSizeWithInsets; + if (safeAreaInsets == null) { + return null; + } + return { + insets: safeAreaInsets, + frame: { x: 0, y: 0, width, height }, + }; + } + return (NativeSafeAreaContext?.getConstants?.()?.initialWindowMetrics ?? + null) as Metrics | null; +} + +export const initialWindowMetrics = getInitialWindowMetrics(); /** * @deprecated diff --git a/src/JSSafeAreaView.tsx b/src/JSSafeAreaView.tsx new file mode 100644 index 00000000..0459a00f --- /dev/null +++ b/src/JSSafeAreaView.tsx @@ -0,0 +1,123 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { + Edge, + EdgeMode, + EdgeRecord, + NativeSafeAreaViewInstance, + NativeSafeAreaViewProps, +} from './SafeArea.types'; +import { useSafeAreaInsets } from './SafeAreaContext'; + +const defaultEdges: Record = { + top: 'additive', + left: 'additive', + bottom: 'additive', + right: 'additive', +}; + +function getEdgeValue( + inset: number, + current: number, + mode: EdgeMode | undefined, +) { + switch (mode) { + case 'maximum': + return Math.max(current, inset); + case 'additive': + return current + inset; + case 'off': + default: + return current; + } +} + +export const JSSafeAreaView = React.forwardRef< + NativeSafeAreaViewInstance, + NativeSafeAreaViewProps +>(({ style = {}, mode, edges, ...rest }, ref) => { + const insets = useSafeAreaInsets(); + + const edgesRecord = React.useMemo(() => { + if (edges == null) { + return defaultEdges; + } + + return Array.isArray(edges) + ? edges.reduce((acc, edge: Edge) => { + acc[edge] = 'additive'; + return acc; + }, {}) + : // ts has trouble with refining readonly arrays. + (edges as EdgeRecord); + }, [edges]); + + const appliedStyle = React.useMemo(() => { + const flatStyle = StyleSheet.flatten(style) as Record; + + if (mode === 'margin') { + const { + margin = 0, + marginVertical = margin, + marginHorizontal = margin, + marginTop = marginVertical, + marginRight = marginHorizontal, + marginBottom = marginVertical, + marginLeft = marginHorizontal, + } = flatStyle; + + const marginStyle = { + marginTop: getEdgeValue(insets.top, marginTop, edgesRecord.top), + marginRight: getEdgeValue(insets.right, marginRight, edgesRecord.right), + marginBottom: getEdgeValue( + insets.bottom, + marginBottom, + edgesRecord.bottom, + ), + marginLeft: getEdgeValue(insets.left, marginLeft, edgesRecord.left), + }; + + return [style, marginStyle]; + } else { + const { + padding = 0, + paddingVertical = padding, + paddingHorizontal = padding, + paddingTop = paddingVertical, + paddingRight = paddingHorizontal, + paddingBottom = paddingVertical, + paddingLeft = paddingHorizontal, + } = flatStyle; + + const paddingStyle = { + paddingTop: getEdgeValue(insets.top, paddingTop, edgesRecord.top), + paddingRight: getEdgeValue( + insets.right, + paddingRight, + edgesRecord.right, + ), + paddingBottom: getEdgeValue( + insets.bottom, + paddingBottom, + edgesRecord.bottom, + ), + paddingLeft: getEdgeValue(insets.left, paddingLeft, edgesRecord.left), + }; + + return [style, paddingStyle]; + } + }, [ + edgesRecord.bottom, + edgesRecord.left, + edgesRecord.right, + edgesRecord.top, + insets.bottom, + insets.left, + insets.right, + insets.top, + mode, + style, + ]); + + return ; +}); diff --git a/src/NativeSafeAreaProvider.tsx b/src/NativeSafeAreaProvider.tsx index d078d068..cebd6f4f 100644 --- a/src/NativeSafeAreaProvider.tsx +++ b/src/NativeSafeAreaProvider.tsx @@ -1,3 +1,24 @@ -import NativeSafeAreaProvider from './specs/NativeSafeAreaProvider'; +import * as React from 'react'; +import type { NativeSafeAreaProviderProps } from './SafeArea.types'; +import { + InsetsView, + SUPPORTS_CORE_SAFE_AREA_INSETS, +} from './coreSafeAreaInsets'; +import NativeSafeAreaProviderComponent from './specs/NativeSafeAreaProvider'; -export { NativeSafeAreaProvider }; +function CoreNativeSafeAreaProvider({ + children, + style, + onInsetsChange, + ...rest +}: NativeSafeAreaProviderProps) { + return ( + + {children} + + ); +} + +export const NativeSafeAreaProvider = SUPPORTS_CORE_SAFE_AREA_INSETS + ? CoreNativeSafeAreaProvider + : NativeSafeAreaProviderComponent; diff --git a/src/SafeAreaView.tsx b/src/SafeAreaView.tsx index 1fe6b802..bed19c56 100644 --- a/src/SafeAreaView.tsx +++ b/src/SafeAreaView.tsx @@ -6,6 +6,8 @@ import type { NativeSafeAreaViewInstance, NativeSafeAreaViewProps, } from './SafeArea.types'; +import { JSSafeAreaView } from './JSSafeAreaView'; +import { SUPPORTS_CORE_SAFE_AREA_INSETS } from './coreSafeAreaInsets'; import NativeSafeAreaView from './specs/NativeSafeAreaView'; import { useMemo } from 'react'; @@ -18,7 +20,7 @@ const defaultEdges: Record = { export type SafeAreaViewProps = NativeSafeAreaViewProps; -export const SafeAreaView = React.forwardRef< +const NativeBackedSafeAreaView = React.forwardRef< NativeSafeAreaViewInstance, SafeAreaViewProps >(({ edges, ...props }, ref) => { @@ -48,3 +50,12 @@ export const SafeAreaView = React.forwardRef< return ; }); + +// When core reports safe area insets itself (see ./coreSafeAreaInsets), the +// JS implementation the library already uses on web works on every platform: +// the provider distributes insets through context and this component applies +// them per-edge as padding or margin. Sync dispatch in core keeps the +// same-frame behavior the native shadow node implementation had. +export const SafeAreaView = SUPPORTS_CORE_SAFE_AREA_INSETS + ? (JSSafeAreaView as typeof NativeBackedSafeAreaView) + : NativeBackedSafeAreaView; diff --git a/src/SafeAreaView.web.tsx b/src/SafeAreaView.web.tsx index 431f0aa3..a3b81d59 100644 --- a/src/SafeAreaView.web.tsx +++ b/src/SafeAreaView.web.tsx @@ -1,123 +1 @@ -import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; -import { - Edge, - EdgeMode, - EdgeRecord, - NativeSafeAreaViewInstance, - NativeSafeAreaViewProps, -} from './SafeArea.types'; -import { useSafeAreaInsets } from './SafeAreaContext'; - -const defaultEdges: Record = { - top: 'additive', - left: 'additive', - bottom: 'additive', - right: 'additive', -}; - -function getEdgeValue( - inset: number, - current: number, - mode: EdgeMode | undefined, -) { - switch (mode) { - case 'maximum': - return Math.max(current, inset); - case 'additive': - return current + inset; - case 'off': - default: - return current; - } -} - -export const SafeAreaView = React.forwardRef< - NativeSafeAreaViewInstance, - NativeSafeAreaViewProps ->(({ style = {}, mode, edges, ...rest }, ref) => { - const insets = useSafeAreaInsets(); - - const edgesRecord = React.useMemo(() => { - if (edges == null) { - return defaultEdges; - } - - return Array.isArray(edges) - ? edges.reduce((acc, edge: Edge) => { - acc[edge] = 'additive'; - return acc; - }, {}) - : // ts has trouble with refining readonly arrays. - (edges as EdgeRecord); - }, [edges]); - - const appliedStyle = React.useMemo(() => { - const flatStyle = StyleSheet.flatten(style) as Record; - - if (mode === 'margin') { - const { - margin = 0, - marginVertical = margin, - marginHorizontal = margin, - marginTop = marginVertical, - marginRight = marginHorizontal, - marginBottom = marginVertical, - marginLeft = marginHorizontal, - } = flatStyle; - - const marginStyle = { - marginTop: getEdgeValue(insets.top, marginTop, edgesRecord.top), - marginRight: getEdgeValue(insets.right, marginRight, edgesRecord.right), - marginBottom: getEdgeValue( - insets.bottom, - marginBottom, - edgesRecord.bottom, - ), - marginLeft: getEdgeValue(insets.left, marginLeft, edgesRecord.left), - }; - - return [style, marginStyle]; - } else { - const { - padding = 0, - paddingVertical = padding, - paddingHorizontal = padding, - paddingTop = paddingVertical, - paddingRight = paddingHorizontal, - paddingBottom = paddingVertical, - paddingLeft = paddingHorizontal, - } = flatStyle; - - const paddingStyle = { - paddingTop: getEdgeValue(insets.top, paddingTop, edgesRecord.top), - paddingRight: getEdgeValue( - insets.right, - paddingRight, - edgesRecord.right, - ), - paddingBottom: getEdgeValue( - insets.bottom, - paddingBottom, - edgesRecord.bottom, - ), - paddingLeft: getEdgeValue(insets.left, paddingLeft, edgesRecord.left), - }; - - return [style, paddingStyle]; - } - }, [ - edgesRecord.bottom, - edgesRecord.left, - edgesRecord.right, - edgesRecord.top, - insets.bottom, - insets.left, - insets.right, - insets.top, - mode, - style, - ]); - - return ; -}); +export { JSSafeAreaView as SafeAreaView } from './JSSafeAreaView'; diff --git a/src/__tests__/InitialWindow-test.tsx b/src/__tests__/InitialWindow-test.tsx index 3097eab3..daf3c6fc 100644 --- a/src/__tests__/InitialWindow-test.tsx +++ b/src/__tests__/InitialWindow-test.tsx @@ -3,13 +3,34 @@ import type { Metrics } from '../SafeArea.types'; describe('InitialWindow', () => { describe('initialWindowMetrics', () => { - it('is null when no view config is available', () => { + it('is null when the window safe area insets are not available', () => { jest.resetModules(); expect(require('../InitialWindow').initialWindowMetrics).toBe(null); }); - it('it uses the constant provided by the view config', () => { + it('uses the window metrics from the Dimensions module', () => { jest.resetModules(); + const { Dimensions } = require('react-native'); + jest.spyOn(Dimensions, 'get').mockReturnValue({ + width: 100, + height: 100, + scale: 2, + fontScale: 1, + safeAreaInsets: { top: 20, right: 0, bottom: 0, left: 0 }, + }); + + expect(require('../InitialWindow').initialWindowMetrics).toEqual({ + insets: { top: 20, right: 0, bottom: 0, left: 0 }, + frame: { x: 0, y: 0, width: 100, height: 100 }, + }); + }); + + it('uses the constant provided by the native module on older React Native', () => { + jest.resetModules(); + jest.doMock('../coreSafeAreaInsets', () => ({ + ...jest.requireActual('../coreSafeAreaInsets'), + SUPPORTS_CORE_SAFE_AREA_INSETS: false, + })); const testMetrics: Metrics = { insets: { top: 20, @@ -42,6 +63,7 @@ describe('InitialWindow', () => { testMetrics, ); expect(TurboModuleRegistry.get).toBeCalledWith('RNCSafeAreaContext'); + jest.dontMock('../coreSafeAreaInsets'); }); }); }); diff --git a/src/__tests__/SafeAreaContext-test.tsx b/src/__tests__/SafeAreaContext-test.tsx index 1ce1c8b3..43b306d6 100644 --- a/src/__tests__/SafeAreaContext-test.tsx +++ b/src/__tests__/SafeAreaContext-test.tsx @@ -61,7 +61,11 @@ describe('SafeAreaContext', () => { expect(toJSON()).toMatchSnapshot(); const element = await screen.findByTestId('safe-area-provider'); act(() => { - element.props.onInsetsChange({ + // The prop name on the host element depends on whether insets come from + // the core `onSafeAreaInsetsChange` view prop or the native provider. + const onInsetsChange = + element.props.onSafeAreaInsetsChange ?? element.props.onInsetsChange; + onInsetsChange({ nativeEvent: TEST_METRICS_1, }); }); diff --git a/src/__tests__/__snapshots__/SafeAreaContext-test.tsx.snap b/src/__tests__/__snapshots__/SafeAreaContext-test.tsx.snap index 6dbf657d..66627073 100644 --- a/src/__tests__/__snapshots__/SafeAreaContext-test.tsx.snap +++ b/src/__tests__/__snapshots__/SafeAreaContext-test.tsx.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SafeAreaContext does not render child until inset values are received 1`] = ` - - + `; exports[`SafeAreaContext supports setting initial insets 1`] = ` - - + `; exports[`SafeAreaContext uses inner insets 1`] = ` - - - - + + `; exports[`SafeAreaContext uses parent insets when available 1`] = ` - - - - + + `; diff --git a/src/__tests__/__snapshots__/SafeAreaView-test.tsx.snap b/src/__tests__/__snapshots__/SafeAreaView-test.tsx.snap index a6079afb..4c2a8865 100644 --- a/src/__tests__/__snapshots__/SafeAreaView-test.tsx.snap +++ b/src/__tests__/__snapshots__/SafeAreaView-test.tsx.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SafeAreaView can override padding styles 1`] = ` - - - - + + `; exports[`SafeAreaView can set edges 1`] = ` - - - - + + `; exports[`SafeAreaView can set edges value type 1`] = ` - - - - + + `; exports[`SafeAreaView renders 1`] = ` - - - - + + `; diff --git a/src/coreSafeAreaInsets.ts b/src/coreSafeAreaInsets.ts new file mode 100644 index 00000000..40442532 --- /dev/null +++ b/src/coreSafeAreaInsets.ts @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { Platform, View, type ViewProps } from 'react-native'; +import type { InsetChangeNativeCallback } from './SafeArea.types'; + +// Prototype for https://github.com/facebook/react-native/pull/57967, which adds +// an `onSafeAreaInsetsChange` prop to every view in core, with the same payload +// as our own `onInsetsChange`. Where it is available the library needs no +// native code: any view can observe its own insets, and rendering becomes +// synchronous — core dispatches the event synchronously, so insets are applied +// in the frame they changed in rather than the one after it. +// +// The version to gate on is a placeholder until that lands. React Native is +// Fabric-only by then, so the version implies the new architecture. +const { major, minor } = Platform.constants.reactNativeVersion; +export const SUPPORTS_CORE_SAFE_AREA_INSETS = major > 0 || minor >= 88; + +// Drop once the installed react-native types declare the prop. +export type ViewPropsWithSafeAreaInsets = ViewProps & { + onSafeAreaInsetsChange?: InsetChangeNativeCallback; +}; +export const InsetsView = View as React.ComponentType< + ViewPropsWithSafeAreaInsets & React.RefAttributes +>;