From af66d58ae74713f8776dfe2ab4ca79306f1d670d Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Mon, 12 Oct 2026 10:45:47 -0700 Subject: [PATCH] Fix scaled and clipped Android safe-area insets (#58997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Safe-area insets measure how far a view's rendered bounds extend into the unsafe area, in Android dp or iOS points. Views with the same rendered bounds should report the same insets, regardless of scale or content padding. Fix Android's calculation to use the full transformed bounds instead of mixing a clipped origin with untransformed dimensions, and preserve fractional values through the single density conversion. The example compares a 1× root and a 2× observer without applying padding. iOS native behavior is unchanged. Changelog: [Android][Fixed] - Correct safe-area inset measurements for scaled views and views clipped by ancestors. Reviewed By: fkgozali Differential Revision: D124663000 --- .../Components/View/ViewPropTypes.js | 16 +- .../Libraries/Types/CoreEventTypes.js | 3 +- .../events/SafeAreaInsetsChangeEvent.kt | 10 +- .../internal/SafeAreaInsetsObserver.kt | 119 ++++- .../uimanager/SafeAreaInsetsObserverTest.kt | 470 ++++++++++++++++++ .../SafeAreaInsets/SafeAreaInsetsExample.js | 97 ++++ .../__tests__/SafeAreaInsetsExample-test.js | 112 +++++ 7 files changed, 786 insertions(+), 41 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/SafeAreaInsetsObserverTest.kt create mode 100644 packages/rn-tester/js/examples/SafeAreaInsets/__tests__/SafeAreaInsetsExample-test.js diff --git a/packages/react-native/Libraries/Components/View/ViewPropTypes.js b/packages/react-native/Libraries/Components/View/ViewPropTypes.js index 73e537b1569..63ff6ede58d 100644 --- a/packages/react-native/Libraries/Components/View/ViewPropTypes.js +++ b/packages/react-native/Libraries/Components/View/ViewPropTypes.js @@ -65,20 +65,18 @@ type DirectEventProps = Readonly<{ onLayout?: ?(event: LayoutChangeEvent) => unknown, /** - * Invoked when the part of this view that is covered by the system UI - * (status bar, navigation bar, home indicator, display cutouts, ...) - * changes, with: + * Invoked when this view's safe-area insets change, with: * * `{nativeEvent: {insets: {top, right, bottom, left}}}` * - * `insets` are relative to this view: an inset is only non-zero for the part - * of the view that actually overlaps the system UI. + * `insets` measure how far the view extends into the unsafe area, in points + * on iOS and dp on Android, not display pixels. Content padding does not + * affect them. * - * The event is dispatched synchronously, so the rendering it schedules is - * applied in the same frame the insets changed in. + * Android accounts for scaling and ignores ancestor clipping. Rotation, + * skew, and perspective are unsupported; fully clipped views emit no updates. * - * Setting this prop makes the view observe safe area changes; views without - * it are unaffected. + * Dispatched synchronously so rendering updates apply in the same frame. * * @experimental */ diff --git a/packages/react-native/Libraries/Types/CoreEventTypes.js b/packages/react-native/Libraries/Types/CoreEventTypes.js index c1dc36073cd..a3d81114fcf 100644 --- a/packages/react-native/Libraries/Types/CoreEventTypes.js +++ b/packages/react-native/Libraries/Types/CoreEventTypes.js @@ -86,8 +86,7 @@ export type SafeAreaInsets = Readonly<{ export type SafeAreaInsetsChangeEvent = NativeSyntheticEvent< Readonly<{ /** - * The part of the view that is covered by the system UI, in the view's own - * coordinate space. + * Inset measurements in points (iOS) or dp (Android), not display pixels. */ insets: SafeAreaInsets, }>, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt index 97ce2cb4a28..f1c75a0ad2c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt @@ -20,10 +20,10 @@ import com.facebook.react.uimanager.PixelUtil.pxToDp internal class SafeAreaInsetsChangeEvent( surfaceId: Int, viewTag: Int, - private val insetTop: Int, - private val insetRight: Int, - private val insetBottom: Int, - private val insetLeft: Int, + private val insetTop: Float, + private val insetRight: Float, + private val insetBottom: Float, + private val insetLeft: Float, ) : Event(surfaceId, viewTag) { override fun getEventName(): String = EVENT_NAME @@ -46,6 +46,6 @@ internal class SafeAreaInsetsChangeEvent( internal companion object { const val EVENT_NAME: String = "topSafeAreaInsetsChange" - private fun Int.toDp(): Double = toFloat().pxToDp().toDouble() + private fun Float.toDp(): Double = pxToDp().toDouble() } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt index c25333f59b9..6ac247081b1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt @@ -7,7 +7,9 @@ package com.facebook.react.uimanager.internal +import android.graphics.Matrix import android.graphics.Rect +import android.graphics.RectF import android.view.View import android.view.ViewTreeObserver import androidx.core.graphics.Insets @@ -16,19 +18,18 @@ import androidx.core.view.WindowInsetsCompat import com.facebook.react.R import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.SafeAreaInsetsChangeEvent -import kotlin.math.max -import kotlin.math.min +import kotlin.math.roundToInt /** - * Observes the part of a view that is covered by the system UI, and emits - * [SafeAreaInsetsChangeEvent] whenever it changes. + * Observes the part of a view covered by system UI, and emits [SafeAreaInsetsChangeEvent] whenever + * it changes. */ internal class SafeAreaInsetsObserver private constructor(private val view: View) : ViewTreeObserver.OnPreDrawListener, View.OnAttachStateChangeListener { - private val visibleRect = Rect() - private val insets = IntArray(4) - private val lastInsets = IntArray(4) + private val geometry = Geometry() + private val insets = FloatArray(4) + private val lastInsets = FloatArray(4) private var hasLastInsets = false private var isListening = false @@ -72,6 +73,7 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View override fun onViewDetachedFromWindow(v: View) { stopListening() + hasLastInsets = false } override fun onPreDraw(): Boolean { @@ -83,7 +85,8 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View // Emitting on anything but an inset change would loop: the synchronous // render an event causes produces a new frame, which runs this listener // again. - if (!computeSafeAreaInsets(view, visibleRect, insets)) { + if (!computeSafeAreaInsets(view, geometry, insets)) { + hasLastInsets = false return } if (hasLastInsets && insets.contentEquals(lastInsets)) { @@ -107,6 +110,13 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View ) } + private class Geometry { + val visibleRect = Rect() + val toRoot = Matrix() + val boundsRect = RectF() + val matrixValues = FloatArray(9) + } + companion object { private const val TOP = 0 private const val RIGHT = 1 @@ -131,46 +141,105 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View } /** - * The insets of the window that overlap [view], in the view's own coordinate space. A view that - * does not reach under the system UI has no insets. + * Insets from [view]'s full transformed bounds to the window safe area, in window physical + * pixels, rounded to integers. Ancestor clipping is only a visibility check, not a change to + * those bounds. Returns null for empty/fully clipped views, missing window insets, or + * unsupported transforms. The event preserves fractional pixels and converts them to dp. */ @JvmStatic fun getSafeAreaInsets(view: View): Insets? { - val insets = IntArray(4) - if (!computeSafeAreaInsets(view, Rect(), insets)) { + val insets = FloatArray(4) + if (!computeSafeAreaInsets(view, Geometry(), insets)) { return null } - return Insets.of(insets[LEFT], insets[TOP], insets[RIGHT], insets[BOTTOM]) + return Insets.of( + insets[LEFT].roundToInt(), + insets[TOP].roundToInt(), + insets[RIGHT].roundToInt(), + insets[BOTTOM].roundToInt(), + ) } /** - * Writes the insets of [view] into [out], ordered [TOP], [RIGHT], [BOTTOM], [LEFT], using - * [visibleRect] as scratch space. Returns false when they cannot be computed, leaving [out] - * untouched. + * Writes window physical-pixel insets into [out], ordered [TOP], [RIGHT], [BOTTOM], [LEFT]. + * Returns false when they cannot be computed, leaving [out] untouched. */ - private fun computeSafeAreaInsets(view: View, visibleRect: Rect, out: IntArray): Boolean { - if (view.width == 0 || view.height == 0) { + private fun computeSafeAreaInsets(view: View, geometry: Geometry, out: FloatArray): Boolean { + if (view.width <= 0 || view.height <= 0) { return false } val rootView = view.rootView + if (rootView.width <= 0 || rootView.height <= 0) { + return false + } val windowInsets = ViewCompat.getRootWindowInsets(rootView) ?.getInsets( WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), ) ?: return false - if (!view.getGlobalVisibleRect(visibleRect)) { + if (!view.getGlobalVisibleRect(geometry.visibleRect)) { // The view is fully clipped by an ancestor (e.g. scrolled out of a // scroll view); the rect is undefined in that case, and a view that is // not visible has no meaningful insets. return false } - out[TOP] = max(windowInsets.top - visibleRect.top, 0) - out[RIGHT] = - max(min(visibleRect.left + view.width - rootView.width, 0) + windowInsets.right, 0) - out[BOTTOM] = - max(min(visibleRect.top + view.height - rootView.height, 0) + windowInsets.bottom, 0) - out[LEFT] = max(windowInsets.left - visibleRect.left, 0) + + val toRoot = geometry.toRoot + toRoot.reset() + var current = view + while (current !== rootView) { + val parent = current.parent as? View ?: return false + toRoot.postConcat(current.matrix) + toRoot.postTranslate( + (current.left - parent.scrollX).toFloat(), + (current.top - parent.scrollY).toFloat(), + ) + current = parent + } + toRoot.getValues(geometry.matrixValues) + if ( + geometry.matrixValues.any { !it.isFinite() } || + geometry.matrixValues[Matrix.MSKEW_X] != 0f || + geometry.matrixValues[Matrix.MSKEW_Y] != 0f || + geometry.matrixValues[Matrix.MPERSP_0] != 0f || + geometry.matrixValues[Matrix.MPERSP_1] != 0f || + geometry.matrixValues[Matrix.MPERSP_2] != 1f || + geometry.matrixValues[Matrix.MSCALE_X] == 0f || + geometry.matrixValues[Matrix.MSCALE_Y] == 0f + ) { + return false + } + + val safeLeft = windowInsets.left.toFloat() + val safeTop = windowInsets.top.toFloat() + val safeRight = (rootView.width - windowInsets.right).toFloat() + val safeBottom = (rootView.height - windowInsets.bottom).toFloat() + if (safeRight <= safeLeft || safeBottom <= safeTop) { + return false + } + val bounds = geometry.boundsRect + bounds.set(0f, 0f, view.width.toFloat(), view.height.toFloat()) + // mapRect returns ordered bounding edges, including under reflection. + toRoot.mapRect(bounds) + val width = bounds.width() + val height = bounds.height() + if ( + !bounds.left.isFinite() || + !bounds.top.isFinite() || + !bounds.right.isFinite() || + !bounds.bottom.isFinite() || + !width.isFinite() || + !height.isFinite() || + width <= 0f || + height <= 0f + ) { + return false + } + out[TOP] = (safeTop - bounds.top).coerceIn(0f, height) + out[RIGHT] = (bounds.right - safeRight).coerceIn(0f, width) + out[BOTTOM] = (bounds.bottom - safeBottom).coerceIn(0f, height) + out[LEFT] = (safeLeft - bounds.left).coerceIn(0f, width) return true } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/SafeAreaInsetsObserverTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/SafeAreaInsetsObserverTest.kt new file mode 100644 index 00000000000..2722d47c197 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/SafeAreaInsetsObserverTest.kt @@ -0,0 +1,470 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager + +import android.annotation.SuppressLint +import android.graphics.Rect +import android.util.DisplayMetrics +import android.view.View +import android.view.WindowInsets +import android.widget.FrameLayout +import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat +import com.facebook.react.R +import com.facebook.react.bridge.WritableMap +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.react.uimanager.events.EventDispatcherProvider +import com.facebook.react.uimanager.events.RCTModernEventEmitter +import com.facebook.react.uimanager.events.SafeAreaInsetsChangeEvent +import com.facebook.react.uimanager.internal.SafeAreaInsetsObserver +import com.facebook.testutils.shadows.ShadowArguments +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowArguments::class]) +class SafeAreaInsetsObserverTest { + private lateinit var root: FrameLayout + private val events = mutableListOf() + private var windowInsets = Insets.of(40, 100, 40, 100) + + // BridgeReactContext provides a concrete fixture without starting a React runtime. + @Before + @Suppress("DEPRECATION") + @SuppressLint("DeprecatedSuperclass") + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 1f }) + val dispatcher = mock() + doAnswer { invocation -> + events.add(invocation.getArgument(0)) + null + } + .whenever(dispatcher) + .dispatchEvent(any()) + val application = RuntimeEnvironment.getApplication() + val reactContext = + object : + com.facebook.react.bridge.BridgeReactContext(application), EventDispatcherProvider { + override fun getEventDispatcher(): EventDispatcher = dispatcher + } + val context = ThemedReactContext(reactContext, application, null, 1) + root = + object : FrameLayout(context) { + override fun getRootWindowInsets(): WindowInsets = + checkNotNull( + WindowInsetsCompat.Builder() + .setInsets(WindowInsetsCompat.Type.systemBars(), windowInsets) + .build() + .toWindowInsets(), + ) + } + root.layout(0, 0, 500, 1000) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + @Test + fun fullWindow_reportsAllEdges() { + val view = addView(root, 0, 0, 500, 1000) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)) + .isEqualTo(Insets.of(40, 100, 40, 100)) + } + + @Test + fun insideSafeArea_reportsZero() { + val view = addView(root, 40, 100, 460, 900) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.NONE) + } + + @Test + fun paddingKeepsObserversInsetsAndChildInsideSafeAreaReportsZero() { + val parent = addParent(0, 0, 500, 1000) + parent.setPadding(40, 100, 40, 100) + val child = addView(parent, 40, 100, 460, 900) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(parent)) + .isEqualTo(Insets.of(40, 100, 40, 100)) + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(child)).isEqualTo(Insets.NONE) + } + + @Test + fun scaledObserver_reportsTheSameWindowInsetsBeforeAndAfterPadding() { + val parent = addParent(125, 250, 375, 750) + parent.scaleX = 2f + parent.scaleY = 2f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(parent)) + .isEqualTo(Insets.of(40, 100, 40, 100)) + parent.setPadding(40, 100, 40, 100) + val child = addView(parent, 40, 100, 210, 400) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(parent)) + .isEqualTo(Insets.of(40, 100, 40, 100)) + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(child)).isEqualTo(Insets.NONE) + } + + @Test + fun coincidentRenderedBounds_reportSameInsetsRegardlessOfScale() { + val unscaled = addView(root, 0, 0, 500, 1000) + val enlarged = addView(root, 125, 250, 375, 750) + enlarged.scaleX = 2f + enlarged.scaleY = 2f + val reduced = addView(root, -250, -500, 750, 1500) + reduced.scaleX = 0.5f + reduced.scaleY = 0.5f + + val expected = Insets.of(40, 100, 40, 100) + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(unscaled)).isEqualTo(expected) + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(enlarged)).isEqualTo(expected) + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(reduced)).isEqualTo(expected) + } + + @Test + fun scaledView_reportsTopAndBottomInWindowCoordinates() { + val view = addView(root, 40, 0, 460, 500) + view.pivotY = 0f + view.scaleY = 2f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.of(0, 100, 0, 100)) + } + + @Test + fun scaledView_reportsLeftAndRightInWindowCoordinates() { + val view = addView(root, 0, 100, 250, 900) + view.pivotX = 0f + view.scaleX = 2f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.of(40, 0, 40, 0)) + } + + @Test + fun scaledAncestor_isIncluded() { + val parent = addParent(0, 0, 500, 500) + parent.pivotY = 0f + parent.scaleY = 2f + val view = addView(parent, 40, 0, 460, 500) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.of(0, 100, 0, 100)) + } + + @Test + fun nestedScales_areComposed() { + val parent = addParent(0, 0, 500, 500) + parent.clipChildren = false + parent.pivotY = 0f + parent.scaleY = 2f + val view = addView(parent, 40, 0, 460, 1000) + view.pivotY = 0f + view.scaleY = 0.5f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.of(0, 100, 0, 100)) + } + + @Test + fun scaleAroundDefaultPivot_includesPivotTranslation() { + val view = addView(root, 40, 250, 460, 750) + view.scaleY = 2f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.of(0, 100, 0, 100)) + } + + @Test + fun ancestorScrollAndTranslation_areIncluded() { + val parent = addParent(0, 0, 500, 1000) + val view = addView(parent, 40, 100, 460, 1100) + parent.scrollTo(0, 125) + parent.translationY = 25f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.of(0, 100, 0, 100)) + } + + @Test + fun rootOffset_isNotMixedWithRootLocalInsets() { + root.layout(70, 120, 570, 1120) + val view = addView(root, 0, 0, 500, 1000) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)) + .isEqualTo(Insets.of(40, 100, 40, 100)) + } + + @Test + fun topClipping_doesNotInventBottomInset() { + val parent = addParent(0, 200, 500, 900) + val view = addView(parent, 40, -50, 460, 660) + val visibleRect = Rect() + assertThat(view.getGlobalVisibleRect(visibleRect)).isTrue() + assertThat(visibleRect).isEqualTo(Rect(40, 200, 460, 860)) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.NONE) + } + + @Test + fun leftClipping_doesNotInventRightInset() { + val parent = addParent(100, 0, 400, 1000) + val view = addView(parent, -50, 100, 340, 900) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.NONE) + } + + @Test + fun bottomClipping_doesNotShortenLayoutBounds() { + val parent = addParent(0, 0, 500, 900) + val view = addView(parent, 40, 100, 460, 1000) + val visibleRect = Rect() + assertThat(view.getGlobalVisibleRect(visibleRect)).isTrue() + assertThat(visibleRect.bottom).isEqualTo(900) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.of(0, 0, 0, 100)) + } + + @Test + fun fullyClipped_returnsNull() { + val parent = addParent(0, 100, 500, 900) + val view = addView(parent, 0, -200, 500, -100) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull() + } + + @Test + fun zeroScale_returnsNull() { + val view = addView(root, 0, 0, 500, 1000) + view.scaleY = 0f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull() + } + + @Test + fun arbitraryRotation_returnsNull() { + val view = addView(root, 0, 0, 500, 1000) + view.rotation = 45f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull() + } + + @Test + fun reflectedView_preservesWindowEdges() { + windowInsets = Insets.of(40, 100, 20, 60) + val view = addView(root, 0, 0, 500, 1000) + view.scaleX = -1f + view.scaleY = -1f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.of(40, 100, 20, 60)) + } + + @Test + fun verticallyReflectedScaledView_preservesAsymmetricWindowEdges() { + windowInsets = Insets.of(40, 100, 20, 60) + val view = addView(root, 0, 250, 500, 750) + view.scaleY = -2f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.of(40, 100, 20, 60)) + } + + @Test + fun horizontallyReflectedScaledAncestor_preservesAsymmetricWindowEdges() { + windowInsets = Insets.of(40, 100, 20, 60) + val parent = addParent(125, 0, 375, 1000) + parent.scaleX = -2f + val view = addView(parent, 0, 0, 250, 1000) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.of(40, 100, 20, 60)) + } + + @Test + fun quarterTurn_returnsNull() { + val view = addView(root, 0, 0, 1000, 500) + view.pivotX = 0f + view.pivotY = 0f + view.rotation = 90f + view.translationX = 500f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull() + view.rotation = 270f + view.translationX = 0f + view.translationY = 1000f + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull() + } + + @Test + fun scaledAncestorScroll_isComposedInParentCoordinates() { + val parent = addParent(0, 0, 500, 500) + parent.pivotY = 0f + parent.scaleY = 2f + parent.scrollTo(0, 50) + val view = addView(parent, 40, 50, 460, 550) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.of(0, 100, 0, 100)) + } + + @Test + fun oversizedInsets_doNotInventASafeRegionAndRecoveryReEmits() { + val view = addView(root, 0, 0, 250, 1000) + view.pivotX = 0f + view.scaleX = 2f + val observer = observe(view) + observer.onPreDraw() + windowInsets = Insets.of(400, 100, 200, 100) + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull() + observer.onPreDraw() + assertThat(events).hasSize(1) + windowInsets = Insets.of(40, 100, 40, 100) + observer.onPreDraw() + + assertThat(events).hasSize(2) + } + + @Test + fun emptySafeArea_returnsNull() { + windowInsets = Insets.of(250, 100, 250, 100) + val view = addView(root, 0, 0, 500, 1000) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull() + } + + @Test + fun smallView_insetsAreClampedToItsBounds() { + val view = addView(root, 0, 0, 20, 30) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isEqualTo(Insets.of(20, 30, 0, 0)) + } + + // receiveEvent is deprecated, but exercises the emitted JS payload rather than native values + // alone. + @Test + @Suppress("DEPRECATION") + @SuppressLint("DeprecatedMethod") + fun event_convertsDensityOnceWithoutUndoingScaleAndPreservesFractionalPixels() { + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 2f }) + windowInsets = Insets.of(40, 101, 40, 101) + val view = addView(root, 40, 0, 460, 500) + view.pivotY = 0f + view.scaleY = 2f + view.translationY = 0.5f + val observer = observe(view) + + observer.onPreDraw() + + assertThat(events).hasSize(1) + val event = events.single() + val emitter = mock() + val payload = argumentCaptor() + event.dispatchModern(emitter) + verify(emitter) + .receiveEvent( + eq(1), + eq(2), + eq("topSafeAreaInsetsChange"), + any(), + any(), + payload.capture(), + any(), + ) + val insets = checkNotNull(payload.firstValue.getMap("insets")) + assertThat(insets.getDouble("top")).isEqualTo(50.25) + assertThat(insets.getDouble("bottom")).isEqualTo(50.75) + assertThat(insets.getDouble("left")).isZero() + assertThat(insets.getDouble("right")).isZero() + } + + @Test + fun unchangedInsets_areDeduplicated() { + val observer = observe(addView(root, 0, 0, 500, 1000)) + + observer.onPreDraw() + observer.onPreDraw() + + assertThat(events).hasSize(1) + } + + @Test + fun supportedUnsupportedSupported_reEmitsAfterRecovery() { + val view = addView(root, 0, 0, 500, 1000) + val observer = observe(view) + observer.onPreDraw() + view.rotation = 45f + observer.onPreDraw() + observer.onPreDraw() + assertThat(events).hasSize(1) + view.rotation = 0f + observer.onPreDraw() + observer.onPreDraw() + observer.onPreDraw() + + assertThat(events).hasSize(2) + } + + @Test + fun fullyClippedAndVisible_reEmitsAfterRecovery() { + val parent = addParent(0, 100, 500, 900) + val view = addView(parent, 40, 0, 460, 800) + val observer = observe(view) + observer.onPreDraw() + view.layout(40, -200, 460, -100) + observer.onPreDraw() + observer.onPreDraw() + assertThat(events).hasSize(1) + view.layout(40, 0, 460, 800) + observer.onPreDraw() + observer.onPreDraw() + observer.onPreDraw() + + assertThat(events).hasSize(2) + } + + @Test + fun disabledObserver_isRemovedAndReenableStartsFresh() { + val view = addView(root, 0, 0, 500, 1000) + val observer = observe(view) + observer.onPreDraw() + SafeAreaInsetsObserver.setEnabled(view, false) + assertThat(view.getTag(R.id.safe_area_insets_observer)).isNull() + val nextObserver = observe(view) + nextObserver.onPreDraw() + + assertThat(nextObserver).isNotSameAs(observer) + assertThat(events).hasSize(2) + } + + private fun observe(view: View): SafeAreaInsetsObserver { + view.id = 2 + SafeAreaInsetsObserver.setEnabled(view, true) + return view.getTag(R.id.safe_area_insets_observer) as SafeAreaInsetsObserver + } + + private fun addParent(left: Int, top: Int, right: Int, bottom: Int): FrameLayout = + FrameLayout(root.context).also { parent -> + root.addView(parent) + parent.layout(left, top, right, bottom) + } + + private fun addView(parent: FrameLayout, left: Int, top: Int, right: Int, bottom: Int): View = + View(parent.context).also { view -> + parent.addView(view) + view.layout(left, top, right, bottom) + } +} diff --git a/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js index cbd60b5e99b..2d0f0ea2f05 100644 --- a/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js +++ b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js @@ -19,6 +19,7 @@ import {useCallback, useState} from 'react'; import { Button, Modal, + Platform, ScrollView, StyleSheet, TextInput, @@ -198,6 +199,95 @@ function FeedbackLoopExample(): React.Node { ); } +component GeometryModal(onClose: () => void) { + const [layout, setLayout] = useState({width: 0, height: 0}); + const [rootInsets, onRootInsetsChange] = useSafeAreaInsets(); + const [observerInsets, onObserverInsetsChange] = useSafeAreaInsets(); + const ready = rootInsets != null && layout.width > 0 && layout.height > 0; + const units = Platform.OS === 'ios' ? 'pt' : 'dp'; + + return ( + { + const {width, height} = event.nativeEvent.layout; + setLayout({width, height}); + }}> + {ready ? ( + + ) : null} + + + Same rendered bounds, same safe-area insets. + + + Neither observing view applies padding. + + + Fractional layout can slightly shift the rendered bounds. + + + {`Root layout: ${layout.width} × ${layout.height} ${units}; observer before scaling: ${layout.width / 2} × ${layout.height / 2} ${units}`} + + + {`Root (1×), ${units}: ${JSON.stringify(rootInsets) ?? 'null'}`} + + + {`Observer (2×), ${units}: ${JSON.stringify(observerInsets) ?? 'null'}`} + + + +