Skip to content

Commit 95941fd

Browse files
Abbondanzometa-codesync[bot]
authored andcommitted
Fix Android safe-area transform round-off and notification precision (#59017)
Summary: Pull Request resolved: #59017 Cancelling transforms can leave floating-point skew that incorrectly disables Android safe-area updates. Bound accepted round-off by its displacement at the view's edges, and match iOS's one-physical-pixel change threshold without rounding the reported dp values. Unavailable geometry still emits no update rather than fabricated zero insets. Changelog: [Android][Fixed] - Preserve safe-area updates for cancelling transforms and match iOS notification precision. Reviewed By: fkgozali Differential Revision: D124830220 fbshipit-source-id: 77fe0fd8b21e7d267e2a6d45f4ab1572713ac217
1 parent 8bbd422 commit 95941fd

6 files changed

Lines changed: 308 additions & 44 deletions

File tree

‎packages/react-native/Libraries/Components/View/ViewPropTypes.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,9 @@ type DirectEventProps = Readonly<{
7373
* on iOS and dp on Android, not display pixels. Content padding does not
7474
* affect them.
7575
*
76-
* Android accounts for scaling and ignores ancestor clipping. Rotation,
77-
* skew, and perspective are unsupported; fully clipped views emit no updates.
76+
* Android measures full rendered bounds when the combined transform is
77+
* translation/scaling; partial clipping does not change them. Other transforms
78+
* or unavailable geometry emit no update, not zeros.
7879
*
7980
* Dispatched synchronously so rendering updates apply in the same frame.
8081
*

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import com.facebook.react.bridge.WritableMap
1212
import com.facebook.react.uimanager.PixelUtil.pxToDp
1313

1414
/**
15-
* Emitted when the part of a view that is covered by the system UI changes.
15+
* Emitted when the unsafe-area overlap of a view's rendered bounds changes.
1616
*
1717
* Dispatched synchronously so that the layout depending on the insets is mounted in the frame the
1818
* insets changed in, rather than the one after it.

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ import androidx.core.view.WindowInsetsCompat
1818
import com.facebook.react.R
1919
import com.facebook.react.uimanager.UIManagerHelper
2020
import com.facebook.react.uimanager.events.SafeAreaInsetsChangeEvent
21+
import kotlin.math.abs
2122
import kotlin.math.roundToInt
2223

2324
/**
24-
* Observes the part of a view covered by system UI, and emits [SafeAreaInsetsChangeEvent] whenever
25-
* it changes.
25+
* Observes unsafe-area overlap of a view's rendered bounds and emits [SafeAreaInsetsChangeEvent].
2626
*/
2727
internal class SafeAreaInsetsObserver private constructor(private val view: View) :
2828
ViewTreeObserver.OnPreDrawListener, View.OnAttachStateChangeListener {
@@ -89,7 +89,10 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View
8989
hasLastInsets = false
9090
return
9191
}
92-
if (hasLastInsets && insets.contentEquals(lastInsets)) {
92+
if (
93+
hasLastInsets &&
94+
insets.indices.all { abs(insets[it] - lastInsets[it]) <= INSET_CHANGE_THRESHOLD_PX }
95+
) {
9396
return
9497
}
9598
val eventDispatcher =
@@ -122,6 +125,10 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View
122125
private const val RIGHT = 1
123126
private const val BOTTOM = 2
124127
private const val LEFT = 3
128+
// Match iOS notification precision without rounding the emitted measurement.
129+
private const val INSET_CHANGE_THRESHOLD_PX = 1f
130+
// Bound accumulated transform round-off to one thousandth of a physical pixel.
131+
private const val MAX_AXIS_ALIGNMENT_ERROR_PX = 0.001f
125132

126133
// One observer per view that sets the prop; views without it pay nothing.
127134
@JvmStatic
@@ -200,8 +207,10 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View
200207
toRoot.getValues(geometry.matrixValues)
201208
if (
202209
geometry.matrixValues.any { !it.isFinite() } ||
203-
geometry.matrixValues[Matrix.MSKEW_X] != 0f ||
204-
geometry.matrixValues[Matrix.MSKEW_Y] != 0f ||
210+
abs(geometry.matrixValues[Matrix.MSKEW_X]) * view.height >
211+
MAX_AXIS_ALIGNMENT_ERROR_PX ||
212+
abs(geometry.matrixValues[Matrix.MSKEW_Y]) * view.width >
213+
MAX_AXIS_ALIGNMENT_ERROR_PX ||
205214
geometry.matrixValues[Matrix.MPERSP_0] != 0f ||
206215
geometry.matrixValues[Matrix.MPERSP_1] != 0f ||
207216
geometry.matrixValues[Matrix.MPERSP_2] != 1f ||

‎packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/SafeAreaInsetsObserverTest.kt‎

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package com.facebook.react.uimanager
99

1010
import android.annotation.SuppressLint
11+
import android.graphics.Matrix
1112
import android.graphics.Rect
1213
import android.util.DisplayMetrics
1314
import android.view.View
@@ -39,9 +40,11 @@ import org.mockito.kotlin.whenever
3940
import org.robolectric.RobolectricTestRunner
4041
import org.robolectric.RuntimeEnvironment
4142
import org.robolectric.annotation.Config
43+
import org.robolectric.annotation.GraphicsMode
4244

4345
@RunWith(RobolectricTestRunner::class)
4446
@Config(shadows = [ShadowArguments::class])
47+
@GraphicsMode(GraphicsMode.Mode.NATIVE)
4548
class SafeAreaInsetsObserverTest {
4649
private lateinit var root: FrameLayout
4750
private val events = mutableListOf<SafeAreaInsetsChangeEvent>()
@@ -204,7 +207,7 @@ class SafeAreaInsetsObserverTest {
204207
}
205208

206209
@Test
207-
fun rootOffset_isNotMixedWithRootLocalInsets() {
210+
fun rootLayoutOrigin_isNotAddedToRootLocalBounds() {
208211
root.layout(70, 120, 570, 1120)
209212
val view = addView(root, 0, 0, 500, 1000)
210213

@@ -266,6 +269,97 @@ class SafeAreaInsetsObserverTest {
266269
assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull()
267270
}
268271

272+
@Test
273+
fun counterRotatedAncestorAndView_reportAxisAlignedInsets() {
274+
val parent = addParent(0, 0, 500, 1000)
275+
parent.clipChildren = false
276+
parent.pivotX = 0f
277+
parent.pivotY = 0f
278+
parent.rotation = 30f
279+
val view = addView(parent, 0, 0, 500, 1000)
280+
view.pivotX = 0f
281+
view.pivotY = 0f
282+
view.rotation = -30f
283+
284+
assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view))
285+
.isEqualTo(Insets.of(40, 100, 40, 100))
286+
}
287+
288+
@Test
289+
fun threeCancellingRotations_reportAxisAlignedInsets() {
290+
val outer = addParent(0, 0, 500, 1000)
291+
outer.clipChildren = false
292+
outer.pivotX = 0f
293+
outer.pivotY = 0f
294+
outer.rotation = 10f
295+
val inner = FrameLayout(root.context)
296+
outer.addView(inner)
297+
inner.layout(0, 0, 500, 1000)
298+
inner.clipChildren = false
299+
inner.pivotX = 0f
300+
inner.pivotY = 0f
301+
inner.rotation = 20f
302+
val view = addView(inner, 0, 0, 500, 1000)
303+
view.pivotX = 0f
304+
view.pivotY = 0f
305+
view.rotation = -30f
306+
307+
assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view))
308+
.isEqualTo(Insets.of(40, 100, 40, 100))
309+
}
310+
311+
@Test
312+
fun rotatedAncestor_returnsNull() {
313+
val parent = addParent(0, 0, 500, 1000)
314+
val view = addView(parent, 0, 0, 500, 1000)
315+
parent.rotation = 45f
316+
317+
assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull()
318+
}
319+
320+
@Test
321+
fun skewedAncestor_returnsNull() {
322+
val matrix = Matrix().apply { setSkew(0.25f, 0f) }
323+
val parent =
324+
object : FrameLayout(root.context) {
325+
override fun getMatrix(): Matrix = matrix
326+
}
327+
root.addView(parent)
328+
parent.layout(0, 0, 500, 1000)
329+
val view = addView(parent, 0, 0, 500, 1000)
330+
331+
assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull()
332+
}
333+
334+
@Test
335+
fun skewBeyondAxisAlignmentErrorBudget_returnsNull() {
336+
for (matrix in
337+
arrayOf(
338+
Matrix().apply { setSkew(-0.000002f, 0f) },
339+
Matrix().apply { setSkew(0.000002f, 0f) },
340+
Matrix().apply { setSkew(0f, -0.000002f) },
341+
Matrix().apply { setSkew(0f, 0.000002f) },
342+
)) {
343+
val view =
344+
object : View(root.context) {
345+
override fun getMatrix(): Matrix = matrix
346+
}
347+
root.addView(view)
348+
view.layout(0, 0, 10000, 10000)
349+
350+
assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull()
351+
root.removeView(view)
352+
}
353+
}
354+
355+
@Test
356+
fun perspectiveRotation_returnsNull() {
357+
val view = addView(root, 0, 0, 500, 1000)
358+
view.rotationX = 45f
359+
360+
assertThat(SafeAreaInsetsObserver.getSafeAreaInsets(view)).isNull()
361+
}
362+
269363
@Test
270364
fun reflectedView_preservesWindowEdges() {
271365
windowInsets = Insets.of(40, 100, 20, 60)
@@ -401,6 +495,42 @@ class SafeAreaInsetsObserverTest {
401495
assertThat(events).hasSize(1)
402496
}
403497

498+
@Test
499+
@Suppress("DEPRECATION")
500+
@SuppressLint("DeprecatedMethod")
501+
fun subpixelChanges_emitOnlyBeyondOnePhysicalPixelWithoutRoundingPayload() {
502+
DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 2f })
503+
val view = addView(root, 0, 0, 500, 1000)
504+
val observer = observe(view)
505+
observer.onPreDraw()
506+
view.translationY = 0.5f
507+
observer.onPreDraw()
508+
view.translationY = 1f
509+
observer.onPreDraw()
510+
assertThat(events).hasSize(1)
511+
512+
view.translationY = 1.25f
513+
observer.onPreDraw()
514+
observer.onPreDraw()
515+
assertThat(events).hasSize(2)
516+
val emitter = mock<RCTModernEventEmitter>()
517+
val payload = argumentCaptor<WritableMap>()
518+
events.last().dispatchModern(emitter)
519+
verify(emitter)
520+
.receiveEvent(
521+
eq(1),
522+
eq(2),
523+
eq("topSafeAreaInsetsChange"),
524+
any(),
525+
any(),
526+
payload.capture(),
527+
any(),
528+
)
529+
val insets = checkNotNull(payload.firstValue.getMap("insets"))
530+
assertThat(insets.getDouble("top")).isEqualTo(49.375)
531+
assertThat(insets.getDouble("bottom")).isEqualTo(50.625)
532+
}
533+
404534
@Test
405535
fun supportedUnsupportedSupported_reEmitsAfterRecovery() {
406536
val view = addView(root, 0, 0, 500, 1000)
@@ -436,6 +566,21 @@ class SafeAreaInsetsObserverTest {
436566
assertThat(events).hasSize(2)
437567
}
438568

569+
@Test
570+
fun detachAndReattach_reEmitsUnchangedInsetsOnNextPreDraw() {
571+
val view = addView(root, 0, 0, 500, 1000)
572+
val observer = observe(view)
573+
observer.onPreDraw()
574+
observer.onViewDetachedFromWindow(view)
575+
observer.onViewAttachedToWindow(view)
576+
577+
assertThat(view.getTag(R.id.safe_area_insets_observer)).isSameAs(observer)
578+
assertThat(events).hasSize(1)
579+
observer.onPreDraw()
580+
observer.onPreDraw()
581+
assertThat(events).hasSize(2)
582+
}
583+
439584
@Test
440585
fun disabledObserver_isRemovedAndReenableStartsFresh() {
441586
val view = addView(root, 0, 0, 500, 1000)

0 commit comments

Comments
 (0)