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 ba30c96..6c21446 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 @@ -19,23 +19,25 @@ package com.tunjid.demo.common.ui import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.InteractionSource 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.BoxScope +import androidx.compose.foundation.layout.fillMaxHeight 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 @@ -44,16 +46,20 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffo import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf +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.geometry.Offset import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity @@ -121,6 +127,12 @@ fun SampleApp( ) } + var canAnimatePanes by remember { mutableStateOf(true) } + val interactingWithPanes = appState.isInteractingWithPanes() + LaunchedEffect(interactingWithPanes) { + canAnimatePanes = !interactingWithPanes + } + PanedNavHost( state = appState.rememberPanedNavHostState { this @@ -152,7 +164,7 @@ fun SampleApp( ThreePane.Primary, ThreePane.TransientPrimary, ThreePane.Secondary, - ThreePane.Tertiary -> true + ThreePane.Tertiary -> canAnimatePanes null, ThreePane.Overlay -> false @@ -173,97 +185,102 @@ fun SampleApp( ThreePane.Primary, ) } + val filteredOrder by remember { + derivedStateOf { order.filter { nodeFor(it) != null } } + } val segmentedLayoutState = remember { SegmentedLayoutState( - indexVisibilityList = order.map { nodeFor(it) != null }, + totalCount = order.size, ) }.also { - for (index in order.indices) it.setVisibilityAt( - index = index, - visible = nodeFor(order[index]) != null, - ) + it.visibleCount = filteredOrder.size } - Box( + SegmentedLayout( + state = segmentedLayoutState, modifier = Modifier .fillMaxSize() then movableSharedElementHostState.modifier then sharedTransitionModifier, - ) { - 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) - } - ) - } + itemSeparators = { paneIndex, offset -> + PaneSeparator( + segmentedLayoutState = segmentedLayoutState, + interactionSource = appState.paneInteractionSourceAt(paneIndex), + index = paneIndex, + density = density, + xOffset = offset, + ) + }, + itemContent = { index -> + val pane = filteredOrder[index] + Destination(pane) + if (pane == ThreePane.Primary) Destination(ThreePane.TransientPrimary) + } + ) } } } } @Composable -private fun BoxScope.PaneSeparator( +private fun PaneSeparator( segmentedLayoutState: SegmentedLayoutState, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, index: Int, density: Density, xOffset: Dp, ) { + var alpha by remember { mutableFloatStateOf(0f) } 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) - + val active = interactionSource.isActive() Box( - modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = animateDpAsState(separatorContainerOffset).value) + modifier = modifier + .alpha(alpha) + .offset(x = xOffset - (PaneSeparatorTouchTargetWidthDp / 2)) .draggable( state = draggableState, orientation = Orientation.Horizontal, interactionSource = interactionSource, ) .hoverable(interactionSource) - .widthIn(min = PaneSeparatorTouchTargetWidthDp) - .height(PaneSeparatorActiveWidthDp) + .width(PaneSeparatorTouchTargetWidthDp) + .fillMaxHeight() ) { Box( modifier = Modifier .align(Alignment.Center) .background( color = animateColorAsState( - if (hovered) MaterialTheme.colorScheme.onSurfaceVariant + if (active) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface ).value, shape = RoundedCornerShape(PaneSeparatorActiveWidthDp), ) - .width(animateDpAsState(separatorWidth).value) + .width(animateDpAsState(if (active) PaneSeparatorActiveWidthDp else 1.dp).value) .height(PaneSeparatorActiveWidthDp) ) } + LaunchedEffect(Unit) { + animate( + initialValue = 0f, + targetValue = 1f, + animationSpec = tween(1000), + block = { value, _ -> alpha = value } + ) + } +} + +@Composable +fun InteractionSource.isActive(): Boolean { + val hovered by collectIsHoveredAsState() + val pressed by collectIsPressedAsState() + val dragged by collectIsDraggedAsState() + return hovered || pressed || dragged } @Stable @@ -277,6 +294,7 @@ class SampleAppState( private val panedNavHostConfiguration = sampleAppNavHostConfiguration( navigationState ) + private val paneInteractionSourceList = mutableStateListOf() val backTouchOffsetState = mutableStateOf(IntOffset.Zero) val backProgressFractionState = mutableFloatStateOf(Float.NaN) @@ -291,6 +309,17 @@ class SampleAppState( } } + fun paneInteractionSourceAt(index: Int): MutableInteractionSource { + while (paneInteractionSourceList.lastIndex < index) { + paneInteractionSourceList.add(MutableInteractionSource()) + } + return paneInteractionSourceList[index] + } + + @Composable + fun isInteractingWithPanes(): Boolean = + paneInteractionSourceList.any { it.isActive() } + fun updatePredictiveBack( touchOffset: Offset, fraction: Float, 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 fa0ffd4..d8f1f15 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,7 +24,7 @@ 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.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -39,34 +39,34 @@ 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 totalCount: Int, + visibleCount: Int = totalCount, minWidth: Dp = 80.dp, - indexVisibilityList: List, ) { + var visibleCount by mutableIntStateOf(visibleCount) var minWidth by mutableStateOf(minWidth) - val count get() = indexVisibilityList.size + var width by mutableStateOf(DpSize.Zero.width) + internal set - private var size by mutableStateOf(DpSize.Zero) - private val indexVisibilityList = mutableStateListOf(*indexVisibilityList.toTypedArray()) private val weightMap = mutableStateMapOf().apply { - (0.. put(index, 1f / count) } + (0.. put(index, 1f / totalCount) } } fun weightAt(index: Int): Float = weightMap.getValue(index) fun setWeightAt(index: Int, weight: Float) { - if (weight * size.width < minWidth) return + if (weight * width < minWidth) return val oldWeight = weightMap.getValue(index) val weightDifference = oldWeight - weight - val adjustedIndex = (0.. - val searchIndex = (index + i) % count + val adjustedIndex = (0.. + val searchIndex = (index + i) % totalCount if (searchIndex == index) return@search null - val adjustedWidth = (weightMap.getValue(searchIndex) + weightDifference) * size.width + val adjustedWidth = (weightMap.getValue(searchIndex) + weightDifference) * width if (adjustedWidth < minWidth) return@search null searchIndex @@ -76,17 +76,13 @@ class SegmentedLayoutState( weightMap[adjustedIndex] = weightMap.getValue(adjustedIndex) + weightDifference } - fun isVisibleAt(index: Int) = indexVisibilityList[index] - - fun setVisibilityAt(index: Int, visible: Boolean) { - indexVisibilityList[index] = visible - } + fun isVisibleAt(index: Int) = index < visibleCount fun dragBy(index: Int, delta: Dp) { val oldWeight = weightAt(index) - val width = oldWeight * size.width + val width = oldWeight * width val newWidth = width + delta - val newWeight = newWidth / size.width + val newWeight = newWidth / this.width setWeightAt(index = index, weight = newWeight) } @@ -94,33 +90,32 @@ class SegmentedLayoutState( @Composable fun SegmentedLayoutState.Separators( - separator: @Composable ( - paneIndex: Int, offset: Dp - ) -> Unit + separator: @Composable (paneIndex: Int, offset: Dp) -> Unit ) { - val visibleIndices by remember { + val totalWeight by remember { derivedStateOf { - indexVisibilityList.mapIndexedNotNull { index, visible -> - index.takeIf { visible } - } + (0.. + val previousIndexOffset = + if (index == 0) 0.dp + else (weightAt(index - 1) / totalWeight) * width + val indexOffset = (weightAt(index) / totalWeight) * width + previousIndexOffset + indexOffset + } } } - - if (visibleIndices.size > 1) for (index in visibleIndices) { - if (index != indexVisibilityList.lastIndex) separator( - index, - (weightAt(index) / totalWeight) * size.width - ) - } + if (visibleCount > 1) + for (index in 0..