diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Tree.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Tree.kt new file mode 100644 index 00000000..5b439b87 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/Tree.kt @@ -0,0 +1,299 @@ +package com.konyaco.fluent.component + +import androidx.compose.animation.* +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.animation.FluentDuration +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.regular.ChevronDown +import com.konyaco.fluent.icons.regular.ChevronRight +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.VisualState +import com.konyaco.fluent.scheme.VisualStateScheme +import com.konyaco.fluent.scheme.collectVisualState + +// ------------------------------------- +// Data structures and tree builder +// ------------------------------------- + +data class Tree(val roots: List>) { + fun isEmpty() = roots.isEmpty() +} + +sealed class TreeElement { + abstract val data: T + abstract val id: Any + abstract val children: List> + abstract val depth: Int + abstract val onClick: ((Boolean) -> Unit)? + val isLeaf: Boolean get() = children.isEmpty() + + data class Leaf( + override val data: T, + override val id: Any, + override val depth: Int, + override val onClick: ((Boolean) -> Unit)? + ) : TreeElement() { + override val children: List> = emptyList() + } + + data class Node( + override val data: T, + override val id: Any, + override val depth: Int, + override val children: List>, + override val onClick: ((Boolean) -> Unit)? + ) : TreeElement() +} + +fun buildTree(block: TreeBuilder.() -> Unit): Tree { + val builder = TreeBuilder() + builder.block() + return Tree(builder.buildRoots()) +} + +class TreeBuilder { + private val elements = mutableListOf>() + + fun node(data: T, id: Any = data.toString(), onClick: ((Boolean) -> Unit)? = null, children: NodeBuilder.() -> Unit) { + val childBuilder = NodeBuilder(0) + childBuilder.children() + elements.add(BuilderElement.Node(data, id, onClick, childBuilder.elements, 0)) + } + + fun leaf(data: T, id: Any = data.toString(), onClick: ((Boolean) -> Unit)? = null) { + elements.add(BuilderElement.Leaf(data, id, onClick, 0)) + } + + internal fun buildRoots(): List> = elements.map { it.buildElement(depth = 0) } + + sealed class BuilderElement { + abstract fun buildElement(depth: Int): TreeElement + + data class Leaf(val data: T, val id: Any, val onClick: ((Boolean) -> Unit)?, val depthInit: Int) : BuilderElement() { + override fun buildElement(depth: Int): TreeElement { + return TreeElement.Leaf(data, id, depth, onClick) + } + } + + data class Node( + val data: T, + val id: Any, + val onClick: ((Boolean) -> Unit)?, + val children: List>, + val depthInit: Int + ) : BuilderElement() { + override fun buildElement(depth: Int): TreeElement { + val builtChildren = children.map { it.buildElement(depth + 1) } + return TreeElement.Node(data, id, depth, builtChildren, onClick) + } + } + } +} + +class NodeBuilder(private val parentDepth: Int) { + internal val elements = mutableListOf>() + + fun node(data: T, id: Any = data.toString(), onClick: ((Boolean) -> Unit)? = null, children: NodeBuilder.() -> Unit) { + val childBuilder = NodeBuilder(parentDepth + 1) + childBuilder.children() + elements.add(TreeBuilder.BuilderElement.Node(data, id, onClick, childBuilder.elements, parentDepth + 1)) + } + + fun leaf(data: T, id: Any = data.toString(), onClick: ((Boolean) -> Unit)? = null) { + elements.add(TreeBuilder.BuilderElement.Leaf(data, id, onClick, parentDepth + 1)) + } +} + +// ------------------------------------- +// Tree Node Color Scheme +// ------------------------------------- + +@Immutable +data class TreeNodeColor( + val backgroundColor: Color, + val contentColor: Color, + val borderColor: Color, + val labelTextColor: Color +) + +typealias TreeNodeColorScheme = PentaVisualScheme + +object TreeNodeDefaults { + @Stable + @Composable + fun defaultNodeColors( + default: TreeNodeColor = TreeNodeColor( + backgroundColor = Color.Transparent, + contentColor = FluentTheme.colors.text.onAccent.primary, + borderColor = FluentTheme.colors.controlStrong.default, + labelTextColor = FluentTheme.colors.text.text.primary + ), + hovered: TreeNodeColor = TreeNodeColor( + backgroundColor = FluentTheme.colors.controlAlt.tertiary, + contentColor = FluentTheme.colors.text.onAccent.primary, + borderColor = FluentTheme.colors.controlStrong.default, + labelTextColor = FluentTheme.colors.text.text.primary + ), + pressed: TreeNodeColor = TreeNodeColor( + backgroundColor = FluentTheme.colors.controlAlt.quaternary, + contentColor = FluentTheme.colors.text.onAccent.secondary, + borderColor = FluentTheme.colors.controlStrong.default, + labelTextColor = FluentTheme.colors.text.text.primary + ), + disabled: TreeNodeColor = TreeNodeColor( + backgroundColor = FluentTheme.colors.controlAlt.disabled, + contentColor = FluentTheme.colors.text.onAccent.disabled, + borderColor = FluentTheme.colors.controlStrong.disabled, + labelTextColor = FluentTheme.colors.text.text.disabled + ) + ) = TreeNodeColorScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) +} + +@Composable +fun TreeNodeColorScheme.schemeFor(state: VisualState): TreeNodeColor { + return when (state) { + VisualState.Default -> default + VisualState.Hovered -> hovered + VisualState.Pressed -> pressed + VisualState.Disabled -> disabled + VisualState.Focused -> focused + } +} + +// ------------------------------------- +// Tree composables +// ------------------------------------- + +@Composable +fun TreeView( + tree: Tree, + modifier: Modifier = Modifier, + nodeColors: VisualStateScheme = TreeNodeDefaults.defaultNodeColors() +) { + Column(modifier) { + tree.roots.forEach { element -> + TreeElementView(element, nodeColors) + } + } +} + +@Composable +fun TreeElementView( + element: TreeElement, + colors: VisualStateScheme +) { + val isNode = element is TreeElement.Node + val expandedState = if (isNode) remember { mutableStateOf(false) } else null + + TreeNodeView( + element = element, + colors = colors, + onClick = { + if (isNode) expandedState!!.value = !expandedState.value + element.onClick?.invoke(expandedState?.value ?: false) + } + ) + + if (isNode) { + val node = element as TreeElement.Node + AnimatedVisibility( + visible = expandedState!!.value, + enter = fadeIn() + expandVertically( + animationSpec = tween( + durationMillis = FluentDuration.QuickDuration, + easing = EaseInOut + ) + ), + exit = fadeOut() + shrinkVertically( + animationSpec = tween( + durationMillis = FluentDuration.QuickDuration, + easing = EaseInOut + ) + ) + ) { + Column { + node.children.forEach { child -> + TreeElementView(child, colors) + } + } + } + } +} + +@Composable +fun TreeNodeView( + element: TreeElement, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: VisualStateScheme, + onClick: (() -> Unit)? = null +) { + val interactionSource = remember { MutableInteractionSource() } + val visualState = interactionSource.collectVisualState(!enabled) + val color = colors.schemeFor(visualState) + + val indentation = (element.depth * 16).dp + val isNode = element is TreeElement.Node && element.children.isNotEmpty() + + val expandedState = if (isNode) remember { mutableStateOf(false) } else null + + Row( + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 32.dp) + .clip(RoundedCornerShape(8.dp)) + .hoverable(interactionSource) + .background(if (visualState == VisualState.Hovered) color.backgroundColor else Color.Transparent) + .clickable( + enabled = enabled, + role = Role.Button, + indication = null, + interactionSource = interactionSource + ) { + expandedState?.value = !(expandedState?.value ?: false) + onClick?.invoke() + }, + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(Modifier.padding(start = 4.dp).width(indentation)) + + val icon = when { + isNode -> if (expandedState?.value == true) Icons.Regular.ChevronDown else Icons.Regular.ChevronRight + else -> null + } + + if (icon != null) { + Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) { + Icon(imageVector = icon, contentDescription = null) + } + } else { + Spacer(Modifier.size(20.dp)) + } + + Spacer(Modifier.width(8.dp)) + Text( + text = element.data.toString(), + style = FluentTheme.typography.body.copy(color = color.labelTextColor) + ) + } +} diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/navigation/TreeViewScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/navigation/TreeViewScreen.kt new file mode 100644 index 00000000..2f3b8508 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/navigation/TreeViewScreen.kt @@ -0,0 +1,69 @@ +package com.konyaco.fluent.gallery.screen.navigation + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.konyaco.fluent.component.TreeView +import com.konyaco.fluent.component.buildTree +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile + + +@Component( + index = 2, + description = "A control that displays a hierarchical structure of items that can be expanded or collapsed." +) +@Composable +fun TreeViewScreen() { + GalleryPage( + title = "TreeView", + description = "A TreeView provides a hierarchical structure of items that can be expanded or collapsed.", + componentPath = FluentSourceFile.Tree, + galleryPath = ComponentPagePath.TreeViewScreen + ) { + Section( + title = "Basic TreeView", + sourceCode = sourceCodeOfTreeViewSample, + content = { TreeViewSample() } + ) + } +} + + +@Sample +@Composable +fun TreeViewSample() { + val tree = remember { + buildTree { + node(data = "Root", onClick = { isExpanded -> + println("Root clicked, expanded: $isExpanded") + }) { + node("Folder 1", onClick = { isExpanded -> + println("Folder 1 clicked, expanded: $isExpanded") + }) { + leaf(data = "File 1-1", onClick = { _ -> println("File 1-1 clicked") }) + leaf("File 1-2") + node("Folder 1-3", onClick = { isExpanded -> + println("Folder 1-3 clicked, expanded: $isExpanded") + }) { + leaf("File 1-3-1") + } + } + node("Folder 2", onClick = { isExpanded -> + println("Folder 2 clicked, expanded: $isExpanded") + }) { + leaf("File 2-1") + } + } + } + } + TreeView( + tree = tree, + modifier = Modifier.fillMaxWidth() + ) +} +