From b3c4194994efa0b57c2dc827a9ce792e27b8d2b2 Mon Sep 17 00:00:00 2001 From: Moritz Lindner Date: Thu, 21 May 2026 13:18:46 +0200 Subject: [PATCH] update android sample --- .github/workflows/ci.yml | 21 ++ library/build.gradle.kts | 2 +- sample/android/settings.gradle.kts | 6 + .../sample/android/TreeVisualizationScreen.kt | 223 +++++++++++------- 4 files changed, 168 insertions(+), 84 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 325bcae..60ab9ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,24 @@ jobs: - name: Build and test run: ./gradlew :library:check + + samples: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 17 + + - uses: gradle/actions/setup-gradle@v4 + + - name: Build shared sample + run: ./gradlew :sample:assemble + + - name: Build iOS framework + run: ./gradlew :sample:linkDebugFrameworkIosSimulatorArm64 + + - name: Build Android sample + run: cd sample/android && ./gradlew assembleDebug diff --git a/library/build.gradle.kts b/library/build.gradle.kts index abf92b3..c579d69 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } group = "io.github.linde9821" -version = "0.3.0" +version = "0.3.1" kotlin { explicitApi() diff --git a/sample/android/settings.gradle.kts b/sample/android/settings.gradle.kts index e6efcf0..f82b952 100644 --- a/sample/android/settings.gradle.kts +++ b/sample/android/settings.gradle.kts @@ -14,3 +14,9 @@ dependencyResolutionManagement { } rootProject.name = "TreeLayoutKMP-Android-Sample" + +includeBuild("../..") { + dependencySubstitution { + substitute(module("io.github.linde9821:treelayout-kmp")).using(project(":library")) + } +} diff --git a/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt b/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt index a95cd60..38c9e56 100644 --- a/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt +++ b/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt @@ -3,7 +3,7 @@ package io.github.linde9821.treelayout.sample.android import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -31,6 +31,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -45,12 +46,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.github.linde9821.treelayout.NodeExtentProvider import io.github.linde9821.treelayout.Orientation +import io.github.linde9821.treelayout.Point import io.github.linde9821.treelayout.TreeAdapter +import io.github.linde9821.treelayout.radial.angular.DirectAngularPlacementConfiguration +import io.github.linde9821.treelayout.radial.angular.DirectAngularPlacementLayout +import io.github.linde9821.treelayout.radial.walker.RadialWalkerLayoutConfiguration +import io.github.linde9821.treelayout.radial.walker.RadialWalkerTreeLayout import io.github.linde9821.treelayout.walker.WalkerLayoutConfiguration import io.github.linde9821.treelayout.walker.WalkerTreeLayout +import kotlin.math.PI +import kotlin.math.roundToInt private const val DEFAULT_INPUT: String = "Not all those who wander are lost" +private enum class LayoutType { Walker, RadialWalker, DirectAngular } + private class PrefixNode(val label: String, val children: MutableList = mutableListOf()) private fun buildPrefixTree(words: List): PrefixNode { @@ -97,12 +107,17 @@ private fun insertWord(root: PrefixNode, word: String) { @Composable internal fun TreeVisualizationScreen() { var input by remember { mutableStateOf(DEFAULT_INPUT) } + var layoutType by remember { mutableStateOf(LayoutType.Walker) } var horizontalDistance by remember { mutableStateOf(40f) } var verticalDistance by remember { mutableStateOf(60f) } var orientation by remember { mutableStateOf(Orientation.TopToBottom) } var nodePaddingH by remember { mutableStateOf(12f) } var nodePaddingV by remember { mutableStateOf(8f) } + var layerDistance by remember { mutableStateOf(80f) } + var margin by remember { mutableStateOf(0.5f) } + var rotation by remember { mutableStateOf(0f) } var orientationExpanded by remember { mutableStateOf(false) } + var layoutTypeExpanded by remember { mutableStateOf(false) } val words = input.lowercase().split("\\s+".toRegex()) .map { it.filter(Char::isLetter) } @@ -143,13 +158,31 @@ internal fun TreeVisualizationScreen() { (textLayouts[node]?.size?.height?.toFloat() ?: 0f) + nodePaddingV * 2 } - val config = WalkerLayoutConfiguration( - horizontalDistance = horizontalDistance, - verticalDistance = verticalDistance, - orientation = orientation, - ) - val result = WalkerTreeLayout(adapter, config, extents).layout() - val positions = result.getPositions() + val positions: Map = when (layoutType) { + LayoutType.Walker -> { + val config = WalkerLayoutConfiguration( + horizontalDistance = horizontalDistance, + verticalDistance = verticalDistance, + orientation = orientation, + ) + WalkerTreeLayout(adapter, config, extents).layout().getPositions() + } + LayoutType.RadialWalker -> { + val config = RadialWalkerLayoutConfiguration( + layerDistance = layerDistance, + margin = margin, + rotation = rotation, + ) + RadialWalkerTreeLayout(adapter, config, extents).layout().getPositions() + } + LayoutType.DirectAngular -> { + val config = DirectAngularPlacementConfiguration( + layerDistance = layerDistance, + rotation = rotation, + ) + DirectAngularPlacementLayout(adapter, config).layout().getPositions() + } + } val focusManager = LocalFocusManager.current @@ -161,9 +194,7 @@ internal fun TreeVisualizationScreen() { .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null - ) { - focusManager.clearFocus() - } + ) { focusManager.clearFocus() } .padding(16.dp) ) { Column( @@ -173,52 +204,76 @@ internal fun TreeVisualizationScreen() { verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text("Layout Controls", style = MaterialTheme.typography.subtitle1) - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Column(modifier = Modifier.weight(1f)) { - Text("H Distance: ${horizontalDistance.toInt()}", fontSize = 12.sp) - Slider( - value = horizontalDistance, - onValueChange = { horizontalDistance = it }, - valueRange = 0f..200f - ) + + // Layout type selector + Box { + OutlinedButton(onClick = { layoutTypeExpanded = true }) { Text(layoutType.name) } + DropdownMenu(expanded = layoutTypeExpanded, onDismissRequest = { layoutTypeExpanded = false }) { + LayoutType.entries.forEach { lt -> + DropdownMenuItem(onClick = { layoutType = lt; layoutTypeExpanded = false }) { Text(lt.name) } + } } - Column(modifier = Modifier.weight(1f)) { - Text("V Distance: ${verticalDistance.toInt()}", fontSize = 12.sp) - Slider( - value = verticalDistance, - onValueChange = { verticalDistance = it }, - valueRange = 0f..200f - ) + } + + when (layoutType) { + LayoutType.Walker -> { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text("H Distance: ${horizontalDistance.toInt()}", fontSize = 12.sp) + Slider(value = horizontalDistance, onValueChange = { horizontalDistance = it }, valueRange = 0f..200f) + } + Column(modifier = Modifier.weight(1f)) { + Text("V Distance: ${verticalDistance.toInt()}", fontSize = 12.sp) + Slider(value = verticalDistance, onValueChange = { verticalDistance = it }, valueRange = 0f..200f) + } + } + Box { + OutlinedButton(onClick = { orientationExpanded = true }) { Text(orientation.name) } + DropdownMenu(expanded = orientationExpanded, onDismissRequest = { orientationExpanded = false }) { + Orientation.entries.forEach { o -> + DropdownMenuItem(onClick = { orientation = o; orientationExpanded = false }) { Text(o.name) } + } + } + } + } + LayoutType.RadialWalker -> { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text("Layer: ${layerDistance.toInt()}", fontSize = 12.sp) + Slider(value = layerDistance, onValueChange = { layerDistance = it }, valueRange = 10f..200f) + } + Column(modifier = Modifier.weight(1f)) { + Text("Margin: ${(margin * 100).roundToInt() / 100f}", fontSize = 12.sp) + Slider(value = margin, onValueChange = { margin = it }, valueRange = 0f..PI.toFloat()) + } + } + Column { + Text("Rotation: ${(rotation * 100).roundToInt() / 100f}", fontSize = 12.sp) + Slider(value = rotation, onValueChange = { rotation = it }, valueRange = 0f..2f * PI.toFloat()) + } + } + LayoutType.DirectAngular -> { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text("Layer: ${layerDistance.toInt()}", fontSize = 12.sp) + Slider(value = layerDistance, onValueChange = { layerDistance = it }, valueRange = 10f..200f) + } + Column(modifier = Modifier.weight(1f)) { + Text("Rotation: ${(rotation * 100).roundToInt() / 100f}", fontSize = 12.sp) + Slider(value = rotation, onValueChange = { rotation = it }, valueRange = 0f..2f * PI.toFloat()) + } + } } } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Column(modifier = Modifier.weight(1f)) { Text("Padding H: ${nodePaddingH.toInt()}", fontSize = 12.sp) - Slider( - value = nodePaddingH, - onValueChange = { nodePaddingH = it }, - valueRange = 0f..40f - ) + Slider(value = nodePaddingH, onValueChange = { nodePaddingH = it }, valueRange = 0f..40f) } Column(modifier = Modifier.weight(1f)) { Text("Padding V: ${nodePaddingV.toInt()}", fontSize = 12.sp) - Slider( - value = nodePaddingV, - onValueChange = { nodePaddingV = it }, - valueRange = 0f..40f - ) - } - } - Box { - OutlinedButton(onClick = { orientationExpanded = true }) { Text(orientation.name) } - DropdownMenu( - expanded = orientationExpanded, - onDismissRequest = { orientationExpanded = false }) { - Orientation.entries.forEach { o -> - DropdownMenuItem(onClick = { - orientation = o; orientationExpanded = false - }) { Text(o.name) } - } + Slider(value = nodePaddingV, onValueChange = { nodePaddingV = it }, valueRange = 0f..40f) } } } @@ -228,9 +283,7 @@ internal fun TreeVisualizationScreen() { OutlinedTextField( value = input, onValueChange = { input = it }, - modifier = Modifier - .fillMaxWidth() - .height(80.dp), + modifier = Modifier.fillMaxWidth().height(80.dp), label = { Text("Words (space-separated)") }, textStyle = TextStyle(fontSize = 13.sp), ) @@ -238,54 +291,58 @@ internal fun TreeVisualizationScreen() { Divider(modifier = Modifier.padding(vertical = 8.dp)) var panOffset by remember { mutableStateOf(Offset.Zero) } + var zoom by remember { mutableStateOf(1f) } Canvas( modifier = Modifier .fillMaxWidth() .weight(0.6f) + .clipToBounds() .background(Color.White) .pointerInput(Unit) { - detectDragGestures { change, dragAmount -> - change.consume() - panOffset += dragAmount + detectTransformGestures { _, pan, gestureZoom, _ -> + panOffset += pan + zoom = (zoom * gestureZoom).coerceIn(0.1f, 5f) } } ) { val centerX = size.width / 2f + panOffset.x val centerY = size.height / 2f + panOffset.y - positions.forEach { (node, pos) -> - node.children.forEach { child -> - val childPos = positions[child] ?: return@forEach - drawLine( - color = Color.Gray, - start = Offset(pos.x + centerX, pos.y + centerY), - end = Offset(childPos.x + centerX, childPos.y + centerY), - strokeWidth = 2f, - ) + scale(zoom, pivot = Offset(size.width / 2f, size.height / 2f)) { + positions.forEach { (node, pos) -> + node.children.forEach { child -> + val childPos = positions[child] ?: return@forEach + drawLine( + color = Color.Gray, + start = Offset(pos.x + centerX, pos.y + centerY), + end = Offset(childPos.x + centerX, childPos.y + centerY), + strokeWidth = 2f / zoom, + ) + } } - } - positions.forEach { (node, pos) -> - val textLayout = textLayouts[node] ?: return@forEach - val x = pos.x + centerX - val y = pos.y + centerY - val rectW = textLayout.size.width + nodePaddingH * 2 - val rectH = textLayout.size.height + nodePaddingV * 2 + positions.forEach { (node, pos) -> + val textLayout = textLayouts[node] ?: return@forEach + val x = pos.x + centerX + val y = pos.y + centerY + val rectW = textLayout.size.width + nodePaddingH * 2 + val rectH = textLayout.size.height + nodePaddingV * 2 - drawRoundRect( - color = Color(0xFF4CAF50), - topLeft = Offset(x - rectW / 2f, y - rectH / 2f), - size = Size(rectW, rectH), - cornerRadius = CornerRadius(6f, 6f), - ) - drawText( - textLayoutResult = textLayout, - topLeft = Offset( - x - textLayout.size.width / 2f, - y - textLayout.size.height / 2f - ), - ) + drawRoundRect( + color = Color(0xFF4CAF50), + topLeft = Offset(x - rectW / 2f, y - rectH / 2f), + size = Size(rectW, rectH), + cornerRadius = CornerRadius(6f, 6f), + ) + drawText( + textLayoutResult = textLayout, + topLeft = Offset( + x - textLayout.size.width / 2f, + y - textLayout.size.height / 2f, + ), + ) + } } } }