From 0969da46a84b34c677bada339c4f3e90bb0a3463 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Sun, 20 Oct 2024 21:08:59 -0400 Subject: [PATCH 1/3] Update docs for StackNav and MultiStackNav --- .../com/tunjid/treenav/MultiStackNav.kt | 5 + .../kotlin/com/tunjid/treenav/StackNav.kt | 3 + .../commonTest/kotlin/MultiStackNavTest.kt | 57 ++++++++-- .../src/commonTest/kotlin/StackNavTest.kt | 16 +++ .../com/tunjid/demo/common/ui/DemoApp.kt | 104 +++++++++++++----- .../tunjid/demo/common/ui/SegmentedLayout.kt | 14 ++- 6 files changed, 163 insertions(+), 36 deletions(-) diff --git a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt index 72ce554..141ff2d 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt @@ -66,6 +66,11 @@ fun MultiStackNav.switch(toIndex: Int): MultiStackNav = copy( indexHistory = (indexHistory - toIndex) + toIndex ) +/** + * Pops every node in the [StackNav] at [indexToPop] up until the last one. + * + * @see StackNav.popToRoot + */ fun MultiStackNav.popToRoot(indexToPop: Int = currentIndex) = copy( stacks = stacks.mapIndexed { index: Int, stackNav: StackNav -> if (index == indexToPop) stackNav.popToRoot() diff --git a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt index 7e0f6cc..6efef3d 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt @@ -48,6 +48,9 @@ fun StackNav.pop(popLast: Boolean = false) = when { else -> copy(children = children.dropLast(1)) } +/** + * Pops every node in this [StackNav] up until the last one. + */ fun StackNav.popToRoot() = copy( children = children.take(1) ) diff --git a/library/treenav/src/commonTest/kotlin/MultiStackNavTest.kt b/library/treenav/src/commonTest/kotlin/MultiStackNavTest.kt index 98f9489..f330303 100644 --- a/library/treenav/src/commonTest/kotlin/MultiStackNavTest.kt +++ b/library/treenav/src/commonTest/kotlin/MultiStackNavTest.kt @@ -67,17 +67,17 @@ class MultiStackNavTest { children = listOf("A", "B", "C").map(::TestNode) ) ) - + listOf("A", "B", "C").map(::TestNode) - + StackNav( + + listOf("A", "B", "C").map(::TestNode) + + StackNav( name = "1", children = listOf("F").map(::TestNode) ) - + listOf("F").map(::TestNode) - + StackNav( + + listOf("F").map(::TestNode) + + StackNav( name = "2", children = listOf("D", "E").map(::TestNode) ) - + listOf("D", "E").map(::TestNode), + + listOf("D", "E").map(::TestNode), actual = pushed.flatten(order = Order.DepthFirst) ) } @@ -110,9 +110,9 @@ class MultiStackNavTest { children = listOf("D", "E").map(::TestNode) ) ) - + listOf("A", "B", "C").map(::TestNode) - + listOf("F").map(::TestNode) - + listOf("D", "E").map(::TestNode), + + listOf("A", "B", "C").map(::TestNode) + + listOf("F").map(::TestNode) + + listOf("D", "E").map(::TestNode), actual = pushed.flatten(order = Order.BreadthFirst) ) } @@ -267,4 +267,45 @@ class MultiStackNavTest { .toSet() ) } + + @Test + fun testPoppingToRoot() { + val multiPush = subject + .push(TestNode(name = "A")) + .push(TestNode(name = "B")) + .push(TestNode(name = "C")) + .copy(currentIndex = 1) + .push(TestNode(name = "D")) + .push(TestNode(name = "E")) + .push(TestNode(name = "F")) + + assertEquals( + expected = setOf( + TestNode(name = "A"), + TestNode(name = "B"), + TestNode(name = "C"), + TestNode(name = "D"), + ), + actual = multiPush + .popToRoot() + .minus(subject) + .filterIsInstance() + .toSet() + ) + + assertEquals( + expected = setOf( + TestNode(name = "A"), + TestNode(name = "D"), + TestNode(name = "E"), + TestNode(name = "F"), + ), + actual = multiPush + .copy(currentIndex = 0) + .popToRoot() + .minus(subject) + .filterIsInstance() + .toSet() + ) + } } diff --git a/library/treenav/src/commonTest/kotlin/StackNavTest.kt b/library/treenav/src/commonTest/kotlin/StackNavTest.kt index f389a7f..78b8af3 100644 --- a/library/treenav/src/commonTest/kotlin/StackNavTest.kt +++ b/library/treenav/src/commonTest/kotlin/StackNavTest.kt @@ -128,4 +128,20 @@ class StackNavTest { .children ) } + + @Test + fun testPoppingToRoot() { + val multiPush = subject + .push(TestNode(name = "A")) + .push(TestNode(name = "B")) + .push(TestNode(name = "C")) + + + assertEquals( + expected = listOf(TestNode(name = "A")), + actual = multiPush + .popToRoot() + .children + ) + } } diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt index cbc1676..e85befe 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt @@ -26,11 +26,16 @@ import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -50,12 +55,14 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round +import com.tunjid.composables.scrollbars.scrollable.sumOf import com.tunjid.demo.common.ui.SampleAppState.Companion.rememberPanedNavHostState import com.tunjid.demo.common.ui.chat.chatPaneStrategy import com.tunjid.demo.common.ui.chatrooms.chatRoomPaneStrategy @@ -173,39 +180,34 @@ fun SampleApp( isIndexVisible = { nodeFor(order[it]) != null } ) } - SegmentedLayout( - state = segmentedLayoutState, + Box( modifier = Modifier .fillMaxSize() then movableSharedElementHostState.modifier then sharedTransitionModifier, - ) { index -> - Box( - modifier = Modifier.fillMaxSize() - ) { + ) { + SegmentedLayout( + state = segmentedLayoutState, + modifier = Modifier + .fillMaxSize() + ) { index -> val pane = order[index] Destination(pane) if (pane == ThreePane.Primary) Destination(ThreePane.TransientPrimary) + } - val draggableState = rememberDraggableState { - segmentedLayoutState.dragBy( - index = index, - delta = with(density) { it.toDp() } - ) - } - val interactionSource = remember { MutableInteractionSource() } - val hovered by interactionSource.collectIsHoveredAsState() - val width by animateDpAsState(if (hovered) 2.dp else 2.dp) - val color by animateColorAsState(if (hovered) Color.LightGray else Color.Gray) + val visibleIndices = order.indices.mapNotNull { index -> + index.takeIf { nodeFor(order[it]) != null } + } - Box( - modifier = Modifier - .hoverable(interactionSource) - .align(Alignment.TopEnd) - .background(color) - .width(width) - .fillMaxHeight() - .draggable(draggableState, Orientation.Horizontal) + if (visibleIndices.size > 1) for (index in visibleIndices) { + val totalWeight = visibleIndices.sumOf(segmentedLayoutState::weightAt) + if (index != visibleIndices.last()) PaneSeparator( + segmentedLayoutState = segmentedLayoutState, + index = index, + density = density, + xOffset = segmentedLayoutState.size.width + .times(segmentedLayoutState.weightAt(index) / totalWeight) ) } } @@ -214,6 +216,54 @@ fun SampleApp( } } +@Composable +private fun BoxScope.PaneSeparator( + segmentedLayoutState: SegmentedLayoutState, + index: Int, + density: Density, + xOffset: Dp, +) { + val draggableState = rememberDraggableState { + segmentedLayoutState.dragBy( + index = index, + delta = with(density) { it.toDp() } + ) + } + + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + val pressed by interactionSource.collectIsPressedAsState() + val dragged by interactionSource.collectIsDraggedAsState() + val active = hovered || pressed || dragged + + val width by animateDpAsState(if (active) PaneSeparatorActiveWidthDp else 1.dp) + val color by animateColorAsState( + if (hovered) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface + ) + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .offset(x = xOffset - (width/2)) + .draggable( + state = draggableState, + orientation = Orientation.Horizontal, + interactionSource = interactionSource, + ) + .hoverable(interactionSource) + .widthIn(min = 12.dp) + .height(PaneSeparatorActiveWidthDp) + ) { + Box( + modifier = Modifier + .align(Alignment.Center) + .background(color, RoundedCornerShape(PaneSeparatorActiveWidthDp)) + .width(width) + .height(PaneSeparatorActiveWidthDp) + ) + } +} + @Stable class SampleAppState( private val navigationRepository: NavigationRepository = NavigationRepository @@ -306,3 +356,5 @@ private fun sampleAppNavHostConfiguration( } } ) + +private val PaneSeparatorActiveWidthDp = 56.dp \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/SegmentedLayout.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/SegmentedLayout.kt index 008429d..0680796 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/SegmentedLayout.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/SegmentedLayout.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf @@ -34,6 +35,7 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import androidx.compose.ui.unit.toSize +import com.tunjid.composables.scrollbars.scrollable.sumOf @Stable class SegmentedLayoutState( @@ -42,7 +44,8 @@ class SegmentedLayoutState( val isIndexVisible: (Int) -> Boolean = { true }, ) { var minWidth by mutableStateOf(minWidth) - internal var size by mutableStateOf(DpSize.Zero) + var size by mutableStateOf(DpSize.Zero) + internal set private val weightMap = mutableStateMapOf().apply { (0.. put(index, 1f / count) } @@ -76,6 +79,13 @@ class SegmentedLayoutState( val newWeight = newWidth / size.width setWeightAt(index = index, weight = newWeight) } + + companion object { + + inline val SegmentedLayoutState.visibleIndices get() = (0.. Date: Mon, 21 Oct 2024 10:13:21 -0400 Subject: [PATCH 2/3] SegmentedLayout changes --- .../com/tunjid/demo/common/ui/DemoApp.kt | 47 ++++++----- .../tunjid/demo/common/ui/SegmentedLayout.kt | 79 +++++++++++++++---- 2 files changed, 84 insertions(+), 42 deletions(-) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt index e85befe..6c08bf7 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt @@ -62,7 +62,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round -import com.tunjid.composables.scrollbars.scrollable.sumOf import com.tunjid.demo.common.ui.SampleAppState.Companion.rememberPanedNavHostState import com.tunjid.demo.common.ui.chat.chatPaneStrategy import com.tunjid.demo.common.ui.chatrooms.chatRoomPaneStrategy @@ -176,8 +175,12 @@ fun SampleApp( } val segmentedLayoutState = remember { SegmentedLayoutState( - count = 3, - isIndexVisible = { nodeFor(order[it]) != null } + indexVisibilityList = order.map { nodeFor(it) != null }, + ) + }.also { + for (index in order.indices) it.setVisibilityAt( + index = index, + visible = nodeFor(order[index]) != null, ) } Box( @@ -189,27 +192,21 @@ fun SampleApp( SegmentedLayout( state = segmentedLayoutState, modifier = Modifier - .fillMaxSize() - ) { index -> - val pane = order[index] - Destination(pane) - if (pane == ThreePane.Primary) Destination(ThreePane.TransientPrimary) - } - - val visibleIndices = order.indices.mapNotNull { index -> - index.takeIf { nodeFor(order[it]) != null } - } - - if (visibleIndices.size > 1) for (index in visibleIndices) { - val totalWeight = visibleIndices.sumOf(segmentedLayoutState::weightAt) - if (index != visibleIndices.last()) PaneSeparator( - segmentedLayoutState = segmentedLayoutState, - index = index, - density = density, - xOffset = segmentedLayoutState.size.width - .times(segmentedLayoutState.weightAt(index) / totalWeight) - ) - } + .fillMaxSize(), + itemSeparators = { paneIndex, offset -> + PaneSeparator( + segmentedLayoutState = segmentedLayoutState, + index = paneIndex, + density = density, + xOffset = offset, + ) + }, + itemContent = { index -> + val pane = order[index] + Destination(pane) + if (pane == ThreePane.Primary) Destination(ThreePane.TransientPrimary) + } + ) } } } @@ -244,7 +241,7 @@ private fun BoxScope.PaneSeparator( Box( modifier = Modifier .align(Alignment.CenterStart) - .offset(x = xOffset - (width/2)) + .offset(x = xOffset - (width / 2)) .draggable( state = draggableState, orientation = Orientation.Horizontal, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/SegmentedLayout.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/SegmentedLayout.kt index 0680796..fa0ffd4 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/SegmentedLayout.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/SegmentedLayout.kt @@ -24,14 +24,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import androidx.compose.ui.unit.toSize @@ -39,14 +43,14 @@ import com.tunjid.composables.scrollbars.scrollable.sumOf @Stable class SegmentedLayoutState( - val count: Int, minWidth: Dp = 80.dp, - val isIndexVisible: (Int) -> Boolean = { true }, + indexVisibilityList: List, ) { var minWidth by mutableStateOf(minWidth) - var size by mutableStateOf(DpSize.Zero) - internal set + val count get() = indexVisibilityList.size + private var size by mutableStateOf(DpSize.Zero) + private val indexVisibilityList = mutableStateListOf(*indexVisibilityList.toTypedArray()) private val weightMap = mutableStateMapOf().apply { (0.. put(index, 1f / count) } } @@ -72,6 +76,12 @@ class SegmentedLayoutState( weightMap[adjustedIndex] = weightMap.getValue(adjustedIndex) + weightDifference } + fun isVisibleAt(index: Int) = indexVisibilityList[index] + + fun setVisibilityAt(index: Int, visible: Boolean) { + indexVisibilityList[index] = visible + } + fun dragBy(index: Int, delta: Dp) { val oldWeight = weightAt(index) val width = oldWeight * size.width @@ -80,11 +90,38 @@ class SegmentedLayoutState( setWeightAt(index = index, weight = newWeight) } - companion object { + internal companion object SegmentedLayoutInstance { - inline val SegmentedLayoutState.visibleIndices get() = (0.. Unit + ) { + val visibleIndices by remember { + derivedStateOf { + indexVisibilityList.mapIndexedNotNull { index, visible -> + index.takeIf { visible } + } + } + } + val totalWeight by remember { + derivedStateOf { + visibleIndices.sumOf(::weightAt) + } + } - val SegmentedLayoutState.weightSum get() = visibleIndices.sumOf(weightMap::getValue) + if (visibleIndices.size > 1) for (index in visibleIndices) { + if (index != indexVisibilityList.lastIndex) separator( + index, + (weightAt(index) / totalWeight) * size.width + ) + } + } + + fun SegmentedLayoutState.updateSize(size: IntSize, density: Density) { + this.size = with(density) { size.toSize().toDpSize() } + } } } @@ -92,23 +129,31 @@ class SegmentedLayoutState( fun SegmentedLayout( state: SegmentedLayoutState, modifier: Modifier = Modifier, + itemSeparators: @Composable (paneIndex: Int, offset: Dp) -> Unit = { _, _ -> }, itemContent: @Composable (Int) -> Unit, -) { +) = with(SegmentedLayoutState) { val density = LocalDensity.current - Row( + Box( modifier = modifier .onSizeChanged { - state.size = with(density) { it.toSize().toDpSize() } + state.updateSize(it, density) }, ) { - for (index in 0.. Date: Mon, 21 Oct 2024 10:36:28 -0400 Subject: [PATCH 3/3] Remove debugging colors --- .../com/tunjid/demo/common/ui/DemoApp.kt | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt index 6c08bf7..ba30c96 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt @@ -233,29 +233,34 @@ private fun BoxScope.PaneSeparator( val dragged by interactionSource.collectIsDraggedAsState() val active = hovered || pressed || dragged - val width by animateDpAsState(if (active) PaneSeparatorActiveWidthDp else 1.dp) - val color by animateColorAsState( - if (hovered) MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.onSurface - ) + val separatorWidth = if (active) PaneSeparatorActiveWidthDp else 1.dp + val separatorContainerWidth = if (active) separatorWidth else PaneSeparatorTouchTargetWidthDp + val separatorContainerOffset = xOffset - (separatorContainerWidth / 2) + Box( modifier = Modifier .align(Alignment.CenterStart) - .offset(x = xOffset - (width / 2)) + .offset(x = animateDpAsState(separatorContainerOffset).value) .draggable( state = draggableState, orientation = Orientation.Horizontal, interactionSource = interactionSource, ) .hoverable(interactionSource) - .widthIn(min = 12.dp) + .widthIn(min = PaneSeparatorTouchTargetWidthDp) .height(PaneSeparatorActiveWidthDp) ) { Box( modifier = Modifier .align(Alignment.Center) - .background(color, RoundedCornerShape(PaneSeparatorActiveWidthDp)) - .width(width) + .background( + color = animateColorAsState( + if (hovered) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface + ).value, + shape = RoundedCornerShape(PaneSeparatorActiveWidthDp), + ) + .width(animateDpAsState(separatorWidth).value) .height(PaneSeparatorActiveWidthDp) ) } @@ -354,4 +359,5 @@ private fun sampleAppNavHostConfiguration( } ) -private val PaneSeparatorActiveWidthDp = 56.dp \ No newline at end of file +private val PaneSeparatorActiveWidthDp = 56.dp +private val PaneSeparatorTouchTargetWidthDp = 16.dp \ No newline at end of file