diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bcc9d73..edd34e4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,8 @@ dependencies { implementation(libs.androidx.navigation2) implementation(libs.hilt.android) + implementation(libs.androidx.navigationevent) + implementation(libs.androidx.navigationevent.compose) ksp(libs.hilt.compiler) implementation(libs.koin.compose.viewmodel) diff --git a/app/src/main/java/com/example/nav3recipes/scenes/twopane/DragToPop.kt b/app/src/main/java/com/example/nav3recipes/scenes/twopane/DragToPop.kt new file mode 100644 index 0000000..f82658b --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/scenes/twopane/DragToPop.kt @@ -0,0 +1,296 @@ +package com.example.nav3recipes.scenes.twopane + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.Draggable2DState +import androidx.compose.foundation.gestures.draggable2D +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round +import androidx.navigationevent.DirectNavigationEventInput +import androidx.navigationevent.NavigationEvent +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.math.min + +@Stable +class DragToPopState private constructor( + private val dismissThresholdSquared: Float, + private val dragToDismissState: DragToDismissState, + private val input: DirectNavigationEventInput, +) { + + private val channel = Channel() + private var dismissOffset by mutableStateOf(null) + + suspend fun awaitEvents() { + channel.consumeAsFlow() + .collectLatest { status -> + when (status) { + NavigationEventStatus.Completed.Cancelled -> { + input.backCancelled() + } + + NavigationEventStatus.Completed.Commited -> { + input.backCompleted() + } + + NavigationEventStatus.Seeking -> { + input.backStarted(dragToDismissState.navigationEvent(progress = 0f)) + + snapshotFlow(dragToDismissState::offset).collectLatest { + input.backProgressed( + dragToDismissState.navigationEvent( + min( + a = dragToDismissState.offset.getDistanceSquared() / dismissThresholdSquared, + b = 1f, + ) + ) + ) + } + } + } + } + } + + companion object { + fun Modifier.dragToPop( + dragToPopState: DragToPopState, + ): Modifier = dragToDismiss( + state = dragToPopState.dragToDismissState, + shouldDismiss = { offset, _ -> + offset.getDistanceSquared() > dragToPopState.dismissThresholdSquared + }, + // Enable back preview + onStart = { + dragToPopState.channel.trySend(NavigationEventStatus.Seeking) + }, + onCancelled = cancelled@{ hasResetOffset -> + if (hasResetOffset) return@cancelled + dragToPopState.channel.trySend(NavigationEventStatus.Completed.Cancelled) + }, + onDismissed = { + dragToPopState.dismissOffset = dragToPopState.dragToDismissState.offset.round() + dragToPopState.channel.trySend(NavigationEventStatus.Completed.Commited) + } + ) + .offset { + dragToPopState.dismissOffset ?: dragToPopState.dragToDismissState.offset.round() + } + + @Composable + fun rememberDragToPopState( + dismissThreshold: Dp = 200.dp + ): DragToPopState { + + val floatDismissThreshold = with(LocalDensity.current) { + dismissThreshold.toPx().let { it * it } + } + + val dragToDismissState = rememberUpdatedDragToDismissState() + + val dispatcher = checkNotNull( + LocalNavigationEventDispatcherOwner.current + ?.navigationEventDispatcher + ) + val input = remember(dispatcher) { + DirectNavigationEventInput() + } + + DisposableEffect(dispatcher) { + dispatcher.addInput(input) + onDispose { + dispatcher.removeInput(input) + } + } + + val dragToPopState = remember(dragToDismissState, input) { + DragToPopState( + dismissThresholdSquared = floatDismissThreshold, + dragToDismissState = dragToDismissState, + input = input + ) + } + + LaunchedEffect(dragToPopState) { + dragToPopState.awaitEvents() + } + + return dragToPopState + } + } +} + +/** + * State for utilizing [Modifier.dragToDismiss]. + * + * @param enabled The initial enabled state of the [DragToDismissState]. + * @param animationSpec The animation spec used to reset the dragged item back + * to its starting [Offset]. + */ +@Stable +class DragToDismissState( + internal val coroutineScope: CoroutineScope, + enabled: Boolean = true, + animationSpec: AnimationSpec = DefaultDragToDismissSpring +) { + /** + * Whether or not drag to dismiss is available. + */ + var enabled by mutableStateOf(enabled) + + /** + * The animation spec used to reset the dragged item back + * to its starting [Offset]. + */ + var animationSpec by mutableStateOf(animationSpec) + + /** + * The current [Offset] from the starting position of the drag. + */ + var offset by mutableStateOf(Offset.Zero) + internal set + + internal val draggable2DState = Draggable2DState { dragAmount -> + offset += dragAmount + } + + internal var startDragImmediately by mutableStateOf(false) +} + +/** + * Remembers a [DragToDismissState] for utilizing [Modifier.dragToDismiss]. + * + * @param enabled The initial enabled state of the [DragToDismissState]. + * @param animationSpec The animation spec used to reset the dragged item back + * to its starting [Offset]. + */ +@Composable +fun rememberUpdatedDragToDismissState( + enabled: Boolean = true, + animationSpec: AnimationSpec = DefaultDragToDismissSpring, +): DragToDismissState { + val coroutineScope = rememberCoroutineScope() + return remember { + DragToDismissState( + coroutineScope = coroutineScope, + enabled = enabled, + animationSpec = animationSpec, + ) + }.also { + it.enabled = enabled + it.animationSpec = animationSpec + } +} + +/** + * A Modifier for performing the drag to dismiss UI gesture pattern. When the dragged item + * is not being dismissed and is being reset into its original position, the reset may be + * interrupted by dragging it again. + * + * @param state state controlling the properties of the [Modifier]. + * @param shouldDismiss a lambda for checking if the threshold for the drag offset for + * dismissal has been reached. + * It provides two arguments, the current displacement [Offset], and the [Velocity] at which + * the drag was stopped. Return true if the offset has been reached and the composable should be + * dismissed, else false for the composable to be animated back to its starting position. + * @param onStart called when the drag commences and the composable has been displaced + * from its original position. + * @param onCancelled called when the drag to dismiss gesture has been cancelled because the drag + * gesture stopped and the [shouldDismiss] returned false. It may be invoked up to twice + * per session, with each invocation guaranteed to have different arguments: + * - First: Invoked with false, signifying the cancellation of the gesture, but that the + * Composable is not yet back at its starting position. + * - Second: Invoked with true, signifying the Composable has settled back into its original + * position. + * It will only be called if the reset animation completes without being cancelled. + * @param onDismissed called when the composable has been dragged past its dismissal + * threshold and should be dismissed. Note that the Composable will have its displacement + * [Offset] reset to [Offset.Zero] immediately after this is called. + * + */ +fun Modifier.dragToDismiss( + state: DragToDismissState, + shouldDismiss: (Offset, Velocity) -> Boolean, + onStart: () -> Unit = {}, + onCancelled: (reset: Boolean) -> Unit = {}, + onDismissed: () -> Unit, +): Modifier = draggable2D( + state = state.draggable2DState, + startDragImmediately = state.startDragImmediately, + enabled = state.enabled, + onDragStarted = { + onStart() + }, + onDragStopped = { velocity -> + if (shouldDismiss(state.offset, velocity)) { + state.startDragImmediately = false + onDismissed() + // Reset offset back to zero. + state.offset = Offset.Zero + } else { + onCancelled(false) + state.coroutineScope.launch { + try { + state.startDragImmediately = true + state.draggable2DState.drag { + animate( + typeConverter = Offset.VectorConverter, + initialValue = state.offset, + targetValue = Offset.Zero, + initialVelocity = Offset( + x = velocity.x, + y = velocity.y + ), + animationSpec = state.animationSpec, + block = { value, _ -> + dragBy(value - state.offset) + } + ) + } + // Notify that it has been reset. + onCancelled(true) + } finally { + state.startDragImmediately = false + // Reset offset if canceled and modifier is out of the composition, otherwise + // allow user catch the drag as it settles. + if (!state.coroutineScope.isActive) state.offset = Offset.Zero + } + } + } + } +) + +private fun DragToDismissState.navigationEvent( + progress: Float +) = NavigationEvent( + touchX = offset.x, + touchY = offset.y, + progress = progress, + swipeEdge = NavigationEvent.EDGE_LEFT, +) + +private val DefaultDragToDismissSpring = spring() diff --git a/app/src/main/java/com/example/nav3recipes/scenes/twopane/SlideToPop.kt b/app/src/main/java/com/example/nav3recipes/scenes/twopane/SlideToPop.kt new file mode 100644 index 0000000..2f46939 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/scenes/twopane/SlideToPop.kt @@ -0,0 +1,507 @@ +package com.example.nav3recipes.scenes.twopane + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.DraggableState +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.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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +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.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import androidx.compose.ui.unit.toSize +import androidx.navigation3.runtime.NavEntry +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.NavigationEventTransitionState +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import kotlin.math.abs +import kotlin.math.roundToInt + +@Composable +fun SlideToPopLayout( + isCurrentScene: Boolean, + entries: List>, + onBack: () -> Unit, +) { + val density = LocalDensity.current + + val splitLayoutState = remember { + SplitLayoutState( + orientation = Orientation.Horizontal, + maxCount = 2, + minSize = 1.dp, + visibleCount = { + entries.size + }, + keyAtIndex = { index -> + entries[index].contentKey + } + ) + } + val draggableState = rememberDraggableState { delta -> + splitLayoutState.dragBy( + index = 0, + delta = with(density) { delta.toDp() } + ) + } + SplitLayout( + state = splitLayoutState, + modifier = Modifier.fillMaxSize(), + itemSeparators = { _, offset -> + PaneSeparator( + splitLayoutState = splitLayoutState, + draggableState = draggableState, + offset = offset, + onBack = onBack, + ) + }, + itemContent = { index -> + Box( + modifier = Modifier.constrainedSizePlacement( + orientation = Orientation.Horizontal, + minSize = splitLayoutState.size / 3, + atStart = index == 0, + ) + ) { + entries[index].Content() + } + }, + ) + + if (isCurrentScene) { + SlideToPopNavigationEventHandler( + enabled = (entries.lastOrNull()?.metadata?.containsKey(TwoPaneScene.TWO_PANE_KEY) == true), + splitLayoutState = splitLayoutState, + draggableState = draggableState, + onBack = onBack, + ) + } +} + +@Composable +private fun PaneSeparator( + splitLayoutState: SplitLayoutState, + draggableState: DraggableState, + modifier: Modifier = Modifier, + offset: Dp, + onBack: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val isPressed by interactionSource.collectIsPressedAsState() + val isDragged by interactionSource.collectIsDraggedAsState() + val active = isHovered || isPressed || isDragged + val width by animateDpAsState( + label = "App Pane Draggable thumb", + targetValue = + if (active) DraggableDividerSizeDp + else 8.dp + ) + val density = LocalDensity.current + Box( + modifier = modifier + .offset { + IntOffset( + x = with(density) { (offset - (width / 2)).roundToPx() }, + y = 0, + ) + } + .width(width) + .fillMaxHeight() + ) { + Box( + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + .height(DraggableDividerSizeDp) + .draggable( + state = draggableState, + orientation = splitLayoutState.orientation, + interactionSource = interactionSource, + onDragStopped = { + if (splitLayoutState.weightAt(0) > DragPopThreshold) onBack() + } + ) + .background(MaterialTheme.colorScheme.primary, CircleShape) + .hoverable(interactionSource) + ) { + if (active) Icon( + modifier = Modifier + .align(Alignment.Center), + imageVector = Icons.Rounded.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } +} + +private val SplitLayoutState.firstPaneWidth + get() = (weightAt(0) * size.value).roundToInt() + +@Composable +private fun SlideToPopNavigationEventHandler( + enabled: Boolean, + splitLayoutState: SplitLayoutState, + draggableState: DraggableState, + onBack: () -> Unit, +) { + val currentlyEnabled by rememberUpdatedState(enabled) + var started by remember { mutableStateOf(false) } + var widthAtStart by remember { mutableIntStateOf(0) } + var desiredPaneWidth by remember { mutableFloatStateOf(0f) } + + NavigationBackHandler( + state = rememberNavigationEventState( + currentInfo = SecondaryPaneCloseNavigationEventInfo, + ), + isBackEnabled = currentlyEnabled, + onBackCancelled = { + started = false + }, + onBackCompleted = { + started = false + onBack() + }, + ) + + val navigationEventDispatcher = LocalNavigationEventDispatcherOwner.current!! + .navigationEventDispatcher + + LaunchedEffect(navigationEventDispatcher) { + var wasIdle = true + navigationEventDispatcher + .transitionState + .collect { state -> + when (state) { + NavigationEventTransitionState.Idle -> wasIdle = true + is NavigationEventTransitionState.InProgress -> if (currentlyEnabled) { + if (wasIdle) { + widthAtStart = splitLayoutState.firstPaneWidth + started = true + } + val progress = state.latestEvent.progress + val distanceToCover = splitLayoutState.size.value - widthAtStart + desiredPaneWidth = (progress * distanceToCover) + widthAtStart + wasIdle = false + } + } + } + } + + // Make sure desiredPaneWidth is synced with paneSplitState.width before the back gesture + LaunchedEffect(Unit) { + snapshotFlow { started to splitLayoutState.firstPaneWidth } + .collect { (isStarted, firstPaneWidth) -> + if (isStarted) return@collect + desiredPaneWidth = firstPaneWidth.toFloat() + } + } + + // Dispatch changes as the user presses back + LaunchedEffect(Unit) { + snapshotFlow { started to desiredPaneWidth } + .collect { (isStarted, targetWidth) -> + if (!isStarted) return@collect + draggableState.dispatchRawDelta( + delta = targetWidth - splitLayoutState.firstPaneWidth.toFloat() + ) + } + } +} + +/** + * State describing the behavior for [SplitLayout]. + * + * @param orientation The orientation of the layout. + * @param maxCount The maximum number of children in the layout. + * @param minSize The minimum size of a child in the layout. + * @param visibleCount The number of children that are currently visible in the layout. This + * lambda can be snapshot aware. + * @param keyAtIndex Provides a key for the item at an index to identify its position in the + * case of visibility changes of other indices. Defaults to the index of the item. + */ +@Stable +class SplitLayoutState( + val orientation: Orientation, + val maxCount: Int, + minSize: Dp = 80.dp, + internal val visibleCount: () -> Int = { maxCount }, + internal val keyAtIndex: SplitLayoutState.(Int) -> Any = { it }, +) { + + private val weightMap = mutableStateMapOf().apply { + (0.. put(index, 1f / maxCount) } + } + + /** + * Th sum of the weights of the visible children in the layout + */ + private val weightSum by derivedStateOf { + checkVisibleCount() + (0.. weightSum) return false + if (weight * size < minSize) return false + + val oldWeight = weightMap.getValue(index) + val weightDifference = oldWeight - weight + + var adjustedIndex = -1 + for (i in 0.. Unit + ) { + val visibleCount = visibleCount() + if (visibleCount > 1) + for (index in 0.. dpSize.height + Orientation.Horizontal -> dpSize.width + } + } + } + } +} + +/** + * A layout for consecutively placing resizable children along the axis of + * [SplitLayoutState.orientation]. The children should be the same size perpendicular to + * [SplitLayoutState.orientation]. + * + * Children may be hidden by writing to [SplitLayoutState.visibleCount]. + * + * @param state The state of the layout. + * @param modifier The modifier to be applied to the layout. + * @param itemSeparators Separators to be drawn when more than one child is visible. + * @param itemContent the content to be drawn in each visible index. + */ +@Composable +fun SplitLayout( + state: SplitLayoutState, + modifier: Modifier = Modifier, + itemSeparators: @Composable (paneIndex: Int, offset: Dp) -> Unit = { _, _ -> }, + itemContent: @Composable (Int) -> Unit, +) = with(SplitLayoutState) { + val density = LocalDensity.current + Box( + modifier = modifier + .onSizeChanged { + state.updateSize(it, density) + }, + ) { + val visibleCount = state.visibleCount() + when (state.orientation) { + Orientation.Vertical -> Column( + modifier = Modifier + .matchParentSize(), + ) { + for (index in 0.. Row( + modifier = Modifier + .matchParentSize(), + ) { + for (index in 0.. + val minPaneSize = minSize.roundToPx() + val actualConstraints = when (orientation) { + Orientation.Horizontal -> + if (!constraints.hasBoundedWidth || constraints.maxWidth >= minPaneSize) constraints + else constraints.copy(maxWidth = minPaneSize, minWidth = minPaneSize) + + Orientation.Vertical -> + if (!constraints.hasBoundedHeight || constraints.maxHeight >= minPaneSize) constraints + else constraints.copy(maxHeight = minPaneSize, minHeight = minPaneSize) + } + val placeable = measurable.measure(actualConstraints) + + layout(width = placeable.width, height = placeable.height) { + // In the below, when the dimension is larger than the constraints, the placement is + // coordinate is halved because when the dimension is larger than the constraints, the + // content is automatically centered within the constraints. + when (orientation) { + Orientation.Horizontal -> placeable.placeRelativeWithLayer( + x = if (constraints.maxWidth >= minPaneSize) 0 + else when { + atStart -> constraints.maxWidth - minPaneSize + else -> minPaneSize - constraints.maxWidth + } / 2, + y = 0, + ) + + Orientation.Vertical -> placeable.placeRelativeWithLayer( + x = 0, + y = if (constraints.maxHeight >= minPaneSize) 0 + else when { + atStart -> constraints.maxHeight - minPaneSize + else -> minPaneSize - constraints.maxHeight + } / 2, + ) + } + } +} + +internal object SecondaryPaneCloseNavigationEventInfo : NavigationEventInfo() + +private val DraggableDividerSizeDp = 64.dp + +private const val DragPopThreshold = 0.7f diff --git a/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneActivity.kt b/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneActivity.kt index 883eda7..ea5cf8c 100644 --- a/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneActivity.kt @@ -19,25 +19,47 @@ package com.example.nav3recipes.scenes.twopane import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text -import androidx.compose.runtime.remember +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.fromColorLong +import androidx.compose.ui.graphics.toColorLong +import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay +import androidx.navigationevent.NavigationEventTransitionState +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner import com.example.nav3recipes.content.ContentBase import com.example.nav3recipes.content.ContentGreen import com.example.nav3recipes.content.ContentRed +import com.example.nav3recipes.scenes.twopane.DragToPopState.Companion.dragToPop +import com.example.nav3recipes.scenes.twopane.DragToPopState.Companion.rememberDragToPopState import com.example.nav3recipes.ui.setEdgeToEdgeConfig import com.example.nav3recipes.ui.theme.colors +import kotlinx.coroutines.flow.map import kotlinx.serialization.Serializable +import kotlin.math.max /** * This example shows how to create custom layouts using the Scenes API. @@ -58,59 +80,124 @@ private object Home : NavKey private data class Product(val id: Int) : NavKey @Serializable -private data object Profile : NavKey +private data class Profile( + val colorLong: Long, +) : NavKey class TwoPaneActivity : ComponentActivity() { - @OptIn(ExperimentalSharedTransitionApi::class) override fun onCreate(savedInstanceState: Bundle?) { setEdgeToEdgeConfig() super.onCreate(savedInstanceState) setContent { val backStack = rememberNavBackStack(Home) - val twoPaneStrategy = rememberTwoPaneSceneStrategy() + val twoPaneStrategy = rememberTwoPaneSceneStrategy( + backStackSize = backStack::size + ) - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - sceneStrategy = twoPaneStrategy, - entryProvider = entryProvider { - entry( - metadata = TwoPaneScene.twoPane() - ) { - ContentRed("Welcome to Nav3") { - Button(onClick = { backStack.addProductRoute(1) }) { - Text("View the first product") - } - } - } - entry( - metadata = TwoPaneScene.twoPane() - ) { product -> - ContentBase( - "Product ${product.id} ", - Modifier.background(colors[product.id % colors.size]) + SharedTransitionLayout { + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + sceneStrategy = twoPaneStrategy, + entryProvider = entryProvider { + entry( + metadata = TwoPaneScene.twoPane() ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { - backStack.addProductRoute(product.id + 1) - }) { - Text("View the next product") + ContentRed( + title = "Welcome to Nav3", + modifier = Modifier + ) { + Button(onClick = { backStack.addProductRoute(1) }) { + Text("View the first product") } - Button(onClick = { - backStack.add(Profile) - }) { - Text("View profile") + } + } + entry( + metadata = TwoPaneScene.twoPane() + ) { product -> + ContentBase( + title = "Product ${product.id} ", + Modifier + .background(colors[product.id % colors.size]) + ) { + val stickyAnimatedVisibilityScope = + rememberStickyAnimatedVisibilityScope() + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { + backStack.addProductRoute(product.id + 1) + }) { + Text("View the next product") + } + val profileColor = colors[(product.id + 3) % colors.size] + OutlinedButton(onClick = { + backStack.add(Profile(colorLong = profileColor.toColorLong())) + }) { + Box( + modifier = Modifier + .sharedElementWithCallerManagedVisibility( + sharedContentState = rememberSharedContentState( + profileColor, + ), + visible = stickyAnimatedVisibilityScope.isStickySharedElementVisible, + ) + .background( + color = profileColor, + shape = CircleShape, + ) + .size(24.dp) + ) + Spacer(Modifier.width(16.dp)) + Text("View profile") + } } } } +entry( + metadata = NavDisplay.predictivePopTransitionSpec { + // Update the pop back transition spec, the default scale + // down will run on everything but the sticky shared element + EnterTransition.None togetherWith fadeOut(targetAlpha = 0.8f) + } +) { profile -> + val stickyAnimatedVisibilityScope = + rememberStickyAnimatedVisibilityScope() + + val scale = LocalNavigationEventDispatcherOwner.current!! + .navigationEventDispatcher + .transitionState + .map(NavigationEventTransitionState::predictiveBackScale) + .collectAsState(1f) + + ContentGreen( + title = "Profile (single pane only)", + modifier = Modifier + .fillMaxSize(fraction = scale.value) + .dragToPop(rememberDragToPopState()) + ) { + val profileColor = Color.fromColorLong(profile.colorLong) + Box( + modifier = Modifier + .statusBarsPadding() + .padding(vertical = 16.dp) + .sharedElementWithCallerManagedVisibility( + sharedContentState = rememberSharedContentState( + profileColor + ), + visible = stickyAnimatedVisibilityScope.isStickySharedElementVisible, + ) + .background( + color = profileColor, + shape = CircleShape, + ) + .size(80.dp) + ) + } +} } - entry { - ContentGreen("Profile (single pane only)") - } - } - ) + ) + } } } } @@ -123,3 +210,12 @@ private fun NavBackStack.addProductRoute(productId: Int) { add(productRoute) } } + +private fun NavigationEventTransitionState.predictiveBackScale() = when (this) { + NavigationEventTransitionState.Idle -> 1f + is NavigationEventTransitionState.InProgress -> max( + 1f - latestEvent.progress, + 0.7f + ) +} + diff --git a/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneScene.kt b/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneScene.kt index fb4cc99..e3d620e 100644 --- a/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneScene.kt +++ b/app/src/main/java/com/example/nav3recipes/scenes/twopane/TwoPaneScene.kt @@ -1,18 +1,25 @@ package com.example.nav3recipes.scenes.twopane -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.Transition import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier +import androidx.compose.runtime.rememberUpdatedState import androidx.navigation3.runtime.NavEntry import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneInfo import androidx.navigation3.scene.SceneStrategy import androidx.navigation3.scene.SceneStrategyScope +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import androidx.navigationevent.NavigationEventHistory +import androidx.navigationevent.NavigationEventTransitionState +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner import androidx.window.core.layout.WindowSizeClass import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND @@ -22,25 +29,36 @@ import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOW * A custom [Scene] that displays two [NavEntry]s side-by-side in a 50/50 split. */ class TwoPaneScene( - override val key: Any, - override val previousEntries: List>, - val firstEntry: NavEntry, - val secondEntry: NavEntry + override val key: TwoPaneSceneKey, + private val sceneBackStack: List>, + private val maxSimultaneousEntries: () -> Int, + private val actualBackStackSize: () -> Int, + onBack: () -> Unit, ) : Scene { - override val entries: List> = listOf(firstEntry, secondEntry) - override val content: @Composable (() -> Unit) = { - Row(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.weight(0.5f)) { - firstEntry.Content() - } - Column(modifier = Modifier.weight(0.5f)) { - secondEntry.Content() + + override val previousEntries: List> = + sceneBackStack.dropLast(1) + + override val entries: List> + get() = sceneBackStack.takeLast(maxSimultaneousEntries()).let { navEntries -> + when { + navEntries.size < 2 -> return@let navEntries + navEntries.all { it.metadata.containsKey(TWO_PANE_KEY) } -> navEntries + else -> navEntries.takeLast(1) } } + + override val content: @Composable (() -> Unit) = { + SlideToPopLayout( + isCurrentScene = sceneBackStack.size == actualBackStackSize(), + entries = entries, + onBack = onBack, + ) } companion object { internal const val TWO_PANE_KEY = "TwoPane" + /** * Helper function to add metadata to a [NavEntry] indicating it can be displayed * in a two-pane layout. @@ -50,11 +68,16 @@ class TwoPaneScene( } @Composable -fun rememberTwoPaneSceneStrategy() : TwoPaneSceneStrategy { - val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - - return remember(windowSizeClass){ - TwoPaneSceneStrategy(windowSizeClass) +fun rememberTwoPaneSceneStrategy( + backStackSize: () -> Int, +): TwoPaneSceneStrategy { + val windowSizeClass = rememberUpdatedState(currentWindowAdaptiveInfo().windowSizeClass) + + return remember(windowSizeClass) { + TwoPaneSceneStrategy( + windowSizeClass = windowSizeClass::value, + backStackSize = backStackSize, + ) } } @@ -64,47 +87,136 @@ fun rememberTwoPaneSceneStrategy() : TwoPaneSceneStrategy { * A [SceneStrategy] that activates a [TwoPaneScene] if the window is wide enough * and the top two back stack entries declare support for two-pane display. */ -class TwoPaneSceneStrategy(val windowSizeClass: WindowSizeClass) : SceneStrategy { +class TwoPaneSceneStrategy( + private val windowSizeClass: () -> WindowSizeClass, + private val backStackSize: () -> Int, +) : SceneStrategy { + + override fun SceneStrategyScope.calculateScene( + entries: List> + ): Scene { + val contentKeys = entries.map { it.contentKey } + + return TwoPaneScene( + key = TwoPaneSceneKey( + contentKeys = contentKeys, + ), + maxSimultaneousEntries = { + if (windowSizeClass().isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) 2 + else 1 + }, + sceneBackStack = entries.toList(), + actualBackStackSize = backStackSize, + onBack = onBack, + ) + } +} + +/** + * Scene key with a flag if it was created for a predictive back gesture. + * NOTE: its equals and hashcode are completely independent of this predictive back flag. + * This is to let [NavDisplay] find the appropriate scene to go back to with this key. The + * flag is only used internally. + */ +data class TwoPaneSceneKey( + val contentKeys: List, +) + +@Composable +fun rememberStickyAnimatedVisibilityScope(): StickyAnimatedVisibilityScope { + val navigationEventStatusState = rememberNavigationEventStatus() + val animatedContentScope = LocalNavAnimatedContentScope.current + + return remember(navigationEventStatusState, animatedContentScope) { + StickyAnimatedVisibilityScope( + backStatus = navigationEventStatusState::value, + animatedContentScope = animatedContentScope, + ) + } +} + +class StickyAnimatedVisibilityScope( + val backStatus: () -> NavigationEventStatus, + animatedContentScope: AnimatedContentScope, +) : AnimatedVisibilityScope by animatedContentScope { + + val isStickySharedElementVisible: Boolean + get() = if (isShowingBackContent) !isEntering else isEntering + + private val isEntering + get() = transition.targetState == EnterExitState.Visible - override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { + private val isShowingBackContent: Boolean + get() { + val currentSize = transition.sceneCurrentDestinationKey?.contentKeys?.size ?: 0 + val targetSize = transition.sceneTargetDestinationKey?.contentKeys?.size ?: 0 - // Condition 1: Only return a Scene if the window is sufficiently wide to render two panes. - // We use isWidthAtLeastBreakpoint with WIDTH_DP_MEDIUM_LOWER_BOUND (600dp). - if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) { - return null + val animationIsSettling = targetSize < currentSize + + return when (backStatus()) { + NavigationEventStatus.Seeking -> animationIsSettling + NavigationEventStatus.Completed.Cancelled -> animationIsSettling + NavigationEventStatus.Completed.Commited -> false + } + } + +} + +@Composable +fun rememberNavigationEventStatus(): State { + val navigationEventDispatcher = LocalNavigationEventDispatcherOwner.current!! + .navigationEventDispatcher + + val lastSceneKey = remember { + mutableStateOf(null) + } + val navigationEventStatusState = remember { + mutableStateOf(NavigationEventStatus.Completed.Commited) + } + + LaunchedEffect(navigationEventDispatcher) { + navigationEventDispatcher.history.collect { history -> + history.currentSceneKey?.let(lastSceneKey::value::set) } + } + + LaunchedEffect(navigationEventDispatcher) { + navigationEventDispatcher.transitionState.collect { transitionState -> + navigationEventStatusState.value = when (transitionState) { + NavigationEventTransitionState.Idle -> when (lastSceneKey.value) { + navigationEventDispatcher.history.value.currentSceneKey -> NavigationEventStatus.Completed.Cancelled + else -> NavigationEventStatus.Completed.Commited + } - val lastTwoEntries = entries.takeLast(2) - - // Condition 2: Only return a Scene if there are two entries, and both have declared - // they can be displayed in a two pane scene. - return if (lastTwoEntries.size == 2 - && lastTwoEntries.all { it.metadata.containsKey(TwoPaneScene.TWO_PANE_KEY) } - ) { - val firstEntry = lastTwoEntries.first() - val secondEntry = lastTwoEntries.last() - - // The scene key must uniquely represent the state of the scene. - // A Pair of the first and second entry keys ensures uniqueness. - val sceneKey = Pair(firstEntry.contentKey, secondEntry.contentKey) - - TwoPaneScene( - key = sceneKey, - // Where we go back to is a UX decision. In this case, we only remove the top - // entry from the back stack, despite displaying two entries in this scene. - // This is because in this app we only ever add one entry to the - // back stack at a time. It would therefore be confusing to the user to add one - // when navigating forward, but remove two when navigating back. - previousEntries = entries.dropLast(1), - firstEntry = firstEntry, - secondEntry = secondEntry - ) - - } else { - null + is NavigationEventTransitionState.InProgress -> NavigationEventStatus.Seeking + } } } + return navigationEventStatusState +} +private val NavigationEventHistory.currentSceneKey + get() = when (val navigationEventInfo = mergedHistory.getOrNull(currentIndex)) { + is SceneInfo<*> -> navigationEventInfo.scene.key + else -> null + } +sealed class NavigationEventStatus { + data object Seeking : NavigationEventStatus() + sealed class Completed : NavigationEventStatus() { + data object Commited : Completed() + data object Cancelled : Completed() + } } +private val Transition<*>.sceneTargetDestinationKey: TwoPaneSceneKey + get() { + val target = parentTransition?.targetState as Scene<*> + return target.key as TwoPaneSceneKey + } + +private val Transition<*>.sceneCurrentDestinationKey: TwoPaneSceneKey + get() { + val target = parentTransition?.currentState as Scene<*> + return target.key as TwoPaneSceneKey + } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 756e60f..2b33c26 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,16 +23,18 @@ espressoCore = "3.7.0" kotlinxSerializationCore = "1.9.0" lifecycleRuntimeKtx = "2.9.4" lifecycleViewmodel = "1.0.0-alpha04" -activityCompose = "1.12.0-alpha09" -composeBom = "2025.09.01" +activityCompose = "1.12.0-beta01" +composeBom = "2025.10.01" navigation2 = "2.9.1" -navigation3 = "1.0.0-alpha11" +navigation3 = "1.0.0-beta01" material3 = "1.4.0" nav3Material = "1.3.0-alpha01" ksp = "2.2.10-2.0.2" hilt = "2.57.1" hiltNavigationCompose = "1.3.0" koin = "4.1.1" +navigationEvent = "1.0.0-beta01" +navigationEventCompose= "1.0.0-beta01" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -63,6 +65,8 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-material3-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "nav3Material" } koin-compose-viewmodel = {group = "io.insert-koin", name = "koin-compose-viewmodel", version.ref = "koin"} +androidx-navigationevent = { group = "androidx.navigationevent", name = "navigationevent", version.ref = "navigationEvent" } +androidx-navigationevent-compose = { group = "androidx.navigationevent", name = "navigationevent-compose-android", version.ref = "navigationEventCompose" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }