Skip to content

Commit 9641acd

Browse files
Abbondanzometa-codesync[bot]
authored andcommitted
Fix scaled and clipped Android safe-area insets (#58997)
Summary: Pull Request resolved: #58997 Safe-area insets measure how far a view's rendered bounds extend into the unsafe area, in Android dp or iOS points. Views with the same rendered bounds should report the same insets, regardless of scale or content padding. Fix Android's calculation to use the full transformed bounds instead of mixing a clipped origin with untransformed dimensions, and preserve fractional values through the single density conversion. The example compares a 1× root and a 2× observer without applying padding. iOS native behavior is unchanged. Changelog: [Android][Fixed] - Correct safe-area inset measurements for scaled views and views clipped by ancestors. Reviewed By: fkgozali Differential Revision: D124663000 fbshipit-source-id: 84a9ff162ec428e8e98f12d51f0baa2bfad0f060
1 parent a4a9be9 commit 9641acd

7 files changed

Lines changed: 786 additions & 41 deletions

File tree

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

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,18 @@ type DirectEventProps = Readonly<{
6565
onLayout?: ?(event: LayoutChangeEvent) => unknown,
6666

6767
/**
68-
* Invoked when the part of this view that is covered by the system UI
69-
* (status bar, navigation bar, home indicator, display cutouts, ...)
70-
* changes, with:
68+
* Invoked when this view's safe-area insets change, with:
7169
*
7270
* `{nativeEvent: {insets: {top, right, bottom, left}}}`
7371
*
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.
72+
* `insets` measure how far the view extends into the unsafe area, in points
73+
* on iOS and dp on Android, not display pixels. Content padding does not
74+
* affect them.
7675
*
77-
* The event is dispatched synchronously, so the rendering it schedules is
78-
* applied in the same frame the insets changed in.
76+
* Android accounts for scaling and ignores ancestor clipping. Rotation,
77+
* skew, and perspective are unsupported; fully clipped views emit no updates.
7978
*
80-
* Setting this prop makes the view observe safe area changes; views without
81-
* it are unaffected.
79+
* Dispatched synchronously so rendering updates apply in the same frame.
8280
*
8381
* @experimental
8482
*/

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,7 @@ 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+
* Inset measurements in points (iOS) or dp (Android), not display pixels.
9190
*/
9291
insets: SafeAreaInsets,
9392
}>,

‎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: 94 additions & 25 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-
* [SafeAreaInsetsChangeEvent] whenever it changes.
24+
* Observes the part of a view covered by system UI, and emits [SafeAreaInsetsChangeEvent] whenever
25+
* 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,13 @@ 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 boundsRect = RectF()
117+
val matrixValues = FloatArray(9)
118+
}
119+
110120
companion object {
111121
private const val TOP = 0
112122
private const val RIGHT = 1
@@ -131,46 +141,105 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View
131141
}
132142

133143
/**
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.
144+
* Insets from [view]'s full transformed bounds to the window safe area, in window physical
145+
* pixels, rounded to integers. Ancestor clipping is only a visibility check, not a change to
146+
* those bounds. Returns null for empty/fully clipped views, missing window insets, or
147+
* unsupported transforms. The event preserves fractional pixels and converts them to dp.
136148
*/
137149
@JvmStatic
138150
fun getSafeAreaInsets(view: View): Insets? {
139-
val insets = IntArray(4)
140-
if (!computeSafeAreaInsets(view, Rect(), insets)) {
151+
val insets = FloatArray(4)
152+
if (!computeSafeAreaInsets(view, Geometry(), insets)) {
141153
return null
142154
}
143-
return Insets.of(insets[LEFT], insets[TOP], insets[RIGHT], insets[BOTTOM])
155+
return Insets.of(
156+
insets[LEFT].roundToInt(),
157+
insets[TOP].roundToInt(),
158+
insets[RIGHT].roundToInt(),
159+
insets[BOTTOM].roundToInt(),
160+
)
144161
}
145162

146163
/**
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.
164+
* Writes window physical-pixel insets into [out], ordered [TOP], [RIGHT], [BOTTOM], [LEFT].
165+
* Returns false when they cannot be computed, leaving [out] untouched.
150166
*/
151-
private fun computeSafeAreaInsets(view: View, visibleRect: Rect, out: IntArray): Boolean {
152-
if (view.width == 0 || view.height == 0) {
167+
private fun computeSafeAreaInsets(view: View, geometry: Geometry, out: FloatArray): Boolean {
168+
if (view.width <= 0 || view.height <= 0) {
153169
return false
154170
}
155171
val rootView = view.rootView
172+
if (rootView.width <= 0 || rootView.height <= 0) {
173+
return false
174+
}
156175
val windowInsets =
157176
ViewCompat.getRootWindowInsets(rootView)
158177
?.getInsets(
159178
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
160179
) ?: return false
161180

162-
if (!view.getGlobalVisibleRect(visibleRect)) {
181+
if (!view.getGlobalVisibleRect(geometry.visibleRect)) {
163182
// The view is fully clipped by an ancestor (e.g. scrolled out of a
164183
// scroll view); the rect is undefined in that case, and a view that is
165184
// not visible has no meaningful insets.
166185
return false
167186
}
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)
187+
188+
val toRoot = geometry.toRoot
189+
toRoot.reset()
190+
var current = view
191+
while (current !== rootView) {
192+
val parent = current.parent as? View ?: return false
193+
toRoot.postConcat(current.matrix)
194+
toRoot.postTranslate(
195+
(current.left - parent.scrollX).toFloat(),
196+
(current.top - parent.scrollY).toFloat(),
197+
)
198+
current = parent
199+
}
200+
toRoot.getValues(geometry.matrixValues)
201+
if (
202+
geometry.matrixValues.any { !it.isFinite() } ||
203+
geometry.matrixValues[Matrix.MSKEW_X] != 0f ||
204+
geometry.matrixValues[Matrix.MSKEW_Y] != 0f ||
205+
geometry.matrixValues[Matrix.MPERSP_0] != 0f ||
206+
geometry.matrixValues[Matrix.MPERSP_1] != 0f ||
207+
geometry.matrixValues[Matrix.MPERSP_2] != 1f ||
208+
geometry.matrixValues[Matrix.MSCALE_X] == 0f ||
209+
geometry.matrixValues[Matrix.MSCALE_Y] == 0f
210+
) {
211+
return false
212+
}
213+
214+
val safeLeft = windowInsets.left.toFloat()
215+
val safeTop = windowInsets.top.toFloat()
216+
val safeRight = (rootView.width - windowInsets.right).toFloat()
217+
val safeBottom = (rootView.height - windowInsets.bottom).toFloat()
218+
if (safeRight <= safeLeft || safeBottom <= safeTop) {
219+
return false
220+
}
221+
val bounds = geometry.boundsRect
222+
bounds.set(0f, 0f, view.width.toFloat(), view.height.toFloat())
223+
// mapRect returns ordered bounding edges, including under reflection.
224+
toRoot.mapRect(bounds)
225+
val width = bounds.width()
226+
val height = bounds.height()
227+
if (
228+
!bounds.left.isFinite() ||
229+
!bounds.top.isFinite() ||
230+
!bounds.right.isFinite() ||
231+
!bounds.bottom.isFinite() ||
232+
!width.isFinite() ||
233+
!height.isFinite() ||
234+
width <= 0f ||
235+
height <= 0f
236+
) {
237+
return false
238+
}
239+
out[TOP] = (safeTop - bounds.top).coerceIn(0f, height)
240+
out[RIGHT] = (bounds.right - safeRight).coerceIn(0f, width)
241+
out[BOTTOM] = (bounds.bottom - safeBottom).coerceIn(0f, height)
242+
out[LEFT] = (safeLeft - bounds.left).coerceIn(0f, width)
174243
return true
175244
}
176245
}

0 commit comments

Comments
 (0)