diff --git a/packages/react-native/Libraries/Components/View/ViewPropTypes.js b/packages/react-native/Libraries/Components/View/ViewPropTypes.js index 73e537b15699..63ff6ede58d0 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 c1dc36073cd3..a3d81114fcfd 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 97ce2cb4a282..f1c75a0ad2c1 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 c25333f59b94..6ac247081b1c 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 000000000000..2722d47c1973 --- /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 cbd60b5e99b0..2d0f0ea2f055 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'}`} + + + +