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..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 @@ -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,9 +55,10 @@ 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 @@ -169,51 +175,97 @@ 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, ) } - SegmentedLayout( - state = segmentedLayoutState, + Box( modifier = Modifier .fillMaxSize() then movableSharedElementHostState.modifier then sharedTransitionModifier, - ) { index -> - Box( - modifier = Modifier.fillMaxSize() - ) { - 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() } + ) { + SegmentedLayout( + state = segmentedLayoutState, + modifier = Modifier + .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) } - 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) - - Box( - modifier = Modifier - .hoverable(interactionSource) - .align(Alignment.TopEnd) - .background(color) - .width(width) - .fillMaxHeight() - .draggable(draggableState, Orientation.Horizontal) - ) - } + ) } } } } } +@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 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 = animateDpAsState(separatorContainerOffset).value) + .draggable( + state = draggableState, + orientation = Orientation.Horizontal, + interactionSource = interactionSource, + ) + .hoverable(interactionSource) + .widthIn(min = PaneSeparatorTouchTargetWidthDp) + .height(PaneSeparatorActiveWidthDp) + ) { + Box( + modifier = Modifier + .align(Alignment.Center) + .background( + color = animateColorAsState( + if (hovered) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface + ).value, + shape = RoundedCornerShape(PaneSeparatorActiveWidthDp), + ) + .width(animateDpAsState(separatorWidth).value) + .height(PaneSeparatorActiveWidthDp) + ) + } +} + @Stable class SampleAppState( private val navigationRepository: NavigationRepository = NavigationRepository @@ -306,3 +358,6 @@ private fun sampleAppNavHostConfiguration( } } ) + +private val PaneSeparatorActiveWidthDp = 56.dp +private val PaneSeparatorTouchTargetWidthDp = 16.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..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 @@ -22,28 +22,35 @@ 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.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 +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) - internal var size by mutableStateOf(DpSize.Zero) + 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) } } @@ -69,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 @@ -76,29 +89,71 @@ class SegmentedLayoutState( val newWeight = newWidth / size.width setWeightAt(index = index, weight = newWeight) } + + internal companion object SegmentedLayoutInstance { + + @Composable + fun SegmentedLayoutState.Separators( + separator: @Composable ( + paneIndex: Int, offset: Dp + ) -> Unit + ) { + val visibleIndices by remember { + derivedStateOf { + indexVisibilityList.mapIndexedNotNull { index, visible -> + index.takeIf { visible } + } + } + } + val totalWeight by remember { + derivedStateOf { + visibleIndices.sumOf(::weightAt) + } + } + + 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() } + } + } } @Composable 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..