diff --git a/sample-compose/build.gradle.kts b/sample-compose/build.gradle.kts index 9a8b000..f35e13c 100644 --- a/sample-compose/build.gradle.kts +++ b/sample-compose/build.gradle.kts @@ -55,6 +55,7 @@ tasks.withType { dependencies { implementation(project(":radiography")) + implementation(project(":slot-table-inspector")) implementation(Dependencies.AppCompat) implementation(Dependencies.Compose(sampleComposeVersion).Activity()) implementation(Dependencies.Compose(sampleComposeVersion).Material) diff --git a/sample-compose/src/main/java/com/squareup/radiography/sample/compose/ComposeSampleApp.kt b/sample-compose/src/main/java/com/squareup/radiography/sample/compose/ComposeSampleApp.kt index 96b938a..1b50893 100644 --- a/sample-compose/src/main/java/com/squareup/radiography/sample/compose/ComposeSampleApp.kt +++ b/sample-compose/src/main/java/com/squareup/radiography/sample/compose/ComposeSampleApp.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Checkbox import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.TextField @@ -31,15 +32,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import radiography.ExperimentalRadiographyComposeApi import radiography.Radiography import radiography.ScanScopes.FocusedWindowScope @@ -50,6 +55,9 @@ import radiography.ViewStateRenderers.DefaultsNoPii import radiography.ViewStateRenderers.ViewRenderer import radiography.ViewStateRenderers.androidViewStateRendererFor import radiography.ViewStateRenderers.textViewRenderer +import radiography.compose.slottable.SlotTableInspectable +import radiography.compose.slottable.SlotTableInspector +import radiography.compose.slottable.SlotTableInspectorState internal const val TEXT_FIELD_TEST_TAG = "text-field" internal const val LIVE_HIERARCHY_TEST_TAG = "live-hierarchy" @@ -59,84 +67,115 @@ internal const val LIVE_HIERARCHY_TEST_TAG = "live-hierarchy" ComposeSampleApp() } -@OptIn(ExperimentalRadiographyComposeApi::class, ExperimentalAnimationApi::class) +@OptIn( + ExperimentalRadiographyComposeApi::class, + ExperimentalAnimationApi::class, + ExperimentalComposeUiApi::class, +) @Composable fun ComposeSampleApp() { val context = LocalContext.current val liveHierarchy = remember { mutableStateOf(null) } + val slotTableInspectorState = remember { SlotTableInspectorState() } + var showSlotTableInspector by remember { mutableStateOf(false) } var username by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var rememberMe by remember { mutableStateOf(false) } - MaterialTheme { - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - RadiographyLogo(Modifier.height(128.dp)) + SlotTableInspectable(slotTableInspectorState) { + MaterialTheme { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + RadiographyLogo(Modifier.height(128.dp)) - TextField( - value = username, - onValueChange = { username = it }, - label = { Text("Username") }, - colors = TextFieldDefaults.outlinedTextFieldColors(), - modifier = Modifier.testTag(TEXT_FIELD_TEST_TAG) - ) - TextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password") }, - colors = TextFieldDefaults.outlinedTextFieldColors(), - visualTransformation = PasswordVisualTransformation() - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = rememberMe, onCheckedChange = { rememberMe = it }) - Spacer(Modifier.width(8.dp)) - Text("Remember me") - } - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - TextButton(onClick = {}) { - Text("SIGN IN") + TextField( + value = username, + onValueChange = { username = it }, + label = { Text("Username") }, + colors = TextFieldDefaults.outlinedTextFieldColors(), + modifier = Modifier.testTag(TEXT_FIELD_TEST_TAG) + ) + TextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + colors = TextFieldDefaults.outlinedTextFieldColors(), + visualTransformation = PasswordVisualTransformation() + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = rememberMe, onCheckedChange = { rememberMe = it }) + Spacer(Modifier.width(8.dp)) + Text("Remember me") } - TextButton(onClick = {}) { - Text("FORGOT PASSWORD") + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + TextButton(onClick = {}) { + Text("SIGN IN") + } + TextButton(onClick = {}) { + Text("FORGOT PASSWORD") + } } - } - // Include a classic Android view in the composition. - AndroidView(::TextView) { - @SuppressLint("SetTextI18n") - it.text = "By signing in, you agree to our Terms and Conditions." - } + // Include a classic Android view in the composition. + AndroidView(::TextView) { + @SuppressLint("SetTextI18n") + it.text = "By signing in, you agree to our Terms and Conditions." + } - liveHierarchy.value?.let { - Row( - modifier = Modifier - .horizontalScroll(rememberScrollState()) - .weight(1f) - ) { - Column(Modifier.verticalScroll(rememberScrollState())) { - Text( - liveHierarchy.value.orEmpty(), - fontFamily = FontFamily.Monospace, - fontSize = 6.sp, - modifier = Modifier.testTag(LIVE_HIERARCHY_TEST_TAG) - ) + liveHierarchy.value?.let { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .weight(1f) + ) { + Column(Modifier.verticalScroll(rememberScrollState())) { + Text( + liveHierarchy.value.orEmpty(), + fontFamily = FontFamily.Monospace, + fontSize = 6.sp, + modifier = Modifier.testTag(LIVE_HIERARCHY_TEST_TAG) + ) + } + } + } + Row { + TextButton( + modifier = Modifier.weight(1f), + onClick = { showSelectionDialog(context) } + ) { + Text("SHOW STRING RENDERING DIALOG", textAlign = TextAlign.Center) + } + TextButton( + modifier = Modifier.weight(1f), + onClick = { + slotTableInspectorState.captureSlotTables() + showSlotTableInspector = true + }) { + Text("SHOW SLOT TABLE INSPECTOR", textAlign = TextAlign.Center) } } - } - TextButton(onClick = { showSelectionDialog(context) }) { - Text("SHOW STRING RENDERING DIALOG") - } - SideEffect { - liveHierarchy.value = Radiography.scan( - viewStateRenderers = DefaultsIncludingPii, - // Don't trigger infinite recursion. - viewFilter = skipComposeTestTagsFilter(LIVE_HIERARCHY_TEST_TAG) - ) + SideEffect { + liveHierarchy.value = Radiography.scan( + viewStateRenderers = DefaultsIncludingPii, + // Don't trigger infinite recursion. + viewFilter = skipComposeTestTagsFilter(LIVE_HIERARCHY_TEST_TAG) + ) + } + if (showSlotTableInspector) { + Dialog( + onDismissRequest = { showSlotTableInspector = false }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface { + SlotTableInspector(slotTableInspectorState) + } + } + } } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 428c510..583be4b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,5 +20,6 @@ include( ":compose-unsupported-tests", ":radiography", ":sample", - ":sample-compose" + ":sample-compose", + ":slot-table-inspector", ) diff --git a/slot-table-inspector/api/slot-table-inspector.api b/slot-table-inspector/api/slot-table-inspector.api new file mode 100644 index 0000000..838e860 --- /dev/null +++ b/slot-table-inspector/api/slot-table-inspector.api @@ -0,0 +1,11 @@ +public final class radiography/compose/slottable/SlotTableInspectorKt { + public static final fun SlotTableInspectable (Lradiography/compose/slottable/SlotTableInspectorState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun SlotTableInspector (Lradiography/compose/slottable/SlotTableInspectorState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class radiography/compose/slottable/SlotTableInspectorState { + public static final field $stable I + public fun ()V + public final fun captureSlotTables ()V +} + diff --git a/slot-table-inspector/build.gradle.kts b/slot-table-inspector/build.gradle.kts new file mode 100644 index 0000000..8e6e22a --- /dev/null +++ b/slot-table-inspector/build.gradle.kts @@ -0,0 +1,71 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("com.android.library") + kotlin("android") + id("com.vanniktech.maven.publish") +} + +android { + compileSdk = 30 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + defaultConfig { + // Compose minSdk is also 21. + minSdk = 21 + targetSdk = 30 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + buildConfig = false + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.Compose + } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs += listOfNotNull( + "-Xopt-in=kotlin.RequiresOptIn", + + // Require explicit public modifiers and types. + // TODO this should be moved to a top-level `kotlin { explicitApi() }` once that's working + // for android projects, see https://youtrack.jetbrains.com/issue/KT-37652. + "-Xexplicit-api=strict".takeUnless { + // Tests aren't part of the public API, don't turn explicit API mode on for them. + name.contains("test", ignoreCase = true) + } + ) + } +} + +dependencies { + implementation(Dependencies.Compose().Material) + // implementation(Dependencies.Compose().ToolingData) + implementation(Dependencies.Compose().Tooling) + + testImplementation(Dependencies.JUnit) + testImplementation(Dependencies.Mockito) + testImplementation(Dependencies.Robolectric) + testImplementation(Dependencies.Truth) + + androidTestImplementation(Dependencies.Compose().Testing) + androidTestImplementation(Dependencies.InstrumentationTests.Core) + androidTestImplementation(Dependencies.InstrumentationTests.Espresso) + androidTestImplementation(Dependencies.InstrumentationTests.Rules) + androidTestImplementation(Dependencies.InstrumentationTests.Runner) + androidTestImplementation(Dependencies.Truth) + androidTestUtil(Dependencies.InstrumentationTests.Orchestrator) +} diff --git a/slot-table-inspector/gradle.properties b/slot-table-inspector/gradle.properties new file mode 100644 index 0000000..fdce78b --- /dev/null +++ b/slot-table-inspector/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=slot-table-inspector +POM_NAME=Radiography slot table inspector +POM_PACKAGING=aar diff --git a/slot-table-inspector/src/androidTest/AndroidManifest.xml b/slot-table-inspector/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..e37ea8f --- /dev/null +++ b/slot-table-inspector/src/androidTest/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/slot-table-inspector/src/main/AndroidManifest.xml b/slot-table-inspector/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d7524bc --- /dev/null +++ b/slot-table-inspector/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/slot-table-inspector/src/main/java/radiography/compose/slottable/SlotTableInspector.kt b/slot-table-inspector/src/main/java/radiography/compose/slottable/SlotTableInspector.kt new file mode 100644 index 0000000..6c305b2 --- /dev/null +++ b/slot-table-inspector/src/main/java/radiography/compose/slottable/SlotTableInspector.kt @@ -0,0 +1,355 @@ +@file:OptIn( + UiToolingDataApi::class, + InternalComposeApi::class +) + +package radiography.compose.slottable + +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composer +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.data.Group +import androidx.compose.ui.tooling.data.ParameterInformation +import androidx.compose.ui.tooling.data.UiToolingDataApi +import androidx.compose.ui.tooling.data.asTree +import androidx.compose.ui.tooling.data.position +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.util.Objects + +/** + * State shared between [SlotTableInspectable]s, which define the content whose slot table to + * inspect, and [SlotTableInspector]s, which actually display the slot table contents. + * + * To update a [SlotTableInspector] with new data, for example after changing some state, call + * [captureSlotTables]. If you do not call this method, [SlotTableInspector] will call it the first + * time it's composed. + * + * E.g.: + * ``` + * @Composable fun App() { + * val inspectorState = remember { SlotTableInspectorState() } + * var showInspector by remember { mutableStateOf(false) } + * + * SlotTableInspectable(inspectorState) { + * Column { + * Button(onClick = { + * inspectorState.captureSlotTables() + * showInspector = true + * }) { + * Text("Inspect") + * } + * } + * } + * + * if (showInspector) { + * Dialog { + * SlotTableInspector(inspectorState) + * } + * } + * } + * ``` + */ +public class SlotTableInspectorState { + + internal val composers: MutableList> = mutableStateListOf() + private var rootGroups: List by mutableStateOf(emptyList()) + + internal val rootTreeItems: List by derivedStateOf { + rootGroups.map { it.toTreeItem() } + } + + /** + * Reads fresh slot table data from all [SlotTableInspector]s registered with this state. + */ + public fun captureSlotTables() { + rootGroups = composers.mapNotNull { + it.value?.compositionData?.asTree() + } + } +} + +/** + * Defines some content to be inspected by a [SlotTableInspector]. The same + * [SlotTableInspectorState] can be passed to multiple occurances of this function, and they will + * each show as separate root groups in the [SlotTableInspector]. + */ +@Composable public fun SlotTableInspectable( + state: SlotTableInspectorState, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val composer: MutableState = remember { mutableStateOf(null) } + DisposableEffect(state) { + + state.composers += composer + onDispose { + state.composers -= composer + } + } + + // We don't need to actually do any layout. We introduce a subcomposition so that the content gets + // its own Composer, and thus its own slot table, to scope the nodes shown in the inspector. + SubcomposeLayout(modifier) { constraints -> + val placeables = subcompose(Unit) { + // Take the composer from the subcomposition. + composer.value = currentComposer + content() + }.map { it.measure(constraints) } + + layout( + width = placeables.maxOf { it.width }, + height = placeables.maxOf { it.height } + ) { + placeables.forEach { it.placeRelative(IntOffset.Zero) } + } + } +} + +/** + * Displays an interactive tree view of the slot table inside all the [SlotTableInspectable]s + * to which [state] has been passed. + */ +@Composable public fun SlotTableInspector( + state: SlotTableInspectorState, + modifier: Modifier = Modifier +) { + DisposableEffect(Unit) { + // If we're called without any slot tables, we probably just need to perform the initial + // capture. + if (state.rootTreeItems.isEmpty()) { + state.captureSlotTables() + } + onDispose {} + } + + if (state.rootTreeItems.isEmpty()) { + Text("No slot tables captured.", modifier.wrapContentSize()) + } else { + TreeBrowser( + items = state.rootTreeItems, + modifier = modifier.fillMaxSize() + ) + } +} + +@OptIn(ExperimentalStdlibApi::class) +private fun Group.toTreeItem(): TreeItem { + val id = Objects.hash(this.key, this.data, this.location, this.name).toString() + return TreeItem( + id = id, + computeChildren = { + val items = mutableListOf() + + key?.let { key -> + val locationString = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append("Key: ") + } + withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + append(key.toString()) + } + } + items += TreeItem("$id-key") { + Text( + locationString, + fontSize = 12.sp, + modifier = Modifier.alpha(.7f) + ) + } + } + + location?.let { location -> + val locationString = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append("Location: ") + } + withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + if (location.sourceFile.isNullOrEmpty()) { + append(location.toString()) + } else { + append("${location.sourceFile}:${location.lineNumber}") + } + } + } + items += TreeItem("$id-location") { + Text( + locationString, + fontSize = 12.sp, + modifier = Modifier.alpha(.7f) + ) + } + } + + this.position?.let { position -> + val locationString = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append("Position: ") + } + withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + append(position) + } + } + items += TreeItem("$id-position") { + Text( + locationString, + fontSize = 12.sp, + modifier = Modifier.alpha(.7f) + ) + } + } + + this.parameters.takeUnless { it.isEmpty() }?.let { params -> + items += TreeItem( + id = "$id-parameters", + computeChildren = { + params.mapIndexed { index, param -> + TreeItem("$id-parameters[$index]") { + ParameterRow(param) + } + } + } + ) { + Text("${parameters.size} Parameters") + } + } + + this.modifierInfo.takeUnless { it.isEmpty() }?.let { modifiers -> + items += TreeItem( + id = "$id-modifiers", + computeChildren = { + modifiers.mapIndexed { index, modifier -> + TreeItem("$id-modifiers[$index]") { + Text(modifier.toString(), fontFamily = FontFamily.Monospace) + } + } + } + ) { + Text("${modifierInfo.size} Modifiers") + } + } + + this.data.takeUnless { it.isEmpty() }?.let { data -> + items += TreeItem( + id = "$id-data", + computeChildren = { + data.mapIndexed { index, datum -> + TreeItem("$id-data[$index]") { + Text( + datum.toString(), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + } + ) { + Text("${data.size} Data") + } + } + + children.takeUnless { it.isEmpty() }?.let { children -> + items += TreeItem( + id = "$id-groups", + computeChildren = { + buildList { + children.mapTo(this) { it.toTreeItem() } + } + } + ) { + Text("${children.size} Groups") + } + } + + return@TreeItem items + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = spacedBy(8.dp), + ) { + Text( + this@toTreeItem.javaClass.simpleName, + fontStyle = FontStyle.Italic, + modifier = Modifier.alignByBaseline() + ) + name?.let { + Text( + it, + fontWeight = FontWeight.Medium, + modifier = Modifier.alignByBaseline() + ) + } + Text( + "[${box.width}x${box.height}]", + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + modifier = Modifier + .alpha(.7f) + .alignByBaseline() + ) + } + } +} + +@Composable private fun ParameterRow(param: ParameterInformation) { + Row(horizontalArrangement = spacedBy(4.dp)) { + Text( + "${param.name}=${param.value}", + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + modifier = Modifier.alignByBaseline() + ) + + @Composable fun Flag(text: String) { + Text( + text, + fontSize = 10.sp, + fontWeight = FontWeight.Light, + modifier = Modifier.alignByBaseline() + ) + } + + if (param.fromDefault) { + Flag("fromDefault") + } + if (param.compared) { + Flag("compared") + } + if (param.stable) { + Flag("stable") + } + if (param.static) { + Flag("static") + } + param.inlineClass?.let { + Flag("inlineClass=$it") + } + } +} diff --git a/slot-table-inspector/src/main/java/radiography/compose/slottable/TreeBrowser.kt b/slot-table-inspector/src/main/java/radiography/compose/slottable/TreeBrowser.kt new file mode 100644 index 0000000..5e1ae20 --- /dev/null +++ b/slot-table-inspector/src/main/java/radiography/compose/slottable/TreeBrowser.kt @@ -0,0 +1,201 @@ +package radiography.compose.slottable + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp + +/** + * Represents a single row in a [TreeBrowser] that may have children. + * + * @param id An ID for the item that is unique across the entire tree. + * @param computeChildren Function that returns the children for this item. Defaults to returning + * an empty list. The function does not need to perform any caching itself, as long as any mutable + * data it uses to derive the list of children is stored in snapshot state. + * @param content The content of the row. + */ +internal class TreeItem( + val id: String, + private val computeChildren: () -> List = ::emptyList, + val content: @Composable RowScope.() -> Unit, +) { + var isExpanded: Boolean by mutableStateOf(false) + val hasChildren: Boolean by derivedStateOf { computeChildren().isNotEmpty() } + val children: List by derivedStateOf { + if (isExpanded) computeChildren() else emptyList() + } +} + +/** + * A vertical list of rows, where each row maybe have children and be expanded and collapsed. + */ +@Composable internal fun TreeBrowser( + items: List, + modifier: Modifier = Modifier +) { + val updatedItems by rememberUpdatedState(items) + val flattenedTree by derivedStateOf { + updatedItems.flatMap { it.flatten() } + } + + BoxWithConstraints { + val screenHeight = maxHeight + + Column( + modifier + .verticalScroll(rememberScrollState()) + .horizontalScroll(rememberScrollState()) + .width(IntrinsicSize.Max) + ) { + flattenedTree.forEach { item -> + key(item.item.id + "-" + item.nestingLevel) { + TreeRow(item) + } + } + + // Add some space at the end to allow scrolling the last item up a little bit. + Spacer(Modifier.height(screenHeight / 2)) + } + } +} + +@Preview +@Composable +private fun TreeBrowserPreview() { + fun TreeItem(text: String, vararg children: TreeItem) = TreeItem( + id = text, + computeChildren = { children.asList() }, + content = { Text(text) } + ) + + val tree = remember { + listOf( + TreeItem( + "root1", + TreeItem("child 1"), + TreeItem( + "child 2", + TreeItem( + "foo really long name that hopefully should wrap at least in portrait mode on a phone", + TreeItem("bar") + ), + TreeItem(" baz"), + ), + ), + TreeItem("root2") + ) + } + + TreeBrowser(tree) +} + +@Composable private fun TreeRow(item: FlattenedTreeItem) { + val toggleSize = 36.dp + val toggleableModifier = if (item.item.hasChildren) { + Modifier.toggleable( + value = item.item.isExpanded, + onValueChange = { item.item.isExpanded = it } + ) + } else Modifier + + Row( + Modifier + .animateHeightFromZero() + .fillMaxWidth() + .then(toggleableModifier) + .padding( + start = toggleSize * item.nestingLevel, + top = 4.dp, + bottom = 4.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + if (item.item.hasChildren) { + val isExpanded = item.item.isExpanded + val iconAngle by animateFloatAsState( + targetValue = if (isExpanded) 0f else -90f + ) + Icon( + Icons.Default.ArrowDropDown, + contentDescription = if (isExpanded) "Expand" else "Collapse", + modifier = Modifier + .size(toggleSize) + .wrapContentSize() + .rotate(iconAngle) + ) + } else { + Spacer(Modifier.width(toggleSize)) + } + item.item.content(this) + } +} + +/** + * Returns a list of [FlattenedTreeItem] that is the depth-first traversal of the nodes starting + * at this [TreeItem]. + */ +private fun TreeItem.flatten(nestingLevel: Int = 0): Sequence { + val root = FlattenedTreeItem(this, nestingLevel) + val children = children.asSequence().flatMap { + it.flatten(nestingLevel = nestingLevel + 1) + } + return sequenceOf(root) + children +} + +private fun Modifier.animateHeightFromZero(): Modifier = composed { + val yScale = remember { Animatable(0f) } + LaunchedEffect(Unit) { + yScale.animateTo(1f) + } + Modifier + .graphicsLayer { + scaleY = yScale.value + } + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, (placeable.height * yScale.value).toInt()) { + placeable.placeRelative(IntOffset.Zero) + } + } +} + +private data class FlattenedTreeItem( + val item: TreeItem, + val nestingLevel: Int, +)