Skip to content

Commit f368446

Browse files
Abbondanzofacebook-github-bot
authored andcommitted
Fix scaled and clipped Android safe-area insets
Summary: `experimental_onSafeAreaInsetsChange` already uses density-independent layout units, but Android mixed clipped window coordinates with untransformed view dimensions. Compute padding from the full view bounds in local layout space so scale and ancestor clipping do not distort it. Preserve fractional insets through dp conversion and document the units and supported Android transforms. iOS continues forwarding UIKit points; equivalent pre-transform padding is not guaranteed for transformed views. Changelog: [Android][Fixed] - Correct safe-area padding for scaled views and views clipped by ancestors. Differential Revision: D124663000
1 parent 85065b3 commit f368446

7 files changed

Lines changed: 765 additions & 33 deletions

File tree

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,28 @@ type DirectEventProps = Readonly<{
7171
*
7272
* `{nativeEvent: {insets: {top, right, bottom, left}}}`
7373
*
74-
* `insets` are relative to this view: an inset is only non-zero for the part
75-
* of the view that actually overlaps the system UI.
74+
* `insets` are padding distances from this view's layout bounds to the
75+
* safe area, in density-independent layout units (points on iOS, dp on
76+
* Android), like `onLayout` and style padding. They are not display pixels;
77+
* do not multiply or divide them by `PixelRatio`.
78+
*
79+
* Padding this view's contents does not change its own insets. The values
80+
* describe the view receiving this event, not its padded content box. A
81+
* descendant entirely inside the safe area has no overlap; UIKit may report
82+
* small fractional insets due to layout rounding.
83+
*
84+
* On Android, the values use this view's local, pre-transform layout
85+
* coordinate space. A view inside the safe area reports zeros. A 100 dp
86+
* overlap after `scaleY: 2` needs 50 dp of local padding; ancestor clipping
87+
* does not shorten the view's layout bounds. iOS forwards UIKit's
88+
* `safeAreaInsets` in points; transformed views are not guaranteed to receive
89+
* equivalent pre-transform padding.
90+
*
91+
* On Android, scaling, translation, and reflection along the view's axes
92+
* are supported; rotations, skew, and perspective are not. No event is
93+
* emitted while the view is fully clipped, has empty bounds, or has an
94+
* unsupported/non-invertible transform. Silence does not mean zero insets;
95+
* the last delivered values may no longer apply.
7696
*
7797
* The event is dispatched synchronously, so the rendering it schedules is
7898
* applied in the same frame the insets changed in.

‎packages/react-native/Libraries/Types/CoreEventTypes.js‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,11 @@ export type SafeAreaInsets = Readonly<{
8686
export type SafeAreaInsetsChangeEvent = NativeSyntheticEvent<
8787
Readonly<{
8888
/**
89-
* The part of the view that is covered by the system UI, in the view's own
90-
* coordinate space.
89+
* Safe-area padding in density-independent layout units (iOS points /
90+
* Android dp), not display pixels. Android uses full pre-transform local
91+
* layout bounds, unaffected by ancestor clipping. iOS forwards UIKit's
92+
* safeAreaInsets; equivalent pre-transform padding is not guaranteed for
93+
* transformed views.
9194
*/
9295
insets: SafeAreaInsets,
9396
}>,

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ import com.facebook.react.uimanager.PixelUtil.pxToDp
2020
internal class SafeAreaInsetsChangeEvent(
2121
surfaceId: Int,
2222
viewTag: Int,
23-
private val insetTop: Int,
24-
private val insetRight: Int,
25-
private val insetBottom: Int,
26-
private val insetLeft: Int,
23+
private val insetTop: Float,
24+
private val insetRight: Float,
25+
private val insetBottom: Float,
26+
private val insetLeft: Float,
2727
) : Event<SafeAreaInsetsChangeEvent>(surfaceId, viewTag) {
2828

2929
override fun getEventName(): String = EVENT_NAME
@@ -46,6 +46,6 @@ internal class SafeAreaInsetsChangeEvent(
4646
internal companion object {
4747
const val EVENT_NAME: String = "topSafeAreaInsetsChange"
4848

49-
private fun Int.toDp(): Double = toFloat().pxToDp().toDouble()
49+
private fun Float.toDp(): Double = pxToDp().toDouble()
5050
}
5151
}

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

Lines changed: 90 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88
package com.facebook.react.uimanager.internal
99

10+
import android.graphics.Matrix
1011
import android.graphics.Rect
12+
import android.graphics.RectF
1113
import android.view.View
1214
import android.view.ViewTreeObserver
1315
import androidx.core.graphics.Insets
@@ -16,19 +18,18 @@ import androidx.core.view.WindowInsetsCompat
1618
import com.facebook.react.R
1719
import com.facebook.react.uimanager.UIManagerHelper
1820
import com.facebook.react.uimanager.events.SafeAreaInsetsChangeEvent
19-
import kotlin.math.max
20-
import kotlin.math.min
21+
import kotlin.math.roundToInt
2122

2223
/**
23-
* Observes the part of a view that is covered by the system UI, and emits
24+
* Observes padding from a view's layout bounds to the window safe area, and emits
2425
* [SafeAreaInsetsChangeEvent] whenever it changes.
2526
*/
2627
internal class SafeAreaInsetsObserver private constructor(private val view: View) :
2728
ViewTreeObserver.OnPreDrawListener, View.OnAttachStateChangeListener {
2829

29-
private val visibleRect = Rect()
30-
private val insets = IntArray(4)
31-
private val lastInsets = IntArray(4)
30+
private val geometry = Geometry()
31+
private val insets = FloatArray(4)
32+
private val lastInsets = FloatArray(4)
3233

3334
private var hasLastInsets = false
3435
private var isListening = false
@@ -72,6 +73,7 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View
7273

7374
override fun onViewDetachedFromWindow(v: View) {
7475
stopListening()
76+
hasLastInsets = false
7577
}
7678

7779
override fun onPreDraw(): Boolean {
@@ -83,7 +85,8 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View
8385
// Emitting on anything but an inset change would loop: the synchronous
8486
// render an event causes produces a new frame, which runs this listener
8587
// again.
86-
if (!computeSafeAreaInsets(view, visibleRect, insets)) {
88+
if (!computeSafeAreaInsets(view, geometry, insets)) {
89+
hasLastInsets = false
8790
return
8891
}
8992
if (hasLastInsets && insets.contentEquals(lastInsets)) {
@@ -107,6 +110,14 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View
107110
)
108111
}
109112

113+
private class Geometry {
114+
val visibleRect = Rect()
115+
val toRoot = Matrix()
116+
val toLocal = Matrix()
117+
val safeRect = RectF()
118+
val matrixValues = FloatArray(9)
119+
}
120+
110121
companion object {
111122
private const val TOP = 0
112123
private const val RIGHT = 1
@@ -131,46 +142,101 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View
131142
}
132143

133144
/**
134-
* The insets of the window that overlap [view], in the view's own coordinate space. A view that
135-
* does not reach under the system UI has no insets.
145+
* Padding from [view]'s full layout bounds to the window safe area, in local physical pixels,
146+
* rounded to integers. Ancestor clipping is only a visibility check, not a change to those
147+
* bounds. Returns null for empty/fully clipped views, missing window insets, or unsupported
148+
* transforms. The event preserves fractional pixels and converts them to dp.
136149
*/
137150
@JvmStatic
138151
fun getSafeAreaInsets(view: View): Insets? {
139-
val insets = IntArray(4)
140-
if (!computeSafeAreaInsets(view, Rect(), insets)) {
152+
val insets = FloatArray(4)
153+
if (!computeSafeAreaInsets(view, Geometry(), insets)) {
141154
return null
142155
}
143-
return Insets.of(insets[LEFT], insets[TOP], insets[RIGHT], insets[BOTTOM])
156+
return Insets.of(
157+
insets[LEFT].roundToInt(),
158+
insets[TOP].roundToInt(),
159+
insets[RIGHT].roundToInt(),
160+
insets[BOTTOM].roundToInt(),
161+
)
144162
}
145163

146164
/**
147-
* Writes the insets of [view] into [out], ordered [TOP], [RIGHT], [BOTTOM], [LEFT], using
148-
* [visibleRect] as scratch space. Returns false when they cannot be computed, leaving [out]
149-
* untouched.
165+
* Writes local physical-pixel insets into [out], ordered [TOP], [RIGHT], [BOTTOM], [LEFT].
166+
* Returns false when they cannot be computed, leaving [out] untouched.
150167
*/
151-
private fun computeSafeAreaInsets(view: View, visibleRect: Rect, out: IntArray): Boolean {
152-
if (view.width == 0 || view.height == 0) {
168+
private fun computeSafeAreaInsets(view: View, geometry: Geometry, out: FloatArray): Boolean {
169+
if (view.width <= 0 || view.height <= 0) {
153170
return false
154171
}
155172
val rootView = view.rootView
173+
if (rootView.width <= 0 || rootView.height <= 0) {
174+
return false
175+
}
156176
val windowInsets =
157177
ViewCompat.getRootWindowInsets(rootView)
158178
?.getInsets(
159179
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
160180
) ?: return false
161181

162-
if (!view.getGlobalVisibleRect(visibleRect)) {
182+
if (!view.getGlobalVisibleRect(geometry.visibleRect)) {
163183
// The view is fully clipped by an ancestor (e.g. scrolled out of a
164184
// scroll view); the rect is undefined in that case, and a view that is
165185
// not visible has no meaningful insets.
166186
return false
167187
}
168-
out[TOP] = max(windowInsets.top - visibleRect.top, 0)
169-
out[RIGHT] =
170-
max(min(visibleRect.left + view.width - rootView.width, 0) + windowInsets.right, 0)
171-
out[BOTTOM] =
172-
max(min(visibleRect.top + view.height - rootView.height, 0) + windowInsets.bottom, 0)
173-
out[LEFT] = max(windowInsets.left - visibleRect.left, 0)
188+
189+
val toRoot = geometry.toRoot
190+
toRoot.reset()
191+
var current = view
192+
while (current !== rootView) {
193+
val parent = current.parent as? View ?: return false
194+
toRoot.postConcat(current.matrix)
195+
toRoot.postTranslate(
196+
(current.left - parent.scrollX).toFloat(),
197+
(current.top - parent.scrollY).toFloat(),
198+
)
199+
current = parent
200+
}
201+
toRoot.getValues(geometry.matrixValues)
202+
if (
203+
geometry.matrixValues.any { !it.isFinite() } ||
204+
geometry.matrixValues[Matrix.MSKEW_X] != 0f ||
205+
geometry.matrixValues[Matrix.MSKEW_Y] != 0f ||
206+
geometry.matrixValues[Matrix.MPERSP_0] != 0f ||
207+
geometry.matrixValues[Matrix.MPERSP_1] != 0f ||
208+
geometry.matrixValues[Matrix.MPERSP_2] != 1f ||
209+
!toRoot.invert(geometry.toLocal)
210+
) {
211+
return false
212+
}
213+
214+
val safeRect = geometry.safeRect
215+
safeRect.set(
216+
windowInsets.left.toFloat(),
217+
windowInsets.top.toFloat(),
218+
(rootView.width - windowInsets.right).toFloat(),
219+
(rootView.height - windowInsets.bottom).toFloat(),
220+
)
221+
if (safeRect.isEmpty) {
222+
return false
223+
}
224+
// mapRect returns ordered bounding edges, including under reflection.
225+
geometry.toLocal.mapRect(safeRect)
226+
if (
227+
!safeRect.left.isFinite() ||
228+
!safeRect.top.isFinite() ||
229+
!safeRect.right.isFinite() ||
230+
!safeRect.bottom.isFinite()
231+
) {
232+
return false
233+
}
234+
val width = view.width.toFloat()
235+
val height = view.height.toFloat()
236+
out[TOP] = safeRect.top.coerceIn(0f, height)
237+
out[RIGHT] = (width - safeRect.right).coerceIn(0f, width)
238+
out[BOTTOM] = (height - safeRect.bottom).coerceIn(0f, height)
239+
out[LEFT] = safeRect.left.coerceIn(0f, width)
174240
return true
175241
}
176242
}

0 commit comments

Comments
 (0)