1 /*
<lambda>null2 * Copyright 2020 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.ui.inspection.inspector
18
19 import android.view.View
20 import android.view.ViewGroup
21 import android.view.inspector.WindowInspector
22 import android.widget.TextView
23 import androidx.compose.animation.Crossfade
24 import androidx.compose.foundation.Image
25 import androidx.compose.foundation.background
26 import androidx.compose.foundation.layout.Arrangement
27 import androidx.compose.foundation.layout.Column
28 import androidx.compose.foundation.layout.Row
29 import androidx.compose.foundation.layout.Spacer
30 import androidx.compose.foundation.layout.fillMaxSize
31 import androidx.compose.foundation.layout.height
32 import androidx.compose.foundation.layout.heightIn
33 import androidx.compose.foundation.layout.padding
34 import androidx.compose.foundation.lazy.LazyColumn
35 import androidx.compose.foundation.text.BasicText
36 import androidx.compose.material.AlertDialog
37 import androidx.compose.material.Button
38 import androidx.compose.material.Icon
39 import androidx.compose.material.LinearProgressIndicator
40 import androidx.compose.material.MaterialTheme
41 import androidx.compose.material.ModalDrawer
42 import androidx.compose.material.Scaffold
43 import androidx.compose.material.Surface
44 import androidx.compose.material.Text
45 import androidx.compose.material.icons.Icons
46 import androidx.compose.material.icons.filled.Call
47 import androidx.compose.material.icons.filled.FavoriteBorder
48 import androidx.compose.runtime.Composable
49 import androidx.compose.runtime.CompositionLocalProvider
50 import androidx.compose.runtime.InternalComposeApi
51 import androidx.compose.runtime.currentComposer
52 import androidx.compose.runtime.getValue
53 import androidx.compose.runtime.mutableStateOf
54 import androidx.compose.runtime.remember
55 import androidx.compose.runtime.rememberCompositionContext
56 import androidx.compose.runtime.setValue
57 import androidx.compose.runtime.tooling.CompositionData
58 import androidx.compose.runtime.tooling.LocalInspectionTables
59 import androidx.compose.ui.Alignment
60 import androidx.compose.ui.Modifier
61 import androidx.compose.ui.R
62 import androidx.compose.ui.graphics.Color
63 import androidx.compose.ui.graphics.graphicsLayer
64 import androidx.compose.ui.inspection.compose.flatten
65 import androidx.compose.ui.inspection.testdata.TestActivity
66 import androidx.compose.ui.inspection.util.ThreadUtils
67 import androidx.compose.ui.layout.GraphicLayerInfo
68 import androidx.compose.ui.node.Ref
69 import androidx.compose.ui.platform.LocalDensity
70 import androidx.compose.ui.platform.LocalInspectionMode
71 import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
72 import androidx.compose.ui.platform.testTag
73 import androidx.compose.ui.semantics.clearAndSetSemantics
74 import androidx.compose.ui.semantics.semantics
75 import androidx.compose.ui.semantics.text
76 import androidx.compose.ui.test.junit4.createAndroidComposeRule
77 import androidx.compose.ui.test.onNodeWithTag
78 import androidx.compose.ui.test.onNodeWithText
79 import androidx.compose.ui.test.performClick
80 import androidx.compose.ui.test.performScrollToIndex
81 import androidx.compose.ui.text.AnnotatedString
82 import androidx.compose.ui.text.TextStyle
83 import androidx.compose.ui.text.font.Font
84 import androidx.compose.ui.text.font.toFontFamily
85 import androidx.compose.ui.text.style.TextDecoration
86 import androidx.compose.ui.tooling.data.Group
87 import androidx.compose.ui.tooling.data.UiToolingDataApi
88 import androidx.compose.ui.tooling.data.asTree
89 import androidx.compose.ui.tooling.data.position
90 import androidx.compose.ui.unit.Density
91 import androidx.compose.ui.unit.Dp
92 import androidx.compose.ui.unit.TextUnit
93 import androidx.compose.ui.unit.dp
94 import androidx.compose.ui.unit.sp
95 import androidx.compose.ui.viewinterop.AndroidView
96 import androidx.compose.ui.window.Popup
97 import androidx.test.ext.junit.runners.AndroidJUnit4
98 import androidx.test.filters.LargeTest
99 import androidx.test.filters.SdkSuppress
100 import com.google.common.truth.Truth.assertThat
101 import com.google.common.truth.Truth.assertWithMessage
102 import java.util.Collections
103 import java.util.WeakHashMap
104 import kotlin.math.roundToInt
105 import org.junit.After
106 import org.junit.Before
107 import org.junit.Rule
108 import org.junit.Test
109 import org.junit.runner.RunWith
110
111 private const val DEBUG = false
112 private const val ROOT_ID = 3L
113 private const val MAX_RECURSIONS = 2
114 private const val MAX_ITERABLE_SIZE = 5
115
116 @LargeTest
117 @RunWith(AndroidJUnit4::class)
118 @SdkSuppress(minSdkVersion = 29) // Render id is not returned for api < 29
119 @OptIn(UiToolingDataApi::class)
120 class LayoutInspectorTreeTest {
121 private lateinit var density: Density
122
123 @get:Rule val composeTestRule = createAndroidComposeRule<TestActivity>()
124
125 private val fontFamily = Font(androidx.testutils.fonts.R.font.sample_font).toFontFamily()
126
127 @Before
128 fun before() {
129 composeTestRule.activityRule.scenario.onActivity { density = Density(it) }
130 isDebugInspectorInfoEnabled = true
131 }
132
133 private fun findAndroidComposeView(): View {
134 return findAllAndroidComposeViews().single()
135 }
136
137 private fun findAllAndroidComposeViews(): List<View> = findAllViews("AndroidComposeView")
138
139 private fun findAllViews(className: String): List<View> {
140 val views = mutableListOf<View>()
141 WindowInspector.getGlobalWindowViews().forEach {
142 collectAllViews(it.rootView, className, views)
143 }
144 return views
145 }
146
147 private fun collectAllViews(view: View, className: String, views: MutableList<View>) {
148 if (view.javaClass.simpleName == className) {
149 views.add(view)
150 }
151 if (view !is ViewGroup) {
152 return
153 }
154 for (i in 0 until view.childCount) {
155 collectAllViews(view.getChildAt(i), className, views)
156 }
157 }
158
159 @After
160 fun after() {
161 isDebugInspectorInfoEnabled = false
162 }
163
164 @Test
165 fun doNotCommitWithDebugSetToTrue() {
166 assertThat(DEBUG).isFalse()
167 }
168
169 @Test // regression test for b/383639244
170 fun noViews() {
171 val builder = LayoutInspectorTree()
172 builder.convert(emptyList())
173 }
174
175 @Test
176 fun buildTree() {
177 val slotTableRecord = CompositionDataRecord.create()
178 val localDensity = Density(density = 1f, fontScale = 1f)
179 show {
180 Inspectable(slotTableRecord) {
181 CompositionLocalProvider(LocalDensity provides localDensity) {
182 Column {
183 // width: 100.dp, height: 10.dp
184 Text(
185 text = "helloworld",
186 color = Color.Green,
187 fontSize = 10.sp,
188 lineHeight = 10.sp,
189 fontFamily = fontFamily
190 )
191 // width: 24.dp, height: 24.dp
192 Icon(Icons.Filled.FavoriteBorder, null)
193 Surface {
194 // minwidth: 64.dp, height: 42.dp
195 Button(onClick = {}) {
196 // width: 20.dp, height: 10.dp
197 Text(
198 text = "ok",
199 fontSize = 10.sp,
200 lineHeight = 10.sp,
201 fontFamily = fontFamily
202 )
203 }
204 }
205 }
206 }
207 }
208 }
209
210 // TODO: Find out if we can set "settings put global debug_view_attributes 1" in tests
211 val view = findAndroidComposeView()
212 view.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
213 val builder = LayoutInspectorTree()
214 builder.includeAllParameters = true
215 val nodes = builder.convert(view)
216 dumpNodes(nodes, view, builder)
217
218 validate(nodes, builder, density = localDensity) {
219 node(
220 name = "Column",
221 fileName = "LayoutInspectorTreeTest.kt",
222 left = 0.0.dp,
223 top = 0.0.dp,
224 width = 100.dp,
225 height = 82.dp,
226 children = listOf("Text", "Icon", "Surface"),
227 inlined = true,
228 )
229 node(
230 name = "Text",
231 isRenderNode = false,
232 fileName = "LayoutInspectorTreeTest.kt",
233 left = 0.dp,
234 top = 0.0.dp,
235 width = 100.dp,
236 height = 10.dp,
237 )
238 node(
239 name = "Icon",
240 isRenderNode = true,
241 fileName = "LayoutInspectorTreeTest.kt",
242 left = 0.dp,
243 top = 10.dp,
244 width = 24.dp,
245 height = 24.dp,
246 )
247 node(
248 name = "Surface",
249 fileName = "LayoutInspectorTreeTest.kt",
250 isRenderNode = true,
251 left = 0.dp,
252 top = 34.dp,
253 width = 64.dp,
254 height = 48.dp,
255 children = listOf("Button")
256 )
257 node(
258 name = "Button",
259 fileName = "LayoutInspectorTreeTest.kt",
260 isRenderNode = true,
261 left = 0.dp,
262 top = 40.dp,
263 width = 64.dp,
264 height = 36.dp,
265 children = listOf("Text")
266 )
267 node(
268 name = "Text",
269 isRenderNode = false,
270 fileName = "LayoutInspectorTreeTest.kt",
271 left = 21.dp,
272 top = 53.dp,
273 width = 23.dp,
274 height = 10.dp,
275 )
276 }
277 }
278
279 @Test
280 fun buildTreeWithTransformedText() {
281 val slotTableRecord = CompositionDataRecord.create()
282 val localDensity = Density(density = 1f, fontScale = 1f)
283 show {
284 Inspectable(slotTableRecord) {
285 CompositionLocalProvider(LocalDensity provides localDensity) {
286 Column {
287 Text(
288 text = "helloworld",
289 fontSize = 10.sp,
290 fontFamily = fontFamily,
291 modifier = Modifier.graphicsLayer(rotationZ = -90f)
292 )
293 }
294 }
295 }
296 }
297
298 // TODO: Find out if we can set "settings put global debug_view_attributes 1" in tests
299 val view = findAndroidComposeView()
300 view.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
301 val builder = LayoutInspectorTree()
302 val nodes = builder.convert(view)
303 dumpNodes(nodes, view, builder)
304
305 validate(nodes, builder, density = localDensity) {
306 node(
307 name = "Column",
308 hasTransformations = false,
309 fileName = "LayoutInspectorTreeTest.kt",
310 left = 0.dp,
311 top = 0.dp,
312 width = 100.dp,
313 height = 10.dp,
314 children = listOf("Text"),
315 inlined = true,
316 )
317 node(
318 name = "Text",
319 isRenderNode = true,
320 hasTransformations = true,
321 fileName = "LayoutInspectorTreeTest.kt",
322 left = 45.dp,
323 top = 55.dp,
324 width = 100.dp,
325 height = 10.dp,
326 )
327 }
328 }
329
330 @Test
331 fun testStitchTreeFromModelDrawerLayout() {
332 val slotTableRecord = CompositionDataRecord.create()
333
334 show {
335 Inspectable(slotTableRecord) {
336 ModalDrawer(
337 drawerContent = { Text("Something") },
338 content = {
339 Column {
340 Text(text = "Hello World", color = Color.Green)
341 Button(onClick = {}) { Text(text = "OK") }
342 }
343 }
344 )
345 }
346 }
347 val view = findAndroidComposeView()
348 view.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
349 dumpSlotTableSet(slotTableRecord)
350 val builder = LayoutInspectorTree()
351 val nodes = builder.convert(view)
352 dumpNodes(nodes, view, builder)
353
354 validate(nodes, builder) {
355 node("ModalDrawer", isRenderNode = true, children = listOf("Column", "Text"))
356 node("Column", inlined = true, children = listOf("Text", "Button"))
357 node("Text", isRenderNode = false)
358 node("Button", isRenderNode = true, children = listOf("Text"))
359 node("Text", isRenderNode = false)
360 node("Text", isRenderNode = false)
361 }
362 assertThat(nodes.size).isEqualTo(1)
363 }
364
365 @Test
366 fun testStitchTreeFromModelDrawerLayoutWithSystemNodes() {
367 val slotTableRecord = CompositionDataRecord.create()
368
369 show {
370 Inspectable(slotTableRecord) {
371 ModalDrawer(
372 drawerContent = { Text("Something") },
373 content = {
374 Column {
375 Text(text = "Hello World", color = Color.Green)
376 Button(onClick = {}) { Text(text = "OK") }
377 }
378 }
379 )
380 }
381 }
382 val view = findAndroidComposeView()
383 view.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
384 dumpSlotTableSet(slotTableRecord)
385 val builder = LayoutInspectorTree()
386 builder.hideSystemNodes = false
387 val nodes = builder.convert(view)
388 dumpNodes(nodes, view, builder)
389
390 if (DEBUG) {
391 validate(nodes, builder) {
392 node("ModalDrawer", children = listOf("WithConstraints"))
393 node("WithConstraints", children = listOf("SubcomposeLayout"))
394 node("SubcomposeLayout", children = listOf("Box"))
395 node("Box", children = listOf("Box", "Canvas", "Surface"))
396 node("Box", children = listOf("Column"))
397 node("Column", children = listOf("Text", "Button"))
398 node("Text", children = listOf("Text"))
399 node("Text", children = listOf("CoreText"))
400 node("CoreText", children = listOf())
401 node("Button", children = listOf("Surface"))
402 node("Surface", children = listOf("ProvideTextStyle"))
403 node("ProvideTextStyle", children = listOf("Row"))
404 node("Row", children = listOf("Text"))
405 node("Text", children = listOf("Text"))
406 node("Text", children = listOf("CoreText"))
407 node("CoreText", children = listOf())
408 node("Canvas", children = listOf("Spacer"))
409 node("Spacer", children = listOf())
410 node("Surface", children = listOf("Column"))
411 node("Column", children = listOf("Text"))
412 node("Text", children = listOf("Text"))
413 node("Text", children = listOf("CoreText"))
414 node("CoreText", children = listOf())
415 }
416 }
417 assertThat(nodes.size).isEqualTo(1)
418 }
419
420 @Test
421 fun testSpacer() {
422 val slotTableRecord = CompositionDataRecord.create()
423
424 show {
425 Inspectable(slotTableRecord) {
426 Column {
427 Text(text = "Hello World", color = Color.Green)
428 Spacer(Modifier.height(16.dp))
429 Image(Icons.Filled.Call, null)
430 }
431 }
432 }
433
434 val view = findAndroidComposeView()
435 view.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
436 val builder = LayoutInspectorTree()
437 val node = builder.convert(view).flatMap { flatten(it) }.firstOrNull { it.name == "Spacer" }
438
439 // Spacer should show up in the Compose tree:
440 assertThat(node).isNotNull()
441 }
442
443 @Test // regression test b/174855322
444 fun testBasicText() {
445 val slotTableRecord = CompositionDataRecord.create()
446
447 show {
448 Inspectable(slotTableRecord) {
449 Column {
450 BasicText(
451 text = "Some text",
452 style = TextStyle(textDecoration = TextDecoration.Underline)
453 )
454 }
455 }
456 }
457
458 val view = findAndroidComposeView()
459 view.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
460 val builder = LayoutInspectorTree()
461 builder.includeAllParameters = false
462 val node =
463 builder.convert(view).flatMap { flatten(it) }.firstOrNull { it.name == "BasicText" }
464
465 assertThat(node).isNotNull()
466 assertThat(node?.parameters).isEmpty()
467
468 // Get parameters for the Spacer after getting the tree without parameters:
469 val paramsNode = builder.findParameters(view, node!!.anchorId)!!
470 val params =
471 builder.convertParameters(
472 ROOT_ID,
473 paramsNode,
474 ParameterKind.Normal,
475 MAX_RECURSIONS,
476 MAX_ITERABLE_SIZE
477 )
478 assertThat(params).isNotEmpty()
479 val text = params.find { it.name == "$0" }
480 assertThat(text?.value).isEqualTo("Some text")
481 }
482
483 @Test
484 fun testTextId() {
485 val slotTableRecord = CompositionDataRecord.create()
486
487 show { Inspectable(slotTableRecord) { Text(text = "Hello World") } }
488
489 val view = findAndroidComposeView()
490 view.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
491 val builder = LayoutInspectorTree()
492 val node = builder.convert(view).flatMap { flatten(it) }.firstOrNull { it.name == "Text" }
493
494 // LayoutNode id should be captured by the Text node:
495 assertThat(node?.id).isGreaterThan(0)
496 }
497
498 @Test
499 fun testSemantics() {
500 val slotTableRecord = CompositionDataRecord.create()
501
502 show {
503 Inspectable(slotTableRecord) {
504 Column {
505 Text(text = "Studio")
506 Row(modifier = Modifier.semantics(true) {}) {
507 Text(text = "Hello")
508 Text(text = "World")
509 }
510 Row(modifier = Modifier.clearAndSetSemantics { text = AnnotatedString("to") }) {
511 Text(text = "Hello")
512 Text(text = "World")
513 }
514 }
515 }
516 }
517
518 val androidComposeView = findAndroidComposeView()
519 androidComposeView.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
520 val builder = LayoutInspectorTree()
521 val nodes = builder.convert(androidComposeView)
522 validate(nodes, builder, checkSemantics = true) {
523 node("Column", children = listOf("Text", "Row", "Row"), inlined = true)
524 node(
525 name = "Text",
526 isRenderNode = false,
527 mergedSemantics = "[Studio]",
528 unmergedSemantics = "[Studio]",
529 )
530 node(
531 name = "Row",
532 children = listOf("Text", "Text"),
533 mergedSemantics = "[Hello, World]",
534 inlined = true,
535 )
536 node("Text", isRenderNode = false, unmergedSemantics = "[Hello]")
537 node("Text", isRenderNode = false, unmergedSemantics = "[World]")
538 node(
539 name = "Row",
540 children = listOf("Text", "Text"),
541 mergedSemantics = "[to]",
542 unmergedSemantics = "[to]",
543 inlined = true,
544 )
545 node("Text", isRenderNode = false, unmergedSemantics = "[Hello]")
546 node("Text", isRenderNode = false, unmergedSemantics = "[World]")
547 }
548 }
549
550 @Test
551 fun testDialog() {
552 val slotTableRecord = CompositionDataRecord.create()
553
554 show {
555 Inspectable(slotTableRecord) {
556 Column(modifier = Modifier.fillMaxSize()) {
557 Text("Hello World!")
558 AlertDialog(
559 onDismissRequest = {},
560 confirmButton = { Button({}) { Text("This is the Confirm Button") } }
561 )
562 }
563 }
564 }
565 val composeViews = findAllAndroidComposeViews()
566 val appView = composeViews[0]
567 val dialogView = composeViews[1]
568 assertThat(composeViews).hasSize(2)
569 appView.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
570 dialogView.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
571
572 val builder = LayoutInspectorTree()
573
574 val allNodes = builder.convert(listOf(appView, dialogView))
575 val appNodes = allNodes[appView.uniqueDrawingId] ?: emptyList()
576 dumpSlotTableSet(slotTableRecord)
577 dumpNodes(appNodes, appView, builder)
578
579 // Verify that the main app does not contain the Popup
580 validate(appNodes, builder) {
581 node(
582 name = "Column",
583 fileName = "LayoutInspectorTreeTest.kt",
584 children = listOf("Text"),
585 inlined = true,
586 isRenderNode = true,
587 )
588 node(
589 name = "Text",
590 isRenderNode = false,
591 fileName = "LayoutInspectorTreeTest.kt",
592 )
593 }
594
595 val dialogNodes = allNodes[dialogView.uniqueDrawingId] ?: emptyList()
596 dumpNodes(dialogNodes, dialogView, builder)
597
598 // Verify that the AlertDialog is captured with content
599 validate(dialogNodes, builder) {
600 node(
601 name = "AlertDialog",
602 fileName = "LayoutInspectorTreeTest.kt",
603 children = listOf("Button")
604 )
605 node(
606 name = "Button",
607 fileName = "LayoutInspectorTreeTest.kt",
608 isRenderNode = true,
609 children = listOf("Text")
610 )
611 node(
612 name = "Text",
613 isRenderNode = false,
614 fileName = "LayoutInspectorTreeTest.kt",
615 )
616 }
617 }
618
619 @Test
620 fun testPopup() {
621 val slotTableRecord = CompositionDataRecord.create()
622
623 show {
624 Inspectable(slotTableRecord) {
625 Column(modifier = Modifier.fillMaxSize()) {
626 Text("Compose Text")
627 Popup(alignment = Alignment.Center) { Text("This is a popup") }
628 }
629 }
630 }
631 val composeViews = findAllAndroidComposeViews()
632 val appView = composeViews[0]
633 val popupView = composeViews[1]
634 appView.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
635 popupView.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
636 val builder = LayoutInspectorTree()
637
638 val allNodes = builder.convert(listOf(appView, popupView))
639 val appNodes = allNodes[appView.uniqueDrawingId] ?: emptyList()
640 dumpNodes(appNodes, appView, builder)
641
642 // Verify that the main app does not contain the Popup
643 validate(appNodes, builder) {
644 node(
645 name = "Column",
646 isRenderNode = true,
647 fileName = "LayoutInspectorTreeTest.kt",
648 children = listOf("Text"),
649 inlined = true,
650 )
651 node(
652 name = "Text",
653 isRenderNode = false,
654 fileName = "LayoutInspectorTreeTest.kt",
655 )
656 }
657
658 val popupNodes = allNodes[popupView.uniqueDrawingId] ?: emptyList()
659 dumpNodes(popupNodes, popupView, builder)
660
661 // Verify that the Popup is captured with content
662 validate(popupNodes, builder) {
663 node(name = "Popup", fileName = "LayoutInspectorTreeTest.kt", children = listOf("Text"))
664 node(
665 name = "Text",
666 isRenderNode = false,
667 fileName = "LayoutInspectorTreeTest.kt",
668 )
669 }
670 }
671
672 @Test
673 fun testAndroidView() {
674 val slotTableRecord = CompositionDataRecord.create()
675
676 show {
677 Inspectable(slotTableRecord) {
678 Column {
679 Text("Compose Text")
680 AndroidView({ context -> TextView(context).apply { text = "AndroidView" } })
681 }
682 }
683 }
684 val composeView = findAndroidComposeView() as ViewGroup
685 composeView.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
686 val builder = LayoutInspectorTree()
687 builder.hideSystemNodes = false
688 val nodes = builder.convert(composeView)
689 dumpNodes(nodes, composeView, builder)
690 val androidView = nodes.flatMap { flatten(it) }.first { it.name == "AndroidView" }
691 assertThat(androidView.viewId).isEqualTo(0)
692
693 validate(listOf(androidView), builder) {
694 node(
695 name = "AndroidView",
696 fileName = "LayoutInspectorTreeTest.kt",
697 children = listOf("AndroidView")
698 )
699 node(
700 name = "AndroidView",
701 fileName = "AndroidView.android.kt",
702 children = listOf("ComposeNode")
703 )
704 node(
705 name = "ComposeNode",
706 fileName = "AndroidView.android.kt",
707 hasViewIdUnder = composeView,
708 isRenderNode = true,
709 inlined = true,
710 )
711 }
712 }
713
714 @Test
715 fun testAndroidViewWithOnResetOverload() {
716 val slotTableRecord = CompositionDataRecord.create()
717
718 show {
719 Inspectable(slotTableRecord) {
720 Column {
721 Text("Compose Text")
722 AndroidView(
723 factory = { context -> TextView(context).apply { text = "AndroidView" } },
724 onReset = {
725 // Do nothing, just use the overload.
726 }
727 )
728 }
729 }
730 }
731 val composeView = findAndroidComposeView() as ViewGroup
732 composeView.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
733 val builder = LayoutInspectorTree()
734 builder.hideSystemNodes = false
735 val nodes = builder.convert(composeView)
736 dumpNodes(nodes, composeView, builder)
737 val androidView = nodes.flatMap { flatten(it) }.first { it.name == "AndroidView" }
738 assertThat(androidView.viewId).isEqualTo(0)
739
740 validate(listOf(androidView), builder) {
741 node(
742 name = "AndroidView",
743 fileName = "LayoutInspectorTreeTest.kt",
744 children = listOf("ReusableComposeNode")
745 )
746 node(
747 name = "ReusableComposeNode",
748 fileName = "AndroidView.android.kt",
749 hasViewIdUnder = composeView,
750 isRenderNode = true,
751 inlined = true,
752 )
753 }
754 }
755
756 @Test
757 fun testDoubleAndroidView() {
758 val slotTableRecord = CompositionDataRecord.create()
759
760 show {
761 Inspectable(slotTableRecord) {
762 Column {
763 Text("Compose Text1")
764 AndroidView({ context -> TextView(context).apply { text = "first" } })
765 Text("Compose Text2")
766 AndroidView({ context -> TextView(context).apply { text = "second" } })
767 }
768 }
769 }
770 val composeView = findAndroidComposeView() as ViewGroup
771 composeView.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
772 val builder = LayoutInspectorTree()
773 builder.hideSystemNodes = false
774 val nodes = builder.convert(composeView)
775 dumpSlotTableSet(slotTableRecord)
776 dumpNodes(nodes, composeView, builder)
777 val textViews = findAllViews("TextView")
778 val firstTextView = textViews.filterIsInstance<TextView>().first { it.text == "first" }
779 val secondTextView = textViews.filterIsInstance<TextView>().first { it.text == "second" }
780 val composeNodes = nodes.flatMap { it.flatten() }.filter { it.name == "ComposeNode" }
781 assertThat(composeNodes).hasSize(2)
782 assertThat(composeNodes[0].viewId).isEqualTo(viewParent(secondTextView)?.uniqueDrawingId)
783 assertThat(composeNodes[1].viewId).isEqualTo(viewParent(firstTextView)?.uniqueDrawingId)
784 }
785
786 // WARNING: The formatting of the lines below here affect test results.
787 val titleLine = Throwable().stackTrace[0].lineNumber + 3
788
789 @Composable
790 private fun Title() {
791 val maxOffset = with(LocalDensity.current) { 80.dp.toPx() }
792 val minOffset = with(LocalDensity.current) { 80.dp.toPx() }
793 val offset = maxOffset.coerceAtLeast(minOffset)
794 Column(
795 verticalArrangement = Arrangement.Bottom,
796 modifier =
797 Modifier.heightIn(min = 128.dp)
798 .graphicsLayer { translationY = offset }
799 .background(color = MaterialTheme.colors.background)
800 ) {
801 Spacer(Modifier.height(16.dp))
802 Text(
803 text = "Snack",
804 style = MaterialTheme.typography.h4,
805 color = MaterialTheme.colors.secondary,
806 modifier = Modifier.padding(horizontal = 24.dp)
807 )
808 Text(
809 text = "Tagline",
810 style = MaterialTheme.typography.subtitle2,
811 fontSize = 20.sp,
812 color = MaterialTheme.colors.secondary,
813 modifier = Modifier.padding(horizontal = 24.dp)
814 )
815 Spacer(Modifier.height(4.dp))
816 Text(
817 text = "$2.95",
818 style = MaterialTheme.typography.h6,
819 color = MaterialTheme.colors.primary,
820 modifier = Modifier.padding(horizontal = 24.dp)
821 )
822 Spacer(Modifier.height(8.dp))
823 }
824 }
825
826 // WARNING: End formatted section
827
828 @Test
829 fun testLineNumbers() {
830 // WARNING: The formatting of the lines below here affect test results.
831 val testLine = Throwable().stackTrace[0].lineNumber
832 val slotTableRecord = CompositionDataRecord.create()
833
834 show { Inspectable(slotTableRecord) { Column { Title() } } }
835 // WARNING: End formatted section
836
837 val androidComposeView = findAndroidComposeView()
838 androidComposeView.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
839 val builder = LayoutInspectorTree()
840 val nodes = builder.convert(androidComposeView)
841 dumpNodes(nodes, androidComposeView, builder)
842
843 validate(nodes, builder, checkLineNumbers = true, checkRenderNodes = false) {
844 node("Column", lineNumber = testLine + 3, children = listOf("Title"), inlined = true)
845 node("Title", lineNumber = testLine + 3, children = listOf("Column"))
846 node(
847 name = "Column",
848 lineNumber = titleLine + 4,
849 children = listOf("Spacer", "Text", "Text", "Spacer", "Text", "Spacer"),
850 inlined = true,
851 )
852 node("Spacer", lineNumber = titleLine + 11)
853 node("Text", lineNumber = titleLine + 12)
854 node("Text", lineNumber = titleLine + 18)
855 node("Spacer", lineNumber = titleLine + 25)
856 node("Text", lineNumber = titleLine + 26)
857 node("Spacer", lineNumber = titleLine + 32)
858 }
859 }
860
861 @Composable
862 @Suppress("UNUSED_PARAMETER")
863 fun First(p1: Int) {
864 Text("First")
865 }
866
867 @Composable
868 @Suppress("UNUSED_PARAMETER")
869 fun Second(p2: Int) {
870 Text("Second")
871 }
872
873 @Test
874 fun testCrossfade() {
875 val slotTableRecord = CompositionDataRecord.create()
876
877 show {
878 Inspectable(slotTableRecord) {
879 Column {
880 var showFirst by remember { mutableStateOf(true) }
881 Button(onClick = { showFirst = !showFirst }) { Text("Button") }
882 Crossfade(showFirst) {
883 when (it) {
884 true -> First(p1 = 1)
885 false -> Second(p2 = 2)
886 }
887 }
888 }
889 }
890 }
891 val androidComposeView = findAndroidComposeView()
892 androidComposeView.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
893 val builder = LayoutInspectorTree()
894 builder.includeAllParameters = true
895 val tree1 = builder.convert(androidComposeView)
896 val first = tree1.flatMap { flatten(it) }.single { it.name == "First" }
897 val hash = packageNameHash(this.javaClass.name.substringBeforeLast('.'))
898 assertThat(first.fileName).isEqualTo("LayoutInspectorTreeTest.kt")
899 assertThat(first.packageHash).isEqualTo(hash)
900 assertThat(first.parameters.map { it.name }).contains("p1")
901
902 val cross1 = tree1.flatMap { flatten(it) }.single { it.name == "Crossfade" }
903 val button1 = tree1.flatMap { flatten(it) }.single { it.name == "Button" }
904 val column1 = tree1.flatMap { flatten(it) }.single { it.name == "Column" }
905
906 assertThat(cross1.id).isGreaterThan(RESERVED_FOR_GENERATED_IDS)
907 assertThat(button1.id).isGreaterThan(RESERVED_FOR_GENERATED_IDS)
908 assertThat(column1.id).isLessThan(RESERVED_FOR_GENERATED_IDS)
909
910 composeTestRule.onNodeWithText("Button").performClick()
911 composeTestRule.runOnIdle {
912 val tree2 = builder.convert(androidComposeView)
913 val second = tree2.flatMap { flatten(it) }.first { it.name == "Second" }
914 assertThat(second.fileName).isEqualTo("LayoutInspectorTreeTest.kt")
915 assertThat(second.packageHash).isEqualTo(hash)
916 assertThat(second.parameters.map { it.name }).contains("p2")
917
918 val cross2 = tree2.flatMap { flatten(it) }.first { it.name == "Crossfade" }
919 val button2 = tree2.flatMap { flatten(it) }.single { it.name == "Button" }
920 val column2 = tree2.flatMap { flatten(it) }.single { it.name == "Column" }
921 assertThat(cross2.id).isNotEqualTo(cross1.id)
922 assertThat(button2.id).isEqualTo(button1.id)
923 assertThat(column2.id).isEqualTo(column1.id)
924 }
925 }
926
927 @Test
928 fun testInlineParameterTypes() {
929 val slotTableRecord = CompositionDataRecord.create()
930
931 show { Inspectable(slotTableRecord) { InlineParameters(20.5.dp, 30.sp) } }
932 val androidComposeView = findAndroidComposeView()
933 androidComposeView.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
934 val builder = LayoutInspectorTree()
935 builder.hideSystemNodes = false
936 builder.includeAllParameters = true
937 val inlineParameters =
938 builder
939 .convert(androidComposeView)
940 .flatMap { flatten(it) }
941 .first { it.name == "InlineParameters" }
942 assertThat(inlineParameters.parameters[0].name).isEqualTo("size")
943 assertThat(inlineParameters.parameters[0].value?.javaClass).isEqualTo(Dp::class.java)
944 assertThat(inlineParameters.parameters[1].name).isEqualTo("fontSize")
945 assertThat(inlineParameters.parameters[1].value?.javaClass).isEqualTo(TextUnit::class.java)
946 assertThat(inlineParameters.parameters).hasSize(2)
947 }
948
949 @Test
950 fun testRemember() {
951 val slotTableRecord = CompositionDataRecord.create()
952
953 // Regression test for: b/235526153
954 // The method: SubCompositionRoots.remember had code like this:
955 // group.data.filterIsInstance<Ref<ViewRootForInspector>>().singleOrNull()?.value
956 // which would cause a ClassCastException if the data contained a Ref to something
957 // else than a ViewRootForInspector instance. This would crash the app.
958 show {
959 Inspectable(slotTableRecord) {
960 rememberCompositionContext()
961 val stringReference = remember { Ref<String>() }
962 stringReference.value = "Hello"
963 }
964 }
965 val androidComposeView = findAndroidComposeView()
966 androidComposeView.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
967 val builder = LayoutInspectorTree()
968 builder.hideSystemNodes = false
969 builder.includeAllParameters = false
970 }
971
972 @Test // regression test for b/311436726
973 fun testLazyColumn() {
974 val slotTableRecord = CompositionDataRecord.create()
975
976 show {
977 Inspectable(slotTableRecord) {
978 LazyColumn(modifier = Modifier.testTag("LazyColumn")) {
979 items(100) { index -> Text(text = "Item: $index") }
980 }
981 }
982 }
983
984 val androidComposeView = findAndroidComposeView()
985 androidComposeView.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
986 val builder = LayoutInspectorTree()
987 builder.hideSystemNodes = false
988 builder.includeAllParameters = true
989 ThreadUtils.runOnMainThread { builder.convert(androidComposeView) }
990 for (index in 20..40) {
991 composeTestRule.onNodeWithTag("LazyColumn").performScrollToIndex(index)
992 }
993 ThreadUtils.runOnMainThread { builder.convert(androidComposeView) }
994 }
995
996 @Test
997 fun testScaffold() {
998 val slotTableRecord = CompositionDataRecord.create()
999
1000 show {
1001 Inspectable(slotTableRecord) {
1002 Scaffold { Column { LinearProgressIndicator(progress = 0.3F) } }
1003 }
1004 }
1005 val androidComposeView = findAndroidComposeView()
1006 androidComposeView.setTag(R.id.inspection_slot_table_set, slotTableRecord.store)
1007 val builder = LayoutInspectorTree()
1008 builder.hideSystemNodes = false
1009 builder.includeAllParameters = true
1010 val linearProgressIndicator =
1011 builder
1012 .convert(androidComposeView)
1013 .flatMap { flatten(it) }
1014 .firstOrNull { it.name == "LinearProgressIndicator" }
1015 assertThat(linearProgressIndicator).isNotNull()
1016 }
1017
1018 @Suppress("SameParameterValue")
1019 private fun validate(
1020 result: List<InspectorNode>,
1021 builder: LayoutInspectorTree,
1022 checkParameters: Boolean = false,
1023 checkSemantics: Boolean = false,
1024 checkLineNumbers: Boolean = false,
1025 checkRenderNodes: Boolean = true,
1026 density: Density = this.density,
1027 block: TreeValidationReceiver.() -> Unit = {}
1028 ) {
1029 if (DEBUG) {
1030 return
1031 }
1032 val nodes = result.flatMap { flatten(it) }.listIterator()
1033 val tree =
1034 TreeValidationReceiver(
1035 nodes,
1036 density,
1037 checkParameters,
1038 checkSemantics,
1039 checkLineNumbers,
1040 checkRenderNodes,
1041 builder
1042 )
1043 tree.block()
1044 }
1045
1046 private class TreeValidationReceiver(
1047 val nodeIterator: Iterator<InspectorNode>,
1048 val density: Density,
1049 val checkParameters: Boolean,
1050 val checkSemantics: Boolean,
1051 val checkLineNumbers: Boolean,
1052 val checkRenderNodes: Boolean,
1053 val builder: LayoutInspectorTree
1054 ) {
1055 fun node(
1056 name: String,
1057 fileName: String? = null,
1058 lineNumber: Int = -1,
1059 isRenderNode: Boolean = false,
1060 inlined: Boolean = false,
1061 hasViewIdUnder: View? = null,
1062 hasTransformations: Boolean = false,
1063 mergedSemantics: String = "",
1064 unmergedSemantics: String = "",
1065 left: Dp = Dp.Unspecified,
1066 top: Dp = Dp.Unspecified,
1067 width: Dp = Dp.Unspecified,
1068 height: Dp = Dp.Unspecified,
1069 children: List<String> = listOf(),
1070 block: ParameterValidationReceiver.() -> Unit = {}
1071 ) {
1072 assertWithMessage("No such node found: $name").that(nodeIterator.hasNext()).isTrue()
1073 val node = nodeIterator.next()
1074 assertThat(node.name).isEqualTo(name)
1075 assertThat(node.anchorId).isNotEqualTo(UNDEFINED_ID)
1076 val message = "Node: $name"
1077 assertWithMessage(message)
1078 .that(node.children.map { it.name })
1079 .containsExactlyElementsIn(children)
1080 .inOrder()
1081 fileName?.let { assertWithMessage(message).that(node.fileName).isEqualTo(fileName) }
1082 if (lineNumber != -1) {
1083 assertWithMessage(message).that(node.lineNumber).isEqualTo(lineNumber)
1084 }
1085 assertWithMessage(message).that(node.inlined).isEqualTo(inlined)
1086 if (checkRenderNodes) {
1087 if (isRenderNode) {
1088 assertWithMessage(message).that(node.id).isGreaterThan(0L)
1089 } else {
1090 assertWithMessage(message).that(node.id).isLessThan(0L)
1091 }
1092 }
1093 if (hasViewIdUnder != null) {
1094 assertWithMessage(message).that(node.viewId).isGreaterThan(0L)
1095 assertWithMessage(message).that(hasViewIdUnder.hasChild(node.viewId)).isTrue()
1096 } else {
1097 assertWithMessage(message).that(node.viewId).isEqualTo(0L)
1098 }
1099 if (hasTransformations) {
1100 assertWithMessage(message).that(node.bounds).isNotNull()
1101 } else {
1102 assertWithMessage(message).that(node.bounds).isNull()
1103 }
1104 if (left != Dp.Unspecified) {
1105 with(density) {
1106 assertWithMessage(message)
1107 .that(node.left.toDp().value)
1108 .isWithin(2.0f)
1109 .of(left.value)
1110 assertWithMessage(message)
1111 .that(node.top.toDp().value)
1112 .isWithin(2.0f)
1113 .of(top.value)
1114 assertWithMessage(message)
1115 .that(node.width.toDp().value)
1116 .isWithin(2.0f)
1117 .of(width.value)
1118 assertWithMessage(message)
1119 .that(node.height.toDp().value)
1120 .isWithin(2.0f)
1121 .of(height.value)
1122 }
1123 }
1124
1125 if (checkSemantics) {
1126 val merged = node.mergedSemantics.singleOrNull { it.name == "Text" }?.value
1127 assertWithMessage(message).that(merged?.toString() ?: "").isEqualTo(mergedSemantics)
1128 val unmerged = node.unmergedSemantics.singleOrNull { it.name == "Text" }?.value
1129 assertWithMessage(message)
1130 .that(unmerged?.toString() ?: "")
1131 .isEqualTo(unmergedSemantics)
1132 }
1133
1134 if (checkLineNumbers) {
1135 assertThat(node.lineNumber).isEqualTo(lineNumber)
1136 }
1137
1138 if (checkParameters) {
1139 val params =
1140 builder.convertParameters(
1141 ROOT_ID,
1142 node,
1143 ParameterKind.Normal,
1144 MAX_RECURSIONS,
1145 MAX_ITERABLE_SIZE
1146 )
1147 val receiver = ParameterValidationReceiver(params.listIterator())
1148 receiver.block()
1149 receiver.checkFinished(name)
1150 }
1151 }
1152
1153 private fun View.hasChild(id: Long): Boolean {
1154 if (uniqueDrawingId == id) {
1155 return true
1156 }
1157 if (this !is ViewGroup) {
1158 return false
1159 }
1160 for (index in 0..childCount) {
1161 if (getChildAt(index).hasChild(id)) {
1162 return true
1163 }
1164 }
1165 return false
1166 }
1167 }
1168
1169 private fun flatten(node: InspectorNode): List<InspectorNode> =
1170 listOf(node).plus(node.children.flatMap { flatten(it) })
1171
1172 private fun viewParent(view: View): View? = view.parent as? View
1173
1174 private fun show(composable: @Composable () -> Unit) = composeTestRule.setContent(composable)
1175
1176 // region DEBUG print methods
1177 private fun dumpNodes(nodes: List<InspectorNode>, view: View, builder: LayoutInspectorTree) {
1178 @Suppress("ConstantConditionIf")
1179 if (!DEBUG) {
1180 return
1181 }
1182 println()
1183 println("=================== Nodes ==========================")
1184 nodes.forEach { dumpNode(it, indent = 0) }
1185 println()
1186 println("=================== validate statements ==========================")
1187 nodes.forEach { generateValidate(it, view, builder) }
1188 }
1189
1190 private fun dumpNode(node: InspectorNode, indent: Int) {
1191 println(
1192 "\"${" ".repeat(indent * 2)}\", \"${node.name}\", \"${node.fileName}\", " +
1193 "${node.lineNumber}, ${node.left}, ${node.top}, " +
1194 "${node.width}, ${node.height}"
1195 )
1196 node.children.forEach { dumpNode(it, indent + 1) }
1197 }
1198
1199 private fun generateValidate(
1200 node: InspectorNode,
1201 view: View,
1202 builder: LayoutInspectorTree,
1203 generateParameters: Boolean = false
1204 ) {
1205 with(density) {
1206 val left = round(node.left.toDp())
1207 val top = round(node.top.toDp())
1208 val width = if (node.width == view.width) "viewWidth" else round(node.width.toDp())
1209 val height = if (node.height == view.height) "viewHeight" else round(node.height.toDp())
1210
1211 print(
1212 """
1213 validate(
1214 name = "${node.name}",
1215 fileName = "${node.fileName}",
1216 left = $left, top = $top, width = $width, height = $height
1217 """
1218 .trimIndent()
1219 )
1220 }
1221 if (node.id > 0L) {
1222 println(",")
1223 print(" isRenderNode = true")
1224 }
1225 if (node.children.isNotEmpty()) {
1226 println(",")
1227 val children = node.children.joinToString { "\"${it.name}\"" }
1228 print(" children = listOf($children)")
1229 }
1230 println()
1231 print(")")
1232 if (generateParameters && node.parameters.isNotEmpty()) {
1233 generateParameters(
1234 builder.convertParameters(
1235 ROOT_ID,
1236 node,
1237 ParameterKind.Normal,
1238 MAX_RECURSIONS,
1239 MAX_ITERABLE_SIZE
1240 ),
1241 0
1242 )
1243 }
1244 println()
1245 node.children.forEach { generateValidate(it, view, builder) }
1246 }
1247
1248 private fun generateParameters(parameters: List<NodeParameter>, indent: Int) {
1249 val indentation = " ".repeat(indent * 2)
1250 println(" {")
1251 for (param in parameters) {
1252 val name = param.name
1253 val type = param.type
1254 val value = toDisplayValue(type, param.value)
1255 print("$indentation parameter(name = \"$name\", type = $type, value = $value)")
1256 if (param.elements.isNotEmpty()) {
1257 generateParameters(param.elements, indent + 1)
1258 }
1259 println()
1260 }
1261 print("$indentation}")
1262 }
1263
1264 private fun toDisplayValue(type: ParameterType, value: Any?): String =
1265 when (type) {
1266 ParameterType.Boolean -> value.toString()
1267 ParameterType.Color ->
1268 "0x${Integer.toHexString(value as Int)}${if (value < 0) ".toInt()" else ""}"
1269 ParameterType.DimensionSp,
1270 ParameterType.DimensionDp -> "${value}f"
1271 ParameterType.Int32 -> value.toString()
1272 ParameterType.String -> "\"$value\""
1273 else -> value?.toString() ?: "null"
1274 }
1275
1276 private fun dumpSlotTableSet(slotTableRecord: CompositionDataRecord) {
1277 @Suppress("ConstantConditionIf")
1278 if (!DEBUG) {
1279 return
1280 }
1281 println()
1282 println("=================== Groups ==========================")
1283 slotTableRecord.store.forEach { dumpGroup(it.asTree(), indent = 0) }
1284 }
1285
1286 private fun dumpGroup(group: Group, indent: Int) {
1287 val location = group.location
1288 val position = group.position?.let { "\"$it\"" } ?: "null"
1289 val box = group.box
1290 val id =
1291 group.modifierInfo
1292 .mapNotNull { (it.extra as? GraphicLayerInfo)?.layerId }
1293 .singleOrNull() ?: 0
1294 println(
1295 "\"${" ".repeat(indent)}\", ${group.javaClass.simpleName}, \"${group.name}\", " +
1296 "file: ${location?.sourceFile} hash: ${location?.packageHash}, " +
1297 "params: ${group.parameters.size}, children: ${group.children.size}, " +
1298 "$id, $position, " +
1299 "${box.left}, ${box.right}, ${box.right - box.left}, ${box.bottom - box.top}"
1300 )
1301 for (parameter in group.parameters) {
1302 println("\"${" ".repeat(indent + 4)}\"- ${parameter.name}")
1303 }
1304 group.children.forEach { dumpGroup(it, indent + 1) }
1305 }
1306
1307 private fun round(dp: Dp): Dp = Dp((dp.value * 10.0f).roundToInt() / 10.0f)
1308
1309 // endregion
1310 }
1311
1312 /** Storage for the preview generated [CompositionData]s. */
1313 internal interface CompositionDataRecord {
1314 val store: Set<CompositionData>
1315
1316 companion object {
createnull1317 fun create(): CompositionDataRecord = CompositionDataRecordImpl()
1318 }
1319 }
1320
1321 private class CompositionDataRecordImpl : CompositionDataRecord {
1322 @OptIn(InternalComposeApi::class)
1323 override val store: MutableSet<CompositionData> = Collections.newSetFromMap(WeakHashMap())
1324 }
1325
1326 /**
1327 * A wrapper for compositions in inspection mode. The composition inside the Inspectable component
1328 * is in inspection mode.
1329 *
1330 * @param compositionDataRecord [CompositionDataRecord] to record the SlotTable used in the
1331 * composition of [content]
1332 */
1333 @Composable
1334 @OptIn(InternalComposeApi::class)
1335 internal fun Inspectable(
1336 compositionDataRecord: CompositionDataRecord,
1337 content: @Composable () -> Unit
1338 ) {
1339 currentComposer.collectParameterInformation()
1340 val store = (compositionDataRecord as CompositionDataRecordImpl).store
1341 store.add(currentComposer.compositionData)
1342 CompositionLocalProvider(
1343 LocalInspectionMode provides true,
1344 LocalInspectionTables provides store,
1345 content = content
1346 )
1347 }
1348
1349 @Composable
InlineParametersnull1350 fun InlineParameters(size: Dp, fontSize: TextUnit) {
1351 Text("$size $fontSize")
1352 }
1353
LayoutInspectorTreenull1354 fun LayoutInspectorTree.convert(view: View): List<InspectorNode> =
1355 convert(listOf(view))[view.uniqueDrawingId] ?: emptyList()
1356