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
16 changes: 7 additions & 9 deletions packages/react-native/Libraries/Components/View/ViewPropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,20 +65,18 @@ type DirectEventProps = Readonly<{
onLayout?: ?(event: LayoutChangeEvent) => unknown,

/**
* Invoked when the part of this view that is covered by the system UI
* (status bar, navigation bar, home indicator, display cutouts, ...)
* changes, with:
* Invoked when this view's safe-area insets change, with:
*
* `{nativeEvent: {insets: {top, right, bottom, left}}}`
*
* `insets` are relative to this view: an inset is only non-zero for the part
* of the view that actually overlaps the system UI.
* `insets` measure how far the view extends into the unsafe area, in points
* on iOS and dp on Android, not display pixels. Content padding does not
* affect them.
*
* The event is dispatched synchronously, so the rendering it schedules is
* applied in the same frame the insets changed in.
* Android accounts for scaling and ignores ancestor clipping. Rotation,
* skew, and perspective are unsupported; fully clipped views emit no updates.
*
* Setting this prop makes the view observe safe area changes; views without
* it are unaffected.
* Dispatched synchronously so rendering updates apply in the same frame.
*
* @experimental
*/
Expand Down
3 changes: 1 addition & 2 deletions packages/react-native/Libraries/Types/CoreEventTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,7 @@ export type SafeAreaInsets = Readonly<{
export type SafeAreaInsetsChangeEvent = NativeSyntheticEvent<
Readonly<{
/**
* The part of the view that is covered by the system UI, in the view's own
* coordinate space.
* Inset measurements in points (iOS) or dp (Android), not display pixels.
*/
insets: SafeAreaInsets,
}>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ import com.facebook.react.uimanager.PixelUtil.pxToDp
internal class SafeAreaInsetsChangeEvent(
surfaceId: Int,
viewTag: Int,
private val insetTop: Int,
private val insetRight: Int,
private val insetBottom: Int,
private val insetLeft: Int,
private val insetTop: Float,
private val insetRight: Float,
private val insetBottom: Float,
private val insetLeft: Float,
) : Event<SafeAreaInsetsChangeEvent>(surfaceId, viewTag) {

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

private fun Int.toDp(): Double = toFloat().pxToDp().toDouble()
private fun Float.toDp(): Double = pxToDp().toDouble()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

package com.facebook.react.uimanager.internal

import android.graphics.Matrix
import android.graphics.Rect
import android.graphics.RectF
import android.view.View
import android.view.ViewTreeObserver
import androidx.core.graphics.Insets
Expand All @@ -16,19 +18,18 @@ 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.max
import kotlin.math.min
import kotlin.math.roundToInt

/**
* Observes the part of a view that is covered by the system UI, and emits
* [SafeAreaInsetsChangeEvent] whenever it changes.
* Observes the part of a view covered by system UI, and emits [SafeAreaInsetsChangeEvent] whenever
* it changes.
*/
internal class SafeAreaInsetsObserver private constructor(private val view: View) :
ViewTreeObserver.OnPreDrawListener, View.OnAttachStateChangeListener {

private val visibleRect = Rect()
private val insets = IntArray(4)
private val lastInsets = IntArray(4)
private val geometry = Geometry()
private val insets = FloatArray(4)
private val lastInsets = FloatArray(4)

private var hasLastInsets = false
private var isListening = false
Expand Down Expand Up @@ -72,6 +73,7 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View

override fun onViewDetachedFromWindow(v: View) {
stopListening()
hasLastInsets = false
}

override fun onPreDraw(): Boolean {
Expand All @@ -83,7 +85,8 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View
// Emitting on anything but an inset change would loop: the synchronous
// render an event causes produces a new frame, which runs this listener
// again.
if (!computeSafeAreaInsets(view, visibleRect, insets)) {
if (!computeSafeAreaInsets(view, geometry, insets)) {
hasLastInsets = false
return
}
if (hasLastInsets && insets.contentEquals(lastInsets)) {
Expand All @@ -107,6 +110,13 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View
)
}

private class Geometry {
val visibleRect = Rect()
val toRoot = Matrix()
val boundsRect = RectF()
val matrixValues = FloatArray(9)
}

companion object {
private const val TOP = 0
private const val RIGHT = 1
Expand All @@ -131,46 +141,105 @@ internal class SafeAreaInsetsObserver private constructor(private val view: View
}

/**
* The insets of the window that overlap [view], in the view's own coordinate space. A view that
* does not reach under the system UI has no insets.
* Insets from [view]'s full transformed bounds to the window safe area, in window physical
* pixels, rounded to integers. Ancestor clipping is only a visibility check, not a change to
* those bounds. Returns null for empty/fully clipped views, missing window insets, or
* unsupported transforms. The event preserves fractional pixels and converts them to dp.
*/
@JvmStatic
fun getSafeAreaInsets(view: View): Insets? {
val insets = IntArray(4)
if (!computeSafeAreaInsets(view, Rect(), insets)) {
val insets = FloatArray(4)
if (!computeSafeAreaInsets(view, Geometry(), insets)) {
return null
}
return Insets.of(insets[LEFT], insets[TOP], insets[RIGHT], insets[BOTTOM])
return Insets.of(
insets[LEFT].roundToInt(),
insets[TOP].roundToInt(),
insets[RIGHT].roundToInt(),
insets[BOTTOM].roundToInt(),
)
}

/**
* Writes the insets of [view] into [out], ordered [TOP], [RIGHT], [BOTTOM], [LEFT], using
* [visibleRect] as scratch space. Returns false when they cannot be computed, leaving [out]
* untouched.
* Writes window physical-pixel insets into [out], ordered [TOP], [RIGHT], [BOTTOM], [LEFT].
* Returns false when they cannot be computed, leaving [out] untouched.
*/
private fun computeSafeAreaInsets(view: View, visibleRect: Rect, out: IntArray): Boolean {
if (view.width == 0 || view.height == 0) {
private fun computeSafeAreaInsets(view: View, geometry: Geometry, out: FloatArray): Boolean {
if (view.width <= 0 || view.height <= 0) {
return false
}
val rootView = view.rootView
if (rootView.width <= 0 || rootView.height <= 0) {
return false
}
val windowInsets =
ViewCompat.getRootWindowInsets(rootView)
?.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
) ?: return false

if (!view.getGlobalVisibleRect(visibleRect)) {
if (!view.getGlobalVisibleRect(geometry.visibleRect)) {
// The view is fully clipped by an ancestor (e.g. scrolled out of a
// scroll view); the rect is undefined in that case, and a view that is
// not visible has no meaningful insets.
return false
}
out[TOP] = max(windowInsets.top - visibleRect.top, 0)
out[RIGHT] =
max(min(visibleRect.left + view.width - rootView.width, 0) + windowInsets.right, 0)
out[BOTTOM] =
max(min(visibleRect.top + view.height - rootView.height, 0) + windowInsets.bottom, 0)
out[LEFT] = max(windowInsets.left - visibleRect.left, 0)

val toRoot = geometry.toRoot
toRoot.reset()
var current = view
while (current !== rootView) {
val parent = current.parent as? View ?: return false
toRoot.postConcat(current.matrix)
toRoot.postTranslate(
(current.left - parent.scrollX).toFloat(),
(current.top - parent.scrollY).toFloat(),
)
current = parent
}
toRoot.getValues(geometry.matrixValues)
if (
geometry.matrixValues.any { !it.isFinite() } ||
geometry.matrixValues[Matrix.MSKEW_X] != 0f ||
geometry.matrixValues[Matrix.MSKEW_Y] != 0f ||
geometry.matrixValues[Matrix.MPERSP_0] != 0f ||
geometry.matrixValues[Matrix.MPERSP_1] != 0f ||
geometry.matrixValues[Matrix.MPERSP_2] != 1f ||
geometry.matrixValues[Matrix.MSCALE_X] == 0f ||
geometry.matrixValues[Matrix.MSCALE_Y] == 0f
) {
return false
}

val safeLeft = windowInsets.left.toFloat()
val safeTop = windowInsets.top.toFloat()
val safeRight = (rootView.width - windowInsets.right).toFloat()
val safeBottom = (rootView.height - windowInsets.bottom).toFloat()
if (safeRight <= safeLeft || safeBottom <= safeTop) {
return false
}
val bounds = geometry.boundsRect
bounds.set(0f, 0f, view.width.toFloat(), view.height.toFloat())
// mapRect returns ordered bounding edges, including under reflection.
toRoot.mapRect(bounds)
val width = bounds.width()
val height = bounds.height()
if (
!bounds.left.isFinite() ||
!bounds.top.isFinite() ||
!bounds.right.isFinite() ||
!bounds.bottom.isFinite() ||
!width.isFinite() ||
!height.isFinite() ||
width <= 0f ||
height <= 0f
) {
return false
}
out[TOP] = (safeTop - bounds.top).coerceIn(0f, height)
out[RIGHT] = (bounds.right - safeRight).coerceIn(0f, width)
out[BOTTOM] = (bounds.bottom - safeBottom).coerceIn(0f, height)
out[LEFT] = (safeLeft - bounds.left).coerceIn(0f, width)
return true
}
}
Expand Down
Loading
Loading