diff --git a/packages/react-native/Libraries/Components/View/ViewPropTypes.js b/packages/react-native/Libraries/Components/View/ViewPropTypes.js index 63ff6ede58d..0c7b105db1a 100644 --- a/packages/react-native/Libraries/Components/View/ViewPropTypes.js +++ b/packages/react-native/Libraries/Components/View/ViewPropTypes.js @@ -73,8 +73,9 @@ type DirectEventProps = Readonly<{ * on iOS and dp on Android, not display pixels. Content padding does not * affect them. * - * Android accounts for scaling and ignores ancestor clipping. Rotation, - * skew, and perspective are unsupported; fully clipped views emit no updates. + * Android measures full rendered bounds when the combined transform is + * translation/scaling; partial clipping does not change them. Other transforms + * or unavailable geometry emit no update, not zeros. * * Dispatched synchronously so rendering updates apply in the same frame. * 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 f1c75a0ad2c..87e7fed48a5 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 @@ -12,7 +12,7 @@ import com.facebook.react.bridge.WritableMap import com.facebook.react.uimanager.PixelUtil.pxToDp /** - * Emitted when the part of a view that is covered by the system UI changes. + * Emitted when the unsafe-area overlap of a view's rendered bounds changes. * * Dispatched synchronously so that the layout depending on the insets is mounted in the frame the * insets changed in, rather than the one after it. 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 6ac247081b1..51aa3b4995c 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 @@ -18,11 +18,11 @@ 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.abs import kotlin.math.roundToInt /** - * Observes the part of a view covered by system UI, and emits [SafeAreaInsetsChangeEvent] whenever - * it changes. + * Observes unsafe-area overlap of a view's rendered bounds and emits [SafeAreaInsetsChangeEvent]. */ internal class SafeAreaInsetsObserver private constructor(private val view: View) : ViewTreeObserver.OnPreDrawListener, View.OnAttachStateChangeListener { @@ -89,7 +89,10 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View hasLastInsets = false return } - if (hasLastInsets && insets.contentEquals(lastInsets)) { + if ( + hasLastInsets && + insets.indices.all { abs(insets[it] - lastInsets[it]) <= INSET_CHANGE_THRESHOLD_PX } + ) { return } val eventDispatcher = @@ -122,6 +125,10 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View private const val RIGHT = 1 private const val BOTTOM = 2 private const val LEFT = 3 + // Match iOS notification precision without rounding the emitted measurement. + private const val INSET_CHANGE_THRESHOLD_PX = 1f + // Bound accumulated transform round-off to one thousandth of a physical pixel. + private const val MAX_AXIS_ALIGNMENT_ERROR_PX = 0.001f // One observer per view that sets the prop; views without it pay nothing. @JvmStatic @@ -200,8 +207,10 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View toRoot.getValues(geometry.matrixValues) if ( geometry.matrixValues.any { !it.isFinite() } || - geometry.matrixValues[Matrix.MSKEW_X] != 0f || - geometry.matrixValues[Matrix.MSKEW_Y] != 0f || + abs(geometry.matrixValues[Matrix.MSKEW_X]) * view.height > + MAX_AXIS_ALIGNMENT_ERROR_PX || + abs(geometry.matrixValues[Matrix.MSKEW_Y]) * view.width > + MAX_AXIS_ALIGNMENT_ERROR_PX || geometry.matrixValues[Matrix.MPERSP_0] != 0f || geometry.matrixValues[Matrix.MPERSP_1] != 0f || geometry.matrixValues[Matrix.MPERSP_2] != 1f || 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 index 2722d47c197..332e0103f92 100644 --- 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 @@ -8,6 +8,7 @@ package com.facebook.react.uimanager import android.annotation.SuppressLint +import android.graphics.Matrix import android.graphics.Rect import android.util.DisplayMetrics import android.view.View @@ -39,9 +40,11 @@ import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode @RunWith(RobolectricTestRunner::class) @Config(shadows = [ShadowArguments::class]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) class SafeAreaInsetsObserverTest { private lateinit var root: FrameLayout private val events = mutableListOf() @@ -204,7 +207,7 @@ class SafeAreaInsetsObserverTest { } @Test - fun rootOffset_isNotMixedWithRootLocalInsets() { + fun rootLayoutOrigin_isNotAddedToRootLocalBounds() { root.layout(70, 120, 570, 1120) val view = addView(root, 0, 0, 500, 1000) @@ -266,6 +269,97 @@ class SafeAreaInsetsObserverTest { assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull() } + @Test + fun counterRotatedAncestorAndView_reportAxisAlignedInsets() { + val parent = addParent(0, 0, 500, 1000) + parent.clipChildren = false + parent.pivotX = 0f + parent.pivotY = 0f + parent.rotation = 30f + val view = addView(parent, 0, 0, 500, 1000) + view.pivotX = 0f + view.pivotY = 0f + view.rotation = -30f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)) + .isEqualTo(Insets.of(40, 100, 40, 100)) + } + + @Test + fun threeCancellingRotations_reportAxisAlignedInsets() { + val outer = addParent(0, 0, 500, 1000) + outer.clipChildren = false + outer.pivotX = 0f + outer.pivotY = 0f + outer.rotation = 10f + val inner = FrameLayout(root.context) + outer.addView(inner) + inner.layout(0, 0, 500, 1000) + inner.clipChildren = false + inner.pivotX = 0f + inner.pivotY = 0f + inner.rotation = 20f + val view = addView(inner, 0, 0, 500, 1000) + view.pivotX = 0f + view.pivotY = 0f + view.rotation = -30f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)) + .isEqualTo(Insets.of(40, 100, 40, 100)) + } + + @Test + fun rotatedAncestor_returnsNull() { + val parent = addParent(0, 0, 500, 1000) + val view = addView(parent, 0, 0, 500, 1000) + parent.rotation = 45f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull() + } + + @Test + fun skewedAncestor_returnsNull() { + val matrix = Matrix().apply { setSkew(0.25f, 0f) } + val parent = + object : FrameLayout(root.context) { + override fun getMatrix(): Matrix = matrix + } + root.addView(parent) + parent.layout(0, 0, 500, 1000) + val view = addView(parent, 0, 0, 500, 1000) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull() + } + + @Test + fun skewBeyondAxisAlignmentErrorBudget_returnsNull() { + for (matrix in + arrayOf( + Matrix().apply { setSkew(-0.000002f, 0f) }, + Matrix().apply { setSkew(0.000002f, 0f) }, + Matrix().apply { setSkew(0f, -0.000002f) }, + Matrix().apply { setSkew(0f, 0.000002f) }, + )) { + val view = + object : View(root.context) { + override fun getMatrix(): Matrix = matrix + } + root.addView(view) + view.layout(0, 0, 10000, 10000) + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull() + root.removeView(view) + } + } + + @Test + fun perspectiveRotation_returnsNull() { + val view = addView(root, 0, 0, 500, 1000) + view.rotationX = 45f + + assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull() + } + @Test fun reflectedView_preservesWindowEdges() { windowInsets = Insets.of(40, 100, 20, 60) @@ -401,6 +495,42 @@ class SafeAreaInsetsObserverTest { assertThat(events).hasSize(1) } + @Test + @Suppress("DEPRECATION") + @SuppressLint("DeprecatedMethod") + fun subpixelChanges_emitOnlyBeyondOnePhysicalPixelWithoutRoundingPayload() { + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 2f }) + val view = addView(root, 0, 0, 500, 1000) + val observer = observe(view) + observer.onPreDraw() + view.translationY = 0.5f + observer.onPreDraw() + view.translationY = 1f + observer.onPreDraw() + assertThat(events).hasSize(1) + + view.translationY = 1.25f + observer.onPreDraw() + observer.onPreDraw() + assertThat(events).hasSize(2) + val emitter = mock() + val payload = argumentCaptor() + events.last().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(49.375) + assertThat(insets.getDouble("bottom")).isEqualTo(50.625) + } + @Test fun supportedUnsupportedSupported_reEmitsAfterRecovery() { val view = addView(root, 0, 0, 500, 1000) @@ -436,6 +566,21 @@ class SafeAreaInsetsObserverTest { assertThat(events).hasSize(2) } + @Test + fun detachAndReattach_reEmitsUnchangedInsetsOnNextPreDraw() { + val view = addView(root, 0, 0, 500, 1000) + val observer = observe(view) + observer.onPreDraw() + observer.onViewDetachedFromWindow(view) + observer.onViewAttachedToWindow(view) + + assertThat(view.getTag(R.id.safe_area_insets_observer)).isSameAs(observer) + assertThat(events).hasSize(1) + observer.onPreDraw() + observer.onPreDraw() + assertThat(events).hasSize(2) + } + @Test fun disabledObserver_isRemovedAndReenableStartsFresh() { val view = addView(root, 0, 0, 500, 1000) diff --git a/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js index 2d0f0ea2f05..e55956a11ad 100644 --- a/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js +++ b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js @@ -199,23 +199,50 @@ function FeedbackLoopExample(): React.Node { ); } -component GeometryModal(onClose: () => void) { +component GeometryModal( + onClose: () => void, + cancellingRotations: boolean = false, +) { 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'; + let observer: React.Node = null; - return ( - { - const {width, height} = event.nativeEvent.layout; - setLayout({width, height}); - }}> - {ready ? ( + if (ready) { + if (cancellingRotations) { + observer = ( + + + + + + ); + } else { + observer = ( void) { transform: [{scale: 2}], }} /> - ) : null} + ); + } + } + + return ( + { + const {width, height} = event.nativeEvent.layout; + setLayout({width, height}); + }}> + {observer} void) { Neither observing view applies padding. - Fractional layout can slightly shift the rendered bounds. + {cancellingRotations + ? 'Nested rotations: +10°, +20°, −30° (net 0°). All three views share a top-left transform origin.' + : '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}`} + {cancellingRotations + ? `Root and observer layout: ${layout.width} × ${layout.height} ${units}` + : `Root layout: ${layout.width} × ${layout.height} ${units}; observer before scaling: ${layout.width / 2} × ${layout.height / 2} ${units}`} {`Root (1×), ${units}: ${JSON.stringify(rootInsets) ?? 'null'}`} @@ -257,7 +301,7 @@ component GeometryModal(onClose: () => void) { - {`Observer (2×), ${units}: ${JSON.stringify(observerInsets) ?? 'null'}`} + {`Observer (${cancellingRotations ? 'net 0°' : '2×'}), ${units}: ${JSON.stringify(observerInsets) ?? 'null'}`} void) { component GeometryExample() { const [visible, setVisible] = useState(false); + const [cancellingRotations, setCancellingRotations] = useState(false); return ( -