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..efe1bd52 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaContextModule.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaContextModule.kt @@ -21,9 +21,22 @@ 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 mapOf("insets" to edgeInsetsToJavaMap(insets), "frame" to rectToJavaMap(frame)) + } else + mapOf( + "insets" to edgeInsetsToJavaMap(insets), + "verticalCornerAdaptedInsets" to + edgeInsetsToJavaMap( + getCornerAdaptedInsets(decorView, insets, corners, CornerAdaptation.VERTICAL) + ), + "horizontalCornerAdaptedInsets" to + edgeInsetsToJavaMap( + getCornerAdaptedInsets(decorView, insets, corners, 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..3aca0a9a --- /dev/null +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaCornerAdaptation.kt @@ -0,0 +1,139 @@ +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.min +import kotlin.math.roundToInt +import kotlin.math.sqrt + +enum class CornerAdaptation { + NONE, + VERTICAL, + HORIZONTAL, +} + +/** + * Enlarges [insets] of [view] where the display's rounded corners reach past them, matching UIKit's + * `edgeInsets(for: .safeArea(cornerAdaptation:))` on iOS 26. + * + * 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. [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 || + corners == null || + Build.VERSION.SDK_INT < Build.VERSION_CODES.S + ) { + return insets + } + 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 + +@RequiresApi(Build.VERSION_CODES.S) +private fun getCornerAdaptedInsetsS( + view: View, + insets: EdgeInsets, + corners: List, + cornerAdaptation: CornerAdaptation, +): EdgeInsets { + val location = IntArray(2) + view.getLocationInWindow(location) + 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 + + fun cornerInset(position: Int): Float { + val corner = corners[position] ?: return 0f + val radius = corner.radius.toFloat() + 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 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), + ), + ), + ) + } +} diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProvider.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProvider.kt index 041c38a2..1b77db80 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProvider.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaProvider.kt @@ -2,18 +2,27 @@ 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 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 mLastFrame: Rect? = null + private var mLastCorners: List? = null private val mDispatchedInsets = DispatchedWindowInsets() internal fun getDispatchedWindowInsets(): WindowInsets? = mDispatchedInsets.get(this) @@ -22,10 +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 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 mLastFrame = frame + mLastCorners = corners } } 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..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 @@ -15,6 +17,10 @@ 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 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() @@ -64,12 +70,36 @@ class SafeAreaView(context: Context?) : updateInsets() } + fun setCornerAdaptation(cornerAdaptation: CornerAdaptation) { + mCornerAdaptation = cornerAdaptation + mLastSafeAreaInsets = null + 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: 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() @@ -103,6 +133,7 @@ class SafeAreaView(context: Context?) : super.onDetachedFromWindow() mProviderView?.viewTreeObserver?.removeOnPreDrawListener(this) mProviderView = null + mLastSafeAreaInsets = null mDispatchedInsets.clear() } 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-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/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..34e57998 100644 --- a/docs/docs/api/use-safe-area-insets.mdx +++ b/docs/docs/api/use-safe-area-insets.mdx @@ -19,3 +19,31 @@ 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 ; +} +``` + +`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. Both variants are also available from `SafeAreaCornerAdaptedInsetsContext` as `{ vertical, horizontal }`. 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..b17786d6 100644 --- a/ios/Fabric/RNCSafeAreaProviderComponentView.mm +++ b/ios/Fabric/RNCSafeAreaProviderComponentView.mm @@ -15,6 +15,13 @@ @interface RNCSafeAreaProviderComponentView () @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 = _providerView.safeAreaInsets; + UIEdgeInsets safeAreaInsets = [self providerSafeAreaInsetsMeasuringCorners:measureCorners]; if (UIEdgeInsetsEqualToEdgeInsetsWithThreshold(safeAreaInsets, _currentSafeAreaInsets, 1.0 / RCTScreenScale())) { return; @@ -118,6 +142,19 @@ - (void)updateStateIfNecessary [self updateState]; } +- (RNCSafeAreaCornerAdaptation)cornerAdaptation +{ + switch (std::static_pointer_cast(_props)->cornerAdaptation) { + case RNCSafeAreaViewCornerAdaptation::Vertical: + return RNCSafeAreaCornerAdaptationVertical; + case RNCSafeAreaViewCornerAdaptation::Horizontal: + return RNCSafeAreaCornerAdaptationHorizontal; + case RNCSafeAreaViewCornerAdaptation::None: + return RNCSafeAreaCornerAdaptationNone; + } + return RNCSafeAreaCornerAdaptationNone; +} + - (UIView *)findNearestProvider { UIView *current = self.superview; @@ -160,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 @@ -171,6 +208,9 @@ - (void)prepareForRecycle _state.reset(); _providerView = nil; _currentSafeAreaInsets = UIEdgeInsetsZero; +#if TARGET_OS_IPHONE + _measuredCornerAdaptation = RNCSafeAreaCornerAdaptationNone; +#endif } @end 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..7977aa12 100644 --- a/ios/RNCSafeAreaProvider.m +++ b/ios/RNCSafeAreaProvider.m @@ -14,6 +14,13 @@ @interface RNCSafeAreaProvider () @implementation RNCSafeAreaProvider { id _eventDispatcher; UIEdgeInsets _currentSafeAreaInsets; + UIEdgeInsets _currentVerticalCornerAdaptedInsets; + UIEdgeInsets _currentHorizontalCornerAdaptedInsets; +#if TARGET_OS_IPHONE + RNCSafeAreaCornerInputs _cornerInputs; + UIEdgeInsets _measuredVerticalCornerAdaptedInsets; + UIEdgeInsets _measuredHorizontalCornerAdaptedInsets; +#endif CGRect _currentFrame; BOOL _initialInsetsSent; } @@ -49,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. @@ -62,6 +74,16 @@ - (void)invalidateSafeAreaInsets #if TARGET_OS_IPHONE UIEdgeInsets safeAreaInsets = self.safeAreaInsets; + 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, *)) { @@ -69,6 +91,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 +100,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 +112,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]; } @@ -101,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 28961246..68502f1d 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,17 @@ 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); + +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 c71d0911..0bd982fc 100644 --- a/ios/RNCSafeAreaUtils.m +++ b/ios/RNCSafeAreaUtils.m @@ -39,3 +39,42 @@ 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) || \ + (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: + UIViewLayoutRegionAdaptivityAxisVertical]]; + case RNCSafeAreaCornerAdaptationHorizontal: + return [view edgeInsetsForLayoutRegion:[UIViewLayoutRegion safeAreaLayoutRegionWithCornerAdaptation: + UIViewLayoutRegionAdaptivityAxisHorizontal]]; + case RNCSafeAreaCornerAdaptationNone: + break; + } + } +#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 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..462de645 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; @@ -22,9 +24,24 @@ export interface Rect { height: number; } +export interface CornerAdaptedInsets { + vertical: EdgeInsets; + horizontal: EdgeInsets; +} + 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 +58,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..8b1abaa2 100644 --- a/src/SafeAreaContext.tsx +++ b/src/SafeAreaContext.tsx @@ -7,6 +7,8 @@ import { } from 'react-native'; import { NativeSafeAreaProvider } from './NativeSafeAreaProvider'; import type { + CornerAdaptation, + CornerAdaptedInsets, EdgeInsets, InsetChangedEvent, Metrics, @@ -27,6 +29,13 @@ if (isDev) { SafeAreaFrameContext.displayName = 'SafeAreaFrameContext'; } +export const SafeAreaCornerAdaptedInsetsContext = + React.createContext(null); +if (isDev) { + SafeAreaCornerAdaptedInsetsContext.displayName = + 'SafeAreaCornerAdaptedInsetsContext'; +} + export interface SafeAreaProviderProps extends ViewProps { ref?: React.Ref; children?: React.ReactNode; @@ -47,9 +56,30 @@ export function SafeAreaProvider({ }: SafeAreaProviderProps) { const parentInsets = useParentSafeAreaInsets(); const parentFrame = useParentSafeAreaFrame(); + const parentCornerInsets = React.useContext( + SafeAreaCornerAdaptedInsetsContext, + ); const [insets, setInsets] = React.useState( initialMetrics?.insets ?? initialSafeAreaInsets ?? parentInsets ?? null, ); + const [cornerInsets, setCornerInsets] = + React.useState( + initialMetrics + ? { + vertical: + initialMetrics.verticalCornerAdaptedInsets ?? + initialMetrics.insets, + horizontal: + initialMetrics.horizontalCornerAdaptedInsets ?? + initialMetrics.insets, + } + : initialSafeAreaInsets + ? { + vertical: initialSafeAreaInsets, + horizontal: initialSafeAreaInsets, + } + : parentCornerInsets, + ); const [frame, setFrame] = React.useState( initialMetrics?.frame ?? parentFrame ?? { @@ -62,7 +92,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,21 +115,34 @@ export function SafeAreaProvider({ } }); - setInsets((curInsets) => { + setInsets((curInsets) => pickInsets(curInsets, nextInsets)); + setCornerInsets((curInsets) => { + const vertical = pickInsets( + curInsets?.vertical ?? null, + nextVerticalInsets, + ); + const horizontal = pickInsets( + curInsets?.horizontal ?? null, + nextHorizontalInsets, + ); if ( - !curInsets || - nextInsets.bottom !== curInsets.bottom || - nextInsets.left !== curInsets.left || - nextInsets.right !== curInsets.right || - nextInsets.top !== curInsets.top + curInsets && + vertical === curInsets.vertical && + horizontal === curInsets.horizontal ) { - return nextInsets; - } else { return curInsets; } + return { vertical, horizontal }; }); }, []); + const cornerInsetsValue = React.useMemo( + () => + cornerInsets ?? + (insets != null ? { vertical: insets, horizontal: insets } : null), + [cornerInsets, insets], + ); + return ( - {children} + + {children} + ) : null} @@ -139,6 +191,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 +223,32 @@ 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; +} + +export function useSafeAreaInsets( + options?: UseSafeAreaInsetsOptions, +): EdgeInsets { + const cornerAdaptation = options?.cornerAdaptation; + const regularInsets = React.useContext(SafeAreaInsetsContext); + // 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/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..ce952515 100644 --- a/src/__tests__/SafeAreaContext-test.tsx +++ b/src/__tests__/SafeAreaContext-test.tsx @@ -1,9 +1,11 @@ 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 { + SafeAreaCornerAdaptedInsetsContext, + SafeAreaInsetsContext, SafeAreaProvider, useSafeAreaFrame, useSafeAreaInsets, @@ -18,6 +20,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 +157,147 @@ 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('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( + + + , + ); + expect(readInsets('vertical')).toEqual(TEST_METRICS_1.insets); + expect(readInsets('horizontal')).toEqual(TEST_METRICS_1.insets); + }); + + 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..23f89470 100644 --- a/src/jest/mock.tsx +++ b/src/jest/mock.tsx @@ -5,6 +5,8 @@ import type { SafeAreaProviderProps, SafeAreaInsetsContext, SafeAreaFrameContext, + SafeAreaCornerAdaptedInsetsContext, + UseSafeAreaInsetsOptions, } from '../SafeAreaContext'; const MOCK_INITIAL_METRICS: Metrics = { @@ -25,16 +27,22 @@ const MOCK_INITIAL_METRICS: Metrics = { const RNSafeAreaContext = jest.requireActual<{ SafeAreaInsetsContext: typeof SafeAreaInsetsContext; SafeAreaFrameContext: typeof SafeAreaFrameContext; + SafeAreaCornerAdaptedInsetsContext: typeof SafeAreaCornerAdaptedInsetsContext; }>('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( + RNSafeAreaContext.SafeAreaCornerAdaptedInsetsContext, ); + return options?.cornerAdaptation != null + ? (cornerInsets?.[options.cornerAdaptation] ?? 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', {