Skip to content

Instantly share code, notes, and snippets.

@logickoder
Created May 18, 2025 06:59
Show Gist options
  • Save logickoder/4b8ea685074c12e25c82cee176ca5e6f to your computer and use it in GitHub Desktop.
Save logickoder/4b8ea685074c12e25c82cee176ca5e6f to your computer and use it in GitHub Desktop.
Unified View Tree Analyser for Android (Views and Compose)
/**
* Identify this composable as a view
*
* @param tag the view tag, which should uniquely identify this element within the full Composition being rendered.
*/
@Stable
fun Modifier.viewTag(tag: String): Modifier = semantics(
properties = {
viewTagProperty = tag
}
)
import android.content.Context
import android.util.Log
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.SemanticsPropertyKey
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.getOrNull
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import java.lang.reflect.Field
import kotlin.math.roundToInt
private val ViewTagKey = SemanticsPropertyKey<String>("ViewTagKey")
internal var SemanticsPropertyReceiver.viewTagProperty by ViewTagKey
object ViewTreeAnalyser {
private const val ANDROID_COMPOSE_VIEW_CLASS_NAME =
"androidx.compose.ui.platform.AndroidComposeView"
private val semanticsOwnerField: Field? by lazy {
try {
Class.forName(ANDROID_COMPOSE_VIEW_CLASS_NAME)
.getDeclaredField("semanticsOwner")
.apply { isAccessible = true }
} catch (e: Exception) {
Log.e("ViewTreeAnalyser", "Reflection failed: Could not find semanticsOwner field", e)
null
}
}
/**
* Analyses the view tree starting from the root View and generates a JSON representation.
* Includes both traditional Android Views and Compose Views embedded within.
* Reports coordinates relative to the application window.
*
* @param root The root View of the hierarchy to analyse.
* @param screenName A name for the screen being analysed.
* @return A JsonObject representing the view tree.
*/
fun analyseViewRoot(root: View, screenName: String): JsonObject {
val resultJson = JsonObject().apply {
addProperty("name", screenName)
val children = JsonArray()
analyseViewElement(
root,
onElementAnalysed = { children.add(it) }
)
add("children", children)
}
val gson = GsonBuilder().setPrettyPrinting().create()
val formattedJson = gson.toJson(resultJson)
Log.e(
"ViewTreeAnalyser",
"View tree for screen: $screenName\n$formattedJson"
)
return resultJson
}
/**
* Recursively analyses a single View element and its children.
* Reports coordinates relative to the application window.
*
* @param view The current View to analyse.
* @param onElementAnalysed Callback to receive the JsonObject for the analysed element.
*/
private fun analyseViewElement(view: View, onElementAnalysed: (JsonObject) -> Unit) {
// Calculate window-relative position for traditional Views
val locationInWindow = IntArray(2)
view.getLocationInWindow(locationInWindow)
val xInWindow = locationInWindow[0]
val yInWindow = locationInWindow[1]
onElementAnalysed(
JsonObject().apply {
addProperty(
"id", try {
when (view.id) {
View.NO_ID -> "no-id-${view.hashCode()}"
else -> view.resources.getResourceEntryName(view.id)
}
} catch (_: Exception) {
// Handle cases where resource name is not available
"unknown-id-${view.hashCode()}"
}
)
addProperty("type", view.javaClass.simpleName)
addProperty("clickable", view.isClickable)
addProperty("focusable", view.isFocusable)
addProperty(
"visibility", when (view.visibility) {
View.VISIBLE -> "visible"
View.INVISIBLE -> "invisible"
View.GONE -> "gone"
else -> "unknown"
}
)
// Report frame in pixels relative to the application window
add(
"frame",
JsonObject().apply {
addProperty("x", xInWindow)
addProperty("y", yInWindow)
addProperty("width", view.width)
addProperty("height", view.height)
}
)
}
)
// Recursively analyse children for ViewGroups
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
analyseViewElement(view.getChildAt(i), onElementAnalysed)
}
}
// Handle embedded AndroidComposeView
if (view::class.java.name == ANDROID_COMPOSE_VIEW_CLASS_NAME) {
analyseComposeView(view, onElementAnalysed)
}
}
/**
* Analyses the Compose tree within a AndroidComposeView using reflection.
* WARNING: This uses reflection on internal Compose APIs and is highly fragile.
* Reports coordinates relative to the application window.
*
* @param view The AndroidComposeView to analyse.
* @param onElementAnalysed Callback to receive the JsonObject for the analysed element.
*/
private fun analyseComposeView(
view: View,
onElementAnalysed: (JsonObject) -> Unit
) {
@Suppress("TooGenericExceptionCaught")
try {
val semanticsOwner = semanticsOwnerField?.get(view) as? SemanticsOwner
if (semanticsOwner != null) {
// Analyse the root semantics node of the Compose tree
analyseSemanticsNode(
semanticsOwner.rootSemanticsNode,
view.context,
onElementAnalysed
)
} else {
Log.w(
"ViewTreeAnalyser",
"Could not get SemanticsOwner from ComposeView via reflection."
)
}
} catch (ex: Exception) {
// Catching and swallowing exceptions here with the Compose view handling in case
// something changes in the future that breaks the expected structure being accessed
// through reflection here. If anything goes wrong within this block, prefer to continue
// processing the remainder of the view tree as best we can.
Log.e(
"ViewTreeAnalyser",
"Error processing Compose layout via reflection: ${ex.message}"
)
}
}
/**
* Recursively analyses a SemanticsNode and its children.
* Reports coordinates relative to the application window.
*
* @param semanticsNode The current SemanticsNode to analyse.
* @param context The Android Context (needed for density if converting units).
* @param onElementAnalysed Callback to receive the JsonObject for the analysed element.
*/
private fun analyseSemanticsNode(
semanticsNode: SemanticsNode,
context: Context,
onElementAnalysed: (JsonObject) -> Unit
) {
// Use positionInWindow for window-relative coordinates
val xInWindow = semanticsNode.positionInWindow.x.roundToInt()
val yInWindow = semanticsNode.positionInWindow.y.roundToInt()
val widthPx = semanticsNode.size.width
val heightPx = semanticsNode.size.height
onElementAnalysed(
JsonObject().apply {
// Use the viewTagProperty if available, fallback to "no-id" or generated
addProperty(
"id",
semanticsNode.config.getOrNull(ViewTagKey)
?: "no-id-${semanticsNode.hashCode()}"
)
// Get Role if available, otherwise null
addProperty(
"type",
semanticsNode.config.getOrNull(SemanticsProperties.Role)?.toString()
)
// Add other relevant semantics properties if needed, e.g., Text, ContentDescription
addProperty(
"text",
semanticsNode.config.getOrNull(SemanticsProperties.Text)?.joinToString("")
)
addProperty(
"contentDescription",
semanticsNode.config.getOrNull(SemanticsProperties.ContentDescription)
?.joinToString("")
)
// Report frame in pixels relative to the application window
add(
"frame",
JsonObject().apply {
addProperty("x", xInWindow)
addProperty("y", yInWindow)
addProperty("width", widthPx)
addProperty("height", heightPx)
}
)
}
)
// Recursively analyse children SemanticsNodes
semanticsNode.children.forEach { child ->
analyseSemanticsNode(child, context, onElementAnalysed)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment