diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf60892..ea5b497 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,13 @@ [versions] androidGradlePlugin = "8.9.2" androidxActivity = "1.9.2" -activity-compose = "1.11.0-rc01" +activity-compose = "1.12.0-alpha02" androidxAppCompat = "1.7.0" androidxBenchmark = "1.3.4" androidxCore = "1.16.0" androidxCollection = "1.5.0" androidxCompose = "1.7.0" +androidxNavigationEvent = "1.0.0-alpha02" androidxPaging = "3.3.2" androidxSavedState = "1.3.0-alpha07" androidxTestCore = "1.6.1" @@ -14,15 +15,15 @@ androidxTestExt = "1.2.1" androidxTestRunner = "1.6.2" androidxTestRules = "1.6.1" dokka = "1.8.20" -jetbrainsCompose = "1.8.0" -jetbrainsLifecycle = "2.9.0-beta01" +jetbrainsCompose = "1.8.2" +jetbrainsLifecycle = "2.9.1" jetbrainsMaterial3Adaptive = "1.0.1" junit4 = "4.13.2" kotlin = "2.1.20" kotlinxCoroutines = "1.10.2" kotlinxDatetime = "0.6.2" tunjidStateHolder = "1.1.0" -tunjidComposables = "0.0.16" +tunjidComposables = "0.0.19" junit = "4.13.2" runner = "1.0.2" espressoCore = "3.0.2" @@ -37,8 +38,10 @@ android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", ver compose-compiler-plugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } +androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity-compose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" } +androidx-navigation-event = { group = "androidx.navigationevent", name = "navigationevent", version.ref = "androidxNavigationEvent" } jetbrains-compose-animation = { group = "org.jetbrains.compose.animation", name = "animation", version.ref = "jetbrainsCompose" } jetbrains-compose-foundation = { group = "org.jetbrains.compose.foundation", name = "foundation", version.ref = "jetbrainsCompose" } diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt index 877bd12..2d3856c 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt @@ -16,16 +16,16 @@ package com.tunjid.treenav.compose.threepane +import androidx.compose.animation.EnterExitState import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.Adaptation import com.tunjid.treenav.compose.Adaptation.Swap import com.tunjid.treenav.compose.MultiPaneDisplay import com.tunjid.treenav.compose.PaneEntry @@ -42,13 +42,6 @@ enum class ThreePane { */ Primary, - /** - * A optional pane for placing content from the [Primary] pane, if a preview of the previous - * navigation destinations is occurring. The primary content is rendered here, while - * the previous primary content is rendered in the [Primary] pane. - */ - TransientPrimary, - /** * An optional pane for displaying a navigation destination alongside the [Primary] pane. * This is useful for list-detail, or supporting panels flows. @@ -66,77 +59,33 @@ enum class ThreePane { * An optional pane for showing dialogs, or context sheets over existing panes. */ Overlay; - - companion object { - val PrimaryToSecondary = Swap( - from = Primary, - to = Secondary - ) - - val SecondaryToPrimary = Swap( - from = Secondary, - to = Primary - ) - - val PrimaryToTransient = Swap( - from = Primary, - to = TransientPrimary - ) - } } /** - * A [PaneEntry] for selectively running animations in [ThreePane] [MultiPaneDisplay]. When: - * - A navigation destination moves between the [ThreePane.Primary] and [ThreePane.Secondary] - * panes, the pane animations are not run to provide a seamless movement experience. - * - A navigation destination moves between the [ThreePane.Primary] and - * [ThreePane.TransientPrimary] panes, the pane animations are not run. + * Provides a default [PaneEntry] for selectively running animations in + * [ThreePane] [MultiPaneDisplay]. * - * @param enterTransition the transition to run for the entering pane when permitted. - * @param exitTransition the transition to run for the exiting pane when permitted. - * @param paneMapping the mapping of panes to navigation destinations. + * @param enterTransition the [EnterTransition] used when this [PaneEntry] adapts in the display. + * @param exitTransition the [ExitTransition] used when this [PaneEntry] adapts in the display. + * @param paneMapping the [Destination]s that are shown alongside the [Destination] provided and + * which of the [ThreePane]s they should show up in. * @param render the Composable for rendering the current destination. */ -fun threePaneEntry( - enterTransition: PaneScope.() -> EnterTransition = { DefaultFadeIn }, - exitTransition: PaneScope.() -> ExitTransition = { DefaultFadeOut }, - paneMapping: @Composable (R) -> Map = { - mapOf(ThreePane.Primary to it) +fun threePaneEntry( + enterTransition: PaneScope.() -> EnterTransition = { + if (canAnimate()) DefaultFadeIn else EnterTransition.None }, - render: @Composable PaneScope.(R) -> Unit, -) = PaneEntry( - paneTransform = paneMapping, - renderTransform = { destination, original -> - val state = paneState - val shouldAnimate = when (state.pane) { - ThreePane.Primary, - ThreePane.Secondary, - -> when { - ThreePane.PrimaryToSecondary in state.adaptations -> false - ThreePane.SecondaryToPrimary in state.adaptations -> false - else -> true - } - - ThreePane.TransientPrimary -> when { - ThreePane.PrimaryToTransient in state.adaptations -> false - else -> true - } - - else -> true - } - Box( - modifier = - if (shouldAnimate) Modifier.animateEnterExit( - enter = enterTransition(), - exit = exitTransition() - ) - else Modifier, - content = { - original(destination) - } - ) - + exitTransition: PaneScope.() -> ExitTransition = { + if (canAnimate()) DefaultFadeOut else ExitTransition.None + }, + paneMapping: @Composable (Destination) -> Map = { destination -> + mapOf(ThreePane.Primary to destination) }, + render: @Composable (PaneScope.(Destination) -> Unit), +) = PaneEntry( + enterTransition = enterTransition, + exitTransition = exitTransition, + paneMapping = paneMapping, content = render ) @@ -151,3 +100,29 @@ private val DefaultFadeIn = fadeIn( private val DefaultFadeOut = fadeOut( animationSpec = RouteTransitionAnimationSpec, ) + +private fun PaneScope.canAnimate() = + when { + transition.targetState == EnterExitState.PostExit -> true + inPredictiveBack && isActive -> true + paneState.adaptations.any { adaptation -> + adaptation is Adaptation.Same + } -> false + + paneState.adaptations.any { adaptation -> + adaptation is Adaptation.Pop || adaptation is Adaptation.Change + } && paneState.adaptations.none { + it is Swap<*> + } -> true + + else -> when (val pane = paneState.pane) { + ThreePane.Primary, + ThreePane.Secondary, + ThreePane.Tertiary -> paneState.adaptations.any { adaptation -> + adaptation is Swap<*> && adaptation.from == pane + } + + ThreePane.Overlay, + null -> true + } + } \ No newline at end of file diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt index bf3bc52..f307732 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePaneSharedTransitionScope.kt @@ -65,64 +65,32 @@ private class ThreePaneSharedTransitionScope @OptIn( @OptIn(ExperimentalSharedTransitionApi::class) override fun Modifier.paneSharedElement( - key: Any, + sharedContentState: SharedTransitionScope.SharedContentState, boundsTransform: BoundsTransform, placeHolderSize: PlaceHolderSize, renderInOverlayDuringTransition: Boolean, visible: Boolean?, zIndexInOverlay: Float, clipInOverlayDuringTransition: OverlayClip, - ): Modifier = composed { - - when (paneScope.paneState.pane) { - null -> throw IllegalArgumentException( - "Shared elements may only be used in non null panes" - ) - // Allow shared elements in the primary or transient primary content only - ThreePane.Primary -> when { - paneScope.isPreviewingBack -> sharedElementWithCallerManagedVisibility( - sharedContentState = rememberSharedContentState(key), - visible = false, - boundsTransform = boundsTransform, - placeHolderSize = placeHolderSize, - renderInOverlayDuringTransition = renderInOverlayDuringTransition, - zIndexInOverlay = zIndexInOverlay, - clipInOverlayDuringTransition = clipInOverlayDuringTransition, - ) - // Share the element - else -> sharedElementWithCallerManagedVisibility( - sharedContentState = rememberSharedContentState(key), - visible = when (visible) { - null -> paneScope.isActive - else -> paneScope.isActive && visible - }, - boundsTransform = boundsTransform, - placeHolderSize = placeHolderSize, - renderInOverlayDuringTransition = renderInOverlayDuringTransition, - zIndexInOverlay = zIndexInOverlay, - clipInOverlayDuringTransition = clipInOverlayDuringTransition, - ) - } - // Share the element when in the transient pane - ThreePane.TransientPrimary -> sharedElementWithCallerManagedVisibility( - sharedContentState = rememberSharedContentState(key), - visible = paneScope.isActive, - boundsTransform = boundsTransform, - placeHolderSize = placeHolderSize, - renderInOverlayDuringTransition = renderInOverlayDuringTransition, - zIndexInOverlay = zIndexInOverlay, - clipInOverlayDuringTransition = clipInOverlayDuringTransition, - ) + ): Modifier = when (paneScope.paneState.pane) { + null -> throw IllegalArgumentException( + "Shared elements may only be used in non null panes" + ) + // Allow shared elements in the primary or transient primary content only + ThreePane.Primary -> sharedElement( + sharedContentState = sharedContentState, + animatedVisibilityScope = paneScope, + boundsTransform = boundsTransform, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + ) - // In the other panes use the element as is - ThreePane.Secondary, - ThreePane.Tertiary, - ThreePane.Overlay, - -> this - } + // In the other panes use the element as is + ThreePane.Secondary, + ThreePane.Tertiary, + ThreePane.Overlay, + -> this } -} - -private val PaneScope.isPreviewingBack: Boolean - get() = paneState.pane == ThreePane.Primary - && paneState.adaptations.contains(ThreePane.PrimaryToTransient) \ No newline at end of file +} \ No newline at end of file diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/BackPreviewTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/BackPreviewTransform.kt deleted file mode 100644 index 3651a30..0000000 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/BackPreviewTransform.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.tunjid.treenav.compose.threepane.transforms - -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.threepane.ThreePane -import com.tunjid.treenav.compose.transforms.Transform -import com.tunjid.treenav.compose.transforms.compoundTransform - -/** - * An [Transform] that moves the current [Destination] in a [ThreePane.Primary] pane, to - * to the [ThreePane.TransientPrimary] pane when "back" is being previewed. - * - * @param isPreviewingBack provides the state of the predictive back gesture. - * True if the gesture is ongoing. - * @param navigationStateBackTransform provides the [NavigationState] if the app were to - * go "back". - */ -inline fun - backPreviewTransform( - isPreviewingBack: State, - crossinline navigationStateBackTransform: NavigationState.() -> NavigationState, -): Transform { - var lastPrimaryDestination by mutableStateOf(null) - - return compoundTransform( - destinationTransform = { navigationState, previousTransform -> - val previousDestination = previousTransform(navigationState) - lastPrimaryDestination = previousDestination - if (isPreviewingBack.value) previousTransform(navigationState.navigationStateBackTransform()) - else previousDestination - }, - paneTransform = paneTransform@{ destination, previousTransform -> - val previousMapping = previousTransform(destination) - val isPreviewing by isPreviewingBack - if (!isPreviewing) return@paneTransform previousMapping - // Back is being previewed, therefore the original mapping is already for back. - // Pass the previous primary value into transient. - val transientDestination = checkNotNull(lastPrimaryDestination) { - "Attempted to show last destination without calling destination transform" - } - val paneMapping = previousTransform(transientDestination) - val transient = paneMapping[ThreePane.Primary] - previousMapping + (ThreePane.TransientPrimary to transient) - } - ) -} - diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt index e4c17d4..28496d9 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/MovableSharedElementTransform.kt @@ -28,7 +28,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.Adaptation +import com.tunjid.treenav.compose.Adaptation.Swap import com.tunjid.treenav.compose.MultiPaneDisplay import com.tunjid.treenav.compose.MultiPaneDisplayState import com.tunjid.treenav.compose.PaneScope @@ -38,24 +41,15 @@ import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScop import com.tunjid.treenav.compose.moveablesharedelement.PaneMovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.rememberPaneMovableSharedElementScope import com.tunjid.treenav.compose.threepane.ThreePane -import com.tunjid.treenav.compose.transforms.RenderTransform -import com.tunjid.treenav.compose.transforms.Transform +import com.tunjid.treenav.compose.transforms.PaneTransform +import com.tunjid.treenav.compose.transforms.paneRenderTransform /** - * A [Transform] that applies semantics of movable shared elements to + * A [PaneTransform] that applies semantics of movable shared elements to * [ThreePane] layouts. * * It is an opinionated implementation that always shows the movable shared element in - * the [ThreePane.Primary] pane unless: - * - * - The [ThreePane.PrimaryToTransient] adaptation is present and a shared element match is - * found. During this, the movable shared element will be shown in - * the [ThreePane.TransientPrimary] pane. During this, an empty box will be rendered - * in the [ThreePane.Primary] pane. - * - * - The [ThreePane.PrimaryToTransient] adaptation is present and a shared element match is NOT - * found. During this, the element will simply be rendered as is in [ThreePane.Primary], but - * without movable content semantics. + * the [ThreePane.Primary] pane. * * Note: The movable shared element is never rendered in the following panes: * - [ThreePane.Secondary] @@ -68,8 +62,8 @@ import com.tunjid.treenav.compose.transforms.Transform fun threePanedMovableSharedElementTransform( movableSharedElementHostState: MovableSharedElementHostState, -): Transform = - RenderTransform { destination, previousTransform -> +): PaneTransform = + paneRenderTransform { destination, destinationPaneMapper -> val delegate = rememberPaneMovableSharedElementScope( movableSharedElementHostState = movableSharedElementHostState ) @@ -82,7 +76,7 @@ fun ) } - previousTransform(movableSharedElementScope, destination) + destinationPaneMapper(movableSharedElementScope, destination) } /** @@ -128,9 +122,12 @@ private class ThreePaneMovableSharedElementScope( override val isActive: Boolean get() = delegate.paneScope.isActive + override val inPredictiveBack: Boolean + get() = delegate.paneScope.inPredictiveBack + @OptIn(ExperimentalSharedTransitionApi::class) override fun movableSharedElementOf( - key: Any, + sharedContentState: SharedTransitionScope.SharedContentState, boundsTransform: BoundsTransform, placeHolderSize: PlaceHolderSize, renderInOverlayDuringTransition: Boolean, @@ -142,27 +139,9 @@ private class ThreePaneMovableSharedElementScope( null -> throw IllegalArgumentException( "Shared elements may only be used in non null panes" ) - // Allow shared elements in the primary or transient primary content only - ThreePane.Primary -> when { - // Show a blank space for shared elements between the destinations - isPreviewingBack && hostState.isCurrentlyShared(key) -> EmptyElement - // If previewing and it won't be shared, show the item as is - isPreviewingBack -> sharedElement - // Share the element - else -> delegate.movableSharedElementOf( - key = key, - boundsTransform = boundsTransform, - placeHolderSize = placeHolderSize, - renderInOverlayDuringTransition = renderInOverlayDuringTransition, - zIndexInOverlay = zIndexInOverlay, - clipInOverlayDuringTransition = clipInOverlayDuringTransition, - alternateOutgoingSharedElement = alternateOutgoingSharedElement, - sharedElement = sharedElement - ) - } - // Share the element when in the transient pane - ThreePane.TransientPrimary -> delegate.movableSharedElementOf( - key = key, + // Allow movable shared elements in the primary pane only + ThreePane.Primary -> delegate.movableSharedElementOf( + sharedContentState = sharedContentState, boundsTransform = boundsTransform, placeHolderSize = placeHolderSize, renderInOverlayDuringTransition = renderInOverlayDuringTransition, @@ -172,19 +151,73 @@ private class ThreePaneMovableSharedElementScope( sharedElement = sharedElement ) + // In the secondary pane allow shared elements only if certain conditions match + ThreePane.Secondary -> when { + canAnimateSecondary() -> { state, modifier -> + with(hostState) { + Box(modifier) { + Box( + Modifier + .fillMaxConstraints() + .sharedElementWithCallerManagedVisibility( + sharedContentState = sharedContentState, + visible = false, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + ) + ) + (alternateOutgoingSharedElement ?: sharedElement)( + state, + Modifier + .fillMaxConstraints(), + ) + } + } + } + + else -> alternateOutgoingSharedElement ?: sharedElement + } + // In the other panes use the element as is - ThreePane.Secondary, ThreePane.Tertiary, ThreePane.Overlay, -> alternateOutgoingSharedElement ?: sharedElement } } -private val PaneScope.isPreviewingBack: Boolean - get() = paneState.pane == ThreePane.Primary - && paneState.adaptations.contains(ThreePane.PrimaryToTransient) +private fun PaneScope.canAnimateSecondary(): Boolean { + if (inPredictiveBack) return false + if (!paneState.adaptations.contains(PrimaryToSecondary)) return false + if (paneState.adaptations.contains(Adaptation.Pop)) return false + + return true +} + +private val PrimaryToSecondary = Swap( + from = ThreePane.Primary, + to = ThreePane.Secondary +) -// An empty element representing blank space -private val EmptyElement: @Composable (Any?, Modifier) -> Unit = { _, modifier -> - Box(modifier) -} \ No newline at end of file +private fun Modifier.fillMaxConstraints() = + layout { measurable, constraints -> + val placeable = measurable.measure( + constraints.copy( + minWidth = when { + constraints.hasBoundedWidth -> constraints.maxWidth + else -> constraints.minWidth + }, + minHeight = when { + constraints.hasBoundedHeight -> constraints.maxHeight + else -> constraints.minHeight + } + ) + ) + layout( + width = placeable.width, + height = placeable.height + ) { + placeable.place(0, 0) + } + } \ No newline at end of file diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt index b13bca2..bca561a 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/transforms/ThreePaneAdaptiveTransform.kt @@ -26,10 +26,10 @@ import androidx.compose.ui.unit.dp import com.tunjid.treenav.Node import com.tunjid.treenav.compose.threepane.ThreePane import com.tunjid.treenav.compose.transforms.PaneTransform -import com.tunjid.treenav.compose.transforms.Transform +import com.tunjid.treenav.compose.transforms.paneMappingTransform /** - * An [Transform] that selectively displays panes for a [ThreePane] layout + * An [PaneTransform] that selectively displays panes for a [ThreePane] layout * based on the space available determined by the [windowWidthState]. * * @param windowWidthState provides the current width of the display in Dp. @@ -39,8 +39,8 @@ fun windowWidthState: State, secondaryPaneBreakPoint: State = mutableStateOf(SECONDARY_PANE_MIN_WIDTH_BREAKPOINT_DP), tertiaryPaneBreakPoint: State = mutableStateOf(TERTIARY_PANE_MIN_WIDTH_BREAKPOINT_DP), -): Transform = - PaneTransform { destination, previousTransform -> +): PaneTransform = + paneMappingTransform { destination, destinationPaneMapper -> val showSecondary by remember { derivedStateOf { windowWidthState.value >= secondaryPaneBreakPoint.value } } @@ -48,7 +48,7 @@ fun derivedStateOf { windowWidthState.value >= tertiaryPaneBreakPoint.value } } - val originalMapping = previousTransform(destination) + val originalMapping = destinationPaneMapper(destination) val primaryNode = originalMapping[ThreePane.Primary] mapOf( ThreePane.Primary to primaryNode, diff --git a/library/compose/build.gradle.kts b/library/compose/build.gradle.kts index c7c7f15..29dd82b 100644 --- a/library/compose/build.gradle.kts +++ b/library/compose/build.gradle.kts @@ -24,6 +24,7 @@ kotlin { implementation(project(":library:treenav")) implementation(libs.androidx.collection) + implementation(libs.androidx.navigation.event) implementation(libs.jetbrains.compose.runtime) implementation(libs.jetbrains.compose.foundation) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/Utilities.kt b/library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/PaneScope.android.kt similarity index 80% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/Utilities.kt rename to library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/PaneScope.android.kt index 318a24f..19e208c 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/Utilities.kt +++ b/library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/PaneScope.android.kt @@ -14,6 +14,7 @@ * limitations under the License. */ -package com.tunjid.treenav.compose.navigation3.decorators +package com.tunjid.treenav.compose -internal fun getIdForKey(key: Any, count: Int): Int = 31 * key.hashCode() + count +internal actual fun Any.identityHash(): Int = + System.identityHashCode(this) \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntry.kt b/library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.android.kt similarity index 52% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntry.kt rename to library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.android.kt index 8a40bae..ec43f7c 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntry.kt +++ b/library/compose/src/androidMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.android.kt @@ -14,20 +14,13 @@ * limitations under the License. */ -package com.tunjid.treenav.compose.navigation3 +package com.tunjid.treenav.compose.navigation3.ui import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalView +import androidx.navigationevent.NavigationEventDispatcherOwner +import androidx.navigationevent.findViewTreeNavigationEventDispatcherOwner -/** - * Entry maintains and stores the key and the content represented by that key. Entries should be - * created as part of a [NavDisplay.entryProvider](reference/androidx/navigation/NavDisplay). - * - * @param key key for this entry - * @param metadata provides information to the display - * @param content content for this entry to be displayed when this entry is active - */ -internal open class NavEntry( - open val key: T, - open val metadata: Map = emptyMap(), - open val content: @Composable (T) -> Unit -) +@Composable +internal actual fun findViewTreeNavigationEventDispatcherOwner(): NavigationEventDispatcherOwner? = + LocalView.current.findViewTreeNavigationEventDispatcherOwner() \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt deleted file mode 100644 index d3e844c..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/DecoratedNavEntryMultiPaneDisplayScope.kt +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.tunjid.treenav.compose - - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.updateTransition -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.movableContentOf -import androidx.compose.runtime.mutableStateListOf -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.staticCompositionLocalOf -import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.navigation3.DecoratedNavEntryProvider -import com.tunjid.treenav.compose.navigation3.NavEntry -import com.tunjid.treenav.compose.navigation3.decorators.rememberMovableContentNavEntryDecorator -import com.tunjid.treenav.compose.navigation3.decorators.rememberSavedStateNavEntryDecorator -import com.tunjid.treenav.compose.navigation3.decorators.rememberViewModelStoreNavEntryDecorator -import com.tunjid.treenav.compose.navigation3.decorators.transitionAwareLifecycleNavEntryDecorator - -@Composable -internal fun DecoratedNavEntryMultiPaneDisplayScope( - state: MultiPaneDisplayState, - content: @Composable (MultiPaneDisplayScope.() -> Unit), -) { - val navigationState by state.navigationState - val backStack = remember { mutableStateListOf() }.also { mutableBackStack -> - state.backStackTransform(navigationState).let { currentBackStack -> - val sameBackStack = currentBackStack == mutableBackStack - if (sameBackStack) return@let - - mutableBackStack.clear() - mutableBackStack.addAll(currentBackStack) - } - } - val panesToDestinations = state.panesToDestinationsTransform( - state.destinationTransform(navigationState) - ) - - DecoratedNavEntryProvider( - backStack = backStack, - entryProvider = { node -> - NavEntry( - key = node, - content = { destination -> - val scope = LocalPaneScope.current - @Suppress("UNCHECKED_CAST") - state.renderTransform(scope as PaneScope, destination) - } - ) - }, - entryDecorators = listOf( - rememberMovableContentNavEntryDecorator(), - rememberSavedStateNavEntryDecorator(), - transitionAwareLifecycleNavEntryDecorator( - backStack = backStack, - isSettled = { - val scope = LocalPaneScope.current - scope.transition.currentState == scope.transition.targetState - } - ), - rememberViewModelStoreNavEntryDecorator(), - ), - content = { entries -> - val updatedEntries by rememberUpdatedState(entries) - val displayScope = remember { - DecoratedNavEntryMultiPaneDisplayScope( - panes = state.panes, - initialBackStack = backStack, - initialPanesToDestinations = panesToDestinations, - paneRenderer = { - val currentEntry = remember(paneState.currentDestination?.id) { - updatedEntries.findLast { - it.key.id == paneState.currentDestination?.id - } - } - checkNotNull(currentEntry) { - "There is no entry for the current navigation destination with id ${paneState.currentDestination?.id}" - }.content(currentEntry.key) - }, - ) - } - DisposableEffect(navigationState, panesToDestinations) { - displayScope.onBackStackChanged( - backStackIds = backStack.map { it.id }, - panesToDestinations = panesToDestinations - ) - onDispose { } - } - - displayScope.content() - }, - ) -} - -@Stable -private class DecoratedNavEntryMultiPaneDisplayScope( - panes: List, - initialBackStack: List, - initialPanesToDestinations: Map, - private val paneRenderer: @Composable (PaneScope.() -> Unit), -) : MultiPaneDisplayScope { - - private val slots = List( - size = panes.size, - init = ::Slot - ).toSet() - - var panedNavigationState by mutableStateOf( - value = SlotBasedPanedNavigationState.initial(slots = slots) - .adaptTo( - slots = slots, - panesToDestinations = initialPanesToDestinations, - backStackIds = initialBackStack.map { it.id }, - ) - ) - - private val slotsToRoutes = - mutableStateMapOf Unit>().also { map -> - map[null] = {} - slots.forEach { slot -> - map[slot] = movableContentOf { Render(slot) } - } - } - - @Composable - override fun Destination(pane: Pane) { - val slot = panedNavigationState.slotFor(pane) - slotsToRoutes[slot]?.invoke() - } - - override fun adaptationsIn( - pane: Pane, - ): Set = panedNavigationState.adaptationsIn(pane) - - override fun destinationIn( - pane: Pane, - ): Destination? = panedNavigationState.destinationFor(pane) - - fun onBackStackChanged( - backStackIds: List, - panesToDestinations: Map, - ) { - updateAdaptiveNavigationState { - adaptTo( - slots = slots.toSet(), - panesToDestinations = panesToDestinations, - backStackIds = backStackIds, - ) - } - } - - /** - * Renders [slot] into its pane with scopes that allow for animations - * and shared elements. - */ - @Composable - private fun Render( - slot: Slot, - ) { - val paneTransition = updateTransition( - targetState = panedNavigationState.paneStateFor(slot), - label = "$slot-PaneTransition", - ) - paneTransition.AnimatedContent( - contentKey = { it.currentDestination?.id }, - transitionSpec = { - ContentTransform( - targetContentEnter = EnterTransition.None, - initialContentExit = ExitTransition.None, - sizeTransform = null, - ) - } - ) { targetPaneState -> - val scope = remember { - AnimatedPaneScope( - paneState = targetPaneState, - activeState = derivedStateOf { - val activePaneState = panedNavigationState.paneStateFor(slot) - activePaneState.currentDestination?.id == targetPaneState.currentDestination?.id - }, - animatedContentScope = this@AnimatedContent, - ) - } - - // While technically a backwards write, it stabilizes and ensures the values are - // correct at first composition - scope.paneState = targetPaneState - - val destination = targetPaneState.currentDestination - if (destination != null) { - CompositionLocalProvider( - LocalPaneScope provides scope - ) { - scope.paneRenderer() - } - } - } - } - - private inline fun updateAdaptiveNavigationState( - block: SlotBasedPanedNavigationState.() -> SlotBasedPanedNavigationState, - ) { - panedNavigationState = panedNavigationState.block() - } -} - -private val LocalPaneScope = staticCompositionLocalOf> { - throw IllegalArgumentException( - "PaneScope should not be read until provided in the composition" - ) -} - - diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt index 0a48690..eeba64a 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplay.kt @@ -17,10 +17,24 @@ package com.tunjid.treenav.compose import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -28,6 +42,20 @@ import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.children +import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.id +import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.paneEnterTransition +import com.tunjid.treenav.compose.MultiPaneDisplayState.Companion.paneExitTransition +import com.tunjid.treenav.compose.navigation3.decorators.rememberViewModelStoreNavEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry +import com.tunjid.treenav.compose.navigation3.runtime.rememberSavedStateNavEntryDecorator +import com.tunjid.treenav.compose.navigation3.ui.LocalNavAnimatedContentScope +import com.tunjid.treenav.compose.navigation3.ui.NavDisplay +import com.tunjid.treenav.compose.navigation3.ui.NavigationEventHandler +import com.tunjid.treenav.compose.navigation3.ui.Scene +import com.tunjid.treenav.compose.navigation3.ui.SceneStrategy +import com.tunjid.treenav.compose.navigation3.ui.rememberSceneSetupNavEntryDecorator +import kotlinx.coroutines.CancellationException /** * Scope that provides context about individual panes [Pane] in an [MultiPaneDisplay]. @@ -35,6 +63,8 @@ import com.tunjid.treenav.Node @Stable interface MultiPaneDisplayScope { + val panes: Collection + @Composable fun Destination( pane: Pane, @@ -71,17 +101,379 @@ interface MultiPaneDisplayScope { * transforms for each navigation destination shown in the [MultiPaneDisplay]. */ @Composable -fun MultiPaneDisplay( - state: MultiPaneDisplayState, +fun MultiPaneDisplay( + state: MultiPaneDisplayState, modifier: Modifier = Modifier, content: @Composable MultiPaneDisplayScope.() -> Unit, ) { - Box( - modifier = modifier - ) { - DecoratedNavEntryMultiPaneDisplayScope( + val navigationState by state.navigationState + + val backStatusState = remember { + mutableStateOf(BackStatus.Completed.Commited) + } + + val panesToDestinations = rememberUpdatedState( + state.destinationPanes( + state.destinationTransform(navigationState) + ) + ) + + val backStack = remember { mutableStateListOf() }.also { mutableBackStack -> + state.backStackTransform(navigationState).let { currentBackStack -> + val sameBackStack = currentBackStack == mutableBackStack + if (sameBackStack) return@let + + Snapshot.withMutableSnapshot { + mutableBackStack.clear() + mutableBackStack.addAll(currentBackStack) + backStatusState.value = BackStatus.Completed.Commited + } + } + } + + val slots = remember { + List( + size = state.panes.size, + init = ::Slot + ).toSet() + } + + val initialPanedNavigationState = remember { + SlotBasedPanedNavigationState.initial(slots = slots) + .adaptTo( + slots = slots, + panesToDestinations = panesToDestinations.value, + backStackIds = backStack.map(Node::id), + ) + } + + val panedNavigationState = initialPanedNavigationState.rememberUpdatedPanedNavigationState( + backStackIds = backStack.map(Node::id), + panesToDestinations = panesToDestinations.value, + slots = slots + ) + + val sceneStrategy = remember { + MultiPanePaneSceneStrategy( state = state, - content = content + slots = slots, + currentPanedNavigationState = panedNavigationState::value, + backStatus = backStatusState::value, + content = content, ) } + + val transitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform = remember { + { + state.transitionSpec( + sceneStrategy.scenes.getValue(sceneDestinationKey).multiPaneDisplayScope + ) + } + } + + NavDisplay( + backStack = backStack, + modifier = modifier, + onBack = { count -> + val poppedBackStackIds = state.backStackTransform(navigationState) + .map(Node::id) + .dropLast(count) + + val poppedNavigationState = state.findNavigationStateMatching( + backstackIds = poppedBackStackIds, + ) + state.onPopped(poppedNavigationState) + }, + entryDecorators = listOf( + rememberSceneSetupNavEntryDecorator(), + rememberSavedStateNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + sceneStrategy = sceneStrategy, + transitionSpec = transitionSpec, + popTransitionSpec = transitionSpec, + predictivePopTransitionSpec = transitionSpec, + entryProvider = state.navEntryProvider, + ) + + NavigationEventHandler( + enabled = AlwaysTrue, + passThrough = true, + ) { progress -> + try { + progress.collect { + backStatusState.value = BackStatus.Seeking + } + backStatusState.value = BackStatus.Completed.Commited + } catch (e: CancellationException) { + backStatusState.value = BackStatus.Completed.Cancelled + } + } +} + +@Stable +private class MultiPanePaneSceneStrategy( + private val state: MultiPaneDisplayState, + private val slots: Set, + private val backStatus: () -> BackStatus, + private val currentPanedNavigationState: () -> SlotBasedPanedNavigationState, + private val content: @Composable (MultiPaneDisplayScope.() -> Unit), +) : SceneStrategy { + + val scenes = mutableMapOf>() + + @Composable + override fun calculateScene( + entries: List>, + onBack: (count: Int) -> Unit + ): Scene { + + val backstackIds = remember(entries.identityHash()) { + entries.map { it.id } + } + + return remember(backstackIds) { + + // Calculate the scene for the entries specified. + // Since there might be a predictive back gesture, pop until the right navigation state + // is found + val currentNavigationState = state.findNavigationStateMatching( + backstackIds = backstackIds, + ) + + val panedNavigationState = currentPanedNavigationState() + + val destination = state.destinationTransform(currentNavigationState) + + val activeIds = destination.children.mapTo(mutableSetOf(), Node::id) + destination.id + + val poppedNavigationState = state.popTransform(currentNavigationState) + + val poppedBackstack = + if (currentNavigationState == poppedNavigationState) emptyList() + else state.backStackTransform(poppedNavigationState) + + val mutableEntries = entries.toMutableList() + + val sceneKey = MultiPaneSceneKey( + ids = backstackIds, + isPreviewingBack = backstackIds != panedNavigationState.backStackIds + ) + + MultiPaneDisplayScene( + destination = destination, + sceneKey = sceneKey, + slots = slots, + backStatus = backStatus, + panesToDestinations = state.destinationPanes, + onSceneDisposed = { scenes.remove(sceneKey) }, + currentPanedNavigationState = panedNavigationState, + entries = entries.filter { it.id in activeIds }, + // Try to match up NavEntries to state using their id and children. + // Best case is O(n) where the backstack isn't shuffled. + previousEntries = poppedBackstack.map { poppedDestination -> + val index = mutableEntries.indexOfFirst { + it.id == poppedDestination.id && it.children == poppedDestination.children + } + mutableEntries.removeAt(index) + }, + scopeContent = content + ).also { + scenes[sceneKey] = it + } + } + } +} + +@Stable +private class MultiPaneDisplayScene( + override val entries: List>, + override val previousEntries: List>, + private val sceneKey: MultiPaneSceneKey, + private val destination: Destination, + private val slots: Set, + private val currentPanedNavigationState: SlotBasedPanedNavigationState, + private val onSceneDisposed: () -> Unit, + backStatus: () -> BackStatus, + private val panesToDestinations: @Composable (Destination) -> Map, + private val scopeContent: @Composable (MultiPaneDisplayScope.() -> Unit), +) : Scene { + + private val panedNavigationState = mutableStateOf(currentPanedNavigationState) + + @Stable + val multiPaneDisplayScope = PaneDestinationMultiPaneDisplayScope( + panedNavigationState = panedNavigationState, + entries = entries, + backStatus = backStatus, + ) + + override val key: Any = sceneKey + + override val content: @Composable () -> Unit = { + + currentPanedNavigationState.rememberUpdatedPanedNavigationState( + backStackIds = sceneKey.ids, + panesToDestinations = panesToDestinations(destination), + slots = slots, + ).also { panedNavigationState.value = it.value } + + multiPaneDisplayScope.scopeContent() + + DisposableEffect(Unit) { + onDispose(onSceneDisposed) + } + } + + @Stable + class PaneDestinationMultiPaneDisplayScope( + panedNavigationState: State>, + private val entries: List>, + private val backStatus: () -> BackStatus, + ) : MultiPaneDisplayScope { + + private val panedNavigationState by panedNavigationState + + override val panes: Collection + get() = panedNavigationState.panesToDestinations.keys + + @Composable + override fun Destination(pane: Pane) { + val id = panedNavigationState.destinationFor(pane)?.id + val entry = entries.firstOrNull { it.id == id } ?: return + + val paneState = remember(panedNavigationState.identityHash()) { + panedNavigationState.slotFor(pane)?.let(panedNavigationState::paneStateFor) + } ?: return + + val animatedContentScope = LocalNavAnimatedContentScope.current + + val scope = remember { + AnimatedPaneScope( + backStatus = backStatus, + paneState = paneState, + animatedContentScope = animatedContentScope, + ) + }.also { + it.paneState = paneState + } + + CompositionLocalProvider( + LocalPaneScope provides scope + ) { + with(scope) { + val paneModifier = remember( + isActive, + inPredictiveBack, + panedNavigationState.identityHash(), + animatedContentScope.transition.targetState, + ) { + val enterTransition = entry.paneEnterTransition(this) + val exitTransition = entry.paneExitTransition(this) + val shouldAnimate = enterTransition != EnterTransition.None + || exitTransition != ExitTransition.None + + if (shouldAnimate) Modifier.animateEnterExit( + enterTransition, + exitTransition + ) + else Modifier + } + + Box( + modifier = paneModifier, + content = { + entry.Content() + } + ) + } + } + } + + override fun adaptationsIn(pane: Pane): Set = + panedNavigationState.adaptationsIn(pane) + + override fun destinationIn(pane: Pane): Destination? = + panedNavigationState.destinationFor(pane) + } +} + +private fun MultiPaneDisplayState.findNavigationStateMatching( + backstackIds: List, +): NavigationState { + var state = navigationState.value + while (backStackTransform(state).map(Node::id) != backstackIds) { + state = popTransform(state) + } + return state } + +@Composable +private fun SlotBasedPanedNavigationState.rememberUpdatedPanedNavigationState( + backStackIds: List, + panesToDestinations: Map, + slots: Set +): State> = + remember { + mutableStateOf(this) + }.also { + val backStackChanged = it.value.backStackIds != backStackIds + val paneMappingChanged = it.value.panesToDestinations != panesToDestinations + + if (backStackChanged || paneMappingChanged) { + it.value = it.value.adaptTo( + slots = slots, + panesToDestinations = panesToDestinations, + backStackIds = backStackIds, + ) + } + } + +internal class MultiPaneSceneKey( + val ids: List, + val isPreviewingBack: Boolean, +) { + + private val idsHash = ids.hashCode() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as MultiPaneSceneKey + + return ids == other.ids + } + + override fun hashCode(): Int { + return idsHash + } +} + +internal sealed class BackStatus { + data object Seeking : BackStatus() + sealed class Completed : BackStatus() { + data object Commited : Completed() + data object Cancelled : Completed() + } +} + +private val AlwaysTrue = { true } + +private val AnimatedContentTransitionScope<*>.sceneDestinationKey: MultiPaneSceneKey + get() { + val target = targetState as Pair<*, *> + return target.second as MultiPaneSceneKey + } + +private val LocalPaneScope = staticCompositionLocalOf> { + throw IllegalArgumentException( + "PaneScope should not be read until provided in the composition" + ) +} + +@Composable +internal fun localPaneScope(): PaneScope { + @Suppress("UNCHECKED_CAST") + return LocalPaneScope.current as PaneScope +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt index 0e39bee..7df2e03 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/MultiPaneDisplayState.kt @@ -16,14 +16,17 @@ package com.tunjid.treenav.compose +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.State import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.transforms.CompoundTransform -import com.tunjid.treenav.compose.transforms.DestinationTransform +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry +import com.tunjid.treenav.compose.transforms.PaneMappingTransform import com.tunjid.treenav.compose.transforms.PaneTransform -import com.tunjid.treenav.compose.transforms.RenderTransform -import com.tunjid.treenav.compose.transforms.Transform +import com.tunjid.treenav.compose.transforms.PaneRenderTransform /** * Class for configuring a [MultiPaneDisplay] for selecting, adapting and placing navigation @@ -35,18 +38,69 @@ import com.tunjid.treenav.compose.transforms.Transform * @param navigationState the navigation state to be adapted into various panes. * @param backStackTransform a transform to read the back stack of the navigation state. * @param destinationTransform a transform of the [navigationState] to its current destination. - * @param panesToDestinationsTransform provides the strategy used to adapt the current + * @param popTransform a transform of the [navigationState] when back is pressed. + * @param onPopped an action to perform when the navigation state has been popped to a new state. + * @param destinationPanes provides the strategy used to adapt the current * [Destination] to the panes available. - * @param renderTransform the transform used to render a [Destination] in its pane. + * @param destinationContent the transform used to render a [Destination] in its pane. */ -class MultiPaneDisplayState internal constructor( - val panes: List, - val navigationState: State, - val backStackTransform: (NavigationState) -> List, - val destinationTransform: (NavigationState) -> Destination, - val panesToDestinationsTransform: @Composable (Destination) -> Map, - val renderTransform: @Composable PaneScope.(Destination) -> Unit, -) +@Stable +class MultiPaneDisplayState internal constructor( + internal val panes: List, + internal val navigationState: State, + internal val backStackTransform: (NavigationState) -> List, + internal val destinationTransform: (NavigationState) -> Destination, + internal val popTransform: (NavigationState) -> NavigationState, + internal val onPopped: (NavigationState) -> Unit, + internal val transitionSpec: MultiPaneDisplayScope.() -> ContentTransform, + internal val paneEntryProvider: (Destination) -> PaneEntry, + internal val destinationPanes: @Composable (Destination) -> Map, + internal val destinationContent: @Composable PaneScope.(PaneEntry, Destination) -> Unit, +) { + + internal val navEntryProvider = { destination: Destination -> + val paneEntry = paneEntryProvider(destination) + NavEntry( + key = destination, + contentKey = destination.id, + metadata = mapOf( + ID_KEY to destination.id, + DESTINATION_KEY to destination, + CHILDREN_KEY to destination.children, + PANE_ENTER_TRANSITION_KEY to paneEntry.enterTransition, + PANE_EXIT_TRANSITION_KEY to paneEntry.exitTransition, + ), + content = { innerDestination -> + destinationContent(localPaneScope(), paneEntry, innerDestination) + }, + ) + } + + companion object { + private const val ID_KEY = "com.tunjid.treenav.compose.id" + private const val DESTINATION_KEY = "com.tunjid.treenav.compose.destination" + private const val CHILDREN_KEY = "com.tunjid.treenav.compose.children" + private const val PANE_ENTER_TRANSITION_KEY = + "com.tunjid.treenav.compose.pane.enter.transition" + private const val PANE_EXIT_TRANSITION_KEY = + "com.tunjid.treenav.compose.pane.exit.transition" + + internal val NavEntry<*>.id get() = metadata[ID_KEY] as String + internal val NavEntry<*>.children get() = metadata[CHILDREN_KEY] + + @Suppress("UNCHECKED_CAST") + internal inline fun NavEntry<*>.destination() = + metadata[DESTINATION_KEY] as T + + @Suppress("UNCHECKED_CAST") + internal inline val NavEntry<*>.paneEnterTransition + get() = metadata[PANE_ENTER_TRANSITION_KEY] as PaneScope<*, *>.() -> EnterTransition + + @Suppress("UNCHECKED_CAST") + internal inline val NavEntry<*>.paneExitTransition + get() = metadata[PANE_EXIT_TRANSITION_KEY] as PaneScope<*, *>.() -> ExitTransition + } +} /** * Provides an [MultiPaneDisplayState] for configuring a [MultiPaneDisplay] for @@ -59,82 +113,90 @@ class MultiPaneDisplayState in * @param navigationState the navigation state to be adapted into various panes. * @param backStackTransform a transform to read the back stack of the navigation state. * @param destinationTransform a transform of the [navigationState] to its current destination. - * @param entryProvider provides the [Transform]s and content needed to render + * @param popTransform a transform of the [navigationState] when back is pressed. + * @param onPopped an action to perform when the navigation state has been popped to a new state. + * @param entryProvider provides the [PaneTransform]s and content needed to render * a [Destination] in its pane. * @param transforms a list of transforms applied to every [Destination] before it is * rendered in its pane. Order matters; they are applied from last to first. */ -fun MultiPaneDisplayState( +fun MultiPaneDisplayState( panes: List, + transforms: List>, navigationState: State, backStackTransform: (NavigationState) -> List, destinationTransform: (NavigationState) -> Destination, + popTransform: (NavigationState) -> NavigationState, + onPopped: (NavigationState) -> Unit, + transitionSpec: MultiPaneDisplayScope.() -> ContentTransform = { + NoContentTransform + }, entryProvider: (Destination) -> PaneEntry, - transforms: List>, ) = transforms.fold( initial = MultiPaneDisplayState( panes = panes, navigationState = navigationState, backStackTransform = backStackTransform, destinationTransform = destinationTransform, - panesToDestinationsTransform = { destination -> - entryProvider(destination).paneTransform(destination) + popTransform = popTransform, + onPopped = onPopped, + transitionSpec = transitionSpec, + paneEntryProvider = entryProvider, + destinationPanes = { destination -> + entryProvider(destination).paneMapping(destination) }, - renderTransform = { destination -> - val nav = entryProvider(destination) - with(nav.renderTransform) { - Render( - destination = destination, - previousTransform = nav.content, - ) - } + destinationContent = transform@{ paneEntry, destination -> + paneEntry.content(this@transform, destination) } ), - operation = MultiPaneDisplayState::plus + operation = MultiPaneDisplayState::plus ) -private operator fun - MultiPaneDisplayState.plus( - transform: Transform, -): MultiPaneDisplayState = - if (transform is CompoundTransform) transform.transforms.fold( - initial = this, - operation = MultiPaneDisplayState::plus, - ) - else MultiPaneDisplayState( +private operator fun + MultiPaneDisplayState.plus( + transform: PaneTransform, +): MultiPaneDisplayState = + MultiPaneDisplayState( panes = panes, navigationState = navigationState, backStackTransform = backStackTransform, - destinationTransform = when (transform) { - is DestinationTransform -> { destination -> - transform.toDestination( - navigationState = destination, - previousTransform = destinationTransform - ) - } - - else -> destinationTransform - }, - panesToDestinationsTransform = when (transform) { - is PaneTransform -> { destination -> + popTransform = popTransform, + onPopped = onPopped, + destinationTransform = destinationTransform, + transitionSpec = transitionSpec, + paneEntryProvider = paneEntryProvider, + destinationPanes = when (transform) { + is PaneMappingTransform -> { destination -> transform.toPanesAndDestinations( destination = destination, - previousTransform = panesToDestinationsTransform, + previousTransform = destinationPanes, ) } - else -> panesToDestinationsTransform + else -> destinationPanes }, - renderTransform = when (transform) { - is RenderTransform -> { destination -> + destinationContent = when (transform) { + is PaneRenderTransform -> { paneEntry, destination -> with(transform) { Render( destination = destination, - previousTransform = renderTransform, + previousTransform = previous@{ innerDestination -> + destinationContent( + this@previous, + paneEntry, + innerDestination, + ) + }, ) } } - else -> renderTransform + else -> destinationContent }, ) + +private val NoContentTransform = ContentTransform( + targetContentEnter = EnterTransition.None, + initialContentExit = ExitTransition.None, + sizeTransform = null, +) \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneEntry.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneEntry.kt index 7923ecf..172d161 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneEntry.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneEntry.kt @@ -1,9 +1,10 @@ package com.tunjid.treenav.compose +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.transforms.RenderTransform /** * Provides the logic used to select, configure and place a navigation [Destination] for each @@ -11,7 +12,21 @@ import com.tunjid.treenav.compose.transforms.RenderTransform */ @Stable class PaneEntry( - internal val renderTransform: RenderTransform, - internal val paneTransform: @Composable (Destination) -> Map, + /** + * The [EnterTransition] used when this [PaneEntry] adapts to its current [Pane]. + */ + internal val enterTransition: PaneScope.() -> EnterTransition, + /** + * The [ExitTransition] used when this [PaneEntry] adapts to its current [Pane]. + */ + internal val exitTransition: PaneScope.() -> ExitTransition, + /** + * Provides the [Destination]s that are shown alongside the [Destination] provided and + * what [Pane]s they should show up in. + */ + internal val paneMapping: @Composable (Destination) -> Map, + /** + * Provides the content to show for the given [Destination]. + */ internal val content: @Composable PaneScope.(Destination) -> Unit, ) \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneScope.kt index 576f3e1..a9f695e 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneScope.kt @@ -18,8 +18,9 @@ package com.tunjid.treenav.compose import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.Transition import androidx.compose.runtime.Stable -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -38,11 +39,19 @@ interface PaneScope : AnimatedVisibilityScope { val paneState: PaneState /** - * Whether or not this [PaneScope] is active in its current pane. It is inactive when - * it is animating out of its [AnimatedVisibilityScope]. + * Whether or not this [PaneScope] is active in its current pane. It is active when this pane's + * transition matches the pane for the current navigation destination in a given scene. + * + * This means that during predictive back animations, the outgoing panes, i.e the panes + * whose [AnimatedVisibilityScope.transition] have their [Transition.targetState] + * NOT reporting [EnterExitState.Visible] are considered active. */ val isActive: Boolean + /** + * Whether or not a predictive back gesture is in progress + */ + val inPredictiveBack: Boolean } /** @@ -50,14 +59,34 @@ interface PaneScope : AnimatedVisibilityScope { */ @Stable internal class AnimatedPaneScope( + val backStatus: () -> BackStatus, paneState: PaneState, - activeState: State, - val animatedContentScope: AnimatedContentScope + animatedContentScope: AnimatedContentScope, ) : PaneScope, AnimatedVisibilityScope by animatedContentScope { + private val isEntering + get() = transition.targetState == EnterExitState.Visible + override var paneState by mutableStateOf(paneState) - override val isActive: Boolean by activeState + override val isActive: Boolean + get() = if (inPredictiveBack) !isEntering else isEntering + + override val inPredictiveBack: Boolean + get() { + val currentSize = transition.sceneCurrentDestinationKey?.ids?.size ?: 0 + val targetSize = transition.sceneTargetDestinationKey?.ids?.size ?: 0 + + val targetIsPreview = transition.sceneTargetDestinationKey?.isPreviewingBack == true + + val isAnimatingBack = targetSize < currentSize + + return when (backStatus()) { + BackStatus.Seeking -> isAnimatingBack && targetIsPreview + BackStatus.Completed.Cancelled -> isAnimatingBack && targetIsPreview + BackStatus.Completed.Commited -> false + } + } } /** @@ -86,3 +115,17 @@ internal data class SlotPaneState( */ @JvmInline internal value class Slot internal constructor(val index: Int) + +private val Transition<*>.sceneTargetDestinationKey: MultiPaneSceneKey? + get() { + val target = parentTransition?.targetState as? Pair<*, *> ?: return null + return target.second as MultiPaneSceneKey + } + +private val Transition<*>.sceneCurrentDestinationKey: MultiPaneSceneKey? + get() { + val target = parentTransition?.currentState as? Pair<*, *> ?: return null + return target.second as MultiPaneSceneKey + } + +internal expect fun Any.identityHash(): Int diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneSharedTransitionScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneSharedTransitionScope.kt index d85256e..3e5a8ab 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneSharedTransitionScope.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/PaneSharedTransitionScope.kt @@ -46,7 +46,7 @@ interface PaneSharedTransitionScope : * @see [SharedTransitionScope.sharedElement]. */ fun Modifier.paneSharedElement( - key: Any, + sharedContentState: SharedTransitionScope.SharedContentState, boundsTransform: BoundsTransform = Defaults.DefaultBoundsTransform, placeHolderSize: PlaceHolderSize = contentSize, renderInOverlayDuringTransition: Boolean = true, diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlotBasedPanedNavigationState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlotBasedPanedNavigationState.kt index 3208ce5..eec6320 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlotBasedPanedNavigationState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SlotBasedPanedNavigationState.kt @@ -55,9 +55,9 @@ internal data class SlotBasedPanedNavigationState( val destinationIdsAnimatingOut: Set, ) { companion object { - internal fun initial( + internal fun initial( slots: Collection, - ): SlotBasedPanedNavigationState = SlotBasedPanedNavigationState( + ): SlotBasedPanedNavigationState = SlotBasedPanedNavigationState( isPop = false, swapAdaptations = emptySet(), panesToDestinations = emptyMap(), @@ -113,25 +113,31 @@ internal data class SlotBasedPanedNavigationState( fun adaptationsIn( pane: Pane, ): Set { - val swaps = swapAdaptations.filter { pane in it } - val adaptations = if (swaps.isEmpty()) when (panesToDestinations[pane]?.id) { - previousPanesToDestinations[pane]?.id -> setOf(Adaptation.Same) - else -> setOf(Adaptation.Change) + val adaptations = when { + swapAdaptations.any { pane in it } -> swapAdaptations.filterTo(mutableSetOf()) { + pane in it + } + else -> when (panesToDestinations[pane]?.id) { + previousPanesToDestinations[pane]?.id -> SameAdaptations + else -> ChangeAdaptations + } } - else swaps.toSet() return if (isPop) adaptations + Adaptation.Pop else adaptations } } +private val SameAdaptations = setOf(Adaptation.Same) +private val ChangeAdaptations = setOf(Adaptation.Change) + /** * A method that adapts changes in navigation to different panes while allowing for them * to be animated easily. */ -internal fun SlotBasedPanedNavigationState.adaptTo( +internal fun SlotBasedPanedNavigationState.adaptTo( slots: Set, - panesToDestinations: Map, + panesToDestinations: Map, backStackIds: List, -): SlotBasedPanedNavigationState { +): SlotBasedPanedNavigationState { val previous = this val previouslyUsedSlots = previous.destinationIdsToAdaptiveSlots @@ -148,7 +154,7 @@ internal fun SlotBasedPanedNavigationState.adaptTo( val unplacedNodeIds = panesToDestinations.values.mapNotNull { it?.id }.toMutableSet() val nodeIdsToAdaptiveSlots = mutableMapOf() - val swapAdaptations = mutableSetOf>() + val swapAdaptations = mutableSetOf>() // Process nodes that swapped panes from old to new for ((toPane, toNode) in panesToDestinations.entries) { @@ -185,7 +191,7 @@ internal fun SlotBasedPanedNavigationState.adaptTo( } return SlotBasedPanedNavigationState( - backStackIds.let popCheck@{ ids -> + isPop = backStackIds.let popCheck@{ ids -> if (ids.size >= previous.backStackIds.size) return@popCheck false if (ids.isEmpty()) return@popCheck true @@ -208,33 +214,4 @@ internal fun SlotBasedPanedNavigationState.adaptTo( destinationIdsAnimatingOut = previous.destinationIdsAnimatingOut, ) -} - -/** - * Checks if any of the new routes coming in has any conflicts with those animating out. - */ -internal fun SlotBasedPanedNavigationState.hasConflictingRoutes(): Boolean = - panesToDestinations.keys - .map(::destinationFor) - .any { - it?.id?.let(destinationIdsAnimatingOut::contains) == true - } - -/** - * Trims unneeded metadata from the [SlotBasedPanedNavigationState] - */ -internal fun SlotBasedPanedNavigationState.prune(): SlotBasedPanedNavigationState = - copy( - destinationIdsToAdaptiveSlots = destinationIdsToAdaptiveSlots.filter { (routeId) -> - if (routeId == null) return@filter false - backStackIds.contains(routeId) - || destinationIdsAnimatingOut.contains(routeId) - || previousPanesToDestinations.values.map { it?.id }.toSet().contains(routeId) - }, - previousPanesToDestinations = previousPanesToDestinations.filter { (_, route) -> - if (route == null) return@filter false - backStackIds.contains(route.id) - || destinationIdsAnimatingOut.contains(route.id) - || previousPanesToDestinations.values.map { it?.id }.toSet().contains(route.id) - } - ) \ No newline at end of file +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt index 89b29af..d415b5c 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/StackNavExt.kt @@ -29,7 +29,7 @@ inline fun MultiStackNav.multiPaneDisplayBackstack( backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = true, + distinctDestinations = false, ) .filterIsInstance() @@ -41,6 +41,6 @@ inline fun StackNav.multiPaneDisplayBackstack(): Li backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = true, + distinctDestinations = false, ) .filterIsInstance() \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/lifecycle/DestinationLifecycleOwner.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/lifecycle/DestinationLifecycleOwner.kt deleted file mode 100644 index 421073c..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/lifecycle/DestinationLifecycleOwner.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.tunjid.treenav.compose.lifecycle - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.remember -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Lifecycle.State -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.PaneScope -import com.tunjid.treenav.compose.SlotBasedPanedNavigationState - -@Composable -internal fun rememberDestinationLifecycleOwner( - destination: Node, -): DestinationLifecycleOwner { - val hostLifecycleOwner = LocalLifecycleOwner.current - val destinationLifecycleOwner = remember(hostLifecycleOwner) { - DestinationLifecycleOwner( - destination = destination, - host = hostLifecycleOwner - ) - } - return destinationLifecycleOwner -} - -@Stable -internal class DestinationLifecycleOwner( - private val destination: Node, - private val host: LifecycleOwner -) : LifecycleOwner { - - private val lifecycleRegistry = LifecycleRegistry(this) - - override val lifecycle: Lifecycle - get() = lifecycleRegistry - - val hostLifecycleState = host.lifecycle - - fun update( - hostLifecycleState: State, - paneScope: PaneScope<*, *>, - panedNavigationState: SlotBasedPanedNavigationState<*, *>, - ) { - val active = paneScope.isActive - val exists = panedNavigationState.backStackIds.contains( - destination.id - ) - val derivedLifecycleState = when { - !exists -> State.DESTROYED - !active -> State.STARTED - else -> hostLifecycleState - } - lifecycleRegistry.currentState = - if (host.lifecycle.currentState.ordinal < derivedLifecycleState.ordinal) hostLifecycleState - else derivedLifecycleState - } -} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElements.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElements.kt index f55184b..dc17bf1 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElements.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElements.kt @@ -41,7 +41,7 @@ interface MovableSharedElementScope { * NOTE: It is an error to compose the movable shared element in different locations * simultaneously, and the behavior of the shared element is undefined in this case. * - * @param key The shared element key to identify the movable shared element. + * @param sharedContentState The shared element key to identify the movable shared element. * @param boundsTransform Allows for customizing the animation for the bounds of * the [sharedElement]. * @param placeHolderSize Allows for adjusting the reported size to the parent layout during @@ -66,7 +66,7 @@ interface MovableSharedElementScope { */ @OptIn(ExperimentalSharedTransitionApi::class) fun movableSharedElementOf( - key: Any, + sharedContentState: SharedContentState, boundsTransform: BoundsTransform, placeHolderSize: PlaceHolderSize, renderInOverlayDuringTransition: Boolean, @@ -83,7 +83,7 @@ interface MovableSharedElementScope { * * @see [MovableSharedElementScope.movableSharedElementOf]. * - * @param key The shared element key to identify the movable shared element. + * @param sharedContentState The shared element key to identify the movable shared element. * @param boundsTransform Allows for customizing the animation for the bounds of * the [sharedElement]. * @param placeHolderSize Allows for adjusting the reported size to the parent layout during @@ -107,7 +107,7 @@ interface MovableSharedElementScope { @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun MovableSharedElementScope.updatedMovableSharedElementOf( - key: Any, + sharedContentState: SharedContentState, state: T, modifier: Modifier = Modifier, boundsTransform: BoundsTransform = Defaults.DefaultBoundsTransform, @@ -118,7 +118,7 @@ fun MovableSharedElementScope.updatedMovableSharedElementOf( alternateOutgoingSharedElement: (@Composable (T, Modifier) -> Unit)? = null, sharedElement: @Composable (T, Modifier) -> Unit ) = movableSharedElementOf( - key = key, + sharedContentState = sharedContentState, boundsTransform = boundsTransform, placeHolderSize = placeHolderSize, renderInOverlayDuringTransition = renderInOverlayDuringTransition, @@ -164,17 +164,17 @@ class MovableSharedElementHostState( */ @Suppress("UnusedReceiverParameter") fun MovableSharedElementScope.createOrUpdateSharedElement( - key: Any, sharedContentState: SharedContentState, sharedElement: @Composable (S, Modifier) -> Unit, ): @Composable (S, Modifier) -> Unit { - val movableSharedElementState = keysToMovableSharedElements.getOrPut(key) { - MovableSharedElementState( - sharedContentState = sharedContentState, - sharedElement = sharedElement, - onRemoved = { keysToMovableSharedElements.remove(key) } - ) - }.also { it.sharedContentState = sharedContentState } + val movableSharedElementState = + keysToMovableSharedElements.getOrPut(sharedContentState.key) { + MovableSharedElementState( + sharedContentState = sharedContentState, + sharedElement = sharedElement, + onRemoved = { keysToMovableSharedElements.remove(sharedContentState.key) } + ) + }.also { it.sharedContentState = sharedContentState } // Can't really guarantee that the caller will use the same key for the right type return movableSharedElementState.moveableSharedElement @@ -212,7 +212,7 @@ class PaneMovableSharedElementScope internal construct @OptIn(ExperimentalSharedTransitionApi::class) override fun movableSharedElementOf( - key: Any, + sharedContentState: SharedContentState, boundsTransform: BoundsTransform, placeHolderSize: PlaceHolderSize, renderInOverlayDuringTransition: Boolean, @@ -222,7 +222,6 @@ class PaneMovableSharedElementScope internal construct sharedElement: @Composable (T, Modifier) -> Unit ): @Composable (T, Modifier) -> Unit = { state, modifier -> with(movableSharedElementHostState) { - val sharedContentState = rememberSharedContentState(key) Box( modifier .sharedElementWithCallerManagedVisibility( @@ -238,7 +237,6 @@ class PaneMovableSharedElementScope internal construct when { paneScope.isActive -> createOrUpdateSharedElement( - key = key, sharedContentState = sharedContentState, sharedElement = sharedElement )(state, Modifier.fillMaxConstraints()) @@ -248,8 +246,8 @@ class PaneMovableSharedElementScope internal construct else -> when { // The element is being shared in its new destination, stop showing it // in the in active one - movableSharedElementHostState.isCurrentlyShared(key) - && movableSharedElementHostState.isMatchFound(key) -> Defaults.EmptyElement( + movableSharedElementHostState.isCurrentlyShared(sharedContentState.key) + && movableSharedElementHostState.isMatchFound(sharedContentState.key) -> Defaults.EmptyElement( state, Modifier.fillMaxConstraints() ) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/DecoratedNavEntryProvider.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/DecoratedNavEntryProvider.kt deleted file mode 100644 index 497c500..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/DecoratedNavEntryProvider.kt +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.tunjid.treenav.compose.navigation3 - - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.key -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.staticCompositionLocalOf -import com.tunjid.treenav.compose.navigation3.decorators.rememberSavedStateNavEntryDecorator -import kotlin.jvm.JvmSuppressWildcards - -/** - * Function that provides all of the [NavEntry]s wrapped with the given [NavEntryDecorator]s. It is - * responsible for executing the functions provided by each [NavEntryDecorator] appropriately. - * - * Note: the order in which the [NavEntryDecorator]s are added to the list determines their scope, - * i.e. a [NavEntryDecorator] added earlier in a list has its data available to those added later. - * - * @param backStack the list of keys that represent the backstack - * @param entryDecorators the [NavEntryDecorator]s that are providing data to the content - * @param entryProvider a function that returns the [NavEntry] for a given key - * @param content the content to be displayed - */ -@Composable -internal fun DecoratedNavEntryProvider( - backStack: List, - entryProvider: (key: T) -> NavEntry, - entryDecorators: List<@JvmSuppressWildcards NavEntryDecorator<*>> = - listOf(rememberSavedStateNavEntryDecorator()), - content: @Composable (List>) -> Unit -) { - // Kotlin does not know these things are compatible so we need this explicit cast - // to ensure our lambda below takes the correct type - entryProvider as (T) -> NavEntry - - // Generates a list of entries that are wrapped with the given providers - val entries = - backStack.map { - val entry = entryProvider.invoke(it) - decorateEntry(entry, entryDecorators as List>) - } - - // Provides the entire backstack to the previously wrapped entries - val initial: @Composable () -> Unit = remember(entries) { { content(entries) } } - - PrepareBackStack(backStack, entryDecorators, initial) -} - -/** - * Wraps a [NavEntry] with the list of [NavEntryDecorator] in the order that the decorators were - * added to the list. - * - * Invokes pop callback for popped entries that had pop animations and thus could not be cleaned up - * by [PrepareBackStack]. PrepareBackStack has no access to animation state so we rely on this - * function to call onPop when animation finishes. - */ -@Composable -internal fun decorateEntry( - entry: NavEntry, - decorators: List>, -): NavEntry { - val initial = - object : NavEntryWrapper(entry) { - override val content: @Composable ((T) -> Unit) = { - val key = entry.key - // Tracks whether the key is changed - var keyChanged = false - val localInfo = LocalNavEntryDecoratorLocalInfo.current - val keyIds = localInfo.keyIds[key] - val lastId = keyIds!!.last() - var id: Int = - rememberSaveable(keyIds.last()) { - keyChanged = true - lastId - } - id = - rememberSaveable(keyIds.size) { - // if the key changed, use the current id - // If the key was not changed, and the current id is not in composition - // or on - // the - // back - // stack then update the id with the last item from the backstack with - // the - // associated - // key. This ensures that we can handle duplicates, both consecutive and - // non-consecutive - if ( - !keyChanged && - (!localInfo.idsInComposition.contains(id) || keyIds.contains(id)) - ) { - lastId - } else { - id - } - } - - keyChanged = false - - // store onPop for every decorator that has ever decorated this entry - // so that onPop will be called for newly added or removed decorators as well - val popCallbacks = remember { LinkedHashSet<(Any) -> Unit>() } - - DisposableEffect(key1 = key) { - localInfo.idsInComposition.add(id) - onDispose { - val notInComposition = localInfo.idsInComposition.remove(id) - val popped = !localInfo.keyIds.contains(key) - if (notInComposition && popped) { - // we reverse the scopes before popping to imitate the order - // of onDispose calls if each scope/decorator had their own - // onDispose - // calls for clean up - // convert to mutableList first for backwards compat. - popCallbacks.toMutableList().reversed().forEach { it(key) } - // If the refCount is 0, remove the key from the refCount. - if (localInfo.keyIds[key]?.isEmpty() == true) { - localInfo.keyIds.remove(key) - } - } - } - } - decorators.distinct().forEach { decorator -> popCallbacks.add(decorator.onPop) } - DecorateNavEntry(entry, decorators) - } - } - return initial -} - -/** - * Sets up logic to track changes to the backstack and invokes the [DecoratedNavEntryProvider] - * content. - * - * Invokes pop callback for popped entries that: - * 1. are not animating (i.e. no pop animations) AND / OR - * 2. have never been composed (i.e. never invoked with [DecorateNavEntry]) - */ -@Composable -internal fun PrepareBackStack( - backStack: List, - decorators: List>, - content: @Composable (() -> Unit), -) { - val localInfo = remember { NavEntryDecoratorLocalInfo() } - - DisposableEffect(key1 = backStack) { onDispose { localInfo.keyIds.clear() } } - - backStack.forEachIndexed { index, key -> - val id = getIdForEntry(key, index) - localInfo.keyIds.getOrPut(key) { LinkedHashSet() }.add(id) - - // store onPop for every decorator has ever decorated this key - // so that onPop will be called for newly added or removed decorators as well - val popCallbacks = remember(key) { LinkedHashSet<(Any) -> Unit>() } - decorators.distinct().forEach { popCallbacks.add(it.onPop) } - - key(key) { - DisposableEffect(key) { - // We update here as part of composition to ensure the value is available to - // ProvideToEntry - localInfo.keyIds.getOrPut(key) { LinkedHashSet() }.add(id) - onDispose { - // If the backStack count is less than the refCount for the key, remove the - // state since that means we removed a key from the backstack, and set the - // refCount to the backstack count. - val backstackCount = backStack.count { it == key } - val lastKeyCount = localInfo.keyIds[key]?.size ?: 0 - if (backstackCount < lastKeyCount) { - // if popped, remove id from set of ids for this key - localInfo.keyIds[key]!!.remove(id) - // run onPop callback - if (!localInfo.idsInComposition.contains(id)) { - // we reverse the order before popping to imitate the order - // of onDispose calls if each scope/decorator had their own onDispose - // calls for clean up. convert to mutableList first for backwards compat. - popCallbacks.toMutableList().reversed().forEach { it(key) } - } - } - // If the refCount is 0, remove the key from the refCount. - if (localInfo.keyIds[key]?.isEmpty() == true) { - localInfo.keyIds.remove(key) - } - } - } - } - } - CompositionLocalProvider(LocalNavEntryDecoratorLocalInfo provides localInfo) { content() } -} - -private class NavEntryDecoratorLocalInfo { - val keyIds: MutableMap> = mutableMapOf() - - @Suppress("PrimitiveInCollection") // The order of the element matters - val idsInComposition: LinkedHashSet = LinkedHashSet() - val popCallbacks: LinkedHashMap Unit> = LinkedHashMap() - - fun populatePopMap(decorators: List>) { - decorators.reversed().forEach { decorator -> - popCallbacks.getOrPut(decorator.hashCode(), decorator::onPop) - } - } -} - -private val LocalNavEntryDecoratorLocalInfo = - staticCompositionLocalOf { - error( - "CompositionLocal LocalProviderLocalInfo not present. You must call " + - "ProvideToBackStack before calling ProvideToEntry." - ) - } - -private fun getIdForEntry(key: Any, count: Int): Int = 31 * key.hashCode() + count \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntryDecorator.kt deleted file mode 100644 index 543daf8..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntryDecorator.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.tunjid.treenav.compose.navigation3 - -import androidx.compose.runtime.Composable -import kotlin.jvm.JvmSuppressWildcards - -/** Marker class to hold the onPop and decorator functions that will be invoked at runtime. */ -internal class NavEntryDecorator -internal constructor( - internal val onPop: (key: Any) -> Unit, - internal val navEntryDecorator: @Composable (entry: NavEntry) -> Unit -) - -/** - * Function to provide information to all the [NavEntry] that are integrated with a - * [DecoratedNavEntryProvider]. - * - * @param onPop a callback that provides the key of a [NavEntry] that has been popped from the - * backStack and is leaving composition. This optional callback should to be used to clean up - * states that were used to decorate the NavEntry3 - * @param decorator the composable function to provide information to a [NavEntry] [decorator]. Note - * that this function only gets invoked for NavEntries that are actually getting rendered (i.e. by - * invoking the [NavEntry.content].) - */ -internal fun navEntryDecorator( - onPop: (key: Any) -> Unit = {}, - decorator: @Composable (entry: NavEntry) -> Unit -): NavEntryDecorator = NavEntryDecorator(onPop, decorator) - -/** - * Wraps a [NavEntry] with the list of [NavEntryDecorator] in the order that the decorators were - * added to the list and invokes the content of the wrapped entry. - */ -@Composable -internal fun DecorateNavEntry( - entry: NavEntry, - entryDecorators: List<@JvmSuppressWildcards NavEntryDecorator<*>> -) { - @Suppress("UNCHECKED_CAST") - (entryDecorators as List<@JvmSuppressWildcards NavEntryDecorator>) - .distinct() - .foldRight(initial = entry) { decorator, wrappedEntry -> - object : NavEntryWrapper(wrappedEntry) { - override val content: @Composable ((T) -> Unit) = { - decorator.navEntryDecorator(wrappedEntry) - } - } - } - .content - .invoke(entry.key) -} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/ViewModelStoreNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/ViewModelStoreNavEntryDecorator.kt index 7153277..daa1bc4 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/ViewModelStoreNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/ViewModelStoreNavEntryDecorator.kt @@ -36,8 +36,8 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.compose.LocalSavedStateRegistryOwner -import com.tunjid.treenav.compose.navigation3.NavEntryDecorator -import com.tunjid.treenav.compose.navigation3.navEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.NavEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.navEntryDecorator @Composable internal expect fun shouldRemoveViewModelStoreCallback(): () -> Boolean @@ -90,7 +90,7 @@ internal fun ViewModelStoreNavEntryDecorator( } } return navEntryDecorator(onPop) { entry -> - val viewModelStore = storeOwnerProvider.viewModelStoreForKey(entry.key) + val viewModelStore = storeOwnerProvider.viewModelStoreForKey(entry.contentKey) val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current val childViewModelStoreOwner = remember { @@ -123,7 +123,7 @@ internal fun ViewModelStoreNavEntryDecorator( } } CompositionLocalProvider(LocalViewModelStoreOwner provides childViewModelStoreOwner) { - entry.content.invoke(entry.key) + entry.Content() } } } diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/DecoratedNavEntryProvider.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/DecoratedNavEntryProvider.kt new file mode 100644 index 0000000..ad9f931 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/DecoratedNavEntryProvider.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.runtime + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.staticCompositionLocalOf +import kotlin.jvm.JvmSuppressWildcards + +/** + * Function that provides all of the [NavEntry]s wrapped with the given [NavEntryDecorator]s. It is + * responsible for executing the functions provided by each [NavEntryDecorator] appropriately. + * + * Note: the order in which the [NavEntryDecorator]s are added to the list determines their scope, + * i.e. a [NavEntryDecorator] added earlier in a list has its data available to those added later. + * + * @param T the type of the backStack key + * @param backStack the list of keys that represent the backstack + * @param entryDecorators the [NavEntryDecorator]s that are providing data to the content + * @param entryProvider a function that returns the [NavEntry] for a given key + * @param content the content to be displayed + */ +@Composable +internal fun DecoratedNavEntryProvider( + backStack: List, + entryProvider: (key: T) -> NavEntry, + entryDecorators: List<@JvmSuppressWildcards NavEntryDecorator<*>> = + listOf(rememberSavedStateNavEntryDecorator()), + content: @Composable (List>) -> Unit, +) { + // Kotlin does not know these things are compatible so we need this explicit cast + // to ensure our lambda below takes the correct type + entryProvider as (T) -> NavEntry + val entries = + backStack.map { key -> + val entry = entryProvider.invoke(key) + decorateEntry(entry, entryDecorators) + } + + // Provides the entire backstack to the previously wrapped entries + val initial: @Composable () -> Unit = remember(entries) { { content(entries) } } + + PrepareBackStack(entries, entryDecorators, initial) +} + +/** + * Wraps a [NavEntry] with the list of [NavEntryDecorator] in the order that the decorators were + * added to the list. + * + * Invokes pop callback for popped entries that had pop animations and thus could not be cleaned up + * by [PrepareBackStack]. PrepareBackStack has no access to animation state so we rely on this + * function to call onPop when animation finishes. + */ +@Composable +internal fun decorateEntry( + entry: NavEntry, + decorators: List>, +): NavEntry { + val latestDecorators by rememberUpdatedState(decorators) + val initial = + object : NavEntryWrapper(entry) { + @Composable + override fun Content() { + val localInfo = LocalNavEntryDecoratorLocalInfo.current + val idsInComposition = localInfo.idsInComposition + DisposableEffect(key1 = contentKey) { + idsInComposition.add(contentKey) + onDispose { + val notInComposition = idsInComposition.remove(contentKey) + val popped = !localInfo.contentKeys.contains(contentKey) + if (popped && notInComposition) { + // we reverse the scopes before popping to imitate the order + // of onDispose calls if each scope/decorator had their own + // onDispose + // calls for clean up + // convert to mutableList first for backwards compat. + latestDecorators.reversed().forEach { it.onPop(contentKey) } + } + } + } + DecorateNavEntry(entry, decorators) + } + } + return initial +} + +/** + * Sets up logic to track changes to the backstack and invokes the [DecoratedNavEntryProvider] + * content. + * + * Invokes pop callback for popped entries that: + * 1. are not animating (i.e. no pop animations) AND / OR + * 2. have never been composed (i.e. never invoked with [DecorateNavEntry]) + */ +@Composable +internal fun PrepareBackStack( + entries: List>, + decorators: List>, + content: @Composable (() -> Unit), +) { + val localInfo = remember { NavEntryDecoratorLocalInfo() } + val contentKeys = localInfo.contentKeys + + // update this backStack so that onDispose has access to the latest backStack to check + // if an entry has been popped + val latestBackStack by rememberUpdatedState(entries.map { it.contentKey }) + val latestDecorators by rememberUpdatedState(decorators) + latestBackStack.forEach { contentKey -> + contentKeys.add(contentKey) + + DisposableEffect(contentKey) { + onDispose { + val popped = + if (!latestBackStack.contains(contentKey)) { + contentKeys.remove(contentKey) + } else false + // run onPop callback + if (popped && !localInfo.idsInComposition.contains(contentKey)) { + // we reverse the order before popping to imitate the order + // of onDispose calls if each scope/decorator had their own onDispose + // calls for clean up + latestDecorators.reversed().forEach { it.onPop(contentKey) } + } + } + } + } + CompositionLocalProvider(LocalNavEntryDecoratorLocalInfo provides localInfo) { content() } +} + +private class NavEntryDecoratorLocalInfo { + val contentKeys: MutableSet = mutableSetOf() + val idsInComposition: MutableSet = mutableSetOf() + val popCallbacks: LinkedHashMap Unit> = LinkedHashMap() +} + +private val LocalNavEntryDecoratorLocalInfo = + staticCompositionLocalOf { + error( + "CompositionLocal LocalProviderLocalInfo not present. You must call " + + "ProvideToBackStack before calling ProvideToEntry." + ) + } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntry.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntry.kt new file mode 100644 index 0000000..a6f5234 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntry.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.runtime +import androidx.compose.runtime.Composable +import androidx.annotation.RestrictTo + +/** + * Entry maintains and stores the key and the content represented by that key. Entries should be + * created as part of a [NavDisplay.entryProvider](reference/androidx/navigation/NavDisplay). + * + * @param T the type of the key for this NavEntry + * @param key key for this entry + * @param contentKey A unique, stable id that uniquely identifies the content of this NavEntry. To + * maximize stability, it should ge derived from the [key]. The contentKey type must be saveable + * (i.e. on Android, it should be saveable via Android). Defaults to [key].toString(). + * @param metadata provides information to the display + * @param content content for this entry to be displayed when this entry is active + */ +internal open class NavEntry( + private val key: T, + val contentKey: Any = defaultContentKey(key), + open val metadata: Map = emptyMap(), + private val content: @Composable (T) -> Unit, +) { + /** Allows creating a NavEntry from another NavEntry while keeping [content] field private */ + internal constructor( + navEntry: NavEntry + ) : this(navEntry.key, navEntry.contentKey, navEntry.metadata, navEntry.content) + + /** + * Invokes the composable content of this NavEntry with the key that was provided when + * instantiating this NavEntry + */ + @Composable + open fun Content() { + this.content(key) + } + + /** + * Returns true if this NavEntry is in the [backStack], false otherwise. + * + * @param [backStack] the backStack to check if it contains this NavEntry. + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + fun isInBackStack(backStack: List): Boolean = backStack.contains(this.key) +} + +@PublishedApi internal fun defaultContentKey(key: Any): Any = key.toString() diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryDecorator.kt new file mode 100644 index 0000000..bf70004 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryDecorator.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.runtime + + +import androidx.compose.runtime.Composable +import kotlin.jvm.JvmSuppressWildcards + +/** + * Marker class to hold the onPop and decorator functions that will be invoked at runtime. + * + * See documentation on [androidx.navigation3.runtime.navEntryDecorator] for more info. + */ +internal class NavEntryDecorator +internal constructor( + internal val onPop: (key: Any) -> Unit, + internal val navEntryDecorator: @Composable (entry: NavEntry) -> Unit, +) + +/** + * Function to decorate the [NavEntry] that are integrated with a [DecoratedNavEntryProvider]. + * + * Primary usages include but are not limited to: + * 1. provide information to entries with [androidx.compose.runtime.CompositionLocal], i.e. + * + * ``` + * val decorator = navEntryDecorator { entry -> + * ... + * CompositionLocalProvider(LocalMyStateProvider provides myState) { + * entry.content.invoke(entry.key) + * } + * } + * ``` + * 2. Wrap entry content with other composable content + * + * ``` + * val decorator = navEntryDecorator { entry -> + * ... + * MyComposableFunction { + * entry.content.invoke(entry.key) + * } + * } + * ``` + * + * @param T the type of the backStack key + * @param onPop the callback to clean up the decorator state for a [NavEntry] when the entry is + * popped from the backstack and is leaving composition.The lambda provides the [NavEntry.key] of + * the popped entry as input. + * @param [decorator] the composable function to decorate a [NavEntry]. Note that this function only + * gets invoked for NavEntries that are actually getting rendered (i.e. by invoking the + * [NavEntry.content].) + */ +internal fun navEntryDecorator( + onPop: (contentKey: Any) -> Unit = {}, + decorator: @Composable (entry: NavEntry) -> Unit, +): NavEntryDecorator = NavEntryDecorator(onPop, decorator) + +/** + * Wraps a [NavEntry] with the list of [NavEntryDecorator] in the order that the decorators were + * added to the list and invokes the content of the wrapped entry. + * + * @param T the type of the backStack key + * @param entry the [NavEntry] to wrap + * @param entryDecorators the list of decorators to wrap the [entry] with + */ +@Composable +internal fun DecorateNavEntry( + entry: NavEntry, + entryDecorators: List<@JvmSuppressWildcards NavEntryDecorator<*>>, +) { + @Suppress("UNCHECKED_CAST") + (entryDecorators as List<@JvmSuppressWildcards NavEntryDecorator>) + .distinct() + .foldRight(initial = entry) { decorator, wrappedEntry -> + object : NavEntryWrapper(wrappedEntry) { + @Composable + override fun Content() { + decorator.navEntryDecorator(wrappedEntry) + } + } + } + .Content() +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntryWrapper.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryWrapper.kt similarity index 73% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntryWrapper.kt rename to library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryWrapper.kt index 2813c9e..1b470a0 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/NavEntryWrapper.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/NavEntryWrapper.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.tunjid.treenav.compose.navigation3 +package com.tunjid.treenav.compose.navigation3.runtime import androidx.compose.runtime.Composable @@ -23,16 +23,8 @@ import androidx.compose.runtime.Composable * * This provides a nesting mechanism for [NavEntry]s that allows properly nested content. * + * @param T the type of the backStack key * @param navEntry the [NavEntry] to wrap */ internal open class NavEntryWrapper(val navEntry: NavEntry) : - NavEntry(navEntry.key, navEntry.metadata, navEntry.content) { - override val key: T - get() = navEntry.key - - override val metadata: Map - get() = navEntry.metadata - - override val content: @Composable (T) -> Unit - get() = navEntry.content -} + NavEntry(navEntry) \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/SavedStateNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/SavedStateNavEntryDecorator.kt similarity index 77% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/SavedStateNavEntryDecorator.kt rename to library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/SavedStateNavEntryDecorator.kt index e1bb023..e79860b 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/SavedStateNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/runtime/SavedStateNavEntryDecorator.kt @@ -14,10 +14,11 @@ * limitations under the License. */ -package com.tunjid.treenav.compose.navigation3.decorators +package com.tunjid.treenav.compose.navigation3.runtime import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -33,10 +34,6 @@ import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.compose.LocalSavedStateRegistryOwner import androidx.savedstate.savedState -import com.tunjid.treenav.compose.navigation3.NavEntry -import com.tunjid.treenav.compose.navigation3.NavEntryDecorator -import com.tunjid.treenav.compose.navigation3.navEntryDecorator - /** * Returns a [SavedStateNavEntryDecorator] that is remembered across recompositions. @@ -56,21 +53,24 @@ internal fun rememberSavedStateNavEntryDecorator( * * This [NavEntryDecorator] is the only one that is **required** as saving state is considered a * non-optional feature. + * + * @param saveableStateHolder the [SaveableStateHolder] that holds the state defined with + * [rememberSaveable]. A saved state can only be restored from the [SaveableStateHolder] that it + * was saved with. */ -private fun SavedStateNavEntryDecorator( +internal fun SavedStateNavEntryDecorator( saveableStateHolder: SaveableStateHolder ): NavEntryDecorator { - val registryMap = mutableMapOf() + val registryMap = mutableMapOf() - val onPop: (Any) -> Unit = { key -> - val id = getIdForKey(key) - if (registryMap.contains(id)) { + val onPop: (Any) -> Unit = { contentKey -> + if (registryMap.contains(contentKey)) { // saveableStateHolder onPop - saveableStateHolder.removeState(id) + saveableStateHolder.removeState(contentKey) // saved state onPop val savedState = savedState() - val childRegistry = registryMap.getValue(id) + val childRegistry = registryMap.getValue(contentKey) childRegistry.savedStateRegistryController.performSave(savedState) childRegistry.savedState = savedState childRegistry.lifecycle.currentState = Lifecycle.State.DESTROYED @@ -78,34 +78,32 @@ private fun SavedStateNavEntryDecorator( } return navEntryDecorator(onPop = onPop) { entry -> - val key = entry.key - val id = getIdForKey(key) - val childRegistry by rememberSaveable( - key, + entry.contentKey, stateSaver = Saver( save = { it.savedState }, - restore = { EntrySavedStateRegistry().apply { savedState = it } } - ) + restore = { EntrySavedStateRegistry().apply { savedState = it } }, + ), ) { mutableStateOf(EntrySavedStateRegistry()) } - registryMap.put(id, childRegistry) + registryMap.put(entry.contentKey, childRegistry) - saveableStateHolder.SaveableStateProvider(id) { + saveableStateHolder.SaveableStateProvider(entry.contentKey) { CompositionLocalProvider(LocalSavedStateRegistryOwner provides childRegistry) { - entry.content(key) + entry.Content() } } - childRegistry.lifecycle.currentState = Lifecycle.State.RESUMED + DisposableEffect(Unit) { + childRegistry.lifecycle.currentState = Lifecycle.State.RESUMED + onDispose { } + } } } -private fun getIdForKey(key: Any): String = "${key::class.qualifiedName}:$key" - -private class EntrySavedStateRegistry : SavedStateRegistryOwner { +internal class EntrySavedStateRegistry : SavedStateRegistryOwner { override val lifecycle: LifecycleRegistry = LifecycleRegistry(this) val savedStateRegistryController: SavedStateRegistryController = SavedStateRegistryController.create(this) diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt new file mode 100644 index 0000000..39f303a --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/DialogScene.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + + +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry + + +/** An [OverlayScene] that renders an [entry] within a [Dialog]. */ +internal class DialogScene( + override val key: Any, + override val previousEntries: List>, + override val overlaidEntries: List>, + private val entry: NavEntry, + private val dialogProperties: DialogProperties, + private val onBack: (count: Int) -> Unit, +) : OverlayScene { + + override val entries: List> = listOf(entry) + + override val content: @Composable (() -> Unit) = { + Dialog(onDismissRequest = { onBack(1) }, properties = dialogProperties) { entry.Content() } + } +} + +/** + * A [SceneStrategy] that displays entries that have added [dialog] to their [NavEntry.metadata] + * within a [Dialog] instance. + * + * This strategy should always be added before any non-overlay scene strategies. + */ +internal class DialogSceneStrategy() : SceneStrategy { + @Composable + override fun calculateScene( + entries: List>, + onBack: (count: Int) -> Unit, + ): Scene? { + val lastEntry = entries.lastOrNull() + val dialogProperties = lastEntry?.metadata?.get(DIALOG_KEY) as? DialogProperties + return dialogProperties?.let { properties -> + DialogScene( + key = lastEntry.contentKey, + previousEntries = entries.dropLast(1), + overlaidEntries = entries.dropLast(1), + entry = lastEntry, + dialogProperties = properties, + onBack = onBack, + ) + } + } + + public companion object { + /** + * Function to be called on the [NavEntry.metadata] to mark this entry as something that + * should be displayed within a [Dialog]. + * + * @param dialogProperties properties that should be passed to the containing [Dialog]. + */ + public fun dialog( + dialogProperties: DialogProperties = DialogProperties() + ): Map = mapOf(DIALOG_KEY to dialogProperties) + + internal const val DIALOG_KEY = "dialog" + } +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavAnimatedContentScope.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavAnimatedContentScope.kt new file mode 100644 index 0000000..7903dc3 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavAnimatedContentScope.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf + +/** + * Local provider of [AnimatedContentScope] to [androidx.navigation3.runtime.NavEntry.content]. + * + * This does not have a default value since the AnimatedContentScope is provided at runtime by + * AnimatedContent. + * + * @sample androidx.navigation3.ui.samples.SceneNavSharedElementSampleo + */ +internal val LocalNavAnimatedContentScope: ProvidableCompositionLocal = + compositionLocalOf { + // no default, we need an AnimatedContent to get the AnimatedContentScope + throw IllegalStateException( + "Unexpected access to LocalNavAnimatedContentScope. You should only " + + "access LocalNavAnimatedContentScope inside a NavEntry passed " + + "to NavDisplay." + ) + } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.kt new file mode 100644 index 0000000..cf27511 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.compositionLocalOf +import androidx.navigationevent.NavigationEventDispatcher +import androidx.navigationevent.NavigationEventDispatcherOwner + +/** The CompositionLocal containing the current [NavigationEventDispatcher]. */ +object LocalNavigationEventDispatcherOwner { + private val LocalNavigationEventDispatcherOwner = + compositionLocalOf { null } + + /** + * Returns current composition local value for the owner or `null` if one has not been provided + * nor is one available via [findViewTreeNavigationEventDispatcherOwner] on the current + * [androidx.compose.ui.platform.LocalView]. + */ + val current: NavigationEventDispatcherOwner? + @Composable + get() = + LocalNavigationEventDispatcherOwner.current + ?: findViewTreeNavigationEventDispatcherOwner() + + /** + * Associates a [LocalNavigationEventDispatcherOwner] key to a value in a call to + * [CompositionLocalProvider]. + */ + internal infix fun provides( + navigationEventDispatcherOwner: NavigationEventDispatcherOwner + ): ProvidedValue { + return LocalNavigationEventDispatcherOwner.provides(navigationEventDispatcherOwner) + } +} + +@Composable +internal expect fun findViewTreeNavigationEventDispatcherOwner(): NavigationEventDispatcherOwner? \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt new file mode 100644 index 0000000..9820a0e --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavDisplay.kt @@ -0,0 +1,433 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + + +import androidx.collection.mutableObjectFloatMapOf +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.rememberTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleOut +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.util.fastForEachReversed +import com.tunjid.treenav.compose.navigation3.runtime.DecoratedNavEntryProvider +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry +import com.tunjid.treenav.compose.navigation3.runtime.NavEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.rememberSavedStateNavEntryDecorator +import com.tunjid.treenav.compose.navigation3.ui.NavDisplay.DEFAULT_TRANSITION_DURATION_MILLISECOND +import com.tunjid.treenav.compose.navigation3.ui.NavDisplay.POP_TRANSITION_SPEC +import com.tunjid.treenav.compose.navigation3.ui.NavDisplay.PREDICTIVE_POP_TRANSITION_SPEC +import com.tunjid.treenav.compose.navigation3.ui.NavDisplay.TRANSITION_SPEC +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import kotlin.reflect.KClass + +/** Object that indicates the features that can be handled by the [NavDisplay] */ +internal object NavDisplay { + /** + * Function to be called on the [NavEntry.metadata] to notify the [NavDisplay] that the content + * should be animated using the provided [ContentTransform]. + */ + fun transitionSpec( + transitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform? + ): Map = mapOf(TRANSITION_SPEC to transitionSpec) + + /** + * Function to be called on the [NavEntry.metadata] to notify the [NavDisplay] that, when + * popping from backstack, the content should be animated using the provided [ContentTransform]. + */ + fun popTransitionSpec( + popTransitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform? + ): Map = mapOf(POP_TRANSITION_SPEC to popTransitionSpec) + + /** + * Function to be called on the [NavEntry.metadata] to notify the [NavDisplay] that, when + * popping from backstack using a Predictive back gesture, the content should be animated using + * the provided [ContentTransform]. + */ + fun predictivePopTransitionSpec( + predictivePopTransitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform? + ): Map = mapOf(PREDICTIVE_POP_TRANSITION_SPEC to predictivePopTransitionSpec) + + val defaultPredictivePopTransitionSpec: + AnimatedContentTransitionScope<*>.() -> ContentTransform = + { + ContentTransform( + fadeIn( + spring( + dampingRatio = 1.0f, // reflects material3 motionScheme.defaultEffectsSpec() + stiffness = 1600.0f, // reflects material3 motionScheme.defaultEffectsSpec() + ) + ), + scaleOut(targetScale = 0.7f), + ) + } + + internal const val TRANSITION_SPEC = "transitionSpec" + internal const val POP_TRANSITION_SPEC = "popTransitionSpec" + internal const val PREDICTIVE_POP_TRANSITION_SPEC = "predictivePopTransitionSpec" + + internal const val DEFAULT_TRANSITION_DURATION_MILLISECOND = 700 +} + +/** + * A nav display that renders and animates between different [Scene]s, each of which can render one + * or more [NavEntry]s. + * + * The [Scene]s are calculated with the given [SceneStrategy], which may be an assembled delegated + * chain of [SceneStrategy]s. If no [Scene] is calculated, the fallback will be to a + * [SinglePaneSceneStrategy]. + * + * It is allowable for different [Scene]s to render the same [NavEntry]s, perhaps on some conditions + * as determined by the [sceneStrategy] based on window size, form factor, other arbitrary logic. + * + * If this happens, and these [Scene]s are rendered at the same time due to animation or predictive + * back, then the content for the [NavEntry] will only be rendered in the most recent [Scene] that + * is the target for being the current scene as determined by [sceneStrategy]. This enforces a + * unique invocation of each [NavEntry], even if it is displayable by two different [Scene]s. + * + * @param backStack the collection of keys that represents the state that needs to be handled + * @param modifier the modifier to be applied to the layout. + * @param contentAlignment The [Alignment] of the [AnimatedContent] + * @param onBack a callback for handling system back press. The passed [Int] refers to the number of + * entries to pop from the end of the backstack, as calculated by the [sceneStrategy]. + * @param entryDecorators list of [NavEntryDecorator] to add information to the entry content + * @param sceneStrategy the [SceneStrategy] to determine which scene to render a list of entries. + * @param sizeTransform the [SizeTransform] for the [AnimatedContent]. + * @param transitionSpec Default [ContentTransform] when navigating to [NavEntry]s. + * @param popTransitionSpec Default [ContentTransform] when popping [NavEntry]s. + * @param predictivePopTransitionSpec Default [ContentTransform] when popping with predictive back + * [NavEntry]s. + * @param entryProvider lambda used to construct each possible [NavEntry] + * @sample androidx.navigation3.ui.samples.SceneNav + * @sample androidx.navigation3.ui.samples.SceneNavSharedEntrySample + * @sample androidx.navigation3.ui.samples.SceneNavSharedElementSample + */ +@Composable +internal fun NavDisplay( + backStack: List, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + onBack: (Int) -> Unit = { + if (backStack is MutableList) { + repeat(it) { backStack.removeAt(backStack.lastIndex) } + } + }, + entryDecorators: List> = + listOf(rememberSceneSetupNavEntryDecorator(), rememberSavedStateNavEntryDecorator()), + sceneStrategy: SceneStrategy = SinglePaneSceneStrategy(), + sizeTransform: SizeTransform? = null, + transitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform = { + ContentTransform( + fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + ) + }, + popTransitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform = { + ContentTransform( + fadeIn(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + fadeOut(animationSpec = tween(DEFAULT_TRANSITION_DURATION_MILLISECOND)), + ) + }, + predictivePopTransitionSpec: AnimatedContentTransitionScope<*>.() -> ContentTransform = + NavDisplay.defaultPredictivePopTransitionSpec, + entryProvider: (key: T) -> NavEntry, +) { + require(backStack.isNotEmpty()) { "NavDisplay backstack cannot be empty" } + + var isSettled by remember { mutableStateOf(true) } + val transitionAwareLifecycleNavEntryDecorator = + transitionAwareLifecycleNavEntryDecorator(backStack, isSettled) + + DecoratedNavEntryProvider( + backStack = backStack, + entryDecorators = entryDecorators + transitionAwareLifecycleNavEntryDecorator, + entryProvider = entryProvider, + ) { entries -> + val allScenes = + mutableListOf(sceneStrategy.calculateSceneWithSinglePaneFallback(entries, onBack)) + do { + val overlayScene = allScenes.last() as? OverlayScene + val overlaidEntries = overlayScene?.overlaidEntries + if (overlaidEntries != null) { + // TODO Consider allowing a NavDisplay of only OverlayScene instances + require(overlaidEntries.isNotEmpty()) { + "Overlaid entries from $overlayScene must not be empty" + } + allScenes += + sceneStrategy.calculateSceneWithSinglePaneFallback(overlaidEntries, onBack) + } + } while (overlaidEntries != null) + val overlayScenes = allScenes.dropLast(1) + val scene = allScenes.last() + + // Predictive Back Handling + var progress by remember { mutableFloatStateOf(0f) } + var inPredictiveBack by remember { mutableStateOf(false) } + + NavigationEventHandler({ scene.previousEntries.isNotEmpty() }) { navEvent -> + progress = 0f + try { + navEvent.collect { value -> + inPredictiveBack = true + progress = value.progress + } + inPredictiveBack = false + onBack(entries.size - scene.previousEntries.size) + } finally { + inPredictiveBack = false + } + } + + // Scene Handling + val sceneKey = scene::class to scene.key + + val scenes = remember { mutableStateMapOf, Any>, Scene>() } + // TODO: This should really be a mutableOrderedStateSetOf + val mostRecentSceneKeys = remember { mutableStateListOf, Any>>() } + scenes[sceneKey] = scene + + val transitionState = remember { + // The state returned here cannot be nullable cause it produces the input of the + // transitionSpec passed into the AnimatedContent and that must match the non-nullable + // scope exposed by the transitions on the NavHost and composable APIs. + SeekableTransitionState(sceneKey) + } + + val transition = rememberTransition(transitionState, label = sceneKey.toString()) + + LaunchedEffect(transition.targetState) { + if (mostRecentSceneKeys.lastOrNull() != transition.targetState) { + mostRecentSceneKeys.remove(transition.targetState) + mostRecentSceneKeys.add(transition.targetState) + } + } + // Determine which NavEntrys should be rendered within each scene. + // Each renderable Scene, in order from the scene that is most recently the target scene to + // the scene that is least recently the target scene will be assigned each visible + // entry that hasn't already been assigned to a Scene that is more recent. + val sceneToRenderableEntryMap = + remember( + mostRecentSceneKeys.toList(), + scenes.values.map { scene -> scene.entries.map(NavEntry::contentKey) }, + transition.targetState, + ) { + buildMap { + val coveredEntryKeys = mutableSetOf() + (mostRecentSceneKeys.filter { it != transition.targetState } + + listOf(transition.targetState)) + .fastForEachReversed { sceneKey -> + val scene = scenes.getValue(sceneKey) + put( + sceneKey, + scene.entries + .map { it.contentKey } + .filterNot(coveredEntryKeys::contains) + .toSet(), + ) + scene.entries.forEach { coveredEntryKeys.add(it.contentKey) } + } + } + } + + // Transition Handling + /** Keep track of the previous entries for the transition's current scene. */ + val transitionCurrentStateEntries = remember(transition.currentState) { entries.toList() } + + // Consider this a pop if the current entries match the previous entries we have recorded + // from the current state of the transition + val isPop = + isPop( + transitionCurrentStateEntries.map { it.contentKey }, + entries.map { it.contentKey }, + ) + + val zIndices = remember { mutableObjectFloatMapOf, Any>>() } + val initialKey = transition.currentState + val targetKey = transition.targetState + val initialZIndex = zIndices.getOrPut(initialKey) { 0f } + val targetZIndex = + when { + initialKey == targetKey -> initialZIndex + isPop || inPredictiveBack -> initialZIndex - 1f + else -> initialZIndex + 1f + } + zIndices[targetKey] = targetZIndex + val transitionEntry = + if (initialZIndex >= targetZIndex) { + scenes[initialKey]!!.entries.last() + } else { + scenes[targetKey]!!.entries.last() + } + + if (inPredictiveBack) { + val peekScene = + sceneStrategy.calculateSceneWithSinglePaneFallback(scene.previousEntries, onBack) + val peekSceneKey = peekScene::class to peekScene.key + scenes[peekSceneKey] = peekScene + if (transitionState.currentState != peekSceneKey) { + LaunchedEffect(progress) { transitionState.seekTo(progress, peekSceneKey) } + } + } else { + LaunchedEffect(sceneKey) { + if (transitionState.currentState != sceneKey) { + transitionState.animateTo(sceneKey) + } + // This ensures we don't animate after the back gesture is cancelled and we + // are already on the current state + if (transitionState.currentState != sceneKey) { + transitionState.animateTo(sceneKey) + } else { + // convert from nanoseconds to milliseconds + val totalDuration = transition.totalDurationNanos / 1000000 + // When the predictive back gesture is cancelled, we need to manually animate + // the SeekableTransitionState from where it left off, to zero and then + // snapTo the final position. + animate( + transitionState.fraction, + 0f, + animationSpec = tween((transitionState.fraction * totalDuration).toInt()), + ) { value, _ -> + this@LaunchedEffect.launch { + if (value > 0) { + // Seek the original transition back to the currentState + transitionState.seekTo(value) + } + if (value == 0f) { + // Once we animate to the start, we need to snap to the right state. + transitionState.snapTo(sceneKey) + } + } + } + } + } + } + + val contentTransform: AnimatedContentTransitionScope<*>.() -> ContentTransform = { + when { + inPredictiveBack -> { + transitionEntry.contentTransform(PREDICTIVE_POP_TRANSITION_SPEC)?.invoke(this) + ?: predictivePopTransitionSpec(this) + } + isPop -> { + transitionEntry.contentTransform(POP_TRANSITION_SPEC)?.invoke(this) + ?: popTransitionSpec(this) + } + else -> { + transitionEntry.contentTransform(TRANSITION_SPEC)?.invoke(this) + ?: transitionSpec(this) + } + } + } + + transition.AnimatedContent( + contentAlignment = contentAlignment, + modifier = modifier, + transitionSpec = { + ContentTransform( + targetContentEnter = contentTransform(this).targetContentEnter, + initialContentExit = contentTransform(this).initialContentExit, + // z-index increases during navigate and decreases during pop. + targetContentZIndex = targetZIndex, + sizeTransform = sizeTransform, + ) + }, + ) { targetSceneKey -> + val targetScene = scenes.getValue(targetSceneKey) + CompositionLocalProvider( + LocalNavAnimatedContentScope provides this, + LocalEntriesToRenderInCurrentScene provides + sceneToRenderableEntryMap.getValue(targetSceneKey), + ) { + targetScene.content() + } + } + + // Clean-up scene book-keeping once the transition is finished. + LaunchedEffect(transition) { + snapshotFlow { transition.isRunning } + .filter { !it } + .collect { + scenes.keys.toList().forEach { key -> + if (key != transition.targetState) { + scenes.remove(key) + } + } + mostRecentSceneKeys.toList().forEach { key -> + if (key != transition.targetState) { + mostRecentSceneKeys.remove(key) + } + } + } + } + + LaunchedEffect(transition.currentState, transition.targetState) { + // If we've reached the targetState, our animation has settled + val settled = transition.currentState == transition.targetState + isSettled = settled + } + + // Show all OverlayScene instances above the AnimatedContent + overlayScenes.fastForEachReversed { overlayScene -> + // TODO Calculate what entries should be displayed from sceneToRenderableEntryMap + val allEntries = overlayScene.entries.map { it.contentKey }.toSet() + CompositionLocalProvider(LocalEntriesToRenderInCurrentScene provides allEntries) { + overlayScene.content.invoke() + } + } + } +} + +private fun isPop(oldBackStack: List, newBackStack: List): Boolean { + // entire stack replaced + if (oldBackStack.first() != newBackStack.first()) return false + // navigated + if (newBackStack.size > oldBackStack.size) return false + + val divergingIndex = + newBackStack.indices.firstOrNull { index -> newBackStack[index] != oldBackStack[index] } + // if newBackStack never diverged from oldBackStack, then it is a clean subset of the oldStack + // and is a pop + return divergingIndex == null && newBackStack.size != oldBackStack.size +} + +@Suppress("UNCHECKED_CAST") +private fun NavEntry.contentTransform( + key: String +): (AnimatedContentTransitionScope<*>.() -> ContentTransform)? { + return metadata[key] as? AnimatedContentTransitionScope<*>.() -> ContentTransform +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavigationEventHandler.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavigationEventHandler.kt new file mode 100644 index 0000000..ca4c0d2 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/NavigationEventHandler.kt @@ -0,0 +1,171 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.navigationevent.NavigationEvent +import androidx.navigationevent.NavigationEventCallback +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.launch + +@Composable +fun NavigationEventHandler( + enabled: () -> Boolean = { true }, + passThrough: Boolean = false, + onBack: suspend (progress: Flow) -> Unit, +) { + // ensure we don't re-register callbacks when onBack changes + val currentOnBack by rememberUpdatedState(onBack) + val navEventScope = rememberCoroutineScope() + + val navEventCallBack = remember { + NavigationEventHandlerCallback( + enabled = enabled, + passThrough = passThrough, + onBackScope = navEventScope, + currentOnBack = currentOnBack, + ) + } + + // we want to use the same callback, but ensure we adjust the variable on recomposition + SideEffect { + navEventCallBack.currentOnBack = currentOnBack + navEventCallBack.onBackScope = navEventScope + } + + LaunchedEffect(enabled) { navEventCallBack.setIsEnabled(enabled()) } + + val navEventDispatcher = + checkNotNull(LocalNavigationEventDispatcherOwner.current) { + "No NavigationEventDispatcher was provided via LocalNavigationEventDispatcherOwner" + } + .navigationEventDispatcher + + DisposableEffect(navEventDispatcher) { + navEventDispatcher.addCallback(navEventCallBack) + + onDispose { navEventCallBack.remove() } + } +} + +private class OnBackInstance( + scope: CoroutineScope, + var isPredictiveBack: Boolean, + onBack: suspend (progress: Flow) -> Unit, + callback: NavigationEventCallback, +) { + val channel = + Channel(capacity = BUFFERED, onBufferOverflow = BufferOverflow.SUSPEND) + val job = + scope.launch { + if (callback.isEnabled) { + var completed = false + onBack(channel.consumeAsFlow().onCompletion { completed = true }) + check(completed) { "You must collect the progress flow" } + } + } + + fun send(backEvent: NavigationEvent) = channel.trySend(backEvent) + + // idempotent if invoked more than once + fun close() = channel.close() + + fun cancel() { + channel.cancel(CancellationException("navEvent cancelled")) + job.cancel() + } +} + +private class NavigationEventHandlerCallback( + enabled: () -> Boolean, + passThrough: Boolean, + var onBackScope: CoroutineScope, + var currentOnBack: suspend (progress: Flow) -> Unit, +) : NavigationEventCallback(enabled()) { + + init { + this.isPassThrough = passThrough + } + private var onBackInstance: OnBackInstance? = null + private var isActive = false + + fun setIsEnabled(enabled: Boolean) { + // We are disabling a callback that was enabled. + if (!enabled && !isActive && isEnabled) { + onBackInstance?.cancel() + } + isEnabled = enabled + } + + override fun onEventStarted(event: NavigationEvent) { + // in case the previous onBackInstance was started by a normal back gesture + // we want to make sure it's still cancelled before we start a predictive + // back gesture + onBackInstance?.cancel() + if (isEnabled) { + onBackInstance = OnBackInstance(onBackScope, true, currentOnBack, this) + } + isActive = true + } + + override fun onEventProgressed(event: NavigationEvent) { + onBackInstance?.send(event) + } + + override fun onEventCompleted() { + // handleOnBackPressed could be called by regular back to restart + // a new back instance. If this is the case (where current back instance + // was NOT started by handleOnBackStarted) then we need to reset the previous + // regular back. + onBackInstance?.apply { + if (!isPredictiveBack) { + cancel() + onBackInstance = null + } + } + if (onBackInstance == null) { + onBackInstance = OnBackInstance(onBackScope, false, currentOnBack, this) + } + + // finally, we close the channel to ensure no more events can be sent + // but let the job complete normally + onBackInstance?.close() + onBackInstance?.isPredictiveBack = false + isActive = false + } + + override fun onEventCancelled() { + // cancel will purge the channel of any sent events that are yet to be received + onBackInstance?.cancel() + onBackInstance?.isPredictiveBack = false + isActive = false + } +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/OverlayScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/OverlayScene.kt new file mode 100644 index 0000000..8dda4a4 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/OverlayScene.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry + +/** + * A specific scene to render 1 or more [NavEntry] instances as an overlay. + * + * It is expected that the [content] is rendered in one or more separate windows (e.g., a dialog, + * popup window, etc.) that are visible above any additional [Scene] instances calculated from the + * [overlaidEntries]. + * + * When processing [overlaidEntries], expect processing of each [SceneStrategy] to restart from the + * first strategy. This may result in multiple instances of the same [OverlayScene] to be shown + * simultaneously, making a unique [key] even more important. + */ +internal interface OverlayScene : Scene { + + /** + * The [NavEntry]s that should be handled by another [Scene] that sits below this Scene. + * + * This *must* always be a non-empty list to correctly display entries below the overlay. + */ + public val overlaidEntries: List> +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/Scene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/Scene.kt new file mode 100644 index 0000000..2c0173d --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/Scene.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.runtime.Composable +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry + +/** + * A specific scene to render 1 or more [NavEntry]s. + * + * A scene instance is identified by its [key] and the class of the [Scene], and this change drives + * the top-level animation based on the [SceneStrategy] calculating what the current [Scene] is for + * the backstack. + * + * The rendering for [content] should invoke the content for each [NavEntry] contained in [entries] + * at most once concurrently in a given [Scene]. + * + * It is valid for two different instances of a [Scene] to render the same [NavEntry]. In this + * situation, the content for a [NavEntry] will only be rendered in the most recent target [Scene] + * that it is displayed in, as determined by [entries]. + */ +internal interface Scene { + /** + * The key identifying the [Scene]. This key will be combined with the class of the [Scene] to + * determine the key that drives the transition in the top-level animation for the NavDisplay. + * + * Because the class of the [Scene] is also used, this [key] only needs to be unique for a given + * type of [Scene] to indicate a different instance of the [Scene]. + */ + val key: Any + + /** + * The list of [NavEntry]s that can be displayed in this scene. + * + * When animating between scenes, the underlying content for each [NavEntry] will only be + * rendered by the scene that is most recently the target scene, and is displaying that + * [NavEntry] as determined by this [entries] list. + * + * For example, consider a transition from `Scene1` to `Scene2` below: + * ``` + * Scene1: Scene2: + * +---+---+ +---+---+ + * | | | | | | + * | A | B | --> | B | C | + * | | | | | | + * +---+---+ +---+---+ + * ``` + * + * `Scene1.entries` should be `[A, B]`, and `Scene2.entries` should be `[B, C]` + * + * When both are being rendered at the same time during the transition, the content for `A` will + * be rendered in `Scene1`, while the content for `B` and `C` will be rendered in `Scene2`. + */ + public val entries: List> + + /** + * The resulting [NavEntry]s that should be computed after pressing back updates the backstack. + * + * This is required for calculating the proper predictive back state, which may result in a + * different scene being shown. + * + * When predictive back is occurring, this list of entries will be passed through the + * [SceneStrategy] again, to determine what the resulting scene would be if the back happens. + */ + val previousEntries: List> + + /** + * The content rendering the [Scene] itself. + * + * This should call the content for the [entries], and can add any arbitrary UI around them + * specific to the [Scene]. + */ + val content: @Composable () -> Unit +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt similarity index 52% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt rename to library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt index 4b6cef2..d02af78 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/MovableContentNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneSetupNavEntryDecorator.kt @@ -14,21 +14,24 @@ * limitations under the License. */ -package com.tunjid.treenav.compose.navigation3.decorators +package com.tunjid.treenav.compose.navigation3.ui + import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.key import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import com.tunjid.treenav.compose.navigation3.NavEntryDecorator -import com.tunjid.treenav.compose.navigation3.navEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.NavEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.navEntryDecorator -/** Returns a [MovableContentNavEntryDecorator] that is remembered across recompositions. */ +/** Returns a [SceneSetupNavEntryDecorator] that is remembered across recompositions. */ @Composable -internal fun rememberMovableContentNavEntryDecorator(): NavEntryDecorator = remember { - MovableContentNavEntryDecorator() +internal fun rememberSceneSetupNavEntryDecorator(): NavEntryDecorator = remember { + SceneSetupNavEntryDecorator() } /** @@ -39,44 +42,40 @@ internal fun rememberMovableContentNavEntryDecorator(): NavEntryDecorator = * This should likely be the first [NavEntryDecorator] to ensure that other [NavEntryDecorator] * calls that are stateful are moved properly inside the [movableContentOf]. */ -private fun MovableContentNavEntryDecorator(): NavEntryDecorator { +internal fun SceneSetupNavEntryDecorator(): NavEntryDecorator { val movableContentContentHolderMap: MutableMap Unit>> = mutableMapOf() val movableContentHolderMap: MutableMap Unit> = mutableMapOf() - return navEntryDecorator( - onPop = { - movableContentHolderMap.remove(it) - movableContentContentHolderMap.remove(it) - }, - decorator = { entry -> - val key = entry.key - movableContentContentHolderMap.getOrPut(key) { - key(key) { - remember { - mutableStateOf( - @Composable { - error( - "Should not be called, this should always be updated in" + - "DecorateEntry with the real content" - ) - } - ) - } + return navEntryDecorator { entry -> + val key = entry.contentKey + movableContentContentHolderMap.getOrPut(key) { + key(key) { + remember { + mutableStateOf( + @Composable { + error( + "Should not be called, this should always be updated in" + + "DecorateEntry with the real content" + ) + } + ) } } - movableContentHolderMap.getOrPut(key) { - key(key) { - remember { - movableContentOf { - // In case the key is removed from the backstack while this is still - // being rendered, we remember the MutableState directly to allow - // rendering it while we are animating out. - remember { movableContentContentHolderMap.getValue(key) }.value() - } + } + movableContentHolderMap.getOrPut(key) { + key(key) { + remember { + movableContentOf { + // In case the key is removed from the backstack while this is still + // being rendered, we remember the MutableState directly to allow + // rendering it while we are animating out. + remember { movableContentContentHolderMap.getValue(key) }.value() } } } + } + if (LocalEntriesToRenderInCurrentScene.current.contains(key)) { key(key) { // In case the key is removed from the backstack while this is still // being rendered, we remember the MutableState directly to allow @@ -85,7 +84,7 @@ private fun MovableContentNavEntryDecorator(): NavEntryDecorator { movableContentContentHolderMap.getValue(key) } // Update the state holder with the actual entry content - movableContentContentHolder.value = { entry.content(key) } + movableContentContentHolder.value = { entry.Content() } // In case the key is removed from the backstack while this is still // being rendered, we remember the movableContent directly to allow // rendering it while we are animating out. @@ -94,5 +93,18 @@ private fun MovableContentNavEntryDecorator(): NavEntryDecorator { movableContentHolder() } } - ) + } } + +/** + * The entry keys to render in the current [Scene], in the sense of the target of the animation for + * an [androidx.compose.animation.AnimatedContent] that is transitioning between different scenes. + */ +internal val LocalEntriesToRenderInCurrentScene: ProvidableCompositionLocal> = + compositionLocalOf { + throw IllegalStateException( + "Unexpected access to LocalEntriesToRenderInCurrentScene. You should only " + + "access LocalEntriesToRenderInCurrentScene inside a NavEntry passed " + + "to NavDisplay." + ) + } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneStrategy.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneStrategy.kt new file mode 100644 index 0000000..d9f1f83 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SceneStrategy.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + + +import androidx.compose.runtime.Composable +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry + +/** + * A strategy that tries to calculate a [Scene] given a list of [NavEntry]. + * + * If the list of [NavEntry] does not result in a [Scene] for this strategy, `null` will be returned + * instead to delegate to another strategy. + */ +internal fun interface SceneStrategy { + /** + * Given a back stack of [entries], calculate whether this [SceneStrategy] should take on the + * task of rendering one or more of those entries. + * + * By returning a non-null [Scene], your [Scene] takes on the responsibility of rendering the + * set of entries you declare in [Scene.entries]. If you return `null`, the next available + * [SceneStrategy] will be called. + * + * @param entries The entries on the back stack that should be considered valid to render via a + * returned Scene. + * @param onBack a callback that should be connected to any internal handling of system back + * done by the returned [Scene]. The passed [Int] should be the number of entries were popped. + */ + @Composable + public fun calculateScene(entries: List>, onBack: (count: Int) -> Unit): Scene? + + /** + * Chains this [SceneStrategy] with another [sceneStrategy] to return a combined + * [SceneStrategy]. + */ + public infix fun then(sceneStrategy: SceneStrategy): SceneStrategy = + SceneStrategy { entries, onBack -> + calculateScene(entries, onBack) ?: sceneStrategy.calculateScene(entries, onBack) + } +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt new file mode 100644 index 0000000..d2a4f6d --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/SinglePaneScene.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.runtime.Composable +import com.tunjid.treenav.compose.navigation3.runtime.NavEntry + +internal data class SinglePaneScene( + override val key: Any, + val entry: NavEntry, + override val previousEntries: List>, +) : Scene { + override val entries: List> = listOf(entry) + + override val content: @Composable () -> Unit = { entry.Content() } +} + +/** + * A [SceneStrategy] that always creates a 1-entry [Scene] simply displaying the last entry in the + * list. + */ +internal class SinglePaneSceneStrategy : SceneStrategy { + @Composable + override fun calculateScene(entries: List>, onBack: (Int) -> Unit): Scene = + SinglePaneScene( + key = entries.last().contentKey, + entry = entries.last(), + previousEntries = entries.dropLast(1), + ) +} + +@Composable +internal fun SceneStrategy.calculateSceneWithSinglePaneFallback( + entries: List>, + onBack: (count: Int) -> Unit, +): Scene = + calculateScene(entries, onBack) ?: SinglePaneSceneStrategy().calculateScene(entries, onBack) \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt similarity index 79% rename from library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt rename to library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt index 6f6c0b5..4128292 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/decorators/TransitionAwareLifecycleNavEntryDecorator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/TransitionAwareLifecycleNavEntryDecorator.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.tunjid.treenav.compose.navigation3.decorators +package com.tunjid.treenav.compose.navigation3.ui import androidx.compose.runtime.Composable @@ -27,29 +27,27 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.compose.LocalLifecycleOwner -import com.tunjid.treenav.compose.navigation3.navEntryDecorator +import com.tunjid.treenav.compose.navigation3.runtime.navEntryDecorator + @Composable -internal fun transitionAwareLifecycleNavEntryDecorator( - backStack: List, - isSettled: @Composable () -> Boolean -) = navEntryDecorator { entry -> - val isInBackStack = entry.key in backStack - val settled = isSettled() - val maxLifecycle = - when { - isInBackStack && settled -> Lifecycle.State.RESUMED - isInBackStack && !settled -> Lifecycle.State.STARTED - else /* !isInBackStack */ -> Lifecycle.State.CREATED - } - LifecycleOwner(maxLifecycle = maxLifecycle) { entry.content.invoke(entry.key) } -} +internal fun transitionAwareLifecycleNavEntryDecorator(backStack: List, isSettled: Boolean) = + navEntryDecorator { entry -> + val isInBackStack = entry.isInBackStack(backStack) + val maxLifecycle = + when { + isInBackStack && isSettled -> Lifecycle.State.RESUMED + isInBackStack && !isSettled -> Lifecycle.State.STARTED + else /* !isInBackStack */ -> Lifecycle.State.CREATED + } + LifecycleOwner(maxLifecycle = maxLifecycle) { entry.Content() } + } @Composable private fun LifecycleOwner( maxLifecycle: Lifecycle.State = Lifecycle.State.RESUMED, parentLifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { val childLifecycleOwner = remember(parentLifecycleOwner) { ChildLifecycleOwner() } // Pass LifecycleEvents from the parent down to the child @@ -67,9 +65,7 @@ private fun LifecycleOwner( childLifecycleOwner.maxLifecycle = maxLifecycle } // Now install the LifecycleOwner as a composition local - CompositionLocalProvider(LocalLifecycleOwner provides childLifecycleOwner) { - content.invoke() - } + CompositionLocalProvider(LocalLifecycleOwner provides childLifecycleOwner) { content.invoke() } } private class ChildLifecycleOwner : LifecycleOwner { diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt index 0f5f970..bacfb13 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/PaneModifierTransform.kt @@ -28,13 +28,13 @@ import com.tunjid.treenav.compose.PaneScope * * @param paneModifier a lambda for specifying the [Modifier] for each [Pane] in a [PaneScope]. */ -fun paneModifierTransform( +fun paneModifierTransform( paneModifier: PaneScope.() -> Modifier = { Modifier }, -): Transform = - RenderTransform { destination, previousTransform -> +): PaneTransform = + paneRenderTransform { destination, destinationContent -> Box( modifier = paneModifier() ) { - previousTransform(destination) + destinationContent(destination) } } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/Transforms.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/Transforms.kt index a79ccc7..3cf6312 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/Transforms.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/transforms/Transforms.kt @@ -9,34 +9,13 @@ import com.tunjid.treenav.compose.PaneScope /** * Provides APIs for adjusting what is presented in a [MultiPaneDisplay]. */ -sealed interface Transform +sealed interface PaneTransform /** - * A [Transform] that allows for changing the current [Destination] in the [MultiPaneDisplay] - * sees without actually modifying the backing [NavigationState]. + * A [PaneTransform] that allows for changing which [Destination] shows in which [Pane]. */ -fun interface DestinationTransform - : Transform { - - /** - * Given a [NavigationState], provide the current [Destination] to show. The [Destination] - * returned must already exist in the back stack of the [MultiPaneDisplayState.navigationState]. - * - * @param navigationState the current navigation state. - * @param previousTransform a [Transform] that when invoked, returns the [Destination] that - * would have been shown pre-transform that can then be composed with new logic. - */ - fun toDestination( - navigationState: NavigationState, - previousTransform: (NavigationState) -> Destination, - ): Destination -} - -/** - * A [Transform] that allows for changing which [Destination] shows in which [Pane]. - */ -fun interface PaneTransform - : Transform { +internal fun interface PaneMappingTransform + : PaneTransform { /** * Given the current [Destination], provide what [Destination] to show in a [Pane]. @@ -44,7 +23,7 @@ fun interface PaneTransform * back stack of the [MultiPaneDisplayState.navigationState]. * * @param destination the current [Destination] to display. - * @param previousTransform a [Transform] that when invoked, returns the pane to destination + * @param previousTransform a [PaneTransform] that when invoked, returns the pane to destination * mapping for the current [Destination] pre-transform that can then be composed with new logic. */ @Composable @@ -55,18 +34,18 @@ fun interface PaneTransform } /** - * A [Transform] that allows for the rendering semantics of a [Destination] in a given + * A [PaneTransform] that allows for the rendering semantics of a [Destination] in a given * [PaneScope]. */ -fun interface RenderTransform - : Transform { +internal fun interface PaneRenderTransform + : PaneTransform { /** * Given the current [Destination], and its [PaneScope], compose additional presentation * logic. * * @param destination the current [Destination] to display in the provided [PaneScope]. - * @param previousTransform a [Transform] that when invoked, renders the [Destination] + * @param previousTransform a [PaneTransform] that when invoked, renders the [Destination] * for the [PaneScope ]pre-transform that can then be composed with new logic. */ @Composable @@ -76,31 +55,42 @@ fun interface RenderTransform ) } -internal class CompoundTransform( - destinationTransform: DestinationTransform?, - paneTransform: PaneTransform?, - renderTransform: RenderTransform?, -) : Transform { - val transforms = listOfNotNull( - destinationTransform, - paneTransform, - renderTransform, - ) -} +/** + * Given the current [Destination], provide what [Destination]s to show in each of the [Pane]s + * available. + * + * Each [Destination] in the returned mapping must already exist in the + * back stack derived from the [MultiPaneDisplayState.navigationState]. + * + * @param mappingTransform a lambda providing the mapping. It has two arguments: + * - destination: The [Destination] for which it panes will be displayed. + * - destinationPaneMapper: A lambda that when invoked, returns the pane to destination + * mapping for the current [Destination] pre-transform that can then be composed with new logic. + */ +fun paneMappingTransform( + mappingTransform: @Composable ( + destination: Destination, + destinationPaneMapper: @Composable (Destination) -> Map + ) -> Map +): PaneTransform = + PaneMappingTransform { destination, previousTransform -> + mappingTransform(destination, previousTransform) + } /** - * Creates a transform that an aggregation of the transforms provided to it. + * A [PaneTransform] that allows for adjusting the rendering semantics of a [Destination] in a + * for a given [Pane] in the [PaneScope]. * - * @see DestinationTransform - * @see PaneTransform - * @see RenderTransform + * @param renderTransform a lambda providing the Composable to render. It has two arguments: + * - destination: The [Destination] being rendered in the provided [PaneScope]. + * - destinationContent: A lambda that when invoked, renders the [Destination] pre-transform */ -fun compoundTransform( - destinationTransform: DestinationTransform? = null, - paneTransform: PaneTransform? = null, - renderTransform: RenderTransform? = null, -): Transform = CompoundTransform( - destinationTransform = destinationTransform, - paneTransform = paneTransform, - renderTransform = renderTransform, -) \ No newline at end of file +fun paneRenderTransform( + renderTransform: @Composable PaneScope.( + destination: Destination, + destinationContent: @Composable PaneScope.(Destination) -> Unit + ) -> Unit +): PaneTransform = + PaneRenderTransform { destination, previousTransform -> + renderTransform(destination, previousTransform) + } diff --git a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt index 1e9841f..520736c 100644 --- a/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt +++ b/library/compose/src/commonTest/kotlin/com/tunjid/treenav/compose/StackNavExtTest.kt @@ -40,18 +40,11 @@ class StackNavExtTest { .push(TestNode("E", children = listOf(TestNode("1"), TestNode("2")))) .push(TestNode("F")) - println( - pushed.backStack( - includeCurrentDestinationChildren = true, - placeChildrenBeforeParent = true, - distinctDestinations = true, - ) - ) assertEquals( expected = pushed.backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = true, + distinctDestinations = false, ), actual = pushed.multiPaneDisplayBackstack() ) @@ -79,7 +72,7 @@ class StackNavExtTest { expected = pushed.backStack( includeCurrentDestinationChildren = true, placeChildrenBeforeParent = true, - distinctDestinations = true, + distinctDestinations = false, ), actual = pushed.multiPaneDisplayBackstack() ) diff --git a/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/PaneScope.jvm.kt b/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/PaneScope.jvm.kt new file mode 100644 index 0000000..19e208c --- /dev/null +++ b/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/PaneScope.jvm.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose + +internal actual fun Any.identityHash(): Int = + System.identityHashCode(this) \ No newline at end of file diff --git a/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.jvm.kt b/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.jvm.kt new file mode 100644 index 0000000..fb33884 --- /dev/null +++ b/library/compose/src/jvmMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.jvm.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.runtime.Composable +import androidx.navigationevent.NavigationEventDispatcher +import androidx.navigationevent.NavigationEventDispatcherOwner + +@Composable +internal actual fun findViewTreeNavigationEventDispatcherOwner(): NavigationEventDispatcherOwner? = + Owner + +private object Owner: NavigationEventDispatcherOwner { + override val navigationEventDispatcher: NavigationEventDispatcher = + NavigationEventDispatcher() +} \ No newline at end of file diff --git a/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/PaneScope.native.kt b/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/PaneScope.native.kt new file mode 100644 index 0000000..fa1a1a8 --- /dev/null +++ b/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/PaneScope.native.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose + +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.identityHashCode + + +@OptIn(ExperimentalNativeApi::class) +internal actual fun Any.identityHash(): Int = + identityHashCode() \ No newline at end of file diff --git a/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.native.kt b/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.native.kt new file mode 100644 index 0000000..fb33884 --- /dev/null +++ b/library/compose/src/nativeMain/kotlin/com/tunjid/treenav/compose/navigation3/ui/LocalNavigationEventDispatcherOwner.native.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.navigation3.ui + +import androidx.compose.runtime.Composable +import androidx.navigationevent.NavigationEventDispatcher +import androidx.navigationevent.NavigationEventDispatcherOwner + +@Composable +internal actual fun findViewTreeNavigationEventDispatcherOwner(): NavigationEventDispatcherOwner? = + Owner + +private object Owner: NavigationEventDispatcherOwner { + override val navigationEventDispatcher: NavigationEventDispatcher = + NavigationEventDispatcher() +} \ No newline at end of file diff --git a/libraryVersion.properties b/libraryVersion.properties index ae8a36e..da72d84 100644 --- a/libraryVersion.properties +++ b/libraryVersion.properties @@ -14,7 +14,7 @@ # limitations under the License. # groupId=com.tunjid.treenav -treenav_version=0.0.27 -strings_version=0.0.27 -compose_version=0.0.27 -compose-threepane_version=0.0.27 \ No newline at end of file +treenav_version=0.0.30e +strings_version=0.0.30e +compose_version=0.0.30e +compose-threepane_version=0.0.30e \ No newline at end of file diff --git a/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt b/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt index 3b7a87e..fd8ba47 100644 --- a/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt +++ b/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt @@ -17,56 +17,25 @@ package com.tunjid.tyler import android.os.Bundle -import androidx.activity.BackEventCompat -import androidx.activity.compose.PredictiveBackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.remember -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.round +import androidx.compose.ui.platform.LocalView +import androidx.navigationevent.setViewTreeNavigationEventDispatcherOwner import com.tunjid.demo.common.ui.App import com.tunjid.demo.common.ui.AppState import com.tunjid.demo.common.ui.AppTheme -import kotlinx.coroutines.flow.Flow -import kotlin.coroutines.cancellation.CancellationException class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { + LocalView.current.setViewTreeNavigationEventDispatcherOwner(this) AppTheme { val appState = remember { AppState() } App(appState) - - PredictiveBackHandler { backEvents: Flow -> - try { - backEvents.collect { backEvent -> - appState.backPreviewState.apply { - atStart = backEvent.swipeEdge == BackEventCompat.EDGE_LEFT - progress = backEvent.progress - pointerOffset = Offset( - x = backEvent.touchX, - y = backEvent.touchY - ).round() - } - } - // Dismiss back preview - appState.backPreviewState.apply { - progress = Float.NaN - pointerOffset = IntOffset.Zero - } - // Pop navigation - appState.goBack() - } catch (e: CancellationException) { - appState.backPreviewState.apply { - progress = Float.NaN - pointerOffset = IntOffset.Zero - } - } - } } } } diff --git a/sample/common/build.gradle.kts b/sample/common/build.gradle.kts index 3e450ed..eb8aaf6 100755 --- a/sample/common/build.gradle.kts +++ b/sample/common/build.gradle.kts @@ -32,6 +32,8 @@ kotlin { implementation(compose.components.resources) + implementation(libs.androidx.navigation.event) + implementation(libs.jetbrains.compose.runtime) implementation(libs.jetbrains.compose.animation) implementation(libs.jetbrains.compose.material3) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/AppBars.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/AppBars.kt index ea490ce..668394e 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/AppBars.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/AppBars.kt @@ -17,19 +17,19 @@ package com.tunjid.demo.common.ui +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tunjid.composables.collapsingheader.CollapsingHeaderState @@ -50,29 +50,28 @@ fun rememberAppBarCollapsingHeaderState( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SampleTopAppBar( - title: String, + title: @Composable () -> Unit, onBackPressed: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { - TopAppBar( - title = { - Text(text = title) - }, - navigationIcon = { - if (onBackPressed != null) IconButton( - onClick = onBackPressed, - content = { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - ) - } - ) - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), - modifier = modifier, - ) -} \ No newline at end of file + Row( + modifier = modifier + .statusBarsPadding() + .height(56.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (onBackPressed != null) IconButton( + onClick = onBackPressed, + content = { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + ) + title() + } +} 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 c2680c4..da3a9f3 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 @@ -58,10 +58,13 @@ import androidx.compose.runtime.staticCompositionLocalOf 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.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round +import androidx.navigationevent.NavigationEvent import com.tunjid.composables.backpreview.BackPreviewState import com.tunjid.composables.backpreview.backPreview import com.tunjid.composables.splitlayout.SplitLayout @@ -80,11 +83,11 @@ import com.tunjid.treenav.compose.MultiPaneDisplayScope import com.tunjid.treenav.compose.MultiPaneDisplayState import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementHostState import com.tunjid.treenav.compose.multiPaneDisplayBackstack +import com.tunjid.treenav.compose.navigation3.ui.NavigationEventHandler import com.tunjid.treenav.compose.threepane.ThreePane -import com.tunjid.treenav.compose.threepane.transforms.backPreviewTransform import com.tunjid.treenav.compose.threepane.transforms.threePanedAdaptiveTransform import com.tunjid.treenav.compose.threepane.transforms.threePanedMovableSharedElementTransform -import com.tunjid.treenav.compose.transforms.Transform +import com.tunjid.treenav.compose.transforms.PaneTransform import com.tunjid.treenav.compose.transforms.paneModifierTransform import com.tunjid.treenav.pop import com.tunjid.treenav.popToRoot @@ -109,9 +112,8 @@ fun App( sharedTransitionScope = this ) } + MultiPaneDisplay( - modifier = Modifier - .fillMaxSize(), state = appState.rememberMultiPaneDisplayState( remember { listOf( @@ -126,25 +128,25 @@ fun App( appState.splitLayoutState.size } ), - backPreviewTransform( - isPreviewingBack = derivedStateOf { - appState.isPreviewingBack - }, - navigationStateBackTransform = MultiStackNav::pop, - ), threePanedMovableSharedElementTransform( movableSharedElementHostState = movableSharedElementHostState ), paneModifierTransform { - if (paneState.pane == ThreePane.TransientPrimary) Modifier + if (paneState.pane == ThreePane.Primary + && inPredictiveBack + && isActive + && !appState.dragToPopState.isDraggingToPop + ) Modifier .fillMaxSize() .backPreview(appState.backPreviewState) else Modifier .fillMaxSize() - } + }, ) - } + }, ), + modifier = Modifier + .fillMaxSize(), ) { appState.displayScope = this appState.splitLayoutState.visibleCount = appState.filteredPaneOrder.size @@ -162,13 +164,28 @@ fun App( ) }, itemContent = { index -> - DragToPopLayout( - state = appState, - pane = appState.filteredPaneOrder[index] - ) + Destination(appState.filteredPaneOrder[index]) } ) } + + NavigationEventHandler( + enabled = { true }, + passThrough = true, + ) { progress -> + try { + progress.collect { event -> + appState.backPreviewState.progress = event.progress + appState.backPreviewState.atStart = + event.swipeEdge == NavigationEvent.EDGE_LEFT + appState.backPreviewState.pointerOffset = + Offset(event.touchX, event.touchY).round() + } + appState.backPreviewState.progress = 0f + } finally { + appState.backPreviewState.progress = 0f + } + } } } } @@ -264,10 +281,6 @@ class AppState( ) internal val dragToPopState = DragToPopState() - internal val isPreviewingBack - get() = !backPreviewState.progress.isNaN() - || dragToPopState.isDraggingToPop - internal val isMediumScreenWidthOrWider get() = splitLayoutState.size >= SecondaryPaneMinWidthBreakpointDp internal var displayScope by mutableStateOf?>( @@ -306,21 +319,23 @@ class AppState( fun isInteractingWithPanes(): Boolean = paneInteractionSourceList.any { it.isActive() } - fun goBack() { - navigationRepository.navigate(MultiStackNav::pop) - } - companion object { @Composable fun AppState.rememberMultiPaneDisplayState( - transforms: List>, - ): MultiPaneDisplayState { + transforms: List>, + ): MultiPaneDisplayState { val displayState = remember { MultiPaneDisplayState( panes = ThreePane.entries.toList(), navigationState = navigationState, backStackTransform = MultiStackNav::multiPaneDisplayBackstack, destinationTransform = MultiStackNav::requireCurrent, + popTransform = MultiStackNav::pop, + onPopped = { poppedNavigationState -> + navigationRepository.navigate { + poppedNavigationState + } + }, entryProvider = { destination -> when (destination) { SampleDestination.NavTabs.ChatRooms -> chatRoomPaneEntry() diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DragToPop.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DragToPop.kt index a63ce01..4607fa2 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DragToPop.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/DragToPop.kt @@ -16,26 +16,28 @@ package com.tunjid.demo.common.ui -import androidx.compose.foundation.layout.Box 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.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round +import androidx.navigationevent.NavigationEvent import com.tunjid.composables.dragtodismiss.DragToDismissState import com.tunjid.composables.dragtodismiss.dragToDismiss -import com.tunjid.demo.common.ui.data.SampleDestination -import com.tunjid.treenav.compose.MultiPaneDisplayScope -import com.tunjid.treenav.compose.threepane.ThreePane +import com.tunjid.treenav.compose.navigation3.ui.LocalNavigationEventDispatcherOwner +import kotlinx.coroutines.flow.collectLatest +import kotlin.math.min @Stable internal class DragToPopState { @@ -47,46 +49,46 @@ internal class DragToPopState { @Composable fun Modifier.dragToPop(): Modifier { - val state = LocalAppState.current.dragToPopState - DisposableEffect(state) { - state.dragToDismissState.enabled = true - onDispose { state.dragToDismissState.enabled = false } + val state = LocalAppState.current + val dragToPopState = state.dragToPopState + + val density = LocalDensity.current + val dismissThreshold = remember { with(density) { 200.dp.toPx().let { it * it } } } + + val dispatcher = checkNotNull( + LocalNavigationEventDispatcherOwner.current?.navigationEventDispatcher + ) + LaunchedEffect(Unit) { + snapshotFlow { + state.dragToPopState.dragToDismissState.offset + } + .collectLatest { + if (state.dragToPopState.isDraggingToPop) { + dispatcher.dispatchOnProgressed( + state.dragToPopState.dragToDismissState.navigationEvent( + min( + a = 1f, + b = state.dragToPopState.dragToDismissState.offset.getDistanceSquared() / dismissThreshold, + ) + ) + ) + } + } + } + + DisposableEffect(dragToPopState) { + dragToPopState.dragToDismissState.enabled = true + onDispose { dragToPopState.dragToDismissState.enabled = false } } // TODO: This should not be necessary. Figure out why a frame renders with // an offset of zero while the content in the transient primary container // is still visible. val dragToDismissOffset by rememberUpdatedStateIf( - value = state.dragToDismissState.offset.round(), + value = dragToPopState.dragToDismissState.offset.round(), predicate = { it != IntOffset.Zero } ) - return offset { dragToDismissOffset } -} - -@Composable -internal fun MultiPaneDisplayScope.DragToPopLayout( - state: AppState, - pane: ThreePane, -) { - // Only place the DragToDismiss Modifier on the Primary pane - if (pane == ThreePane.Primary) { - Box( - modifier = Modifier.dragToPopInternal(state) - ) { - Destination(pane) - } - // Place the transient primary screen above the primary - Destination(ThreePane.TransientPrimary) - } else { - Destination(pane) - } -} - -@Composable -private fun Modifier.dragToPopInternal(state: AppState): Modifier { - val density = LocalDensity.current - val dismissThreshold = remember { with(density) { 200.dp.toPx().let { it * it } } } return dragToDismiss( state = state.dragToPopState.dragToDismissState, @@ -96,21 +98,35 @@ private fun Modifier.dragToPopInternal(state: AppState): Modifier { // Enable back preview onStart = { state.dragToPopState.isDraggingToPop = true + dispatcher.dispatchOnStarted( + state.dragToPopState.dragToDismissState.navigationEvent(0f) + ) }, onCancelled = { // Dismiss back preview state.dragToPopState.isDraggingToPop = false + dispatcher.dispatchOnCancelled() }, onDismissed = { // Dismiss back preview state.dragToPopState.isDraggingToPop = false // Pop navigation - state.goBack() + dispatcher.dispatchOnCompleted() } ) + .offset { dragToDismissOffset } } +private fun DragToDismissState.navigationEvent( + progress: Float +) = NavigationEvent( + touchX = offset.x, + touchY = offset.y, + progress = progress, + swipeEdge = NavigationEvent.EDGE_LEFT, +) + @Composable private inline fun rememberUpdatedStateIf( value: T, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneNavigation.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneNavigation.kt index 99042dd..06b4a7a 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneNavigation.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneNavigation.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.NavigationRailItem import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.tunjid.demo.common.ui.data.SampleDestination +import com.tunjid.treenav.compose.Adaptation import com.tunjid.treenav.current @Composable @@ -77,7 +78,9 @@ fun PaneScaffoldState.PaneNavigationRail( boundsTransform = NavigationRailBoundsTransform, ), visible = canShowNavigationRail, - enter = if (canShowNavigationRail) enterTransition else EnterTransition.None, + enter = if (canShowNavigationRail + && paneState.adaptations.none { it is Adaptation.Swap<*> } + ) enterTransition else EnterTransition.None, exit = if (canShowNavigationRail) exitTransition else ExitTransition.None, ) { val appState = LocalAppState.current diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneScaffold.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneScaffold.kt index f9dfc7a..f56750b 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneScaffold.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PaneScaffold.kt @@ -41,14 +41,12 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.roundToIntSize import androidx.compose.ui.zIndex -import com.tunjid.composables.ui.skipIf import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.treenav.compose.PaneScope import com.tunjid.treenav.compose.threepane.ThreePane @@ -56,7 +54,6 @@ import com.tunjid.treenav.compose.threepane.ThreePaneMovableElementSharedTransit import com.tunjid.treenav.compose.threepane.rememberThreePaneMovableElementSharedTransitionScope import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNotNull -import kotlin.math.abs @Stable class PaneScaffoldState internal constructor( @@ -71,34 +68,13 @@ class PaneScaffoldState internal constructor( && appState.isMediumScreenWidthOrWider val canUseMovableNavigationBar - get() = canShowNavigationBar && when { - isActive && isPreviewingBack && paneState.pane == ThreePane.TransientPrimary -> true - isActive && !isPreviewingBack && paneState.pane == ThreePane.Primary -> true - else -> false - } + get() = canShowNavigationBar && isActive && paneState.pane == ThreePane.Primary val canUseMovableNavigationRail get() = canShowNavigationRail && isActive - internal val canShowFab - get() = when (paneState.pane) { - ThreePane.Primary -> true - ThreePane.TransientPrimary -> true - ThreePane.Secondary -> false - ThreePane.Tertiary -> false - ThreePane.Overlay -> false - null -> false - } - internal var scaffoldTargetSize by mutableStateOf(IntSize.Zero) internal var scaffoldCurrentSize by mutableStateOf(IntSize.Zero) - - internal fun hasMatchedSize(): Boolean = - abs(scaffoldCurrentSize.width - scaffoldTargetSize.width) <= 2 - && abs(scaffoldCurrentSize.height - scaffoldTargetSize.height) <= 2 - - private val isPreviewingBack: Boolean - get() = paneState.adaptations.contains(ThreePane.PrimaryToTransient) } @Composable @@ -236,9 +212,6 @@ private fun scaffoldBoundsTransform( -> if (canAnimatePane()) spring() else snap() - ThreePane.TransientPrimary, - -> spring().skipIf(paneScaffoldState::hasMatchedSize) - ThreePane.Overlay, null, -> snap() diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt index 539046a..19741f0 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/PredictiveBack.kt @@ -39,8 +39,10 @@ import com.tunjid.treenav.compose.threepane.ThreePane fun Modifier.predictiveBackBackgroundModifier( paneScope: PaneScope, ): Modifier { - if (paneScope.paneState.pane != ThreePane.TransientPrimary) - return this + if (paneScope.paneState.pane != ThreePane.Primary + || !paneScope.isActive + || !paneScope.inPredictiveBack + ) return this var elevation by remember { mutableStateOf(0.dp) } LaunchedEffect(Unit) { diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/ViewModelUtilities.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/ViewModelUtilities.kt new file mode 100644 index 0000000..8056f80 --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/ViewModelUtilities.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +fun viewModelCoroutineScope() = + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt index 9a8bab2..150de85 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarScreen.kt @@ -25,16 +25,16 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import com.tunjid.demo.common.ui.PaneScaffoldState import com.tunjid.demo.common.ui.ProfilePhoto import com.tunjid.demo.common.ui.ProfilePhotoArgs import com.tunjid.demo.common.ui.dragToPop -import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.updatedMovableSharedElementOf @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun AvatarScreen( - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, state: State, onAction: (Action) -> Unit, modifier: Modifier = Modifier, @@ -46,8 +46,10 @@ fun AvatarScreen( .fillMaxSize() ) { val profileName = state.profileName ?: state.profile?.name ?: "" - movableSharedElementScope.updatedMovableSharedElementOf( - key = "${state.roomName}-$profileName", + paneScaffoldState.updatedMovableSharedElementOf( + sharedContentState = paneScaffoldState.rememberSharedContentState( + key = "${state.roomName}-$profileName-profile" + ), state = ProfilePhotoArgs( profileName = profileName, contentScale = ContentScale.Crop, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarViewModel.kt index 8f8ccc9..1947ca8 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/AvatarViewModel.kt @@ -16,7 +16,6 @@ package com.tunjid.demo.common.ui.avatar -import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ViewModel import com.tunjid.demo.common.ui.data.NavigationAction import com.tunjid.demo.common.ui.data.NavigationRepository @@ -31,11 +30,12 @@ import com.tunjid.mutator.coroutines.mapToMutation import com.tunjid.mutator.coroutines.toMutationStream import com.tunjid.treenav.MultiStackNav import com.tunjid.treenav.pop +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow class AvatarViewModel( - coroutineScope: LifecycleCoroutineScope, + coroutineScope: CoroutineScope, profileRepository: ProfileRepository = ProfileRepository, navigationRepository: NavigationRepository = NavigationRepository, profileName: String?, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt index 9cf1556..e3feb24 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/avatar/PaneEntry.kt @@ -19,14 +19,13 @@ package com.tunjid.demo.common.ui.avatar import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel import com.tunjid.demo.common.ui.PaneScaffold import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.data.SampleDestination.NavTabs import com.tunjid.demo.common.ui.rememberPaneScaffoldState +import com.tunjid.demo.common.ui.viewModelCoroutineScope import com.tunjid.treenav.compose.threepane.ThreePane import com.tunjid.treenav.compose.threepane.threePaneEntry @@ -41,10 +40,9 @@ fun avatarPaneEntry() = threePaneEntry( }, render = { destination -> check(destination is SampleDestination.Avatar) - val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope val viewModel = viewModel { AvatarViewModel( - coroutineScope = scope, + coroutineScope = viewModelCoroutineScope(), profileName = destination.profileName, roomName = destination.roomName, ) @@ -55,7 +53,7 @@ fun avatarPaneEntry() = threePaneEntry( containerColor = Color.Transparent, content = { AvatarScreen( - movableSharedElementScope = this, + paneScaffoldState = this, state = viewModel.state.collectAsStateWithLifecycle().value, onAction = viewModel.accept, modifier = Modifier.fillMaxSize() diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt index 32dfd90..baa107f 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.paddingFrom import androidx.compose.foundation.layout.size @@ -47,20 +48,22 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import com.tunjid.demo.common.ui.PaneScaffoldState import com.tunjid.demo.common.ui.ProfilePhoto import com.tunjid.demo.common.ui.ProfilePhotoArgs import com.tunjid.demo.common.ui.SampleTopAppBar import com.tunjid.demo.common.ui.data.Message import com.tunjid.demo.common.ui.data.Profile -import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.updatedMovableSharedElementOf +import com.tunjid.treenav.compose.threepane.ThreePane import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @Composable fun ChatScreen( - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, state: State, onAction: (Action) -> Unit, modifier: Modifier = Modifier, @@ -69,10 +72,17 @@ fun ChatScreen( Column( modifier, ) { + val isInPrimaryPane = paneScaffoldState.paneState.pane == ThreePane.Primary SampleTopAppBar( - title = state.room?.name ?: "", - onBackPressed = remember(state.isInPrimaryPane) { - if (state.isInPrimaryPane) return@remember { + title = { + ChatTitle( + roomName = state.roomName, + participants = state.participants, + paneScaffoldState = paneScaffoldState + ) + }, + onBackPressed = remember(isInPrimaryPane) { + if (isInPrimaryPane) return@remember { onAction(Action.Navigation.Pop) } else null }, @@ -80,16 +90,56 @@ fun ChatScreen( Messages( me = state.me, roomName = state.room?.name, + isInPrimaryPane = isInPrimaryPane, messages = state.chats, - isInPrimaryPane = state.isInPrimaryPane, navigateToProfile = onAction, - modifier = Modifier.fillMaxSize(), scrollState = scrollState, - movableSharedElementScope = movableSharedElementScope, + modifier = Modifier.fillMaxSize(), + paneScaffoldState = paneScaffoldState, ) } } +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun ChatTitle( + roomName: String, + participants: List, + paneScaffoldState: PaneScaffoldState +) = with(paneScaffoldState) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .paneSharedElement(rememberSharedContentState("title")), + text = roomName, + ) + Spacer( + modifier = Modifier + .width(16.dp) + ) + participants.forEachIndexed { index, participant -> + updatedMovableSharedElementOf( + sharedContentState = paneScaffoldState.rememberSharedContentState( + key = "${roomName}-${participant}" + ), + state = ProfilePhotoArgs( + profileName = participant, + contentScale = ContentScale.Crop, + cornerRadius = 42.dp, + contentDescription = null, + ), + modifier = Modifier + .size(24.dp) + .offset(x = index * (-8).dp), + sharedElement = { args: ProfilePhotoArgs, innerModifier: Modifier -> + ProfilePhoto(args, innerModifier) + } + ) + } + } +} @Composable fun Messages( @@ -100,7 +150,7 @@ fun Messages( navigateToProfile: (Action.Navigation.GoToProfile) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier, - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, ) { Box(modifier = modifier) { LazyColumn( @@ -119,13 +169,13 @@ fun Messages( Message( onAuthorClick = navigateToProfile, - roomName = roomName, item = content, + roomName = roomName, isUserMe = content.sender.name == me?.name, isInPrimaryPane = isInPrimaryPane, isFirstMessageByAuthor = isFirstMessageByAuthor, isLastMessageByAuthor = isLastMessageByAuthor, - movableSharedElementScope = movableSharedElementScope, + paneScaffoldState = paneScaffoldState ) } } @@ -142,7 +192,7 @@ fun Message( isInPrimaryPane: Boolean, isFirstMessageByAuthor: Boolean, isLastMessageByAuthor: Boolean, - movableSharedElementScope: MovableSharedElementScope + paneScaffoldState: PaneScaffoldState, ) { val borderColor = if (isUserMe) { MaterialTheme.colorScheme.primary @@ -175,8 +225,10 @@ fun Message( } }, ) { - movableSharedElementScope.updatedMovableSharedElementOf( - key = "$roomName-${item.sender.name}", + paneScaffoldState.updatedMovableSharedElementOf( + sharedContentState = paneScaffoldState.rememberSharedContentState( + key = "$roomName-${item.sender.name}-profile" + ), state = ProfilePhotoArgs( profileName = item.sender.name, contentScale = ContentScale.Crop, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt index 384b5a3..1b146ec 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt @@ -16,7 +16,6 @@ package com.tunjid.demo.common.ui.chat -import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ViewModel import com.tunjid.demo.common.ui.data.ChatRoom import com.tunjid.demo.common.ui.data.ChatsRepository @@ -37,20 +36,24 @@ import com.tunjid.treenav.MultiStackNav import com.tunjid.treenav.pop import com.tunjid.treenav.push import com.tunjid.treenav.swap +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest class ChatViewModel( - coroutineScope: LifecycleCoroutineScope, + coroutineScope: CoroutineScope, chatsRepository: ChatsRepository = ChatsRepository, profileRepository: ProfileRepository = ProfileRepository, navigationRepository: NavigationRepository = NavigationRepository, chat: SampleDestination.Chat, ) : ViewModel(coroutineScope), ActionStateMutator> by coroutineScope.actionStateFlowMutator( - initialState = State(), + initialState = State( + roomName = chat.roomName, + participants = chat.participants, + ), inputs = listOf( profileRepository.meMutations(), chatsRepository.chatRoomMutations(chat), @@ -65,7 +68,6 @@ class ChatViewModel( keySelector = Action::key ) { when (val type = type()) { - is Action.UpdateInPrimaryPane -> type.flow.updateInPrimaryPaneMutations() is Action.Navigation -> navigationRepository.navigationMutations( type.flow ) @@ -105,13 +107,11 @@ private fun chatLoadMutations( copy(chats = it) } -private fun Flow.updateInPrimaryPaneMutations(): Flow> = - mapToMutation { copy(isInPrimaryPane = it.isInPrimaryPane) } - data class State( val me: Profile? = null, + val roomName: String, val room: ChatRoom? = null, - val isInPrimaryPane: Boolean = true, + val participants: List, val chats: List = emptyList(), ) @@ -124,9 +124,6 @@ sealed class Action( val key: String, ) { - data class UpdateInPrimaryPane( - val isInPrimaryPane: Boolean, - ) : Action("UpdateInPrimaryPane") sealed class Navigation : Action("Navigation"), NavigationAction { data object Pop : Navigation(), NavigationAction by navigationAction( diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt index 975362d..1b9bf10 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/PaneEntry.kt @@ -17,11 +17,8 @@ package com.tunjid.demo.common.ui.chat import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel import com.tunjid.demo.common.ui.PaneNavigationBar import com.tunjid.demo.common.ui.PaneNavigationRail @@ -30,6 +27,7 @@ import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.data.SampleDestination.NavTabs import com.tunjid.demo.common.ui.predictiveBackBackgroundModifier import com.tunjid.demo.common.ui.rememberPaneScaffoldState +import com.tunjid.demo.common.ui.viewModelCoroutineScope import com.tunjid.treenav.compose.threepane.ThreePane import com.tunjid.treenav.compose.threepane.threePaneEntry @@ -42,10 +40,9 @@ fun chatPaneEntry() = threePaneEntry( }, render = { destination -> check(destination is SampleDestination.Chat) - val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope val viewModel = viewModel { ChatViewModel( - coroutineScope = scope, + coroutineScope = viewModelCoroutineScope(), chat = destination, ) } @@ -55,17 +52,10 @@ fun chatPaneEntry() = threePaneEntry( .fillMaxSize(), content = { ChatScreen( - movableSharedElementScope = this, + paneScaffoldState = this, state = viewModel.state.collectAsStateWithLifecycle().value, onAction = viewModel.accept, ) - LaunchedEffect(paneState.pane) { - viewModel.accept( - Action.UpdateInPrimaryPane( - isInPrimaryPane = paneState.pane == ThreePane.Primary - ) - ) - } }, navigationBar = { PaneNavigationBar() diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt index 88b504e..be90cdc 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt @@ -19,7 +19,6 @@ package com.tunjid.demo.common.ui.chatrooms import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -45,19 +44,19 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.tunjid.composables.collapsingheader.CollapsingHeaderLayout import com.tunjid.composables.collapsingheader.CollapsingHeaderState +import com.tunjid.demo.common.ui.PaneScaffoldState import com.tunjid.demo.common.ui.ProfilePhoto import com.tunjid.demo.common.ui.ProfilePhotoArgs import com.tunjid.demo.common.ui.SampleTopAppBar import com.tunjid.demo.common.ui.data.ChatRoom import com.tunjid.demo.common.ui.data.Message import com.tunjid.demo.common.ui.rememberAppBarCollapsingHeaderState -import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.updatedMovableSharedElementOf import kotlin.math.roundToInt @Composable fun ChatRoomsScreen( - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, state: State, onAction: (Action) -> Unit, modifier: Modifier = Modifier, @@ -68,11 +67,14 @@ fun ChatRoomsScreen( state = headerState, modifier = modifier, headerContent = { - Header(headerState) + Header( + headerState = headerState, + paneScaffoldState = paneScaffoldState, + ) }, body = { ChatRooms( - movableSharedElementScope = movableSharedElementScope, + paneScaffoldState = paneScaffoldState, state = state, onAction = onAction, ) @@ -80,21 +82,32 @@ fun ChatRoomsScreen( ) } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable -private fun Header(headerState: CollapsingHeaderState) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .offset { - IntOffset( - x = 0, - y = -headerState.translation.roundToInt() - ) - } - ) { +private fun Header( + headerState: CollapsingHeaderState, + paneScaffoldState: PaneScaffoldState, +) = with(paneScaffoldState) { + Box { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .offset { + IntOffset( + x = 0, + y = -headerState.translation.roundToInt() + ) + } + ) SampleTopAppBar( - title = "Chat Rooms", + title = { + Text( + modifier = Modifier + .paneSharedElement(rememberSharedContentState("title")), + text = "Chat Rooms", + ) + }, onBackPressed = null ) } @@ -102,7 +115,7 @@ private fun Header(headerState: CollapsingHeaderState) { @Composable private fun ChatRooms( - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, state: State, onAction: (Action) -> Unit ) { @@ -113,15 +126,21 @@ private fun ChatRooms( items = state.chatRooms, key = ChatRoom::name, itemContent = { room -> + val participants = room.messages + .map(Message::sender) + .distinct() + .take(3) ChatRoomListItem( - movableSharedElementScope = movableSharedElementScope, + paneScaffoldState = paneScaffoldState, roomName = room.name, - participants = room.messages - .map(Message::sender) - .distinct() - .take(3), + participants = participants, onRoomClicked = { - onAction(Action.Navigation.ToRoom(roomName = it)) + onAction( + Action.Navigation.ToRoom( + roomName = it, + participants = participants, + ) + ) } ) } @@ -131,7 +150,7 @@ private fun ChatRooms( @Composable fun ChatRoomListItem( - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, roomName: String, participants: List, modifier: Modifier = Modifier, @@ -153,7 +172,7 @@ fun ChatRoomListItem( verticalAlignment = Alignment.CenterVertically ) { ChatRoomParticipants( - movableSharedElementScope = movableSharedElementScope, + paneScaffoldState = paneScaffoldState, participants = participants, roomName = roomName, ) @@ -166,13 +185,13 @@ fun ChatRoomListItem( } } -@OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun ChatRoomParticipants( - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, participants: List, roomName: String, -) = with(movableSharedElementScope) { +) = with(paneScaffoldState) { FlowRow( modifier = Modifier .width(64.dp) @@ -185,7 +204,9 @@ fun ChatRoomParticipants( ) { participants.forEach { profileName -> updatedMovableSharedElementOf( - key = "$roomName-${profileName}", + sharedContentState = paneScaffoldState.rememberSharedContentState( + key = "$roomName-${profileName}" + ), state = ProfilePhotoArgs( profileName = profileName, contentScale = ContentScale.Crop, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt index 64301fb..42fa786 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt @@ -16,7 +16,6 @@ package com.tunjid.demo.common.ui.chatrooms -import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ViewModel import com.tunjid.demo.common.ui.data.ChatRoom import com.tunjid.demo.common.ui.data.ChatsRepository @@ -31,11 +30,12 @@ import com.tunjid.mutator.coroutines.actionStateFlowMutator import com.tunjid.mutator.coroutines.mapToMutation import com.tunjid.mutator.coroutines.toMutationStream import com.tunjid.treenav.push +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow class ChatRoomsViewModel( - coroutineScope: LifecycleCoroutineScope, + coroutineScope: CoroutineScope, chatsRepository: ChatsRepository = ChatsRepository, navigationRepository: NavigationRepository = NavigationRepository, ) : ViewModel(coroutineScope), @@ -72,8 +72,9 @@ sealed class Action( sealed class Navigation : Action("Navigation"), NavigationAction { data class ToRoom( val roomName: String, + val participants: List, ) : Navigation(), NavigationAction by navigationAction( - { push(SampleDestination.Chat(roomName)) } + { push(SampleDestination.Chat(roomName, participants)) } ) } } \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt index 1eabeba..ce21e94 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/PaneEntry.kt @@ -18,9 +18,7 @@ package com.tunjid.demo.common.ui.chatrooms import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel import com.tunjid.demo.common.ui.PaneNavigationBar import com.tunjid.demo.common.ui.PaneNavigationRail @@ -28,15 +26,15 @@ import com.tunjid.demo.common.ui.PaneScaffold import com.tunjid.demo.common.ui.data.ChatsRepository import com.tunjid.demo.common.ui.predictiveBackBackgroundModifier import com.tunjid.demo.common.ui.rememberPaneScaffoldState +import com.tunjid.demo.common.ui.viewModelCoroutineScope import com.tunjid.treenav.compose.threepane.threePaneEntry fun chatRoomPaneEntry( ) = threePaneEntry( render = { - val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope val viewModel = viewModel { ChatRoomsViewModel( - coroutineScope = scope, + coroutineScope = viewModelCoroutineScope(), chatsRepository = ChatsRepository ) } @@ -46,7 +44,7 @@ fun chatRoomPaneEntry( .fillMaxSize(), content = { ChatRoomsScreen( - movableSharedElementScope = this, + paneScaffoldState = this, state = viewModel.state.collectAsStateWithLifecycle().value, onAction = viewModel.accept, ) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/AppData.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/AppData.kt index 344abb7..5d9fb1e 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/AppData.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/AppData.kt @@ -63,9 +63,9 @@ object ProfileRepository { chatData.profiles.values.random() ) - fun profileFor(name: String): Flow = flow { - chatData.profiles[name]?.let { emit(it) } - } + fun profileFor(name: String): Flow = flowOf( + chatData.profiles.getValue(name) + ) } diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/NavigationRepository.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/NavigationRepository.kt index a45bbb6..6d9d520 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/NavigationRepository.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/NavigationRepository.kt @@ -49,6 +49,7 @@ sealed interface SampleDestination : Node { data class Chat( val roomName: String, + val participants: List = emptyList(), ) : SampleDestination { override val id: String diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt index 07bf1c2..6e0cd8d 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/me/PaneEntry.kt @@ -18,9 +18,7 @@ package com.tunjid.demo.common.ui.me import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel import com.tunjid.demo.common.ui.PaneNavigationBar import com.tunjid.demo.common.ui.PaneNavigationRail @@ -29,15 +27,15 @@ import com.tunjid.demo.common.ui.predictiveBackBackgroundModifier import com.tunjid.demo.common.ui.profile.ProfileScreen import com.tunjid.demo.common.ui.profile.ProfileViewModel import com.tunjid.demo.common.ui.rememberPaneScaffoldState +import com.tunjid.demo.common.ui.viewModelCoroutineScope import com.tunjid.treenav.compose.threepane.threePaneEntry fun mePaneEntry( ) = threePaneEntry( render = { - val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope val viewModel = viewModel { ProfileViewModel( - coroutineScope = scope, + coroutineScope = viewModelCoroutineScope(), profileName = null, roomName = null, ) @@ -48,7 +46,7 @@ fun mePaneEntry( .fillMaxSize(), content = { ProfileScreen( - movableSharedElementScope = this, + paneScaffoldState = this, state = viewModel.state.collectAsStateWithLifecycle().value, onAction = viewModel.accept, ) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt index 8a1bd38..10c3031 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/PaneEntry.kt @@ -18,9 +18,7 @@ package com.tunjid.demo.common.ui.profile import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.coroutineScope import androidx.lifecycle.viewmodel.compose.viewModel import com.tunjid.demo.common.ui.PaneNavigationBar import com.tunjid.demo.common.ui.PaneNavigationRail @@ -29,6 +27,7 @@ import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.data.SampleDestination.NavTabs import com.tunjid.demo.common.ui.predictiveBackBackgroundModifier import com.tunjid.demo.common.ui.rememberPaneScaffoldState +import com.tunjid.demo.common.ui.viewModelCoroutineScope import com.tunjid.treenav.compose.threepane.ThreePane import com.tunjid.treenav.compose.threepane.threePaneEntry @@ -43,10 +42,9 @@ fun profilePaneEntry() = threePaneEntry( }, render = { destination -> check(destination is SampleDestination.Profile) - val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope val viewModel = viewModel { ProfileViewModel( - coroutineScope = scope, + coroutineScope = viewModelCoroutineScope(), profileName = destination.profileName, roomName = destination.roomName, ) @@ -57,7 +55,7 @@ fun profilePaneEntry() = threePaneEntry( .fillMaxSize(), content = { ProfileScreen( - movableSharedElementScope = this, + paneScaffoldState = this, state = viewModel.state.collectAsStateWithLifecycle().value, onAction = viewModel.accept, modifier = Modifier.fillMaxSize() diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt index 55830cb..0feca02 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt @@ -38,17 +38,17 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.tunjid.composables.collapsingheader.CollapsingHeaderLayout +import com.tunjid.demo.common.ui.PaneScaffoldState import com.tunjid.demo.common.ui.ProfilePhoto import com.tunjid.demo.common.ui.ProfilePhotoArgs import com.tunjid.demo.common.ui.SampleTopAppBar import com.tunjid.demo.common.ui.rememberAppBarCollapsingHeaderState -import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.updatedMovableSharedElementOf import kotlin.math.roundToInt @Composable fun ProfileScreen( - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, state: State, onAction: (Action) -> Unit, modifier: Modifier = Modifier, @@ -61,7 +61,7 @@ fun ProfileScreen( headerContent = { ProfileHeader( state = state, - movableSharedElementScope = movableSharedElementScope, + paneScaffoldState = paneScaffoldState, onBackPressed = remember(state.profileName) { if (state.profileName != null) return@remember { onAction(Action.Navigation.Pop) @@ -89,23 +89,30 @@ fun ProfileScreen( ) } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun ProfileHeader( state: State, - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, modifier: Modifier = Modifier, onBackPressed: (() -> Unit)?, -) { +) = with(paneScaffoldState) { Box( modifier = Modifier.heightIn(min = 400.dp) ) { ProfilePhoto( state = state, - movableSharedElementScope = movableSharedElementScope, + paneScaffoldState = paneScaffoldState, modifier = modifier ) SampleTopAppBar( - title = if (state.profileName == null) "Me" else "Profile", + title = { + Text( + modifier = Modifier + .paneSharedElement(rememberSharedContentState("title")), + text = if (state.profileName == null) "Me" else "Profile", + ) + }, onBackPressed = onBackPressed, ) } @@ -115,13 +122,15 @@ private fun ProfileHeader( @Composable private fun ProfilePhoto( state: State, - movableSharedElementScope: MovableSharedElementScope, + paneScaffoldState: PaneScaffoldState, modifier: Modifier = Modifier, ) { val profileName = state.profileName ?: state.profile?.name if (profileName != null) { - movableSharedElementScope.updatedMovableSharedElementOf( - key = "${state.roomName}-$profileName", + paneScaffoldState.updatedMovableSharedElementOf( + sharedContentState = paneScaffoldState.rememberSharedContentState( + key = "${state.roomName}-$profileName-profile" + ), state = ProfilePhotoArgs( profileName = profileName, contentScale = ContentScale.Crop, diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt index a2ec738..23436ca 100644 --- a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt @@ -16,9 +16,7 @@ package com.tunjid.demo.common.ui.profile -import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ViewModel -import com.tunjid.demo.common.ui.chatrooms.Action.Navigation import com.tunjid.demo.common.ui.data.NavigationAction import com.tunjid.demo.common.ui.data.NavigationRepository import com.tunjid.demo.common.ui.data.Profile @@ -34,11 +32,12 @@ import com.tunjid.mutator.coroutines.toMutationStream import com.tunjid.treenav.MultiStackNav import com.tunjid.treenav.pop import com.tunjid.treenav.push +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow class ProfileViewModel( - coroutineScope: LifecycleCoroutineScope, + coroutineScope: CoroutineScope, profileRepository: ProfileRepository = ProfileRepository, navigationRepository: NavigationRepository = NavigationRepository, profileName: String?,