diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5c81a3b..5715b74 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,11 +12,11 @@ androidxTestExt = "1.2.1" androidxTestRunner = "1.6.2" androidxTestRules = "1.6.1" dokka = "1.8.20" -jetbrainsCompose = "1.7.0" -jetbrainsLifecycle = "2.8.3" +jetbrainsCompose = "1.7.1" +jetbrainsLifecycle = "2.8.4" jetbrainsMaterial3Adaptive = "1.0.0" junit4 = "4.13.2" -kotlin = "2.0.20" +kotlin = "2.0.21" kotlinxCoroutines = "1.9.0" kotlinxDatetime = "0.6.1" lifecycle-runtime = "2.8.6" diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SavedStatePanedNavHostState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SavedStatePanedNavHostState.kt index 648fc5c..164da9a 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SavedStatePanedNavHostState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/SavedStatePanedNavHostState.kt @@ -20,6 +20,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.SaveableStateHolder import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.currentStateAsState import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner @@ -64,8 +67,22 @@ interface PanedNavHostScope { } /** - * An implementation of an [PanedNavHostState] that provides a [SaveableStateHolder] for each - * navigation destination that shows up in its panes. + * An implementation of an [PanedNavHostState] that provides the following for each + * navigation [Destination] that shows up in its panes: + * + * - A single [SaveableStateHolder] for each navigation [Destination] that shows up in its panes. + * [SaveableStateHolder.SaveableStateProvider] is keyed on the [Destination]s [Node.id]. + * + * - A [ViewModelStoreOwner] for each [Destination] via [LocalViewModelStoreOwner]. + * Once present in the navigation tree, a [Destination] will always use the same + * [ViewModelStoreOwner], regardless of where in the tree it is, until its is removed from the tree. + * [Destination]s are unique based on their [Node.id]. + * + * - A [LifecycleOwner] for each [Destination] via [LocalLifecycleOwner]. This [LifecycleOwner] + * follows the [Lifecycle] of its immediate parent, unless it is animating out or placed in the + * backstack. This is defined by [PaneScope.isActive], which is a function of the backing + * [AnimatedContent] for each [Pane] displayed and if the current [Destination] + * matches [PanedNavHostScope.nodeFor] in the visible [Pane]. * * @param panes a list of panes that is possible to show in the [PanedNavHost] in all * possible configurations. The panes should consist of enum class instances, or a sealed class @@ -86,7 +103,7 @@ class SavedStatePanedNavHostState( val saveableStateHolder = rememberSaveableStateHolder() val panedContentScope = remember { - SavedStatePanedNavHostScope( + NavHostScope( panes = panes, navHostConfiguration = configuration, initialPanesToNodes = panesToNodes, @@ -106,23 +123,19 @@ class SavedStatePanedNavHostState( companion object { @Stable - private class SavedStatePanedNavHostScope( + class NavHostScope internal constructor( panes: List, initialPanesToNodes: Map, saveableStateHolder: SaveableStateHolder, val navHostConfiguration: PanedNavHostConfiguration, ) : PanedNavHostScope, SaveableStateHolder by saveableStateHolder { - private val destinationViewModelStoreCreator = DestinationViewModelStoreCreator( - rootNodeProvider = navHostConfiguration.navigationState::value - ) - - val slots = List( + private val slots = List( size = panes.size, init = ::Slot ).toSet() - var panedNavigationState by mutableStateOf( + private var panedNavigationState by mutableStateOf( value = SlotBasedPanedNavigationState.initial(slots = slots) .adaptTo( slots = slots, @@ -131,6 +144,10 @@ class SavedStatePanedNavHostState( ) ) + private val destinationViewModelStoreCreator = DestinationViewModelStoreCreator( + validNodeIdsReader = { panedNavigationState.backStackIds + panedNavigationState.destinationIdsAnimatingOut } + ) + private val slotsToRoutes = mutableStateMapOf Unit>().also { map -> map[null] = {} @@ -139,6 +156,19 @@ class SavedStatePanedNavHostState( } } + /** + * Retrieves the a [ViewModelStoreOwner] for a given [destination]. All destinations + * with the same [Node.id] share the same [ViewModelStoreOwner]. + * + * The [destination] must be present in the navigation tree, otherwise an + * [IllegalStateException] will be thrown. + * + * @param destination The destination for which the [ViewModelStoreOwner] should + * be retrieved. + */ + fun viewModelStoreOwnerFor(destination: Destination): ViewModelStoreOwner = + destinationViewModelStoreCreator.viewModelStoreOwnerFor(destination) + @Composable override fun Destination(pane: Pane) { val slot = panedNavigationState.slotFor(pane) @@ -153,7 +183,7 @@ class SavedStatePanedNavHostState( pane: Pane ): Destination? = panedNavigationState.destinationFor(pane) - fun onNewNavigationState( + internal fun onNewNavigationState( navigationState: Node, panesToNodes: Map, ) { @@ -208,8 +238,10 @@ class SavedStatePanedNavHostState( val destinationLifecycleOwner = rememberDestinationLifecycleOwner( destination ) - val destinationViewModelOwner = destinationViewModelStoreCreator - .viewModelStoreOwnerFor(destination) + val destinationViewModelOwner = remember(destination.id) { + destinationViewModelStoreCreator + .viewModelStoreOwnerFor(destination) + } CompositionLocalProvider( LocalLifecycleOwner provides destinationLifecycleOwner, @@ -286,3 +318,13 @@ class SavedStatePanedNavHostState( } } } + +fun PanedNavHostScope< + Pane, + Destination + >.requireSavedStatePanedNavHostScope(): SavedStatePanedNavHostState.Companion.NavHostScope { + check(this is SavedStatePanedNavHostState.Companion.NavHostScope) { + "This PanedNavHostScope instance is not a SavedStatePanedNavHostScope" + } + return this +} diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/lifecycle/DestinationViewModelStoreCreator.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/lifecycle/DestinationViewModelStoreCreator.kt index b18205f..538eb66 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/lifecycle/DestinationViewModelStoreCreator.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/lifecycle/DestinationViewModelStoreCreator.kt @@ -17,42 +17,54 @@ package com.tunjid.treenav.compose.lifecycle import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateMapOf import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import com.tunjid.treenav.Node import com.tunjid.treenav.Order -import com.tunjid.treenav.flatten +import com.tunjid.treenav.traverse +/** + * A class that lazily loads a [ViewModelStoreOwner] for each destination in the navigation graph. + * Each unique destination can only have a single [ViewModelStore], regardless of how many times + * it appears in the navigation graph, or its depth at any point. + */ @Stable internal class DestinationViewModelStoreCreator( - private val rootNodeProvider: () -> Node + private val validNodeIdsReader: () -> Set ) { - private val nodeIdsToViewModelStoreOwner = mutableMapOf() + private val nodeIdsToViewModelStoreOwner = mutableStateMapOf() /** * Creates a [ViewModelStoreOwner] for a given [Node] */ fun viewModelStoreOwnerFor( node: Node - ): ViewModelStoreOwner = nodeIdsToViewModelStoreOwner.getOrPut( - node.id - ) { - object : ViewModelStoreOwner { - override val viewModelStore: ViewModelStore = ViewModelStore() + ): ViewModelStoreOwner { + val existingIds = validNodeIdsReader() + check(existingIds.contains(node.id)) { + """ + Attempted to create a ViewModelStoreOwner for Node $node, but the Node is not + present in the navigation tree + """.trimIndent() + } + return nodeIdsToViewModelStoreOwner.getOrPut( + node.id + ) { + object : ViewModelStoreOwner { + override val viewModelStore: ViewModelStore = ViewModelStore() + } } } fun clearStoreFor(childNode: Node) { - val rootNode = rootNodeProvider() - val existingNodeIds = rootNode.flatten(Order.BreadthFirst).mapTo( - destination = mutableSetOf(), - transform = Node::id - ) - if (existingNodeIds.contains(childNode.id)) { - return + val existingIds = validNodeIdsReader() + childNode.traverse(Order.BreadthFirst) { + if (!existingIds.contains(it.id)) { + nodeIdsToViewModelStoreOwner.remove(it.id) + ?.viewModelStore + ?.clear() + } } - nodeIdsToViewModelStoreOwner.remove(childNode.id) - ?.viewModelStore - ?.clear() } } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElementState.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElementState.kt index 9be768f..05ba0cf 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElementState.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/MovableSharedElementState.kt @@ -1,6 +1,5 @@ package com.tunjid.treenav.compose.moveablesharedelement -import androidx.compose.animation.BoundsTransform import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.runtime.Composable @@ -9,78 +8,29 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.drawscope.ContentDrawScope -import androidx.compose.ui.graphics.drawscope.translate -import androidx.compose.ui.graphics.layer.GraphicsLayer -import androidx.compose.ui.graphics.layer.drawLayer -import androidx.compose.ui.graphics.rememberGraphicsLayer -import androidx.compose.ui.unit.toOffset -import com.tunjid.treenav.Node -import com.tunjid.treenav.compose.PaneScope -import com.tunjid.treenav.compose.PaneState -import com.tunjid.treenav.compose.utilities.AnimatedBoundsState -import com.tunjid.treenav.compose.utilities.AnimatedBoundsState.Companion.animateBounds -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.first -@Stable @OptIn(ExperimentalSharedTransitionApi::class) -internal class MovableSharedElementState( - paneScope: PaneScope, - sharedTransitionScope: SharedTransitionScope, +@Stable +internal class MovableSharedElementState( + sharedContentState: SharedTransitionScope.SharedContentState, sharedElement: @Composable (State, Modifier) -> Unit, - onRemoved: () -> Unit, - boundsTransform: BoundsTransform, - private val canAnimateOnStartingFrames: PaneState.() -> Boolean -) : SharedElementOverlay, SharedTransitionScope by sharedTransitionScope { - - var paneScope by mutableStateOf(paneScope) + onRemoved: () -> Unit +) { + var sharedContentState by mutableStateOf(sharedContentState) private var composedRefCount by mutableIntStateOf(0) - private var layer: GraphicsLayer? = null - var animInProgress by mutableStateOf(false) - private set - - private val canDrawInOverlay get() = animInProgress - private val panesKeysToSeenCount = mutableStateMapOf() - - private val animatedBoundsState = AnimatedBoundsState( - lookaheadScope = this, - boundsTransform = boundsTransform, - inProgress = { animInProgress } - ) - val moveableSharedElement: @Composable (Any?, Modifier) -> Unit = movableContentOf { state, modifier -> - animInProgress = isInProgress() - val layer = rememberGraphicsLayer().also { - this.layer = it - } @Suppress("UNCHECKED_CAST") sharedElement( // The shared element composable will be created by the first screen and reused by // subsequent screens. This updates the state from other screens so changes are seen. state as State, modifier - .animateBounds( - state = animatedBoundsState - ) - .drawWithContent { - layer.record { - this@drawWithContent.drawContent() - } - if (!canDrawInOverlay) { - drawLayer(layer) - } - }, ) DisposableEffect(Unit) { @@ -90,58 +40,5 @@ internal class MovableSharedElementState( } } } - - override fun ContentDrawScope.drawInOverlay() { - if (!canDrawInOverlay) return - val overlayLayer = layer ?: return - val (x, y) = animatedBoundsState.targetOffset.toOffset() - translate(x, y) { - drawLayer(overlayLayer) - } - } - - private fun updatePaneStateSeen( - paneState: PaneState<*, *> - ) { - panesKeysToSeenCount[paneState.key] = Unit - } - - private val hasBeenShared get() = panesKeysToSeenCount.size > 1 - - companion object { - - @Composable - private fun MovableSharedElementState<*, Pane, Destination>.isInProgress(): Boolean { - val paneState = paneScope.paneState.also(::updatePaneStateSeen) - - val (laggingScopeKey, animationInProgressTillFirstIdle) = produceState( - initialValue = Pair( - paneState.key, - paneState.canAnimateOnStartingFrames() - ), - key1 = paneState.key - ) { - value = Pair( - paneState.key, - paneState.canAnimateOnStartingFrames() - ) - value = snapshotFlow { animatedBoundsState.isIdle } - .debounce { if (it) 10 else 0 } - .first(true::equals) - .let { value.first to false } - }.value - - - if (!hasBeenShared) return false - - val isLagging = laggingScopeKey != paneScope.paneState.key - val canAnimateOnStartingFrames = paneScope.paneState.canAnimateOnStartingFrames() - - if (isLagging) return canAnimateOnStartingFrames - - return animationInProgressTillFirstIdle - } - } } -private val PaneState<*, *>.key get() = "${currentDestination?.id}-$pane" 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 080dc78..9f05642 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 @@ -3,6 +3,11 @@ package com.tunjid.treenav.compose.moveablesharedelement import androidx.compose.animation.BoundsTransform import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.SharedTransitionScope.OverlayClip +import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize +import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize.Companion.contentSize +import androidx.compose.animation.SharedTransitionScope.SharedContentState +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -10,22 +15,20 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection import com.tunjid.treenav.Node import com.tunjid.treenav.compose.PaneScope -import com.tunjid.treenav.compose.PaneState import com.tunjid.treenav.compose.PanedNavHost import com.tunjid.treenav.compose.utilities.DefaultBoundsTransform -internal interface SharedElementOverlay { - fun ContentDrawScope.drawInOverlay() -} - /** * Creates movable shared elements that may be shared amongst different [PaneScope] * instances. */ +@Stable interface MovableSharedElementScope { /** @@ -34,18 +37,96 @@ 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 sharedElement a factory function to create the shared element if it does not + * @param key 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 + * the transition. + * @param renderInOverlayDuringTransition Is true by default. In some rare use cases, there may + * be no clipping or layer transform (fade, scale, etc) in the application that prevents + * shared elements from transitioning from one bounds to another without any clipping or + * sudden alpha change. In such cases, [renderInOverlayDuringTransition] could be specified + * to false. + * @param zIndexInOverlay Can be specified to allow shared elements to render in a + * different order than their placement/zOrder when not in the overlay. + * @param clipInOverlayDuringTransition Can be used to specify the clipping for when the + * shared element is going through an active transition towards a new target bounds. + * @param alternateOutgoingSharedElement By default, a separate instance of the + * [sharedElement] is rendered when content is being animated out. When specified, this + * is rendered instead. This is useful for shared elements that can only be reasonable + * rendered in one place at any one time like video. + * @param sharedElement A factory function to create the movable shared element if it does not * currently exist. + * + * @see [SharedTransitionScope.sharedElementWithCallerManagedVisibility] */ @OptIn(ExperimentalSharedTransitionApi::class) fun movableSharedElementOf( key: Any, - boundsTransform: BoundsTransform = DefaultBoundsTransform, + boundsTransform: BoundsTransform, + placeHolderSize: PlaceHolderSize, + renderInOverlayDuringTransition: Boolean, + zIndexInOverlay: Float, + clipInOverlayDuringTransition: OverlayClip, + alternateOutgoingSharedElement: (@Composable (T, Modifier) -> Unit)?, sharedElement: @Composable (T, Modifier) -> Unit ): @Composable (T, Modifier) -> Unit } +/** + * Convenience method for [MovableSharedElementScope.movableSharedElementOf] that invokes the + * movable shared element with the latest values of [state] and [modifier]. + * + * @see [MovableSharedElementScope.movableSharedElementOf]. + * + * @param key 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 + * the transition. + * @param renderInOverlayDuringTransition Is true by default. In some rare use cases, there may + * be no clipping or layer transform (fade, scale, etc) in the application that prevents + * shared elements from transitioning from one bounds to another without any clipping or + * sudden alpha change. In such cases, [renderInOverlayDuringTransition] could be specified + * to false. + * @param zIndexInOverlay Can be specified to allow shared elements to render in a + * different order than their placement/zOrder when not in the overlay. + * @param clipInOverlayDuringTransition Can be used to specify the clipping for when the + * shared element is going through an active transition towards a new target bounds. + * @param alternateOutgoingSharedElement By default, a separate instance of the + * [sharedElement] is rendered when content is being animated out. When specified, this + * is rendered instead. This is useful for shared elements that can only be reasonable + * rendered in one place at any one time like video. + * @param sharedElement A factory function to create the movable shared element if it does not + * currently exist. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun MovableSharedElementScope.updatedMovableSharedElementOf( + key: Any, + state: T, + modifier: Modifier = Modifier, + boundsTransform: BoundsTransform = DefaultBoundsTransform, + placeHolderSize: PlaceHolderSize = contentSize, + renderInOverlayDuringTransition: Boolean = true, + zIndexInOverlay: Float = 0f, + clipInOverlayDuringTransition: OverlayClip = ParentClip, + alternateOutgoingSharedElement: (@Composable (T, Modifier) -> Unit)? = null, + sharedElement: @Composable (T, Modifier) -> Unit +) = movableSharedElementOf( + key = key, + boundsTransform = boundsTransform, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + alternateOutgoingSharedElement = alternateOutgoingSharedElement, + sharedElement = sharedElement +).invoke( + state, + modifier, +) + /** * State for managing movable shared elements within a single [PanedNavHost]. */ @@ -53,28 +134,10 @@ interface MovableSharedElementScope { @Stable class MovableSharedElementHostState( private val sharedTransitionScope: SharedTransitionScope, - private val canAnimateOnStartingFrames: (PaneState) -> Boolean, -) { - - // TODO: This should be unnecessary. Figure out a way to participate arbitrarily in the - // overlays already implemented in [SharedTransitionScope]. - /** - * A [Modifier] for drawing the movable shared element in overlays over existing content. - */ - val modifier = Modifier.drawWithContent { - drawContent() - overlays.forEach { overlay -> - with(overlay) { - drawInOverlay() - } - } - } - - private val overlays: Collection - get() = keysToMovableSharedElements.values +) : SharedTransitionScope by sharedTransitionScope { private val keysToMovableSharedElements = - mutableStateMapOf>() + mutableStateMapOf>() /** * Returns true is a given shared element under a given key is currently being shared. @@ -83,31 +146,31 @@ class MovableSharedElementHostState( keysToMovableSharedElements.contains(key) /** - * Returns true if a movable shared element is animating. + * Returns true if a movable shared element has its shared element match found. + * + * @see [SharedContentState.isMatchFound] */ - fun isInProgress(key: Any): Boolean = - keysToMovableSharedElements[key]?.animInProgress == true + fun isMatchFound(key: Any): Boolean = + keysToMovableSharedElements[key]?.sharedContentState?.isMatchFound == true /** * Provides a movable shared element that can be rendered in a given [PaneScope]. * It is the callers responsibility to perform other verifications on the ability * of the calling [PaneScope] to render the movable shared element. */ - fun PaneScope.createOrUpdateSharedElement( + @Suppress("UnusedReceiverParameter") + fun MovableSharedElementScope.createOrUpdateSharedElement( key: Any, - boundsTransform: BoundsTransform, + sharedContentState: SharedContentState, sharedElement: @Composable (S, Modifier) -> Unit, ): @Composable (S, Modifier) -> Unit { val movableSharedElementState = keysToMovableSharedElements.getOrPut(key) { MovableSharedElementState( - paneScope = this, - sharedTransitionScope = sharedTransitionScope, + sharedContentState = sharedContentState, sharedElement = sharedElement, - boundsTransform = boundsTransform, - canAnimateOnStartingFrames = canAnimateOnStartingFrames, onRemoved = { keysToMovableSharedElements.remove(key) } ) - }.also { it.paneScope = this } + }.also { it.sharedContentState = sharedContentState } // Can't really guarantee that the caller will use the same key for the right type return movableSharedElementState.moveableSharedElement @@ -122,7 +185,7 @@ class MovableSharedElementHostState( * movable shared element implementations. */ @Stable -internal class AdaptiveMovableSharedElementScope( +class PanedMovableSharedElementScope( paneScope: PaneScope, private val movableSharedElementHostState: MovableSharedElementHostState, ) : MovableSharedElementScope { @@ -133,29 +196,69 @@ internal class AdaptiveMovableSharedElementScope( override fun movableSharedElementOf( key: Any, boundsTransform: BoundsTransform, + placeHolderSize: PlaceHolderSize, + renderInOverlayDuringTransition: Boolean, + zIndexInOverlay: Float, + clipInOverlayDuringTransition: OverlayClip, + alternateOutgoingSharedElement: (@Composable (T, Modifier) -> Unit)?, sharedElement: @Composable (T, Modifier) -> Unit - ): @Composable (T, Modifier) -> Unit = when { - paneScope.isActive -> with(movableSharedElementHostState) { - paneScope.createOrUpdateSharedElement( - key = key, - boundsTransform = boundsTransform, - sharedElement = sharedElement - ) - } - // This pane state is be transitioning out. Check if it should be displayed without - // shared element semantics. - else -> when { - movableSharedElementHostState.isCurrentlyShared(key) -> - // The element is being shared in its new destination, stop showing it - // in the in active one - if (movableSharedElementHostState.isInProgress(key)) EmptyElement - // The element is not being shared in its new destination, allow it run its exit - // transition - else sharedElement - // Element isn't being shared anymore, show the element as is without sharing. - else -> sharedElement + ): @Composable (T, Modifier) -> Unit = { state, modifier -> + with(movableSharedElementHostState) { + val sharedContentState = rememberSharedContentState(key) + Box( + modifier + .sharedElementWithCallerManagedVisibility( + sharedContentState = sharedContentState, + visible = paneScope.isActive, + boundsTransform = boundsTransform, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + ) + ) { + when { + paneScope.isActive -> + createOrUpdateSharedElement( + key = key, + sharedContentState = sharedContentState, + sharedElement = sharedElement + )(state, Modifier.matchParentSize()) + + // This pane state is be transitioning out. Check if it should be displayed without + // shared element semantics. + 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) -> EmptyElement( + state, + Modifier.matchParentSize() + ) + // The element is not being shared in its new destination, allow it run its exit + // transition + else -> (alternateOutgoingSharedElement ?: sharedElement)( + state, + Modifier.matchParentSize() + ) + } + } + } } } } -private val EmptyElement: @Composable (Any?, Modifier) -> Unit = { _, _ -> } \ No newline at end of file +private val EmptyElement: @Composable (Any?, Modifier) -> Unit = { _, _ -> } + +@ExperimentalSharedTransitionApi +private val ParentClip: OverlayClip = + object : OverlayClip { + override fun getClipPath( + state: SharedContentState, + bounds: Rect, + layoutDirection: LayoutDirection, + density: Density + ): Path? { + return state.parentSharedContentState?.clipPathInOverlay + } + } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/MovableSharedElementConfiguration.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/MovableSharedElementConfiguration.kt index 8d5fd38..a43c83a 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/MovableSharedElementConfiguration.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/configurations/MovableSharedElementConfiguration.kt @@ -2,6 +2,8 @@ package com.tunjid.treenav.compose.threepane.configurations import androidx.compose.animation.BoundsTransform import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope.OverlayClip +import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -9,11 +11,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.tunjid.treenav.Node import com.tunjid.treenav.compose.PaneScope -import com.tunjid.treenav.compose.PaneState import com.tunjid.treenav.compose.PanedNavHost import com.tunjid.treenav.compose.PanedNavHostConfiguration import com.tunjid.treenav.compose.delegated -import com.tunjid.treenav.compose.moveablesharedelement.AdaptiveMovableSharedElementScope +import com.tunjid.treenav.compose.moveablesharedelement.PanedMovableSharedElementScope import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementHostState import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope import com.tunjid.treenav.compose.threepane.ThreePane @@ -37,7 +38,7 @@ fun PanedNavHostConfiguration< originalStrategy.delegated( render = { paneDestination -> val delegate = remember { - AdaptiveMovableSharedElementScope( + PanedMovableSharedElementScope( paneScope = this, movableSharedElementHostState = movableSharedElementHostState, ) @@ -71,13 +72,18 @@ fun PaneScope.movableSharedElementS @Stable private class ThreePaneMovableSharedElementScope( private val hostState: MovableSharedElementHostState, - private val delegate: AdaptiveMovableSharedElementScope, + private val delegate: PanedMovableSharedElementScope, ) : MovableSharedElementScope, PaneScope by delegate.paneScope { @OptIn(ExperimentalSharedTransitionApi::class) override fun movableSharedElementOf( key: Any, boundsTransform: BoundsTransform, + placeHolderSize: PlaceHolderSize, + renderInOverlayDuringTransition: Boolean, + zIndexInOverlay: Float, + clipInOverlayDuringTransition: OverlayClip, + alternateOutgoingSharedElement: (@Composable (T, Modifier) -> Unit)?, sharedElement: @Composable (T, Modifier) -> Unit ): @Composable (T, Modifier) -> Unit = when (paneState.pane) { null -> throw IllegalArgumentException( @@ -93,6 +99,11 @@ private class ThreePaneMovableSharedElementScope( else -> delegate.movableSharedElementOf( key = key, boundsTransform = boundsTransform, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + alternateOutgoingSharedElement = alternateOutgoingSharedElement, sharedElement = sharedElement ) } @@ -100,19 +111,21 @@ private class ThreePaneMovableSharedElementScope( ThreePane.TransientPrimary -> delegate.movableSharedElementOf( key = key, boundsTransform = boundsTransform, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + alternateOutgoingSharedElement = alternateOutgoingSharedElement, sharedElement = sharedElement ) // In the other panes use the element as is ThreePane.Secondary, ThreePane.Tertiary, - ThreePane.Overlay -> sharedElement + ThreePane.Overlay -> alternateOutgoingSharedElement ?: sharedElement } } -fun PaneState?.canAnimateOnStartingFrames() = - this?.pane != ThreePane.TransientPrimary - private val PaneScope.isPreviewingBack: Boolean get() = paneState.pane == ThreePane.Primary && paneState.adaptations.contains(ThreePane.PrimaryToTransient) diff --git a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/Node.kt b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/Node.kt index 0979b06..ab75335 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/Node.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/Node.kt @@ -17,7 +17,10 @@ package com.tunjid.treenav /** - * A representation of a navigation node with child children [children] + * A representation of a navigation node in a navigation tree. + * + * [Node] instances are identified by their [Node.id]. The same [Node] can appear multiple times + * within the tree. To traverse the navigation tree use [Node.traverse], specifying the [Order]. */ interface Node { val id: String 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 4337a12..06b48e4 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 @@ -23,7 +23,6 @@ import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState @@ -61,7 +60,6 @@ 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.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp @@ -78,7 +76,6 @@ import com.tunjid.demo.common.ui.data.SampleDestination import com.tunjid.demo.common.ui.me.mePaneStrategy import com.tunjid.demo.common.ui.profile.profilePaneStrategy import com.tunjid.treenav.MultiStackNav -import com.tunjid.treenav.compose.PaneState import com.tunjid.treenav.compose.PanedNavHost import com.tunjid.treenav.compose.PanedNavHostConfiguration import com.tunjid.treenav.compose.SavedStatePanedNavHostState @@ -87,7 +84,6 @@ import com.tunjid.treenav.compose.configurations.paneModifierConfiguration import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementHostState import com.tunjid.treenav.compose.panedNavHostConfiguration import com.tunjid.treenav.compose.threepane.ThreePane -import com.tunjid.treenav.compose.threepane.configurations.canAnimateOnStartingFrames import com.tunjid.treenav.compose.threepane.configurations.predictiveBackConfiguration import com.tunjid.treenav.compose.threepane.configurations.threePanedMovableSharedElementConfiguration import com.tunjid.treenav.compose.threepane.configurations.threePanedNavHostConfiguration @@ -138,9 +134,8 @@ fun SampleApp( ) val density = LocalDensity.current val movableSharedElementHostState = remember { - MovableSharedElementHostState( - sharedTransitionScope = this, - canAnimateOnStartingFrames = PaneState::canAnimateOnStartingFrames + MovableSharedElementHostState( + sharedTransitionScope = this ) } @@ -164,14 +159,6 @@ fun SampleApp( ) .background(surfaceColor, RoundedCornerShape(16.dp)) else Modifier - .border(16.dp, when(paneState.pane) { - ThreePane.Primary -> Color.Cyan - ThreePane.TransientPrimary -> Color.Transparent - ThreePane.Secondary -> Color.Magenta - ThreePane.Tertiary -> Color.Yellow - ThreePane.Overlay -> Color.Transparent - null -> Color.Transparent - }) .fillMaxSize() } .threePanedNavHostConfiguration( @@ -210,7 +197,6 @@ fun SampleApp( state = splitLayoutState, modifier = Modifier .fillMaxSize() - then movableSharedElementHostState.modifier then sharedTransitionModifier, itemSeparators = { paneIndex, offset -> PaneSeparator( 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 7040366..e3481c0 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 @@ -53,6 +53,7 @@ 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 kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -153,12 +154,6 @@ fun Message( Row(modifier = spaceBetweenAuthors) { if (isLastMessageByAuthor) { - val sharedImage = movableSharedElementScope.movableSharedElementOf( - key = item.sender.name, - sharedElement = { args: ProfilePhotoArgs, innerModifier: Modifier -> - ProfilePhoto(args, innerModifier) - } - ) // Avatar Box( modifier = Modifier @@ -180,14 +175,18 @@ fun Message( } }, ) { - sharedImage( - ProfilePhotoArgs( + movableSharedElementScope.updatedMovableSharedElementOf( + key = item.sender.name, + state = ProfilePhotoArgs( profileName = item.sender.name, contentScale = ContentScale.Crop, cornerRadius = 42.dp, contentDescription = null, ), - Modifier.matchParentSize(), + modifier = Modifier.matchParentSize(), + sharedElement = { args: ProfilePhotoArgs, innerModifier: Modifier -> + ProfilePhoto(args, innerModifier) + } ) } } else { 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 e3d8951..f911855 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 @@ -42,6 +42,7 @@ 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 @@ -113,19 +114,17 @@ private fun ProfilePhoto( ) { val profileName = state.profileName ?: state.profile?.name if (profileName != null) { - val sharedImage = movableSharedElementScope.movableSharedElementOf( + movableSharedElementScope.updatedMovableSharedElementOf( key = profileName, - sharedElement = { args: ProfilePhotoArgs, innerModifier: Modifier -> - ProfilePhoto(args, innerModifier) - } - ) - sharedImage( - ProfilePhotoArgs( + state = ProfilePhotoArgs( profileName = profileName, contentScale = ContentScale.Crop, contentDescription = null, ), - modifier, + modifier = modifier, + sharedElement = { args: ProfilePhotoArgs, innerModifier: Modifier -> + ProfilePhoto(args, innerModifier) + } ) } }