Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<SafeAreaInsetsChangeEvent>()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<RCTModernEventEmitter>()
val payload = argumentCaptor<WritableMap>()
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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading