From 4afa865631f122b4a5cf74370632b20f5fd53c8f Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 20 Oct 2026 22:29:14 -0400 Subject: [PATCH 1/6] feat: add corner-adapted safe area insets Add a cornerAdaptation option to useSafeAreaInsets and a cornerAdaptation prop to SafeAreaView. Providers measure the vertical and horizontal corner-adapted insets natively with the regular insets, and initialWindowMetrics includes them. iOS 26+ uses the UIKit layout region API, Android 12+ approximates from the window's rounded corners, and other platforms return the regular insets. The default insets are unchanged. --- android/build.gradle | 2 +- .../safeareacontext/InsetsChangeEvent.kt | 7 + .../safeareacontext/SafeAreaContextModule.kt | 14 +- .../SafeAreaCornerAdaptation.kt | 111 ++++++++++++ .../safeareacontext/SafeAreaProvider.kt | 24 ++- .../SafeAreaProviderManager.kt | 19 ++- .../th3rdwave/safeareacontext/SafeAreaView.kt | 9 +- .../safeareacontext/SafeAreaViewManager.kt | 11 ++ .../RNCSafeAreaViewManagerDelegate.java | 3 + .../RNCSafeAreaViewManagerInterface.java | 2 + .../safeareacontext/InsetsChangeEvent.kt | 7 + docs/docs/api/initial-window-metrics.mdx | 4 + docs/docs/api/safe-area-view.mdx | 10 ++ docs/docs/api/use-safe-area-insets.mdx | 24 +++ example/src/App.tsx | 8 + example/src/CornerAdaptationExample.tsx | 149 ++++++++++++++++ .../RNCSafeAreaProviderComponentView.mm | 33 ++++ ios/Fabric/RNCSafeAreaViewComponentView.mm | 14 +- ios/RNCOnInsetsChangeEvent.h | 2 + ios/RNCOnInsetsChangeEvent.m | 18 ++ ios/RNCSafeAreaContext.mm | 19 +++ ios/RNCSafeAreaCornerAdaptation.h | 7 + ios/RNCSafeAreaCornerAdaptation.m | 16 ++ ios/RNCSafeAreaProvider.m | 27 ++- ios/RNCSafeAreaUtils.h | 6 + ios/RNCSafeAreaUtils.m | 22 +++ ios/RNCSafeAreaView.h | 2 + ios/RNCSafeAreaView.m | 9 +- ios/RNCSafeAreaViewManager.m | 2 + src/SafeArea.types.ts | 17 ++ src/SafeAreaContext.tsx | 110 ++++++++++-- src/SafeAreaView.web.tsx | 4 +- src/__tests__/SafeAreaContext-test.tsx | 159 +++++++++++++++++- src/__tests__/SafeAreaView-test.tsx | 13 ++ src/jest/mock.tsx | 18 +- src/specs/NativeSafeAreaContext.ts | 12 ++ src/specs/NativeSafeAreaProvider.ts | 12 ++ src/specs/NativeSafeAreaView.ts | 1 + 38 files changed, 888 insertions(+), 39 deletions(-) create mode 100644 android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaCornerAdaptation.kt create mode 100644 example/src/CornerAdaptationExample.tsx create mode 100644 ios/RNCSafeAreaCornerAdaptation.h create mode 100644 ios/RNCSafeAreaCornerAdaptation.m diff --git a/android/build.gradle b/android/build.gradle index b15bdc58..f7007a61 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -63,7 +63,7 @@ android { } } - compileSdkVersion getExtOrDefault('compileSdkVersion', 30) + compileSdkVersion getExtOrDefault('compileSdkVersion', 31) // Used to override the NDK path/version on internal CI or by allowing // users to customize the NDK path/version from their root project (e.g. for M1 support) diff --git a/android/src/fabric/java/com/th3rdwave/safeareacontext/InsetsChangeEvent.kt b/android/src/fabric/java/com/th3rdwave/safeareacontext/InsetsChangeEvent.kt index 4cb8a235..29659fce 100644 --- a/android/src/fabric/java/com/th3rdwave/safeareacontext/InsetsChangeEvent.kt +++ b/android/src/fabric/java/com/th3rdwave/safeareacontext/InsetsChangeEvent.kt @@ -8,6 +8,8 @@ internal class InsetsChangeEvent( surfaceId: Int, viewTag: Int, private val mInsets: EdgeInsets, + private val mVerticalCornerAdaptedInsets: EdgeInsets, + private val mHorizontalCornerAdaptedInsets: EdgeInsets, private val mFrame: Rect, ) : Event(surfaceId, viewTag) { override fun getEventName() = EVENT_NAME @@ -15,6 +17,11 @@ internal class InsetsChangeEvent( override fun getEventData(): WritableMap? { val event = Arguments.createMap() event.putMap("insets", edgeInsetsToJsMap(mInsets)) + event.putMap("verticalCornerAdaptedInsets", edgeInsetsToJsMap(mVerticalCornerAdaptedInsets)) + event.putMap( + "horizontalCornerAdaptedInsets", + edgeInsetsToJsMap(mHorizontalCornerAdaptedInsets), + ) event.putMap("frame", rectToJsMap(mFrame)) return event } diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaContextModule.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaContextModule.kt index 40526b6b..8cd6b23d 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaContextModule.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaContextModule.kt @@ -23,7 +23,19 @@ class SafeAreaContextModule(reactContext: ReactApplicationContext?) : val frame = getFrame(decorView, contentView) return if (insets == null || frame == null) { null - } else mapOf("insets" to edgeInsetsToJavaMap(insets), "frame" to rectToJavaMap(frame)) + } else + mapOf( + "insets" to edgeInsetsToJavaMap(insets), + "verticalCornerAdaptedInsets" to + edgeInsetsToJavaMap( + getCornerAdaptedInsets(decorView, insets, CornerAdaptation.VERTICAL) + ), + "horizontalCornerAdaptedInsets" to + edgeInsetsToJavaMap( + getCornerAdaptedInsets(decorView, insets, CornerAdaptation.HORIZONTAL) + ), + "frame" to rectToJavaMap(frame), + ) } companion object { diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaCornerAdaptation.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaCornerAdaptation.kt new file mode 100644 index 00000000..f6b6ef1c --- /dev/null +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaCornerAdaptation.kt @@ -0,0 +1,111 @@ +package com.th3rdwave.safeareacontext + +import android.os.Build +import android.view.RoundedCorner +import android.view.View +import androidx.annotation.RequiresApi +import kotlin.math.max +import kotlin.math.sqrt + +enum class CornerAdaptation { + NONE, + VERTICAL, + HORIZONTAL, +} + +/** + * Enlarges [insets] of [view] where the display's rounded corners reach past them. + * + * Vertical adaptation raises the top and bottom insets to where each corner's arc crosses the left + * and right edges of the view's safe area. Horizontal adaptation raises the left and right insets + * to where each arc crosses the top and bottom edges of the safe area. Corners are approximated by + * the quarter circles that [WindowInsets.getRoundedCorner] reports, so before API 31 the insets are + * returned unchanged. + */ +fun getCornerAdaptedInsets( + view: View, + insets: EdgeInsets, + cornerAdaptation: CornerAdaptation, +): EdgeInsets { + if (cornerAdaptation == CornerAdaptation.NONE || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return insets + } + return getCornerAdaptedInsetsS(view, insets, cornerAdaptation) +} + +@RequiresApi(Build.VERSION_CODES.S) +private fun getCornerAdaptedInsetsS( + view: View, + insets: EdgeInsets, + cornerAdaptation: CornerAdaptation, +): EdgeInsets { + val windowInsets = view.rootWindowInsets ?: return insets + val location = IntArray(2) + view.getLocationInWindow(location) + val viewLeft = location[0].toFloat() + val viewTop = location[1].toFloat() + val viewRight = viewLeft + view.width + val viewBottom = viewTop + view.height + val safeLeft = viewLeft + insets.left + val safeTop = viewTop + insets.top + val safeRight = viewRight - insets.right + val safeBottom = viewBottom - insets.bottom + + var top = insets.top + var right = insets.right + var bottom = insets.bottom + var left = insets.left + + for (position in + intArrayOf( + RoundedCorner.POSITION_TOP_LEFT, + RoundedCorner.POSITION_TOP_RIGHT, + RoundedCorner.POSITION_BOTTOM_RIGHT, + RoundedCorner.POSITION_BOTTOM_LEFT, + )) { + val corner = windowInsets.getRoundedCorner(position) ?: continue + val radius = corner.radius.toFloat() + if (radius <= 0f) { + continue + } + val centerX = corner.center.x.toFloat() + val centerY = corner.center.y.toFloat() + val isLeft = + position == RoundedCorner.POSITION_TOP_LEFT || + position == RoundedCorner.POSITION_BOTTOM_LEFT + val isTop = + position == RoundedCorner.POSITION_TOP_LEFT || position == RoundedCorner.POSITION_TOP_RIGHT + + if (cornerAdaptation == CornerAdaptation.VERTICAL) { + val edgeX = if (isLeft) safeLeft else safeRight + val dx = if (isLeft) centerX - edgeX else edgeX - centerX + if (dx <= 0f) { + continue + } + val arcY = arcOffset(radius, dx) + if (isTop) { + top = max(top, centerY - arcY - viewTop) + } else { + bottom = max(bottom, viewBottom - (centerY + arcY)) + } + } else { + val edgeY = if (isTop) safeTop else safeBottom + val dy = if (isTop) centerY - edgeY else edgeY - centerY + if (dy <= 0f) { + continue + } + val arcX = arcOffset(radius, dy) + if (isLeft) { + left = max(left, centerX - arcX - viewLeft) + } else { + right = max(right, viewRight - (centerX + arcX)) + } + } + } + + return EdgeInsets(top = top, right = right, bottom = bottom, left = left) +} + +private fun arcOffset(radius: Float, offset: Float): Float { + return sqrt(max(radius * radius - offset * offset, 0f)) +} diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProvider.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProvider.kt index 041c38a2..6dc36f94 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProvider.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProvider.kt @@ -7,12 +7,21 @@ import android.view.ViewTreeObserver import android.view.WindowInsets import com.facebook.react.views.view.ReactViewGroup -typealias OnInsetsChangeHandler = (view: SafeAreaProvider, insets: EdgeInsets, frame: Rect) -> Unit +typealias OnInsetsChangeHandler = + ( + view: SafeAreaProvider, + insets: EdgeInsets, + verticalCornerAdaptedInsets: EdgeInsets, + horizontalCornerAdaptedInsets: EdgeInsets, + frame: Rect, + ) -> Unit class SafeAreaProvider(context: Context?) : ReactViewGroup(context), ViewTreeObserver.OnPreDrawListener { private var mInsetsChangeHandler: OnInsetsChangeHandler? = null private var mLastInsets: EdgeInsets? = null + private var mLastVerticalCornerAdaptedInsets: EdgeInsets? = null + private var mLastHorizontalCornerAdaptedInsets: EdgeInsets? = null private var mLastFrame: Rect? = null private val mDispatchedInsets = DispatchedWindowInsets() @@ -22,9 +31,18 @@ class SafeAreaProvider(context: Context?) : val insetsChangeHandler = mInsetsChangeHandler ?: return val edgeInsets = getSafeAreaInsets(this, getDispatchedWindowInsets()) ?: return val frame = getFrame(rootView as ViewGroup, this) ?: return - if (mLastInsets != edgeInsets || mLastFrame != frame) { - insetsChangeHandler(this, edgeInsets, frame) + val verticalInsets = getCornerAdaptedInsets(this, edgeInsets, CornerAdaptation.VERTICAL) + val horizontalInsets = getCornerAdaptedInsets(this, edgeInsets, CornerAdaptation.HORIZONTAL) + if ( + mLastInsets != edgeInsets || + mLastVerticalCornerAdaptedInsets != verticalInsets || + mLastHorizontalCornerAdaptedInsets != horizontalInsets || + mLastFrame != frame + ) { + insetsChangeHandler(this, edgeInsets, verticalInsets, horizontalInsets, frame) mLastInsets = edgeInsets + mLastVerticalCornerAdaptedInsets = verticalInsets + mLastHorizontalCornerAdaptedInsets = horizontalInsets mLastFrame = frame } } diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProviderManager.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProviderManager.kt index 42de8375..d440bc46 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProviderManager.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProviderManager.kt @@ -34,9 +34,24 @@ class SafeAreaProviderManager : } } -private fun handleOnInsetsChange(view: SafeAreaProvider, insets: EdgeInsets, frame: Rect) { +private fun handleOnInsetsChange( + view: SafeAreaProvider, + insets: EdgeInsets, + verticalCornerAdaptedInsets: EdgeInsets, + horizontalCornerAdaptedInsets: EdgeInsets, + frame: Rect, +) { val reactContext = view.context as ReactContext val reactTag = view.id UIManagerHelper.getEventDispatcherForReactTag(reactContext, reactTag) - ?.dispatchEvent(InsetsChangeEvent(getSurfaceId(reactContext), reactTag, insets, frame)) + ?.dispatchEvent( + InsetsChangeEvent( + getSurfaceId(reactContext), + reactTag, + insets, + verticalCornerAdaptedInsets, + horizontalCornerAdaptedInsets, + frame, + ) + ) } diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaView.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaView.kt index 701e02f9..41201856 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaView.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaView.kt @@ -15,6 +15,7 @@ class SafeAreaView(context: Context?) : private var mMode = SafeAreaViewMode.PADDING private var mInsets: EdgeInsets? = null private var mEdges: SafeAreaViewEdges? = null + private var mCornerAdaptation = CornerAdaptation.NONE private var mProviderView: View? = null private var mStateWrapper: StateWrapper? = null private val mDispatchedInsets = DispatchedWindowInsets() @@ -64,12 +65,18 @@ class SafeAreaView(context: Context?) : updateInsets() } + fun setCornerAdaptation(cornerAdaptation: CornerAdaptation) { + mCornerAdaptation = cornerAdaptation + maybeUpdateInsets() + } + private fun maybeUpdateInsets(): Boolean { val providerView = mProviderView ?: return false val dispatchedInsets = if (providerView is SafeAreaProvider) providerView.getDispatchedWindowInsets() else mDispatchedInsets.get(this) - val edgeInsets = getSafeAreaInsets(providerView, dispatchedInsets) ?: return false + val safeAreaInsets = getSafeAreaInsets(providerView, dispatchedInsets) ?: return false + val edgeInsets = getCornerAdaptedInsets(providerView, safeAreaInsets, mCornerAdaptation) if (mInsets != edgeInsets) { mInsets = edgeInsets updateInsets() diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaViewManager.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaViewManager.kt index c9f6f6c9..05f62877 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaViewManager.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaViewManager.kt @@ -54,6 +54,17 @@ class SafeAreaViewManager : ReactViewManager() { } } + @ReactProp(name = "cornerAdaptation") + fun setCornerAdaptation(view: SafeAreaView, cornerAdaptation: String?) { + view.setCornerAdaptation( + when (cornerAdaptation) { + "vertical" -> CornerAdaptation.VERTICAL + "horizontal" -> CornerAdaptation.HORIZONTAL + else -> CornerAdaptation.NONE + } + ) + } + override fun updateState( view: ReactViewGroup, props: ReactStylesDiffMap?, diff --git a/android/src/paper/java/com/facebook/react/viewmanagers/RNCSafeAreaViewManagerDelegate.java b/android/src/paper/java/com/facebook/react/viewmanagers/RNCSafeAreaViewManagerDelegate.java index c5f6d257..4324b401 100644 --- a/android/src/paper/java/com/facebook/react/viewmanagers/RNCSafeAreaViewManagerDelegate.java +++ b/android/src/paper/java/com/facebook/react/viewmanagers/RNCSafeAreaViewManagerDelegate.java @@ -34,6 +34,9 @@ public void setProperty(T view, String propName, @Nullable Object value) { case "edges": mViewManager.setEdges(view, (ReadableMap) value); break; + case "cornerAdaptation": + mViewManager.setCornerAdaptation(view, (String) value); + break; default: super.setProperty(view, propName, value); } diff --git a/android/src/paper/java/com/facebook/react/viewmanagers/RNCSafeAreaViewManagerInterface.java b/android/src/paper/java/com/facebook/react/viewmanagers/RNCSafeAreaViewManagerInterface.java index cf6c33c3..89c1698e 100644 --- a/android/src/paper/java/com/facebook/react/viewmanagers/RNCSafeAreaViewManagerInterface.java +++ b/android/src/paper/java/com/facebook/react/viewmanagers/RNCSafeAreaViewManagerInterface.java @@ -17,4 +17,6 @@ public interface RNCSafeAreaViewManagerInterface { void setMode(T view, @Nullable String value); void setEdges(T view, @Nullable ReadableMap value); + + void setCornerAdaptation(T view, @Nullable String value); } diff --git a/android/src/paper/java/com/th3rdwave/safeareacontext/InsetsChangeEvent.kt b/android/src/paper/java/com/th3rdwave/safeareacontext/InsetsChangeEvent.kt index 252c90cd..72dbd0dc 100644 --- a/android/src/paper/java/com/th3rdwave/safeareacontext/InsetsChangeEvent.kt +++ b/android/src/paper/java/com/th3rdwave/safeareacontext/InsetsChangeEvent.kt @@ -10,6 +10,8 @@ internal class InsetsChangeEvent( @Suppress("UNUSED_PARAMETER") surfaceId: Int, viewTag: Int, private val mInsets: EdgeInsets, + private val mVerticalCornerAdaptedInsets: EdgeInsets, + private val mHorizontalCornerAdaptedInsets: EdgeInsets, private val mFrame: Rect, // New ctor is only available in RN 0.65. ) : Event(viewTag) { @@ -19,6 +21,11 @@ internal class InsetsChangeEvent( override fun dispatch(rctEventEmitter: RCTEventEmitter) { val event = Arguments.createMap() event.putMap("insets", edgeInsetsToJsMap(mInsets)) + event.putMap("verticalCornerAdaptedInsets", edgeInsetsToJsMap(mVerticalCornerAdaptedInsets)) + event.putMap( + "horizontalCornerAdaptedInsets", + edgeInsetsToJsMap(mHorizontalCornerAdaptedInsets), + ) event.putMap("frame", rectToJsMap(mFrame)) rctEventEmitter.receiveEvent(viewTag, eventName, event) } diff --git a/docs/docs/api/initial-window-metrics.mdx b/docs/docs/api/initial-window-metrics.mdx index d5c03d42..4bc31b5c 100644 --- a/docs/docs/api/initial-window-metrics.mdx +++ b/docs/docs/api/initial-window-metrics.mdx @@ -12,8 +12,12 @@ Object with: { frame: { x: number, y: number, width: number, height: number }, insets: { top: number, left: number, right: number, bottom: number }, + verticalCornerAdaptedInsets: { top: number, left: number, right: number, bottom: number }, + horizontalCornerAdaptedInsets: { top: number, left: number, right: number, bottom: number }, } ``` +`verticalCornerAdaptedInsets` and `horizontalCornerAdaptedInsets` are the window's insets for the `cornerAdaptation` option of [`useSafeAreaInsets`](/api/use-safe-area-insets#corneradaptation). + **NOTE:** This value can be null or out of date as it is computed when the native module is created. diff --git a/docs/docs/api/safe-area-view.mdx b/docs/docs/api/safe-area-view.mdx index 5ea92b21..3ae765d3 100644 --- a/docs/docs/api/safe-area-view.mdx +++ b/docs/docs/api/safe-area-view.mdx @@ -55,3 +55,13 @@ This can be useful for example to create a safe area aware separator component: ```tsx ``` + +#### `cornerAdaptation` + +Optional, `vertical` or `horizontal`. + +Applies the corner-adapted safe area insets instead of the regular ones. See [`useSafeAreaInsets`](/api/use-safe-area-insets#corneradaptation) for how they are computed. Like the regular insets, they are measured natively on the nearest `SafeAreaProvider`. + +```tsx + +``` diff --git a/docs/docs/api/use-safe-area-insets.mdx b/docs/docs/api/use-safe-area-insets.mdx index 3caef976..6b03762b 100644 --- a/docs/docs/api/use-safe-area-insets.mdx +++ b/docs/docs/api/use-safe-area-insets.mdx @@ -19,3 +19,27 @@ function HookComponent() { return ; } ``` + +### Options + +#### `cornerAdaptation` + +Optional, `vertical` or `horizontal`. + +Returns safe area insets enlarged where the display's rounded corners reach past the regular safe area. `vertical` enlarges the top and bottom insets, `horizontal` enlarges the left and right insets. Without this option the regular insets are returned. + +This is useful on displays where the regular safe area does not cover the rounded corners, for example when the status bar is not along the top edge. Content laid out with the regular insets can then be clipped by the corners. + +```tsx +function Header() { + const insets = useSafeAreaInsets({ cornerAdaptation: 'vertical' }); + + return ; +} +``` + +- **iOS 26+**: uses `edgeInsets(for: .safeArea(cornerAdaptation:))` measured on the `SafeAreaProvider` view. UIKit computes the value from the receiver's position and size, so it can be smaller than the window's value for a short or narrow provider. +- **Android 12+ (API 31)**: approximated from the rounded corners the window reports. With `vertical`, the top and bottom insets grow to where each corner's arc crosses the left and right edges of the safe area. With `horizontal`, the left and right insets grow to where each arc crosses the top and bottom edges of the safe area. +- **Older iOS and Android versions, and web**: same as the regular insets. + +Components that use only the regular insets do not re-render when only the corner-adapted insets change. diff --git a/example/src/App.tsx b/example/src/App.tsx index 9ffdff0c..7b25fbd4 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { DevSettings, View, Text, StatusBar } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import AdditionalInsetsExample from './AdditionalInsetsExample'; +import CornerAdaptationExample from './CornerAdaptationExample'; import HooksExample from './HooksExample'; import ListenerExample from './ListenerExample'; import SafeAreaViewExample from './SafeAreaViewExample'; @@ -16,6 +17,7 @@ type Example = | 'safe-area-view-edges' | 'hooks' | 'listener' + | 'corner-adaptation' | 'react-navigation' | 'native-stack' | 'additional-insets'; @@ -59,6 +61,9 @@ export default function App() { DevSettings.addMenuItem('Show Listener Example', () => { setCurrentExample('listener'); }); + DevSettings.addMenuItem('Show Corner Adaptation Example', () => { + setCurrentExample('corner-adaptation'); + }); DevSettings.addMenuItem('Show React Navigation Example', () => { setCurrentExample('react-navigation'); }); @@ -84,6 +89,9 @@ export default function App() { case 'listener': content = ; break; + case 'corner-adaptation': + content = ; + break; // case 'react-navigation': // content = ; // break; diff --git a/example/src/CornerAdaptationExample.tsx b/example/src/CornerAdaptationExample.tsx new file mode 100644 index 00000000..4c339962 --- /dev/null +++ b/example/src/CornerAdaptationExample.tsx @@ -0,0 +1,149 @@ +import * as React from 'react'; +import { + Modal, + ScrollView, + StatusBar, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { + CornerAdaptation, + SafeAreaProvider, + SafeAreaView, + initialWindowMetrics, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; +import { Card } from './components/Card'; +import { DataView } from './components/DataView'; + +const HEADER_MODES = ['vertical', 'horizontal', 'none'] as const; +type HeaderMode = (typeof HEADER_MODES)[number]; + +function CornerAdaptationScreen() { + const [headerMode, setHeaderMode] = React.useState('vertical'); + const insets = useSafeAreaInsets(); + const verticalInsets = useSafeAreaInsets({ cornerAdaptation: 'vertical' }); + const horizontalInsets = useSafeAreaInsets({ + cornerAdaptation: 'horizontal', + }); + + const cornerAdaptation: CornerAdaptation | undefined = + headerMode === 'none' ? undefined : headerMode; + + return ( + + + + + + Back + + Corners + + setHeaderMode( + (mode) => + HEADER_MODES[ + (HEADER_MODES.indexOf(mode) + 1) % HEADER_MODES.length + ], + ) + } + > + {headerMode} + + + + + + {headerMode} + + + + + + + + + + + + + + + + + + + ); +} + +export default function CornerAdaptationExample() { + // react-native-test-app hosts the app in a UINavigationController, whose + // safe area already clears the corners. A full screen Modal is presented + // outside of it, like the root view of most apps. + return ( + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#eee', + }, + header: { + backgroundColor: '#1f5fbf', + }, + headerContent: { + height: 44, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + headerButton: { + backgroundColor: '#ffb300', + paddingHorizontal: 12, + height: 32, + justifyContent: 'center', + }, + headerButtonText: { + color: '#292929', + fontSize: 15, + fontWeight: '600', + }, + headerTitle: { + color: 'white', + fontSize: 17, + fontWeight: '600', + }, + text: { + fontSize: 16, + color: '#292929', + }, +}); diff --git a/ios/Fabric/RNCSafeAreaProviderComponentView.mm b/ios/Fabric/RNCSafeAreaProviderComponentView.mm index f315ccda..0719f233 100644 --- a/ios/Fabric/RNCSafeAreaProviderComponentView.mm +++ b/ios/Fabric/RNCSafeAreaProviderComponentView.mm @@ -15,6 +15,8 @@ @interface RNCSafeAreaProviderComponentView () (_props)->cornerAdaptation) { + case RNCSafeAreaViewCornerAdaptation::Vertical: + return RNCSafeAreaCornerAdaptationVertical; + case RNCSafeAreaViewCornerAdaptation::Horizontal: + return RNCSafeAreaCornerAdaptationHorizontal; + case RNCSafeAreaViewCornerAdaptation::None: + return RNCSafeAreaCornerAdaptationNone; + } +} + - (UIView *)findNearestProvider { UIView *current = self.superview; diff --git a/ios/RNCOnInsetsChangeEvent.h b/ios/RNCOnInsetsChangeEvent.h index 09184291..dc3c750b 100644 --- a/ios/RNCOnInsetsChangeEvent.h +++ b/ios/RNCOnInsetsChangeEvent.h @@ -6,6 +6,8 @@ - (instancetype)initWithEventName:(NSString *)eventName reactTag:(NSNumber *)reactTag insets:(UIEdgeInsets)insets + verticalCornerAdaptedInsets:(UIEdgeInsets)verticalCornerAdaptedInsets + horizontalCornerAdaptedInsets:(UIEdgeInsets)horizontalCornerAdaptedInsets frame:(CGRect)frame coalescingKey:(uint16_t)coalescingKey NS_DESIGNATED_INITIALIZER; diff --git a/ios/RNCOnInsetsChangeEvent.m b/ios/RNCOnInsetsChangeEvent.m index 1f973c72..7d155c14 100644 --- a/ios/RNCOnInsetsChangeEvent.m +++ b/ios/RNCOnInsetsChangeEvent.m @@ -3,6 +3,8 @@ @implementation RNCOnInsetsChangeEvent { UIEdgeInsets _insets; + UIEdgeInsets _verticalCornerAdaptedInsets; + UIEdgeInsets _horizontalCornerAdaptedInsets; CGRect _frame; uint16_t _coalescingKey; } @@ -13,6 +15,8 @@ @implementation RNCOnInsetsChangeEvent { - (instancetype)initWithEventName:(NSString *)eventName reactTag:(NSNumber *)reactTag insets:(UIEdgeInsets)insets + verticalCornerAdaptedInsets:(UIEdgeInsets)verticalCornerAdaptedInsets + horizontalCornerAdaptedInsets:(UIEdgeInsets)horizontalCornerAdaptedInsets frame:(CGRect)frame coalescingKey:(uint16_t)coalescingKey { @@ -23,6 +27,8 @@ - (instancetype)initWithEventName:(NSString *)eventName _viewTag = reactTag; _frame = frame; _insets = insets; + _verticalCornerAdaptedInsets = verticalCornerAdaptedInsets; + _horizontalCornerAdaptedInsets = horizontalCornerAdaptedInsets; _coalescingKey = coalescingKey; } @@ -45,6 +51,18 @@ - (NSDictionary *)body @"bottom" : @(_insets.bottom), @"left" : @(_insets.left), }, + @"verticalCornerAdaptedInsets" : @{ + @"top" : @(_verticalCornerAdaptedInsets.top), + @"right" : @(_verticalCornerAdaptedInsets.right), + @"bottom" : @(_verticalCornerAdaptedInsets.bottom), + @"left" : @(_verticalCornerAdaptedInsets.left), + }, + @"horizontalCornerAdaptedInsets" : @{ + @"top" : @(_horizontalCornerAdaptedInsets.top), + @"right" : @(_horizontalCornerAdaptedInsets.right), + @"bottom" : @(_horizontalCornerAdaptedInsets.bottom), + @"left" : @(_horizontalCornerAdaptedInsets.left), + }, @"frame" : @{ @"x" : @(_frame.origin.x), @"y" : @(_frame.origin.y), diff --git a/ios/RNCSafeAreaContext.mm b/ios/RNCSafeAreaContext.mm index 08fe2676..7ac43abd 100644 --- a/ios/RNCSafeAreaContext.mm +++ b/ios/RNCSafeAreaContext.mm @@ -9,6 +9,7 @@ #ifdef RCT_NEW_ARCH_ENABLED #import #endif +#import "RNCSafeAreaUtils.h" #ifdef RCT_NEW_ARCH_ENABLED using namespace facebook::react; @@ -48,8 +49,14 @@ - (NSDictionary *)getConstants #if TARGET_OS_IPHONE UIEdgeInsets safeAreaInsets = window.safeAreaInsets; + UIEdgeInsets verticalCornerAdaptedInsets = + RNCSafeAreaInsetsWithCornerAdaptation(window, RNCSafeAreaCornerAdaptationVertical); + UIEdgeInsets horizontalCornerAdaptedInsets = + RNCSafeAreaInsetsWithCornerAdaptation(window, RNCSafeAreaCornerAdaptationHorizontal); #elif TARGET_OS_OSX NSEdgeInsets safeAreaInsets = NSEdgeInsetsZero; + NSEdgeInsets verticalCornerAdaptedInsets = NSEdgeInsetsZero; + NSEdgeInsets horizontalCornerAdaptedInsets = NSEdgeInsetsZero; #endif constants = @{ @@ -60,6 +67,18 @@ - (NSDictionary *)getConstants @"bottom" : @(safeAreaInsets.bottom), @"left" : @(safeAreaInsets.left), }, + @"verticalCornerAdaptedInsets" : @{ + @"top" : @(verticalCornerAdaptedInsets.top), + @"right" : @(verticalCornerAdaptedInsets.right), + @"bottom" : @(verticalCornerAdaptedInsets.bottom), + @"left" : @(verticalCornerAdaptedInsets.left), + }, + @"horizontalCornerAdaptedInsets" : @{ + @"top" : @(horizontalCornerAdaptedInsets.top), + @"right" : @(horizontalCornerAdaptedInsets.right), + @"bottom" : @(horizontalCornerAdaptedInsets.bottom), + @"left" : @(horizontalCornerAdaptedInsets.left), + }, @"frame" : @{ @"x" : @(window.frame.origin.x), @"y" : @(window.frame.origin.y), diff --git a/ios/RNCSafeAreaCornerAdaptation.h b/ios/RNCSafeAreaCornerAdaptation.h new file mode 100644 index 00000000..7a91dca2 --- /dev/null +++ b/ios/RNCSafeAreaCornerAdaptation.h @@ -0,0 +1,7 @@ +#import + +typedef NS_ENUM(NSInteger, RNCSafeAreaCornerAdaptation) { + RNCSafeAreaCornerAdaptationNone, + RNCSafeAreaCornerAdaptationVertical, + RNCSafeAreaCornerAdaptationHorizontal, +}; diff --git a/ios/RNCSafeAreaCornerAdaptation.m b/ios/RNCSafeAreaCornerAdaptation.m new file mode 100644 index 00000000..1bd6d82e --- /dev/null +++ b/ios/RNCSafeAreaCornerAdaptation.m @@ -0,0 +1,16 @@ +#import "RNCSafeAreaCornerAdaptation.h" +#import + +@implementation RCTConvert (RNCSafeAreaCornerAdaptation) + +RCT_ENUM_CONVERTER( + RNCSafeAreaCornerAdaptation, + (@{ + @"none" : @(RNCSafeAreaCornerAdaptationNone), + @"vertical" : @(RNCSafeAreaCornerAdaptationVertical), + @"horizontal" : @(RNCSafeAreaCornerAdaptationHorizontal), + }), + RNCSafeAreaCornerAdaptationNone, + integerValue); + +@end diff --git a/ios/RNCSafeAreaProvider.m b/ios/RNCSafeAreaProvider.m index 6f92859b..1f8e8e8a 100644 --- a/ios/RNCSafeAreaProvider.m +++ b/ios/RNCSafeAreaProvider.m @@ -14,6 +14,8 @@ @interface RNCSafeAreaProvider () @implementation RNCSafeAreaProvider { id _eventDispatcher; UIEdgeInsets _currentSafeAreaInsets; + UIEdgeInsets _currentVerticalCornerAdaptedInsets; + UIEdgeInsets _currentHorizontalCornerAdaptedInsets; CGRect _currentFrame; BOOL _initialInsetsSent; } @@ -62,6 +64,10 @@ - (void)invalidateSafeAreaInsets #if TARGET_OS_IPHONE UIEdgeInsets safeAreaInsets = self.safeAreaInsets; + UIEdgeInsets verticalCornerAdaptedInsets = + RNCSafeAreaInsetsWithCornerAdaptation(self, RNCSafeAreaCornerAdaptationVertical); + UIEdgeInsets horizontalCornerAdaptedInsets = + RNCSafeAreaInsetsWithCornerAdaptation(self, RNCSafeAreaCornerAdaptationHorizontal); #elif TARGET_OS_OSX NSEdgeInsets safeAreaInsets; if (@available(macOS 11.0, *)) { @@ -69,6 +75,8 @@ - (void)invalidateSafeAreaInsets } else { safeAreaInsets = NSEdgeInsetsZero; } + NSEdgeInsets verticalCornerAdaptedInsets = safeAreaInsets; + NSEdgeInsets horizontalCornerAdaptedInsets = safeAreaInsets; #endif CGRect frame = [self convertRect:self.bounds toView:RNCParentViewController(self).view]; @@ -76,6 +84,10 @@ - (void)invalidateSafeAreaInsets if (_initialInsetsSent && #if TARGET_OS_IPHONE UIEdgeInsetsEqualToEdgeInsetsWithThreshold(safeAreaInsets, _currentSafeAreaInsets, 1.0 / RCTScreenScale()) && + UIEdgeInsetsEqualToEdgeInsetsWithThreshold( + verticalCornerAdaptedInsets, _currentVerticalCornerAdaptedInsets, 1.0 / RCTScreenScale()) && + UIEdgeInsetsEqualToEdgeInsetsWithThreshold( + horizontalCornerAdaptedInsets, _currentHorizontalCornerAdaptedInsets, 1.0 / RCTScreenScale()) && #elif TARGET_OS_OSX NSEdgeInsetsEqualToEdgeInsetsWithThreshold(safeAreaInsets, _currentSafeAreaInsets, 1.0 / RCTScreenScale()) && #endif @@ -84,15 +96,20 @@ - (void)invalidateSafeAreaInsets } _initialInsetsSent = YES; _currentSafeAreaInsets = safeAreaInsets; + _currentVerticalCornerAdaptedInsets = verticalCornerAdaptedInsets; + _currentHorizontalCornerAdaptedInsets = horizontalCornerAdaptedInsets; _currentFrame = frame; [NSNotificationCenter.defaultCenter postNotificationName:RNCSafeAreaDidChange object:self userInfo:nil]; - RNCOnInsetsChangeEvent *onInsetsChangeEvent = [[RNCOnInsetsChangeEvent alloc] initWithEventName:@"onInsetsChange" - reactTag:self.reactTag - insets:safeAreaInsets - frame:frame - coalescingKey:0]; + RNCOnInsetsChangeEvent *onInsetsChangeEvent = + [[RNCOnInsetsChangeEvent alloc] initWithEventName:@"onInsetsChange" + reactTag:self.reactTag + insets:safeAreaInsets + verticalCornerAdaptedInsets:verticalCornerAdaptedInsets + horizontalCornerAdaptedInsets:horizontalCornerAdaptedInsets + frame:frame + coalescingKey:0]; [_eventDispatcher sendEvent:onInsetsChangeEvent]; } diff --git a/ios/RNCSafeAreaUtils.h b/ios/RNCSafeAreaUtils.h index 28961246..36fb680a 100644 --- a/ios/RNCSafeAreaUtils.h +++ b/ios/RNCSafeAreaUtils.h @@ -6,6 +6,7 @@ #import typedef NSView UIView; #endif +#import "RNCSafeAreaCornerAdaptation.h" extern NSString *const RNCSafeAreaDidChange; @@ -21,3 +22,8 @@ RCT_EXTERN UIViewController *RNCParentViewController(UIView *view); #elif TARGET_OS_OSX RCT_EXTERN NSViewController *RNCParentViewController(NSView *view); #endif + +#if TARGET_OS_IPHONE +RCT_EXTERN UIEdgeInsets +RNCSafeAreaInsetsWithCornerAdaptation(UIView *view, RNCSafeAreaCornerAdaptation cornerAdaptation); +#endif diff --git a/ios/RNCSafeAreaUtils.m b/ios/RNCSafeAreaUtils.m index c71d0911..38992386 100644 --- a/ios/RNCSafeAreaUtils.m +++ b/ios/RNCSafeAreaUtils.m @@ -39,3 +39,25 @@ BOOL NSEdgeInsetsEqualToEdgeInsetsWithThreshold(NSEdgeInsets insets1, NSEdgeInse return nil; } #endif + +#if TARGET_OS_IPHONE +UIEdgeInsets RNCSafeAreaInsetsWithCornerAdaptation(UIView *view, RNCSafeAreaCornerAdaptation cornerAdaptation) +{ + // UIViewLayoutRegion is only declared by the iOS 26 SDK and later. +#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 + if (@available(iOS 26.0, tvOS 26.0, *)) { + switch (cornerAdaptation) { + case RNCSafeAreaCornerAdaptationVertical: + return [view edgeInsetsForLayoutRegion:[UIViewLayoutRegion safeAreaLayoutRegionWithCornerAdaptation: + UIViewLayoutRegionAdaptivityAxisVertical]]; + case RNCSafeAreaCornerAdaptationHorizontal: + return [view edgeInsetsForLayoutRegion:[UIViewLayoutRegion safeAreaLayoutRegionWithCornerAdaptation: + UIViewLayoutRegionAdaptivityAxisHorizontal]]; + case RNCSafeAreaCornerAdaptationNone: + break; + } + } +#endif + return view.safeAreaInsets; +} +#endif diff --git a/ios/RNCSafeAreaView.h b/ios/RNCSafeAreaView.h index 96f17820..f4e80e31 100644 --- a/ios/RNCSafeAreaView.h +++ b/ios/RNCSafeAreaView.h @@ -6,6 +6,7 @@ #import #endif +#import "RNCSafeAreaCornerAdaptation.h" #import "RNCSafeAreaViewEdges.h" #import "RNCSafeAreaViewMode.h" @@ -19,6 +20,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) RNCSafeAreaViewMode mode; @property (nonatomic, assign) RNCSafeAreaViewEdges edges; +@property (nonatomic, assign) RNCSafeAreaCornerAdaptation cornerAdaptation; @end diff --git a/ios/RNCSafeAreaView.m b/ios/RNCSafeAreaView.m index 9c0ea1ff..5b494da3 100644 --- a/ios/RNCSafeAreaView.m +++ b/ios/RNCSafeAreaView.m @@ -18,6 +18,7 @@ @implementation RNCSafeAreaView { #endif RNCSafeAreaViewMode _mode; RNCSafeAreaViewEdges _edges; + RNCSafeAreaCornerAdaptation _cornerAdaptation; __weak RNCSafeAreaProvider *_Nullable _providerView; } @@ -111,7 +112,7 @@ - (void)invalidateSafeAreaInsets return; } #if TARGET_OS_IPHONE - UIEdgeInsets safeAreaInsets = _providerView.safeAreaInsets; + UIEdgeInsets safeAreaInsets = RNCSafeAreaInsetsWithCornerAdaptation(_providerView, _cornerAdaptation); if (UIEdgeInsetsEqualToEdgeInsetsWithThreshold(safeAreaInsets, _currentSafeAreaInsets, 1.0 / RCTScreenScale())) { return; } @@ -165,4 +166,10 @@ - (void)setEdges:(RNCSafeAreaViewEdges)edges [self updateLocalData]; } +- (void)setCornerAdaptation:(RNCSafeAreaCornerAdaptation)cornerAdaptation +{ + _cornerAdaptation = cornerAdaptation; + [self invalidateSafeAreaInsets]; +} + @end diff --git a/ios/RNCSafeAreaViewManager.m b/ios/RNCSafeAreaViewManager.m index 703b4ddb..da26a690 100644 --- a/ios/RNCSafeAreaViewManager.m +++ b/ios/RNCSafeAreaViewManager.m @@ -1,5 +1,6 @@ #import "RNCSafeAreaViewManager.h" +#import "RNCSafeAreaCornerAdaptation.h" #import "RNCSafeAreaShadowView.h" #import "RNCSafeAreaView.h" #import "RNCSafeAreaViewEdges.h" @@ -25,5 +26,6 @@ - (RNCSafeAreaShadowView *)shadowView RCT_EXPORT_VIEW_PROPERTY(mode, RNCSafeAreaViewMode) RCT_EXPORT_VIEW_PROPERTY(edges, RNCSafeAreaViewEdges) +RCT_EXPORT_VIEW_PROPERTY(cornerAdaptation, RNCSafeAreaCornerAdaptation) @end diff --git a/src/SafeArea.types.ts b/src/SafeArea.types.ts index 5672e5d6..296d1a32 100644 --- a/src/SafeArea.types.ts +++ b/src/SafeArea.types.ts @@ -5,6 +5,8 @@ import NativeSafeAreaView from './specs/NativeSafeAreaView'; export type Edge = 'top' | 'right' | 'bottom' | 'left'; export type EdgeMode = 'off' | 'additive' | 'maximum'; +export type CornerAdaptation = 'vertical' | 'horizontal'; + export type EdgeRecord = Partial>; export type Edges = readonly Edge[] | Readonly; @@ -25,6 +27,16 @@ export interface Rect { export interface Metrics { insets: EdgeInsets; frame: Rect; + /** + * Safe area insets enlarged on the top and bottom edges where the display's + * rounded corners reach past the regular safe area. Defaults to `insets`. + */ + verticalCornerAdaptedInsets?: EdgeInsets; + /** + * Safe area insets enlarged on the left and right edges where the display's + * rounded corners reach past the regular safe area. Defaults to `insets`. + */ + horizontalCornerAdaptedInsets?: EdgeInsets; } export type InsetChangedEvent = NativeSyntheticEvent; @@ -41,6 +53,11 @@ export interface NativeSafeAreaViewProps extends ViewProps { children?: React.ReactNode; mode?: 'padding' | 'margin'; edges?: Edges; + /** + * Apply the corner-adapted safe area insets of the given axis instead of + * the regular ones. + */ + cornerAdaptation?: CornerAdaptation; } export type NativeSafeAreaViewInstance = InstanceType< diff --git a/src/SafeAreaContext.tsx b/src/SafeAreaContext.tsx index 72f2c0b3..e2d132e5 100644 --- a/src/SafeAreaContext.tsx +++ b/src/SafeAreaContext.tsx @@ -7,6 +7,7 @@ import { } from 'react-native'; import { NativeSafeAreaProvider } from './NativeSafeAreaProvider'; import type { + CornerAdaptation, EdgeInsets, InsetChangedEvent, Metrics, @@ -27,6 +28,20 @@ if (isDev) { SafeAreaFrameContext.displayName = 'SafeAreaFrameContext'; } +export const SafeAreaVerticalCornerAdaptedInsetsContext = + React.createContext(null); +if (isDev) { + SafeAreaVerticalCornerAdaptedInsetsContext.displayName = + 'SafeAreaVerticalCornerAdaptedInsetsContext'; +} + +export const SafeAreaHorizontalCornerAdaptedInsetsContext = + React.createContext(null); +if (isDev) { + SafeAreaHorizontalCornerAdaptedInsetsContext.displayName = + 'SafeAreaHorizontalCornerAdaptedInsetsContext'; +} + export interface SafeAreaProviderProps extends ViewProps { ref?: React.Ref; children?: React.ReactNode; @@ -47,9 +62,27 @@ export function SafeAreaProvider({ }: SafeAreaProviderProps) { const parentInsets = useParentSafeAreaInsets(); const parentFrame = useParentSafeAreaFrame(); + const parentVerticalInsets = React.useContext( + SafeAreaVerticalCornerAdaptedInsetsContext, + ); + const parentHorizontalInsets = React.useContext( + SafeAreaHorizontalCornerAdaptedInsetsContext, + ); const [insets, setInsets] = React.useState( initialMetrics?.insets ?? initialSafeAreaInsets ?? parentInsets ?? null, ); + const [verticalInsets, setVerticalInsets] = React.useState( + initialMetrics + ? (initialMetrics.verticalCornerAdaptedInsets ?? initialMetrics.insets) + : (initialSafeAreaInsets ?? parentVerticalInsets ?? null), + ); + const [horizontalInsets, setHorizontalInsets] = + React.useState( + initialMetrics + ? (initialMetrics.horizontalCornerAdaptedInsets ?? + initialMetrics.insets) + : (initialSafeAreaInsets ?? parentHorizontalInsets ?? null), + ); const [frame, setFrame] = React.useState( initialMetrics?.frame ?? parentFrame ?? { @@ -62,7 +95,12 @@ export function SafeAreaProvider({ ); const onInsetsChange = React.useCallback((event: InsetChangedEvent) => { const { - nativeEvent: { frame: nextFrame, insets: nextInsets }, + nativeEvent: { + frame: nextFrame, + insets: nextInsets, + verticalCornerAdaptedInsets: nextVerticalInsets = nextInsets, + horizontalCornerAdaptedInsets: nextHorizontalInsets = nextInsets, + }, } = event; setFrame((curFrame) => { @@ -80,19 +118,11 @@ export function SafeAreaProvider({ } }); - setInsets((curInsets) => { - if ( - !curInsets || - nextInsets.bottom !== curInsets.bottom || - nextInsets.left !== curInsets.left || - nextInsets.right !== curInsets.right || - nextInsets.top !== curInsets.top - ) { - return nextInsets; - } else { - return curInsets; - } - }); + setInsets((curInsets) => pickInsets(curInsets, nextInsets)); + setVerticalInsets((curInsets) => pickInsets(curInsets, nextVerticalInsets)); + setHorizontalInsets((curInsets) => + pickInsets(curInsets, nextHorizontalInsets), + ); }, []); return ( @@ -105,7 +135,15 @@ export function SafeAreaProvider({ {insets != null ? ( - {children} + + + {children} + + ) : null} @@ -139,6 +177,23 @@ export function SafeAreaListener({ ); } +function pickInsets( + curInsets: EdgeInsets | null, + nextInsets: EdgeInsets, +): EdgeInsets { + if ( + !curInsets || + nextInsets.bottom !== curInsets.bottom || + nextInsets.left !== curInsets.left || + nextInsets.right !== curInsets.right || + nextInsets.top !== curInsets.top + ) { + return nextInsets; + } else { + return curInsets; + } +} + const styles = StyleSheet.create({ fill: { flex: 1 }, }); @@ -154,8 +209,29 @@ function useParentSafeAreaFrame(): Rect | null { 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); +export interface UseSafeAreaInsetsOptions { + /** + * Return the safe area insets enlarged on the edges of this axis where the + * display's rounded corners reach past the regular safe area. + */ + cornerAdaptation?: CornerAdaptation; +} + +function getInsetsContext(cornerAdaptation: CornerAdaptation | undefined) { + switch (cornerAdaptation) { + case 'vertical': + return SafeAreaVerticalCornerAdaptedInsetsContext; + case 'horizontal': + return SafeAreaHorizontalCornerAdaptedInsetsContext; + default: + return SafeAreaInsetsContext; + } +} + +export function useSafeAreaInsets( + options?: UseSafeAreaInsetsOptions, +): EdgeInsets { + const insets = React.useContext(getInsetsContext(options?.cornerAdaptation)); if (insets == null) { throw new Error(NO_INSETS_ERROR); } diff --git a/src/SafeAreaView.web.tsx b/src/SafeAreaView.web.tsx index 431f0aa3..c9e184db 100644 --- a/src/SafeAreaView.web.tsx +++ b/src/SafeAreaView.web.tsx @@ -35,8 +35,8 @@ function getEdgeValue( export const SafeAreaView = React.forwardRef< NativeSafeAreaViewInstance, NativeSafeAreaViewProps ->(({ style = {}, mode, edges, ...rest }, ref) => { - const insets = useSafeAreaInsets(); +>(({ style = {}, mode, edges, cornerAdaptation, ...rest }, ref) => { + const insets = useSafeAreaInsets({ cornerAdaptation }); const edgesRecord = React.useMemo(() => { if (edges == null) { diff --git a/src/__tests__/SafeAreaContext-test.tsx b/src/__tests__/SafeAreaContext-test.tsx index 1ce1c8b3..c68f7346 100644 --- a/src/__tests__/SafeAreaContext-test.tsx +++ b/src/__tests__/SafeAreaContext-test.tsx @@ -1,8 +1,8 @@ import { describe, expect, it, jest } from '@jest/globals'; import { act, render, screen } from '@testing-library/react-native'; import * as React from 'react'; -import { View } from 'react-native'; -import type { Metrics } from '../SafeArea.types'; +import { Text, View } from 'react-native'; +import type { CornerAdaptation, Metrics } from '../SafeArea.types'; import { SafeAreaProvider, useSafeAreaFrame, @@ -18,6 +18,45 @@ const TEST_METRICS_2: Metrics = { frame: { x: 0, y: 0, width: 10, height: 16 }, }; +const TEST_CORNER_METRICS: Metrics = { + insets: { top: 0, left: 0, right: 84, bottom: 34 }, + verticalCornerAdaptedInsets: { top: 17, left: 0, right: 84, bottom: 34 }, + horizontalCornerAdaptedInsets: { top: 0, left: 2, right: 84, bottom: 34 }, + frame: { x: 0, y: 0, height: 678, width: 466 }, +}; + +const PrintCornerInsets = ({ + cornerAdaptation, + onRender, +}: { + cornerAdaptation?: CornerAdaptation; + onRender?: () => void; +}) => { + const insets = useSafeAreaInsets( + cornerAdaptation ? { cornerAdaptation } : undefined, + ); + onRender?.(); + return ( + + {JSON.stringify(insets)} + + ); +}; + +const PrintAllCornerInsets = () => ( + <> + + + + +); + +function readInsets(cornerAdaptation?: CornerAdaptation) { + return JSON.parse( + screen.getByTestId(`insets-${cornerAdaptation ?? 'none'}`).props.children, + ); +} + const PrintInsetsTestView = () => { const insets = useSafeAreaInsets(); const frame = useSafeAreaFrame(); @@ -116,4 +155,120 @@ describe('SafeAreaContext', () => { consoleErrorMock.mockRestore(); }); + + describe('cornerAdaptation', () => { + it('returns the regular insets by default and the variants on request', () => { + render( + + + , + ); + expect(readInsets()).toEqual(TEST_CORNER_METRICS.insets); + expect(readInsets('vertical')).toEqual( + TEST_CORNER_METRICS.verticalCornerAdaptedInsets, + ); + expect(readInsets('horizontal')).toEqual( + TEST_CORNER_METRICS.horizontalCornerAdaptedInsets, + ); + }); + + it('falls back to the regular insets when metrics have no variants', () => { + render( + + + , + ); + expect(readInsets('vertical')).toEqual(TEST_METRICS_1.insets); + expect(readInsets('horizontal')).toEqual(TEST_METRICS_1.insets); + }); + + it('updates the variants from the native event', async () => { + render( + + + , + ); + const element = await screen.findByTestId('safe-area-provider'); + act(() => { + element.props.onInsetsChange({ nativeEvent: TEST_CORNER_METRICS }); + }); + expect(readInsets()).toEqual(TEST_CORNER_METRICS.insets); + expect(readInsets('vertical')).toEqual( + TEST_CORNER_METRICS.verticalCornerAdaptedInsets, + ); + expect(readInsets('horizontal')).toEqual( + TEST_CORNER_METRICS.horizontalCornerAdaptedInsets, + ); + + act(() => { + element.props.onInsetsChange({ nativeEvent: TEST_METRICS_2 }); + }); + expect(readInsets('vertical')).toEqual(TEST_METRICS_2.insets); + expect(readInsets('horizontal')).toEqual(TEST_METRICS_2.insets); + }); + + it('does not re-render plain consumers when only a variant changes', async () => { + const onPlainRender = jest.fn(); + const onVerticalRender = jest.fn(); + render( + + + + , + ); + const element = await screen.findByTestId('safe-area-provider'); + onPlainRender.mockClear(); + onVerticalRender.mockClear(); + act(() => { + element.props.onInsetsChange({ + nativeEvent: { + ...TEST_CORNER_METRICS, + verticalCornerAdaptedInsets: { + top: 20, + left: 0, + right: 84, + bottom: 34, + }, + }, + }); + }); + expect(onPlainRender).not.toHaveBeenCalled(); + expect(onVerticalRender).toHaveBeenCalledTimes(1); + expect(readInsets('vertical')).toEqual({ + top: 20, + left: 0, + right: 84, + bottom: 34, + }); + }); + + it('uses parent variants when available', () => { + render( + + + + + , + ); + expect(readInsets('vertical')).toEqual( + TEST_CORNER_METRICS.verticalCornerAdaptedInsets, + ); + expect(readInsets('horizontal')).toEqual( + TEST_CORNER_METRICS.horizontalCornerAdaptedInsets, + ); + }); + }); }); + +const PlainConsumer = React.memo(({ onRender }: { onRender: () => void }) => ( + +)); + +const VerticalConsumer = React.memo( + ({ onRender }: { onRender: () => void }) => ( + + ), +); diff --git a/src/__tests__/SafeAreaView-test.tsx b/src/__tests__/SafeAreaView-test.tsx index c3fd88eb..4088f856 100644 --- a/src/__tests__/SafeAreaView-test.tsx +++ b/src/__tests__/SafeAreaView-test.tsx @@ -58,4 +58,17 @@ describe('SafeAreaView', () => { ); expect(toJSON()).toMatchSnapshot(); }); + + it('passes cornerAdaptation to the native view', () => { + const { toJSON } = render( + + + + + , + ); + expect(toJSON()).toMatchObject({ + children: [{ props: { cornerAdaptation: 'vertical' } }], + }); + }); }); diff --git a/src/jest/mock.tsx b/src/jest/mock.tsx index 3bb05fa8..8f241076 100644 --- a/src/jest/mock.tsx +++ b/src/jest/mock.tsx @@ -5,6 +5,9 @@ import type { SafeAreaProviderProps, SafeAreaInsetsContext, SafeAreaFrameContext, + SafeAreaVerticalCornerAdaptedInsetsContext, + SafeAreaHorizontalCornerAdaptedInsetsContext, + UseSafeAreaInsetsOptions, } from '../SafeAreaContext'; const MOCK_INITIAL_METRICS: Metrics = { @@ -25,16 +28,25 @@ const MOCK_INITIAL_METRICS: Metrics = { const RNSafeAreaContext = jest.requireActual<{ SafeAreaInsetsContext: typeof SafeAreaInsetsContext; SafeAreaFrameContext: typeof SafeAreaFrameContext; + SafeAreaVerticalCornerAdaptedInsetsContext: typeof SafeAreaVerticalCornerAdaptedInsetsContext; + SafeAreaHorizontalCornerAdaptedInsetsContext: typeof SafeAreaHorizontalCornerAdaptedInsetsContext; }>('react-native-safe-area-context'); export default { ...RNSafeAreaContext, initialWindowMetrics: MOCK_INITIAL_METRICS, - useSafeAreaInsets: jest.fn(() => { - return ( + useSafeAreaInsets: jest.fn((options?: UseSafeAreaInsetsOptions) => { + const insets = useContext(RNSafeAreaContext.SafeAreaInsetsContext) ?? - MOCK_INITIAL_METRICS.insets + MOCK_INITIAL_METRICS.insets; + const cornerInsets = useContext( + options?.cornerAdaptation === 'horizontal' + ? RNSafeAreaContext.SafeAreaHorizontalCornerAdaptedInsetsContext + : RNSafeAreaContext.SafeAreaVerticalCornerAdaptedInsetsContext, ); + return options?.cornerAdaptation != null + ? (cornerInsets ?? insets) + : insets; }), useSafeAreaFrame: jest.fn(() => { return ( diff --git a/src/specs/NativeSafeAreaContext.ts b/src/specs/NativeSafeAreaContext.ts index b384b65b..78c65270 100644 --- a/src/specs/NativeSafeAreaContext.ts +++ b/src/specs/NativeSafeAreaContext.ts @@ -10,6 +10,18 @@ export interface Spec extends TurboModule { bottom: Double; left: Double; }; + verticalCornerAdaptedInsets: { + top: Double; + right: Double; + bottom: Double; + left: Double; + }; + horizontalCornerAdaptedInsets: { + top: Double; + right: Double; + bottom: Double; + left: Double; + }; frame: { x: Double; y: Double; diff --git a/src/specs/NativeSafeAreaProvider.ts b/src/specs/NativeSafeAreaProvider.ts index 5e6974ee..9d8c5e20 100644 --- a/src/specs/NativeSafeAreaProvider.ts +++ b/src/specs/NativeSafeAreaProvider.ts @@ -12,6 +12,18 @@ export type Event = Readonly<{ bottom: Double; left: Double; }>; + verticalCornerAdaptedInsets: Readonly<{ + top: Double; + right: Double; + bottom: Double; + left: Double; + }>; + horizontalCornerAdaptedInsets: Readonly<{ + top: Double; + right: Double; + bottom: Double; + left: Double; + }>; frame: Readonly<{ x: Double; y: Double; diff --git a/src/specs/NativeSafeAreaView.ts b/src/specs/NativeSafeAreaView.ts index 0ab6adf8..95e49929 100644 --- a/src/specs/NativeSafeAreaView.ts +++ b/src/specs/NativeSafeAreaView.ts @@ -10,6 +10,7 @@ export interface NativeProps extends ViewProps { bottom: string; left: string; }>; + cornerAdaptation?: WithDefault<'none' | 'vertical' | 'horizontal', 'none'>; } export default codegenNativeComponent('RNCSafeAreaView', { From 9ed22361b84684e64ecbefc80b4d3dd1bf0156e3 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 20 Oct 2026 22:41:03 -0400 Subject: [PATCH 2/6] fix: guard corner adaptation on visionOS and tvOS, fall back to regular insets --- ios/Fabric/RNCSafeAreaViewComponentView.mm | 1 + ios/RNCSafeAreaUtils.m | 5 +++-- src/SafeAreaContext.tsx | 5 ++++- src/__tests__/SafeAreaContext-test.tsx | 11 +++++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/ios/Fabric/RNCSafeAreaViewComponentView.mm b/ios/Fabric/RNCSafeAreaViewComponentView.mm index 3d0f5e05..2f111974 100644 --- a/ios/Fabric/RNCSafeAreaViewComponentView.mm +++ b/ios/Fabric/RNCSafeAreaViewComponentView.mm @@ -128,6 +128,7 @@ - (RNCSafeAreaCornerAdaptation)cornerAdaptation case RNCSafeAreaViewCornerAdaptation::None: return RNCSafeAreaCornerAdaptationNone; } + return RNCSafeAreaCornerAdaptationNone; } - (UIView *)findNearestProvider diff --git a/ios/RNCSafeAreaUtils.m b/ios/RNCSafeAreaUtils.m index 38992386..31effb9f 100644 --- a/ios/RNCSafeAreaUtils.m +++ b/ios/RNCSafeAreaUtils.m @@ -44,8 +44,9 @@ BOOL NSEdgeInsetsEqualToEdgeInsetsWithThreshold(NSEdgeInsets insets1, NSEdgeInse UIEdgeInsets RNCSafeAreaInsetsWithCornerAdaptation(UIView *view, RNCSafeAreaCornerAdaptation cornerAdaptation) { // UIViewLayoutRegion is only declared by the iOS 26 SDK and later. -#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 - if (@available(iOS 26.0, tvOS 26.0, *)) { +#if (defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0) || \ + (defined(__TVOS_26_0) && __TV_OS_VERSION_MAX_ALLOWED >= __TVOS_26_0) + if (@available(iOS 26.0, tvOS 26.0, visionOS 26.0, *)) { switch (cornerAdaptation) { case RNCSafeAreaCornerAdaptationVertical: return [view edgeInsetsForLayoutRegion:[UIViewLayoutRegion safeAreaLayoutRegionWithCornerAdaptation: diff --git a/src/SafeAreaContext.tsx b/src/SafeAreaContext.tsx index e2d132e5..57b24b10 100644 --- a/src/SafeAreaContext.tsx +++ b/src/SafeAreaContext.tsx @@ -231,7 +231,10 @@ function getInsetsContext(cornerAdaptation: CornerAdaptation | undefined) { export function useSafeAreaInsets( options?: UseSafeAreaInsetsOptions, ): EdgeInsets { - const insets = React.useContext(getInsetsContext(options?.cornerAdaptation)); + const regularInsets = React.useContext(SafeAreaInsetsContext); + const insets = + React.useContext(getInsetsContext(options?.cornerAdaptation)) ?? + regularInsets; if (insets == null) { throw new Error(NO_INSETS_ERROR); } diff --git a/src/__tests__/SafeAreaContext-test.tsx b/src/__tests__/SafeAreaContext-test.tsx index c68f7346..86f7f874 100644 --- a/src/__tests__/SafeAreaContext-test.tsx +++ b/src/__tests__/SafeAreaContext-test.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import type { CornerAdaptation, Metrics } from '../SafeArea.types'; import { + SafeAreaInsetsContext, SafeAreaProvider, useSafeAreaFrame, useSafeAreaInsets, @@ -245,6 +246,16 @@ describe('SafeAreaContext', () => { }); }); + it('falls back to the regular insets under a bare insets context', () => { + render( + + + , + ); + expect(readInsets('vertical')).toEqual(TEST_METRICS_1.insets); + expect(readInsets('horizontal')).toEqual(TEST_METRICS_1.insets); + }); + it('uses parent variants when available', () => { render( From deb71a54b2917ed725c48bd228764eedba90498f Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 21 Oct 2026 00:56:12 -0400 Subject: [PATCH 3/6] refactor: provide both corner-adapted variants through one context --- docs/docs/api/safe-area-insets-context.mdx | 4 + docs/docs/api/use-safe-area-insets.mdx | 10 +- src/SafeArea.types.ts | 5 + src/SafeAreaContext.tsx | 118 ++++++++++++--------- src/__tests__/SafeAreaContext-test.tsx | 18 ++++ src/jest/mock.tsx | 12 +-- 6 files changed, 104 insertions(+), 63 deletions(-) diff --git a/docs/docs/api/safe-area-insets-context.mdx b/docs/docs/api/safe-area-insets-context.mdx index af41b252..2d0604ed 100644 --- a/docs/docs/api/safe-area-insets-context.mdx +++ b/docs/docs/api/safe-area-insets-context.mdx @@ -21,3 +21,7 @@ class ClassComponent extends React.Component { } } ``` + +### `SafeAreaCornerAdaptedInsetsContext` + +React Context with the corner-adapted safe area insets as `{ vertical: EdgeInsets, horizontal: EdgeInsets }`. See the [`cornerAdaptation`](/api/use-safe-area-insets#corneradaptation) option of `useSafeAreaInsets`. diff --git a/docs/docs/api/use-safe-area-insets.mdx b/docs/docs/api/use-safe-area-insets.mdx index 6b03762b..34e57998 100644 --- a/docs/docs/api/use-safe-area-insets.mdx +++ b/docs/docs/api/use-safe-area-insets.mdx @@ -38,8 +38,12 @@ function Header() { } ``` -- **iOS 26+**: uses `edgeInsets(for: .safeArea(cornerAdaptation:))` measured on the `SafeAreaProvider` view. UIKit computes the value from the receiver's position and size, so it can be smaller than the window's value for a short or narrow provider. -- **Android 12+ (API 31)**: approximated from the rounded corners the window reports. With `vertical`, the top and bottom insets grow to where each corner's arc crosses the left and right edges of the safe area. With `horizontal`, the left and right insets grow to where each arc crosses the top and bottom edges of the safe area. +`horizontal` is not a no-op on regular phones. An iPhone 18 Pro in portrait has no regular left or right inset, but its corners give `left: 18, right: 18`. + +There is no option that adapts both axes at once because UIKit's `UIViewLayoutRegionAdaptivityAxis` is a single choice of none, horizontal or vertical. If you need it, combine the two results per edge, for example `top` from `vertical` and `left` from `horizontal`. + +- **iOS 26+**: uses `edgeInsets(for: .safeArea(cornerAdaptation:))` measured on the `SafeAreaProvider` view. UIKit computes the value from the provider's position and size, so it can be smaller than the window's value for a short or narrow provider. +- **Android 12+ (API 31)**: computed from the rounded corners the window reports, with the rule UIKit applies. For each corner of radius `R`, the inset is `r × (1 − √2⁄2)` with `r = min(R − max(dx, dy), width / 2, height / 2)`, where `dx` and `dy` are the provider's distances from the two screen edges meeting at that corner. The edge inset is the larger of that value and the regular inset. - **Older iOS and Android versions, and web**: same as the regular insets. -Components that use only the regular insets do not re-render when only the corner-adapted insets change. +Components that use only the regular insets do not re-render when only the corner-adapted insets change. Both variants are also available from `SafeAreaCornerAdaptedInsetsContext` as `{ vertical, horizontal }`. diff --git a/src/SafeArea.types.ts b/src/SafeArea.types.ts index 296d1a32..462de645 100644 --- a/src/SafeArea.types.ts +++ b/src/SafeArea.types.ts @@ -24,6 +24,11 @@ export interface Rect { height: number; } +export interface CornerAdaptedInsets { + vertical: EdgeInsets; + horizontal: EdgeInsets; +} + export interface Metrics { insets: EdgeInsets; frame: Rect; diff --git a/src/SafeAreaContext.tsx b/src/SafeAreaContext.tsx index 57b24b10..8b1abaa2 100644 --- a/src/SafeAreaContext.tsx +++ b/src/SafeAreaContext.tsx @@ -8,6 +8,7 @@ import { import { NativeSafeAreaProvider } from './NativeSafeAreaProvider'; import type { CornerAdaptation, + CornerAdaptedInsets, EdgeInsets, InsetChangedEvent, Metrics, @@ -28,18 +29,11 @@ if (isDev) { SafeAreaFrameContext.displayName = 'SafeAreaFrameContext'; } -export const SafeAreaVerticalCornerAdaptedInsetsContext = - React.createContext(null); +export const SafeAreaCornerAdaptedInsetsContext = + React.createContext(null); if (isDev) { - SafeAreaVerticalCornerAdaptedInsetsContext.displayName = - 'SafeAreaVerticalCornerAdaptedInsetsContext'; -} - -export const SafeAreaHorizontalCornerAdaptedInsetsContext = - React.createContext(null); -if (isDev) { - SafeAreaHorizontalCornerAdaptedInsetsContext.displayName = - 'SafeAreaHorizontalCornerAdaptedInsetsContext'; + SafeAreaCornerAdaptedInsetsContext.displayName = + 'SafeAreaCornerAdaptedInsetsContext'; } export interface SafeAreaProviderProps extends ViewProps { @@ -62,26 +56,29 @@ export function SafeAreaProvider({ }: SafeAreaProviderProps) { const parentInsets = useParentSafeAreaInsets(); const parentFrame = useParentSafeAreaFrame(); - const parentVerticalInsets = React.useContext( - SafeAreaVerticalCornerAdaptedInsetsContext, - ); - const parentHorizontalInsets = React.useContext( - SafeAreaHorizontalCornerAdaptedInsetsContext, + const parentCornerInsets = React.useContext( + SafeAreaCornerAdaptedInsetsContext, ); const [insets, setInsets] = React.useState( initialMetrics?.insets ?? initialSafeAreaInsets ?? parentInsets ?? null, ); - const [verticalInsets, setVerticalInsets] = React.useState( - initialMetrics - ? (initialMetrics.verticalCornerAdaptedInsets ?? initialMetrics.insets) - : (initialSafeAreaInsets ?? parentVerticalInsets ?? null), - ); - const [horizontalInsets, setHorizontalInsets] = - React.useState( + const [cornerInsets, setCornerInsets] = + React.useState( initialMetrics - ? (initialMetrics.horizontalCornerAdaptedInsets ?? - initialMetrics.insets) - : (initialSafeAreaInsets ?? parentHorizontalInsets ?? null), + ? { + vertical: + initialMetrics.verticalCornerAdaptedInsets ?? + initialMetrics.insets, + horizontal: + initialMetrics.horizontalCornerAdaptedInsets ?? + initialMetrics.insets, + } + : initialSafeAreaInsets + ? { + vertical: initialSafeAreaInsets, + horizontal: initialSafeAreaInsets, + } + : parentCornerInsets, ); const [frame, setFrame] = React.useState( initialMetrics?.frame ?? @@ -119,12 +116,33 @@ export function SafeAreaProvider({ }); setInsets((curInsets) => pickInsets(curInsets, nextInsets)); - setVerticalInsets((curInsets) => pickInsets(curInsets, nextVerticalInsets)); - setHorizontalInsets((curInsets) => - pickInsets(curInsets, nextHorizontalInsets), - ); + setCornerInsets((curInsets) => { + const vertical = pickInsets( + curInsets?.vertical ?? null, + nextVerticalInsets, + ); + const horizontal = pickInsets( + curInsets?.horizontal ?? null, + nextHorizontalInsets, + ); + if ( + curInsets && + vertical === curInsets.vertical && + horizontal === curInsets.horizontal + ) { + return curInsets; + } + return { vertical, horizontal }; + }); }, []); + const cornerInsetsValue = React.useMemo( + () => + cornerInsets ?? + (insets != null ? { vertical: insets, horizontal: insets } : null), + [cornerInsets, insets], + ); + return ( - - - {children} - - + {children} + ) : null} @@ -217,24 +231,24 @@ export interface UseSafeAreaInsetsOptions { cornerAdaptation?: CornerAdaptation; } -function getInsetsContext(cornerAdaptation: CornerAdaptation | undefined) { - switch (cornerAdaptation) { - case 'vertical': - return SafeAreaVerticalCornerAdaptedInsetsContext; - case 'horizontal': - return SafeAreaHorizontalCornerAdaptedInsetsContext; - default: - return SafeAreaInsetsContext; - } -} - export function useSafeAreaInsets( options?: UseSafeAreaInsetsOptions, ): EdgeInsets { + const cornerAdaptation = options?.cornerAdaptation; const regularInsets = React.useContext(SafeAreaInsetsContext); - const insets = - React.useContext(getInsetsContext(options?.cornerAdaptation)) ?? - regularInsets; + // Hooks must run unconditionally, so without the option this reads the + // regular context again instead of subscribing to the corner-adapted one. + const cornerInsets = React.useContext( + (cornerAdaptation + ? SafeAreaCornerAdaptedInsetsContext + : SafeAreaInsetsContext) as React.Context< + EdgeInsets | CornerAdaptedInsets | null + >, + ); + const insets = cornerAdaptation + ? ((cornerInsets as CornerAdaptedInsets | null)?.[cornerAdaptation] ?? + regularInsets) + : regularInsets; if (insets == null) { throw new Error(NO_INSETS_ERROR); } diff --git a/src/__tests__/SafeAreaContext-test.tsx b/src/__tests__/SafeAreaContext-test.tsx index 86f7f874..ce952515 100644 --- a/src/__tests__/SafeAreaContext-test.tsx +++ b/src/__tests__/SafeAreaContext-test.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import type { CornerAdaptation, Metrics } from '../SafeArea.types'; import { + SafeAreaCornerAdaptedInsetsContext, SafeAreaInsetsContext, SafeAreaProvider, useSafeAreaFrame, @@ -246,6 +247,23 @@ describe('SafeAreaContext', () => { }); }); + it('provides both variants through one context', () => { + let value: unknown; + const ReadContext = () => { + value = React.useContext(SafeAreaCornerAdaptedInsetsContext); + return null; + }; + render( + + + , + ); + expect(value).toEqual({ + vertical: TEST_CORNER_METRICS.verticalCornerAdaptedInsets, + horizontal: TEST_CORNER_METRICS.horizontalCornerAdaptedInsets, + }); + }); + it('falls back to the regular insets under a bare insets context', () => { render( diff --git a/src/jest/mock.tsx b/src/jest/mock.tsx index 8f241076..23f89470 100644 --- a/src/jest/mock.tsx +++ b/src/jest/mock.tsx @@ -5,8 +5,7 @@ import type { SafeAreaProviderProps, SafeAreaInsetsContext, SafeAreaFrameContext, - SafeAreaVerticalCornerAdaptedInsetsContext, - SafeAreaHorizontalCornerAdaptedInsetsContext, + SafeAreaCornerAdaptedInsetsContext, UseSafeAreaInsetsOptions, } from '../SafeAreaContext'; @@ -28,8 +27,7 @@ const MOCK_INITIAL_METRICS: Metrics = { const RNSafeAreaContext = jest.requireActual<{ SafeAreaInsetsContext: typeof SafeAreaInsetsContext; SafeAreaFrameContext: typeof SafeAreaFrameContext; - SafeAreaVerticalCornerAdaptedInsetsContext: typeof SafeAreaVerticalCornerAdaptedInsetsContext; - SafeAreaHorizontalCornerAdaptedInsetsContext: typeof SafeAreaHorizontalCornerAdaptedInsetsContext; + SafeAreaCornerAdaptedInsetsContext: typeof SafeAreaCornerAdaptedInsetsContext; }>('react-native-safe-area-context'); export default { @@ -40,12 +38,10 @@ export default { useContext(RNSafeAreaContext.SafeAreaInsetsContext) ?? MOCK_INITIAL_METRICS.insets; const cornerInsets = useContext( - options?.cornerAdaptation === 'horizontal' - ? RNSafeAreaContext.SafeAreaHorizontalCornerAdaptedInsetsContext - : RNSafeAreaContext.SafeAreaVerticalCornerAdaptedInsetsContext, + RNSafeAreaContext.SafeAreaCornerAdaptedInsetsContext, ); return options?.cornerAdaptation != null - ? (cornerInsets ?? insets) + ? (cornerInsets?.[options.cornerAdaptation] ?? insets) : insets; }), useSafeAreaFrame: jest.fn(() => { From 4572b55c0b50648d5f8f6a57c31c1689ebc8f0cd Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 21 Oct 2026 00:57:45 -0400 Subject: [PATCH 4/6] fix: match UIKit's corner adaptation rule on Android --- .../SafeAreaCornerAdaptation.kt | 140 +++++++++--------- 1 file changed, 73 insertions(+), 67 deletions(-) diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaCornerAdaptation.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaCornerAdaptation.kt index f6b6ef1c..ebafbfbb 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaCornerAdaptation.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaCornerAdaptation.kt @@ -5,6 +5,8 @@ import android.view.RoundedCorner import android.view.View import androidx.annotation.RequiresApi import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt import kotlin.math.sqrt enum class CornerAdaptation { @@ -14,12 +16,15 @@ enum class CornerAdaptation { } /** - * Enlarges [insets] of [view] where the display's rounded corners reach past them. + * Enlarges [insets] of [view] where the display's rounded corners reach past them, matching UIKit's + * `edgeInsets(for: .safeArea(cornerAdaptation:))` on iOS 26. * - * Vertical adaptation raises the top and bottom insets to where each corner's arc crosses the left - * and right edges of the view's safe area. Horizontal adaptation raises the left and right insets - * to where each arc crosses the top and bottom edges of the safe area. Corners are approximated by - * the quarter circles that [WindowInsets.getRoundedCorner] reports, so before API 31 the insets are + * Measured on iOS 26/27 simulators, UIKit gives each corner the inset `r * (1 - √2 / 2)` (the arc's + * 45° point) of an effective radius `r = min(R - max(dx, dy), width / 2, height / 2)`, where `R` is + * the display corner radius and `dx`, `dy` are the view's distances from the two edges meeting at + * that corner. Vertical adaptation applies the top corners to the top inset and the bottom corners + * to the bottom inset, horizontal applies the left and right corners to the side insets, each never + * below the regular inset. [WindowInsets.getRoundedCorner] needs API 31, so below it the insets are * returned unchanged. */ fun getCornerAdaptedInsets( @@ -33,6 +38,8 @@ fun getCornerAdaptedInsets( return getCornerAdaptedInsetsS(view, insets, cornerAdaptation) } +private val ARC_45_DEGREES = 1f - sqrt(2f) / 2f + @RequiresApi(Build.VERSION_CODES.S) private fun getCornerAdaptedInsetsS( view: View, @@ -42,70 +49,69 @@ private fun getCornerAdaptedInsetsS( val windowInsets = view.rootWindowInsets ?: return insets val location = IntArray(2) view.getLocationInWindow(location) - val viewLeft = location[0].toFloat() - val viewTop = location[1].toFloat() - val viewRight = viewLeft + view.width - val viewBottom = viewTop + view.height - val safeLeft = viewLeft + insets.left - val safeTop = viewTop + insets.top - val safeRight = viewRight - insets.right - val safeBottom = viewBottom - insets.bottom - - var top = insets.top - var right = insets.right - var bottom = insets.bottom - var left = insets.left + val left = location[0].toFloat() + val top = location[1].toFloat() + val right = left + view.width + val bottom = top + view.height + val maxRadius = min(view.width, view.height) / 2f - for (position in - intArrayOf( - RoundedCorner.POSITION_TOP_LEFT, - RoundedCorner.POSITION_TOP_RIGHT, - RoundedCorner.POSITION_BOTTOM_RIGHT, - RoundedCorner.POSITION_BOTTOM_LEFT, - )) { - val corner = windowInsets.getRoundedCorner(position) ?: continue + fun cornerInset(position: Int): Float { + val corner = windowInsets.getRoundedCorner(position) ?: return 0f val radius = corner.radius.toFloat() - if (radius <= 0f) { - continue - } - val centerX = corner.center.x.toFloat() - val centerY = corner.center.y.toFloat() - val isLeft = - position == RoundedCorner.POSITION_TOP_LEFT || - position == RoundedCorner.POSITION_BOTTOM_LEFT - val isTop = - position == RoundedCorner.POSITION_TOP_LEFT || position == RoundedCorner.POSITION_TOP_RIGHT - - if (cornerAdaptation == CornerAdaptation.VERTICAL) { - val edgeX = if (isLeft) safeLeft else safeRight - val dx = if (isLeft) centerX - edgeX else edgeX - centerX - if (dx <= 0f) { - continue - } - val arcY = arcOffset(radius, dx) - if (isTop) { - top = max(top, centerY - arcY - viewTop) - } else { - bottom = max(bottom, viewBottom - (centerY + arcY)) - } - } else { - val edgeY = if (isTop) safeTop else safeBottom - val dy = if (isTop) centerY - edgeY else edgeY - centerY - if (dy <= 0f) { - continue - } - val arcX = arcOffset(radius, dy) - if (isLeft) { - left = max(left, centerX - arcX - viewLeft) - } else { - right = max(right, viewRight - (centerX + arcX)) - } - } + val center = corner.center + val dx = + when (position) { + RoundedCorner.POSITION_TOP_LEFT, + RoundedCorner.POSITION_BOTTOM_LEFT -> left - (center.x - radius) + else -> center.x + radius - right + } + val dy = + when (position) { + RoundedCorner.POSITION_TOP_LEFT, + RoundedCorner.POSITION_TOP_RIGHT -> top - (center.y - radius) + else -> center.y + radius - bottom + } + val effectiveRadius = min(radius - max(dx, dy), maxRadius) + return max(effectiveRadius, 0f).times(ARC_45_DEGREES).roundToInt().toFloat() } - return EdgeInsets(top = top, right = right, bottom = bottom, left = left) -} - -private fun arcOffset(radius: Float, offset: Float): Float { - return sqrt(max(radius * radius - offset * offset, 0f)) + return if (cornerAdaptation == CornerAdaptation.VERTICAL) { + insets.copy( + top = + max( + insets.top, + max( + cornerInset(RoundedCorner.POSITION_TOP_LEFT), + cornerInset(RoundedCorner.POSITION_TOP_RIGHT), + ), + ), + bottom = + max( + insets.bottom, + max( + cornerInset(RoundedCorner.POSITION_BOTTOM_LEFT), + cornerInset(RoundedCorner.POSITION_BOTTOM_RIGHT), + ), + ), + ) + } else { + insets.copy( + left = + max( + insets.left, + max( + cornerInset(RoundedCorner.POSITION_TOP_LEFT), + cornerInset(RoundedCorner.POSITION_BOTTOM_LEFT), + ), + ), + right = + max( + insets.right, + max( + cornerInset(RoundedCorner.POSITION_TOP_RIGHT), + cornerInset(RoundedCorner.POSITION_BOTTOM_RIGHT), + ), + ), + ) + } } From 279900e219bc6e981105b1bca6bedb1fb1b99e76 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 21 Oct 2026 12:09:38 -0400 Subject: [PATCH 5/6] perf: compute Android corner-adapted insets only when their inputs change --- .../safeareacontext/SafeAreaContextModule.kt | 5 +-- .../SafeAreaCornerAdaptation.kt | 32 ++++++++++++++++--- .../safeareacontext/SafeAreaProvider.kt | 25 +++++++-------- .../th3rdwave/safeareacontext/SafeAreaView.kt | 26 ++++++++++++++- 4 files changed, 67 insertions(+), 21 deletions(-) diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaContextModule.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaContextModule.kt index 8cd6b23d..efe1bd52 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaContextModule.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaContextModule.kt @@ -21,6 +21,7 @@ class SafeAreaContextModule(reactContext: ReactApplicationContext?) : val contentView = decorView?.findViewById(android.R.id.content) ?: return null val insets = getSafeAreaInsets(decorView) val frame = getFrame(decorView, contentView) + val corners = getRoundedCorners(decorView) return if (insets == null || frame == null) { null } else @@ -28,11 +29,11 @@ class SafeAreaContextModule(reactContext: ReactApplicationContext?) : "insets" to edgeInsetsToJavaMap(insets), "verticalCornerAdaptedInsets" to edgeInsetsToJavaMap( - getCornerAdaptedInsets(decorView, insets, CornerAdaptation.VERTICAL) + getCornerAdaptedInsets(decorView, insets, corners, CornerAdaptation.VERTICAL) ), "horizontalCornerAdaptedInsets" to edgeInsetsToJavaMap( - getCornerAdaptedInsets(decorView, insets, CornerAdaptation.HORIZONTAL) + getCornerAdaptedInsets(decorView, insets, corners, CornerAdaptation.HORIZONTAL) ), "frame" to rectToJavaMap(frame), ) diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaCornerAdaptation.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaCornerAdaptation.kt index ebafbfbb..3aca0a9a 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaCornerAdaptation.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaCornerAdaptation.kt @@ -24,18 +24,40 @@ enum class CornerAdaptation { * the display corner radius and `dx`, `dy` are the view's distances from the two edges meeting at * that corner. Vertical adaptation applies the top corners to the top inset and the bottom corners * to the bottom inset, horizontal applies the left and right corners to the side insets, each never - * below the regular inset. [WindowInsets.getRoundedCorner] needs API 31, so below it the insets are + * below the regular inset. [corners] comes from [getRoundedCorners]; without it the insets are * returned unchanged. */ fun getCornerAdaptedInsets( view: View, insets: EdgeInsets, + corners: List?, cornerAdaptation: CornerAdaptation, ): EdgeInsets { - if (cornerAdaptation == CornerAdaptation.NONE || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + if ( + cornerAdaptation == CornerAdaptation.NONE || + corners == null || + Build.VERSION.SDK_INT < Build.VERSION_CODES.S + ) { return insets } - return getCornerAdaptedInsetsS(view, insets, cornerAdaptation) + return getCornerAdaptedInsetsS(view, insets, corners, cornerAdaptation) +} + +/** + * The display's rounded corners for [view]'s window, indexed by [RoundedCorner] position, or null + * before API 31 where [WindowInsets.getRoundedCorner] doesn't exist. + */ +fun getRoundedCorners(view: View): List? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return null + } + return getRoundedCornersS(view) +} + +@RequiresApi(Build.VERSION_CODES.S) +private fun getRoundedCornersS(view: View): List? { + val windowInsets = view.rootWindowInsets ?: return null + return List(4) { windowInsets.getRoundedCorner(it) } } private val ARC_45_DEGREES = 1f - sqrt(2f) / 2f @@ -44,9 +66,9 @@ private val ARC_45_DEGREES = 1f - sqrt(2f) / 2f private fun getCornerAdaptedInsetsS( view: View, insets: EdgeInsets, + corners: List, cornerAdaptation: CornerAdaptation, ): EdgeInsets { - val windowInsets = view.rootWindowInsets ?: return insets val location = IntArray(2) view.getLocationInWindow(location) val left = location[0].toFloat() @@ -56,7 +78,7 @@ private fun getCornerAdaptedInsetsS( val maxRadius = min(view.width, view.height) / 2f fun cornerInset(position: Int): Float { - val corner = windowInsets.getRoundedCorner(position) ?: return 0f + val corner = corners[position] ?: return 0f val radius = corner.radius.toFloat() val center = corner.center val dx = diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProvider.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProvider.kt index 6dc36f94..1b77db80 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProvider.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProvider.kt @@ -2,6 +2,7 @@ package com.th3rdwave.safeareacontext import android.content.Context import android.os.Build +import android.view.RoundedCorner import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.WindowInsets @@ -20,9 +21,8 @@ class SafeAreaProvider(context: Context?) : ReactViewGroup(context), ViewTreeObserver.OnPreDrawListener { private var mInsetsChangeHandler: OnInsetsChangeHandler? = null private var mLastInsets: EdgeInsets? = null - private var mLastVerticalCornerAdaptedInsets: EdgeInsets? = null - private var mLastHorizontalCornerAdaptedInsets: EdgeInsets? = null private var mLastFrame: Rect? = null + private var mLastCorners: List? = null private val mDispatchedInsets = DispatchedWindowInsets() internal fun getDispatchedWindowInsets(): WindowInsets? = mDispatchedInsets.get(this) @@ -31,19 +31,18 @@ class SafeAreaProvider(context: Context?) : val insetsChangeHandler = mInsetsChangeHandler ?: return val edgeInsets = getSafeAreaInsets(this, getDispatchedWindowInsets()) ?: return val frame = getFrame(rootView as ViewGroup, this) ?: return - val verticalInsets = getCornerAdaptedInsets(this, edgeInsets, CornerAdaptation.VERTICAL) - val horizontalInsets = getCornerAdaptedInsets(this, edgeInsets, CornerAdaptation.HORIZONTAL) - if ( - mLastInsets != edgeInsets || - mLastVerticalCornerAdaptedInsets != verticalInsets || - mLastHorizontalCornerAdaptedInsets != horizontalInsets || - mLastFrame != frame - ) { - insetsChangeHandler(this, edgeInsets, verticalInsets, horizontalInsets, frame) + val corners = getRoundedCorners(this) + if (mLastInsets != edgeInsets || mLastFrame != frame || mLastCorners != corners) { + insetsChangeHandler( + this, + edgeInsets, + getCornerAdaptedInsets(this, edgeInsets, corners, CornerAdaptation.VERTICAL), + getCornerAdaptedInsets(this, edgeInsets, corners, CornerAdaptation.HORIZONTAL), + frame, + ) mLastInsets = edgeInsets - mLastVerticalCornerAdaptedInsets = verticalInsets - mLastHorizontalCornerAdaptedInsets = horizontalInsets mLastFrame = frame + mLastCorners = corners } } diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaView.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaView.kt index 41201856..e5c56eb9 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaView.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaView.kt @@ -2,7 +2,9 @@ package com.th3rdwave.safeareacontext import android.content.Context import android.os.Build +import android.view.RoundedCorner import android.view.View +import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.WindowInsets import com.facebook.react.bridge.Arguments @@ -16,6 +18,9 @@ class SafeAreaView(context: Context?) : private var mInsets: EdgeInsets? = null private var mEdges: SafeAreaViewEdges? = null private var mCornerAdaptation = CornerAdaptation.NONE + private var mLastSafeAreaInsets: EdgeInsets? = null + private var mLastProviderFrame: Rect? = null + private var mLastCorners: List? = null private var mProviderView: View? = null private var mStateWrapper: StateWrapper? = null private val mDispatchedInsets = DispatchedWindowInsets() @@ -67,6 +72,7 @@ class SafeAreaView(context: Context?) : fun setCornerAdaptation(cornerAdaptation: CornerAdaptation) { mCornerAdaptation = cornerAdaptation + mLastSafeAreaInsets = null maybeUpdateInsets() } @@ -76,7 +82,24 @@ class SafeAreaView(context: Context?) : if (providerView is SafeAreaProvider) providerView.getDispatchedWindowInsets() else mDispatchedInsets.get(this) val safeAreaInsets = getSafeAreaInsets(providerView, dispatchedInsets) ?: return false - val edgeInsets = getCornerAdaptedInsets(providerView, safeAreaInsets, mCornerAdaptation) + val edgeInsets: EdgeInsets + if (mCornerAdaptation == CornerAdaptation.NONE) { + edgeInsets = safeAreaInsets + } else { + val frame = getFrame(providerView.rootView as ViewGroup, providerView) ?: return false + val corners = getRoundedCorners(providerView) + if ( + mLastSafeAreaInsets == safeAreaInsets && + mLastProviderFrame == frame && + mLastCorners == corners + ) { + return false + } + mLastSafeAreaInsets = safeAreaInsets + mLastProviderFrame = frame + mLastCorners = corners + edgeInsets = getCornerAdaptedInsets(providerView, safeAreaInsets, corners, mCornerAdaptation) + } if (mInsets != edgeInsets) { mInsets = edgeInsets updateInsets() @@ -110,6 +133,7 @@ class SafeAreaView(context: Context?) : super.onDetachedFromWindow() mProviderView?.viewTreeObserver?.removeOnPreDrawListener(this) mProviderView = null + mLastSafeAreaInsets = null mDispatchedInsets.clear() } From 7cfb6caa2e53e445377fd9196196cc3346cb8dbc Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 21 Oct 2026 12:22:15 -0400 Subject: [PATCH 6/6] perf: skip iOS corner-adapted measurement outside layout when inputs are unchanged --- .../RNCSafeAreaProviderComponentView.mm | 33 ++++++++++++++--- ios/Fabric/RNCSafeAreaViewComponentView.mm | 37 ++++++++++++++++--- ios/RNCSafeAreaProvider.m | 30 ++++++++++++--- ios/RNCSafeAreaUtils.h | 9 +++++ ios/RNCSafeAreaUtils.m | 16 ++++++++ 5 files changed, 108 insertions(+), 17 deletions(-) diff --git a/ios/Fabric/RNCSafeAreaProviderComponentView.mm b/ios/Fabric/RNCSafeAreaProviderComponentView.mm index 0719f233..b17786d6 100644 --- a/ios/Fabric/RNCSafeAreaProviderComponentView.mm +++ b/ios/Fabric/RNCSafeAreaProviderComponentView.mm @@ -17,6 +17,11 @@ @implementation RNCSafeAreaProviderComponentView { UIEdgeInsets _currentSafeAreaInsets; UIEdgeInsets _currentVerticalCornerAdaptedInsets; UIEdgeInsets _currentHorizontalCornerAdaptedInsets; +#if TARGET_OS_IPHONE + RNCSafeAreaCornerInputs _cornerInputs; + UIEdgeInsets _measuredVerticalCornerAdaptedInsets; + UIEdgeInsets _measuredHorizontalCornerAdaptedInsets; +#endif CGRect _currentFrame; BOOL _initialInsetsSent; BOOL _registeredNotifications; @@ -70,10 +75,15 @@ - (void)registerNotifications - (void)safeAreaInsetsDidChange { - [self invalidateSafeAreaInsets]; + [self invalidateSafeAreaInsetsMeasuringCorners:YES]; } - (void)invalidateSafeAreaInsets +{ + [self invalidateSafeAreaInsetsMeasuringCorners:NO]; +} + +- (void)invalidateSafeAreaInsetsMeasuringCorners:(BOOL)measureCorners { if (self.superview == nil) { return; @@ -86,10 +96,16 @@ - (void)invalidateSafeAreaInsets UIEdgeInsets safeAreaInsets = self.safeAreaInsets; #if TARGET_OS_IPHONE - UIEdgeInsets verticalCornerAdaptedInsets = - RNCSafeAreaInsetsWithCornerAdaptation(self, RNCSafeAreaCornerAdaptationVertical); - UIEdgeInsets horizontalCornerAdaptedInsets = - RNCSafeAreaInsetsWithCornerAdaptation(self, RNCSafeAreaCornerAdaptationHorizontal); + RNCSafeAreaCornerInputs cornerInputs = RNCSafeAreaCornerInputsMake(self, safeAreaInsets); + if (measureCorners || !RNCSafeAreaCornerInputsEqual(cornerInputs, _cornerInputs)) { + _cornerInputs = cornerInputs; + _measuredVerticalCornerAdaptedInsets = + RNCSafeAreaInsetsWithCornerAdaptation(self, RNCSafeAreaCornerAdaptationVertical); + _measuredHorizontalCornerAdaptedInsets = + RNCSafeAreaInsetsWithCornerAdaptation(self, RNCSafeAreaCornerAdaptationHorizontal); + } + UIEdgeInsets verticalCornerAdaptedInsets = _measuredVerticalCornerAdaptedInsets; + UIEdgeInsets horizontalCornerAdaptedInsets = _measuredHorizontalCornerAdaptedInsets; #elif TARGET_OS_OSX UIEdgeInsets verticalCornerAdaptedInsets = safeAreaInsets; UIEdgeInsets horizontalCornerAdaptedInsets = safeAreaInsets; @@ -157,7 +173,9 @@ - (void)layoutSubviews { [super layoutSubviews]; - [self invalidateSafeAreaInsets]; + // UIKit can update corner-adapted insets in a layout pass before anything they depend on changes, e.g. while + // the iPhone Duo switches displays, so layout passes always measure them again. + [self invalidateSafeAreaInsetsMeasuringCorners:YES]; } #pragma mark - RCTComponentViewProtocol @@ -173,6 +191,9 @@ - (void)prepareForRecycle _currentSafeAreaInsets = UIEdgeInsetsZero; _currentVerticalCornerAdaptedInsets = UIEdgeInsetsZero; _currentHorizontalCornerAdaptedInsets = UIEdgeInsetsZero; +#if TARGET_OS_IPHONE + _cornerInputs = (RNCSafeAreaCornerInputs){}; +#endif _currentFrame = CGRectZero; _initialInsetsSent = NO; [NSNotificationCenter.defaultCenter removeObserver:self]; diff --git a/ios/Fabric/RNCSafeAreaViewComponentView.mm b/ios/Fabric/RNCSafeAreaViewComponentView.mm index 2f111974..7d4d4517 100644 --- a/ios/Fabric/RNCSafeAreaViewComponentView.mm +++ b/ios/Fabric/RNCSafeAreaViewComponentView.mm @@ -20,6 +20,11 @@ @interface RNCSafeAreaViewComponentView () @implementation RNCSafeAreaViewComponentView { RNCSafeAreaViewShadowNode::ConcreteState::Shared _state; UIEdgeInsets _currentSafeAreaInsets; +#if TARGET_OS_IPHONE + RNCSafeAreaCornerInputs _cornerInputs; + RNCSafeAreaCornerAdaptation _measuredCornerAdaptation; + UIEdgeInsets _cornerAdaptedInsets; +#endif __weak UIView *_Nullable _providerView; } @@ -75,7 +80,7 @@ - (void)didMoveToWindow UIView *previousProviderView = _providerView; _providerView = [self findNearestProvider]; - [self updateStateIfNecessary]; + [self updateStateIfNecessaryMeasuringCorners:YES]; if (previousProviderView != _providerView) { [NSNotificationCenter.defaultCenter removeObserver:self name:RNCSafeAreaDidChange object:previousProviderView]; @@ -88,10 +93,29 @@ - (void)didMoveToWindow - (void)safeAreaProviderInsetsDidChange:(NSNotification *)notification { - [self updateStateIfNecessary]; + [self updateStateIfNecessaryMeasuringCorners:YES]; } -- (void)updateStateIfNecessary +#if TARGET_OS_IPHONE +- (UIEdgeInsets)providerSafeAreaInsetsMeasuringCorners:(BOOL)measureCorners +{ + UIEdgeInsets safeAreaInsets = _providerView.safeAreaInsets; + RNCSafeAreaCornerAdaptation cornerAdaptation = [self cornerAdaptation]; + if (cornerAdaptation == RNCSafeAreaCornerAdaptationNone) { + return safeAreaInsets; + } + RNCSafeAreaCornerInputs cornerInputs = RNCSafeAreaCornerInputsMake(_providerView, safeAreaInsets); + if (measureCorners || cornerAdaptation != _measuredCornerAdaptation || + !RNCSafeAreaCornerInputsEqual(cornerInputs, _cornerInputs)) { + _cornerInputs = cornerInputs; + _measuredCornerAdaptation = cornerAdaptation; + _cornerAdaptedInsets = RNCSafeAreaInsetsWithCornerAdaptation(_providerView, cornerAdaptation); + } + return _cornerAdaptedInsets; +} +#endif + +- (void)updateStateIfNecessaryMeasuringCorners:(BOOL)measureCorners { // A view detached from the window (e.g. a screen covered by a react-native-screens // native stack) reports zero safeAreaInsets, which would commit a zero-inset layout @@ -103,7 +127,7 @@ - (void)updateStateIfNecessary return; } #if TARGET_OS_IPHONE - UIEdgeInsets safeAreaInsets = RNCSafeAreaInsetsWithCornerAdaptation(_providerView, [self cornerAdaptation]); + UIEdgeInsets safeAreaInsets = [self providerSafeAreaInsetsMeasuringCorners:measureCorners]; if (UIEdgeInsetsEqualToEdgeInsetsWithThreshold(safeAreaInsets, _currentSafeAreaInsets, 1.0 / RCTScreenScale())) { return; @@ -173,7 +197,7 @@ - (void)updateState:(State::Shared const &)state oldState:(State::Shared const & - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask { [super finalizeUpdates:updateMask]; - [self updateStateIfNecessary]; + [self updateStateIfNecessaryMeasuringCorners:NO]; } - (void)prepareForRecycle @@ -184,6 +208,9 @@ - (void)prepareForRecycle _state.reset(); _providerView = nil; _currentSafeAreaInsets = UIEdgeInsetsZero; +#if TARGET_OS_IPHONE + _measuredCornerAdaptation = RNCSafeAreaCornerAdaptationNone; +#endif } @end diff --git a/ios/RNCSafeAreaProvider.m b/ios/RNCSafeAreaProvider.m index 1f8e8e8a..7977aa12 100644 --- a/ios/RNCSafeAreaProvider.m +++ b/ios/RNCSafeAreaProvider.m @@ -16,6 +16,11 @@ @implementation RNCSafeAreaProvider { UIEdgeInsets _currentSafeAreaInsets; UIEdgeInsets _currentVerticalCornerAdaptedInsets; UIEdgeInsets _currentHorizontalCornerAdaptedInsets; +#if TARGET_OS_IPHONE + RNCSafeAreaCornerInputs _cornerInputs; + UIEdgeInsets _measuredVerticalCornerAdaptedInsets; + UIEdgeInsets _measuredHorizontalCornerAdaptedInsets; +#endif CGRect _currentFrame; BOOL _initialInsetsSent; } @@ -51,10 +56,15 @@ - (instancetype)initWithEventDispatcher:(id)eventDis - (void)safeAreaInsetsDidChange { - [self invalidateSafeAreaInsets]; + [self invalidateSafeAreaInsetsMeasuringCorners:YES]; } - (void)invalidateSafeAreaInsets +{ + [self invalidateSafeAreaInsetsMeasuringCorners:NO]; +} + +- (void)invalidateSafeAreaInsetsMeasuringCorners:(BOOL)measureCorners { // This gets called before the view size is set by react-native so // make sure to wait so we don't set wrong insets to JS. @@ -64,10 +74,16 @@ - (void)invalidateSafeAreaInsets #if TARGET_OS_IPHONE UIEdgeInsets safeAreaInsets = self.safeAreaInsets; - UIEdgeInsets verticalCornerAdaptedInsets = - RNCSafeAreaInsetsWithCornerAdaptation(self, RNCSafeAreaCornerAdaptationVertical); - UIEdgeInsets horizontalCornerAdaptedInsets = - RNCSafeAreaInsetsWithCornerAdaptation(self, RNCSafeAreaCornerAdaptationHorizontal); + RNCSafeAreaCornerInputs cornerInputs = RNCSafeAreaCornerInputsMake(self, safeAreaInsets); + if (measureCorners || !RNCSafeAreaCornerInputsEqual(cornerInputs, _cornerInputs)) { + _cornerInputs = cornerInputs; + _measuredVerticalCornerAdaptedInsets = + RNCSafeAreaInsetsWithCornerAdaptation(self, RNCSafeAreaCornerAdaptationVertical); + _measuredHorizontalCornerAdaptedInsets = + RNCSafeAreaInsetsWithCornerAdaptation(self, RNCSafeAreaCornerAdaptationHorizontal); + } + UIEdgeInsets verticalCornerAdaptedInsets = _measuredVerticalCornerAdaptedInsets; + UIEdgeInsets horizontalCornerAdaptedInsets = _measuredHorizontalCornerAdaptedInsets; #elif TARGET_OS_OSX NSEdgeInsets safeAreaInsets; if (@available(macOS 11.0, *)) { @@ -118,7 +134,9 @@ - (void)layoutSubviews { [super layoutSubviews]; - [self invalidateSafeAreaInsets]; + // UIKit can update corner-adapted insets in a layout pass before anything they depend on changes, e.g. while + // the iPhone Duo switches displays, so layout passes always measure them again. + [self invalidateSafeAreaInsetsMeasuringCorners:YES]; } RCT_NOT_IMPLEMENTED(-(instancetype)initWithFrame : (CGRect)frame) diff --git a/ios/RNCSafeAreaUtils.h b/ios/RNCSafeAreaUtils.h index 36fb680a..68502f1d 100644 --- a/ios/RNCSafeAreaUtils.h +++ b/ios/RNCSafeAreaUtils.h @@ -26,4 +26,13 @@ RCT_EXTERN NSViewController *RNCParentViewController(NSView *view); #if TARGET_OS_IPHONE RCT_EXTERN UIEdgeInsets RNCSafeAreaInsetsWithCornerAdaptation(UIView *view, RNCSafeAreaCornerAdaptation cornerAdaptation); + +typedef struct RNCSafeAreaCornerInputs { + UIEdgeInsets safeAreaInsets; + CGRect frameInWindow; + CGRect windowFrame; +} RNCSafeAreaCornerInputs; + +RCT_EXTERN RNCSafeAreaCornerInputs RNCSafeAreaCornerInputsMake(UIView *view, UIEdgeInsets safeAreaInsets); +RCT_EXTERN BOOL RNCSafeAreaCornerInputsEqual(RNCSafeAreaCornerInputs inputs1, RNCSafeAreaCornerInputs inputs2); #endif diff --git a/ios/RNCSafeAreaUtils.m b/ios/RNCSafeAreaUtils.m index 31effb9f..0bd982fc 100644 --- a/ios/RNCSafeAreaUtils.m +++ b/ios/RNCSafeAreaUtils.m @@ -61,4 +61,20 @@ UIEdgeInsets RNCSafeAreaInsetsWithCornerAdaptation(UIView *view, RNCSafeAreaCorn #endif return view.safeAreaInsets; } + +RNCSafeAreaCornerInputs RNCSafeAreaCornerInputsMake(UIView *view, UIEdgeInsets safeAreaInsets) +{ + return (RNCSafeAreaCornerInputs){ + .safeAreaInsets = safeAreaInsets, + .frameInWindow = [view convertRect:view.bounds toView:nil], + .windowFrame = view.window.frame, + }; +} + +BOOL RNCSafeAreaCornerInputsEqual(RNCSafeAreaCornerInputs inputs1, RNCSafeAreaCornerInputs inputs2) +{ + return UIEdgeInsetsEqualToEdgeInsets(inputs1.safeAreaInsets, inputs2.safeAreaInsets) && + CGRectEqualToRect(inputs1.frameInWindow, inputs2.frameInWindow) && + CGRectEqualToRect(inputs1.windowFrame, inputs2.windowFrame); +} #endif