Skip to content

Commit 6a69939

Browse files
committed
Improve bounds calculation for ViewHierarchy and Gesture Target location
1 parent 64df48e commit 6a69939

File tree

5 files changed

+169
-113
lines changed

5 files changed

+169
-113
lines changed

sentry-compose/api/android/sentry-compose.api

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ public final class io/sentry/compose/BuildConfig {
66
public fun <init> ()V
77
}
88

9+
public final class io/sentry/compose/SentryComposeHelperKt {
10+
public static final fun boundsInWindow (Landroidx/compose/ui/layout/LayoutCoordinates;Landroidx/compose/ui/layout/LayoutCoordinates;)Landroidx/compose/ui/geometry/Rect;
11+
}
12+
913
public final class io/sentry/compose/SentryComposeTracingKt {
1014
public static final fun SentryTraced (Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
1115
}
@@ -34,11 +38,7 @@ public final class io/sentry/compose/gestures/ComposeGestureTargetLocator$Compan
3438

3539
public final class io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter : io/sentry/internal/viewhierarchy/ViewHierarchyExporter {
3640
public static final field $stable I
37-
public static final field Companion Lio/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter$Companion;
3841
public fun <init> (Lio/sentry/ILogger;)V
3942
public fun export (Lio/sentry/protocol/ViewHierarchyNode;Ljava/lang/Object;)Z
4043
}
4144

42-
public final class io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter$Companion {
43-
}
44-

sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeHelper.kt

Lines changed: 91 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,20 @@
33
package io.sentry.compose
44

55
import androidx.compose.ui.Modifier
6+
import androidx.compose.ui.geometry.Offset
67
import androidx.compose.ui.geometry.Rect
7-
import androidx.compose.ui.layout.boundsInWindow
8-
import androidx.compose.ui.node.LayoutNode
9-
import androidx.compose.ui.node.LayoutNodeLayoutDelegate
8+
import androidx.compose.ui.layout.LayoutCoordinates
9+
import androidx.compose.ui.layout.findRootCoordinates
1010
import androidx.compose.ui.semantics.SemanticsModifier
1111
import io.sentry.ILogger
1212
import io.sentry.SentryLevel
1313
import java.lang.reflect.Field
1414

1515
internal class SentryComposeHelper(private val logger: ILogger) {
1616

17-
private val layoutDelegateField: Field? =
18-
loadField(logger, "androidx.compose.ui.node.LayoutNode", "layoutDelegate")
1917
private val testTagElementField: Field? =
2018
loadField(logger, "androidx.compose.ui.platform.TestTagElement", "tag")
19+
2120
private val sentryTagElementField: Field? =
2221
loadField(logger, "io.sentry.compose.SentryModifier.SentryTagModifierNodeElement", "tag")
2322

@@ -58,19 +57,6 @@ internal class SentryComposeHelper(private val logger: ILogger) {
5857
return null
5958
}
6059

61-
fun getLayoutNodeBoundsInWindow(node: LayoutNode): Rect? {
62-
if (layoutDelegateField != null) {
63-
try {
64-
val delegate =
65-
layoutDelegateField[node] as LayoutNodeLayoutDelegate
66-
return delegate.outerCoordinator.coordinates.boundsInWindow()
67-
} catch (e: Exception) {
68-
logger.log(SentryLevel.WARNING, "Could not fetch position for LayoutNode", e)
69-
}
70-
}
71-
return null
72-
}
73-
7460
companion object {
7561
private fun loadField(
7662
logger: ILogger,
@@ -89,3 +75,90 @@ internal class SentryComposeHelper(private val logger: ILogger) {
8975
}
9076
}
9177
}
78+
79+
80+
/**
81+
* Copied from sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt
82+
*
83+
* A faster copy of https://github.com/androidx/androidx/blob/fc7df0dd68466ac3bb16b1c79b7a73dd0bfdd4c1/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt#L187
84+
*
85+
* Since we traverse the tree from the root, we don't need to find it again from the leaf node and
86+
* just pass it as an argument.
87+
*
88+
* @return boundaries of this layout relative to the window's origin.
89+
*/
90+
public fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates?): Rect {
91+
val root = rootCoordinates ?: findRootCoordinates()
92+
93+
val rootWidth = root.size.width.toFloat()
94+
val rootHeight = root.size.height.toFloat()
95+
96+
val bounds = root.localBoundingBoxOf(this)
97+
val boundsLeft = bounds.left.fastCoerceIn(0f, rootWidth)
98+
val boundsTop = bounds.top.fastCoerceIn(0f, rootHeight)
99+
val boundsRight = bounds.right.fastCoerceIn(0f, rootWidth)
100+
val boundsBottom = bounds.bottom.fastCoerceIn(0f, rootHeight)
101+
102+
if (boundsLeft == boundsRight || boundsTop == boundsBottom) {
103+
return Rect.Zero
104+
}
105+
106+
val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop))
107+
val topRight = root.localToWindow(Offset(boundsRight, boundsTop))
108+
val bottomRight = root.localToWindow(Offset(boundsRight, boundsBottom))
109+
val bottomLeft = root.localToWindow(Offset(boundsLeft, boundsBottom))
110+
111+
val topLeftX = topLeft.x
112+
val topRightX = topRight.x
113+
val bottomLeftX = bottomLeft.x
114+
val bottomRightX = bottomRight.x
115+
116+
val left = fastMinOf(topLeftX, topRightX, bottomLeftX, bottomRightX)
117+
val right = fastMaxOf(topLeftX, topRightX, bottomLeftX, bottomRightX)
118+
119+
val topLeftY = topLeft.y
120+
val topRightY = topRight.y
121+
val bottomLeftY = bottomLeft.y
122+
val bottomRightY = bottomRight.y
123+
124+
val top = fastMinOf(topLeftY, topRightY, bottomLeftY, bottomRightY)
125+
val bottom = fastMaxOf(topLeftY, topRightY, bottomLeftY, bottomRightY)
126+
127+
return Rect(left, top, right, bottom)
128+
}
129+
130+
/**
131+
* Returns the smaller of the given values. If any value is NaN, returns NaN. Preferred over
132+
* `kotlin.comparisons.minOf()` for 4 arguments as it avoids allocating an array because of the
133+
* varargs.
134+
*/
135+
private inline fun fastMinOf(a: Float, b: Float, c: Float, d: Float): Float {
136+
return minOf(a, minOf(b, minOf(c, d)))
137+
}
138+
139+
/**
140+
* Returns the largest of the given values. If any value is NaN, returns NaN. Preferred over
141+
* `kotlin.comparisons.maxOf()` for 4 arguments as it avoids allocating an array because of the
142+
* varargs.
143+
*/
144+
private inline fun fastMaxOf(a: Float, b: Float, c: Float, d: Float): Float {
145+
return maxOf(a, maxOf(b, maxOf(c, d)))
146+
}
147+
148+
/**
149+
* Returns this float value clamped in the inclusive range defined by [minimumValue] and
150+
* [maximumValue]. Unlike [Float.coerceIn], the range is not validated: the caller must ensure that
151+
* [minimumValue] is less than [maximumValue].
152+
*/
153+
private inline fun Float.fastCoerceIn(minimumValue: Float, maximumValue: Float) =
154+
this.fastCoerceAtLeast(minimumValue).fastCoerceAtMost(maximumValue)
155+
156+
/** Ensures that this value is not less than the specified [minimumValue]. */
157+
private inline fun Float.fastCoerceAtLeast(minimumValue: Float): Float {
158+
return if (this < minimumValue) minimumValue else this
159+
}
160+
161+
/** Ensures that this value is not greater than the specified [maximumValue]. */
162+
private inline fun Float.fastCoerceAtMost(maximumValue: Float): Float {
163+
return if (this > maximumValue) maximumValue else this
164+
}

sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
package io.sentry.compose.gestures
44

5+
import androidx.compose.ui.geometry.Offset
56
import androidx.compose.ui.node.LayoutNode
67
import androidx.compose.ui.node.Owner
78
import androidx.compose.ui.semantics.SemanticsModifier
89
import io.sentry.ILogger
910
import io.sentry.SentryIntegrationPackageStorage
1011
import io.sentry.compose.BuildConfig
1112
import io.sentry.compose.SentryComposeHelper
13+
import io.sentry.compose.boundsInWindow
1214
import io.sentry.internal.gestures.GestureTargetLocator
1315
import io.sentry.internal.gestures.UiElement
1416
import io.sentry.util.AutoClosableReentrantLock
@@ -33,22 +35,23 @@ public class ComposeGestureTargetLocator public constructor(private val logger:
3335
y: Float,
3436
targetType: UiElement.Type
3537
): UiElement? {
36-
// lazy init composeHelper as it's using some reflection under the hood
38+
if (root !is Owner) {
39+
return null
40+
}
3741

42+
// lazy init composeHelper as it's using some reflection under the hood
3843
if (composeHelper == null) {
39-
lock.acquire().use { ignored ->
44+
lock.acquire().use {
4045
if (composeHelper == null) {
4146
composeHelper = SentryComposeHelper(logger)
4247
}
4348
}
4449
}
4550

46-
if (root !is Owner) {
47-
return null
48-
}
51+
val rootLayoutNode = root.root
4952

5053
val queue: Queue<LayoutNode> = LinkedList()
51-
queue.add(root.root)
54+
queue.add(rootLayoutNode)
5255

5356
// the final tag to return
5457
var targetTag: String? = null
@@ -57,9 +60,8 @@ public class ComposeGestureTargetLocator public constructor(private val logger:
5760
var lastKnownTag: String? = null
5861
while (!queue.isEmpty()) {
5962
val node = queue.poll() ?: continue
60-
6163
if (node.isPlaced && layoutNodeBoundsContain(
62-
composeHelper!!,
64+
rootLayoutNode,
6365
node,
6466
x,
6567
y
@@ -128,21 +130,17 @@ public class ComposeGestureTargetLocator public constructor(private val logger:
128130
}
129131
}
130132

133+
private fun layoutNodeBoundsContain(
134+
root: LayoutNode,
135+
node: LayoutNode,
136+
x: Float,
137+
y: Float
138+
): Boolean {
139+
val bounds = node.coordinates.boundsInWindow(root.coordinates)
140+
return bounds.contains(Offset(x, y))
141+
}
142+
131143
public companion object {
132144
private const val ORIGIN = "jetpack_compose"
133-
134-
private fun layoutNodeBoundsContain(
135-
composeHelper: SentryComposeHelper,
136-
node: LayoutNode,
137-
x: Float,
138-
y: Float
139-
): Boolean {
140-
val bounds = composeHelper.getLayoutNodeBoundsInWindow(node)
141-
return if (bounds == null) {
142-
false
143-
} else {
144-
x >= bounds.left && x <= bounds.right && y >= bounds.top && y <= bounds.bottom
145-
}
146-
}
147145
}
148146
}

sentry-compose/src/androidMain/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.kt

Lines changed: 45 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
package io.sentry.compose.viewhierarchy
44

5+
import androidx.compose.ui.layout.boundsInParent
56
import androidx.compose.ui.node.LayoutNode
67
import androidx.compose.ui.node.Owner
78
import io.sentry.ILogger
@@ -31,84 +32,61 @@ public class ComposeViewHierarchyExporter public constructor(private val logger:
3132
}
3233

3334
val rootNode = element.root
34-
addChild(composeHelper!!, parent, null, rootNode)
35+
addChild(composeHelper!!, parent, rootNode, rootNode)
3536
return true
3637
}
3738

38-
public companion object {
39-
private fun addChild(
40-
composeHelper: SentryComposeHelper,
41-
parent: ViewHierarchyNode,
42-
parentNode: LayoutNode?,
43-
node: LayoutNode
44-
) {
45-
if (node.isPlaced) {
46-
val vhNode = ViewHierarchyNode()
47-
setTag(composeHelper, node, vhNode)
48-
setBounds(composeHelper, node, parentNode, vhNode)
39+
private fun addChild(
40+
composeHelper: SentryComposeHelper,
41+
parent: ViewHierarchyNode,
42+
rootNode: LayoutNode,
43+
node: LayoutNode
44+
) {
45+
if (node.isPlaced) {
46+
val vhNode = ViewHierarchyNode()
47+
setTag(composeHelper, node, vhNode)
48+
setBounds(node, vhNode)
49+
vhNode.type = vhNode.tag ?: "@Composable"
4950

50-
if (vhNode.tag != null) {
51-
vhNode.type = vhNode.tag
52-
} else {
53-
vhNode.type = "@Composable"
54-
}
55-
56-
if (parent.children == null) {
57-
parent.children = ArrayList()
58-
}
59-
parent.children!!.add(vhNode)
51+
if (parent.children == null) {
52+
parent.children = ArrayList()
53+
}
54+
parent.children!!.add(vhNode)
6055

61-
val children = node.zSortedChildren
62-
val childrenCount = children.size
63-
for (i in 0 until childrenCount) {
64-
val child = children[i]
65-
addChild(composeHelper, vhNode, node, child)
66-
}
56+
val children = node.zSortedChildren
57+
val childrenCount = children.size
58+
for (i in 0 until childrenCount) {
59+
val child = children[i]
60+
addChild(composeHelper, vhNode, rootNode, child)
6761
}
6862
}
63+
}
6964

70-
private fun setTag(
71-
helper: SentryComposeHelper,
72-
node: LayoutNode,
73-
vhNode: ViewHierarchyNode
74-
) {
75-
// needs to be in-sync with ComposeGestureTargetLocator
76-
val modifiers = node.getModifierInfo()
77-
for (modifierInfo in modifiers) {
78-
val tag = helper.extractTag(modifierInfo.modifier)
79-
if (tag != null) {
80-
vhNode.tag = tag
81-
}
65+
private fun setTag(
66+
helper: SentryComposeHelper,
67+
node: LayoutNode,
68+
vhNode: ViewHierarchyNode
69+
) {
70+
// needs to be in-sync with ComposeGestureTargetLocator
71+
val modifiers = node.getModifierInfo()
72+
for (modifierInfo in modifiers) {
73+
val tag = helper.extractTag(modifierInfo.modifier)
74+
if (tag != null) {
75+
vhNode.tag = tag
8276
}
8377
}
78+
}
8479

85-
private fun setBounds(
86-
composeHelper: SentryComposeHelper,
87-
node: LayoutNode,
88-
parentNode: LayoutNode?,
89-
vhNode: ViewHierarchyNode
90-
) {
91-
val nodeHeight = node.height
92-
val nodeWidth = node.width
93-
94-
vhNode.height = nodeHeight.toDouble()
95-
vhNode.width = nodeWidth.toDouble()
80+
private fun setBounds(
81+
node: LayoutNode,
82+
vhNode: ViewHierarchyNode
83+
) {
84+
// layout coordinates for view hierarchy are relative to the parent node
85+
val bounds = node.coordinates.boundsInParent()
9686

97-
val bounds = composeHelper.getLayoutNodeBoundsInWindow(node)
98-
if (bounds != null) {
99-
var x = bounds.left.toDouble()
100-
var y = bounds.top.toDouble()
101-
// layout coordinates for view hierarchy are relative to the parent node
102-
parentNode?.let {
103-
val parentBounds = composeHelper.getLayoutNodeBoundsInWindow(it)
104-
if (parentBounds != null) {
105-
x -= parentBounds.left.toDouble()
106-
y -= parentBounds.top.toDouble()
107-
}
108-
}
109-
vhNode.x = x
110-
vhNode.y = y
111-
}
112-
}
87+
vhNode.x = bounds.left.toDouble()
88+
vhNode.y = bounds.top.toDouble()
89+
vhNode.height = bounds.height.toDouble()
90+
vhNode.width = bounds.width.toDouble()
11391
}
11492
}

0 commit comments

Comments
 (0)