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 f307732..2d4603f 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 @@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import com.tunjid.treenav.Node import com.tunjid.treenav.compose.PaneScope import com.tunjid.treenav.compose.PaneSharedTransitionScope @@ -69,7 +68,6 @@ private class ThreePaneSharedTransitionScope @OptIn( boundsTransform: BoundsTransform, placeHolderSize: PlaceHolderSize, renderInOverlayDuringTransition: Boolean, - visible: Boolean?, zIndexInOverlay: Float, clipInOverlayDuringTransition: OverlayClip, ): Modifier = when (paneScope.paneState.pane) { @@ -93,4 +91,33 @@ private class ThreePaneSharedTransitionScope @OptIn( ThreePane.Overlay, -> this } + + override fun Modifier.paneStickySharedElement( + sharedContentState: SharedTransitionScope.SharedContentState, + boundsTransform: BoundsTransform, + placeHolderSize: PlaceHolderSize, + renderInOverlayDuringTransition: Boolean, + zIndexInOverlay: Float, + clipInOverlayDuringTransition: OverlayClip + ): 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 -> sharedElementWithCallerManagedVisibility( + sharedContentState = sharedContentState, + visible = isActive, + 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 + } } \ No newline at end of file diff --git a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/panedecorators/ThreePaneMovableSharedElementDecorator.kt b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/panedecorators/ThreePaneMovableSharedElementDecorator.kt index 969a8f9..62d6d31 100644 --- a/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/panedecorators/ThreePaneMovableSharedElementDecorator.kt +++ b/library/compose-threepane/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/panedecorators/ThreePaneMovableSharedElementDecorator.kt @@ -152,39 +152,100 @@ private class ThreePaneMovableSharedElementScope( ) // 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(), - ) - } - } - } + ThreePane.Secondary -> secondaryPaneSharedElement( + sharedContentState = sharedContentState, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + alternateOutgoingSharedElement = alternateOutgoingSharedElement, + sharedElement = sharedElement + ) - else -> alternateOutgoingSharedElement ?: sharedElement - } + // In the other panes use the element as is + ThreePane.Tertiary, + ThreePane.Overlay, + -> alternateOutgoingSharedElement ?: sharedElement + } + + @OptIn(ExperimentalSharedTransitionApi::class) + override fun movableStickySharedElementOf( + sharedContentState: SharedTransitionScope.SharedContentState, + 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( + "Shared elements may only be used in non null panes" + ) + // Allow movable shared elements in the primary pane only + ThreePane.Primary -> delegate.movableStickySharedElementOf( + sharedContentState = sharedContentState, + boundsTransform = boundsTransform, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + alternateOutgoingSharedElement = alternateOutgoingSharedElement, + sharedElement = sharedElement + ) + + // In the secondary pane allow shared elements only if certain conditions match + ThreePane.Secondary -> secondaryPaneSharedElement( + sharedContentState = sharedContentState, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + alternateOutgoingSharedElement = alternateOutgoingSharedElement, + sharedElement = sharedElement + ) // In the other panes use the element as is ThreePane.Tertiary, ThreePane.Overlay, -> alternateOutgoingSharedElement ?: sharedElement } + + private fun secondaryPaneSharedElement( + sharedContentState: SharedTransitionScope.SharedContentState, + placeHolderSize: PlaceHolderSize, + renderInOverlayDuringTransition: Boolean, + zIndexInOverlay: Float, + clipInOverlayDuringTransition: OverlayClip, + alternateOutgoingSharedElement: @Composable ((T, Modifier) -> Unit)?, + sharedElement: @Composable (T, Modifier) -> Unit + ) = 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 + } } private fun PaneScope.canAnimateSecondary(): Boolean { 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 3e5a8ab..d422fa8 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 @@ -38,10 +38,12 @@ interface PaneSharedTransitionScope : PaneScope, SharedTransitionScope { /** - * Conceptual equivalent of [SharedTransitionScope.sharedElement], with the exception - * of a key being passed instead of [SharedTransitionScope.SharedContentState]. This is because - * each [PaneState.pane] may need its own [SharedTransitionScope.SharedContentState] and - * will need to be managed by the implementation of this method. + * Creates a shared element transition where the shared element is seekable + * with the transition with the transition driving the [PaneScope]. + * + * This is a pane specific implementation of [SharedTransitionScope.sharedElement], where + * implementations can specify extra logic for how the shared element behaves when + * rendered concurrently in multiple panes. * * @see [SharedTransitionScope.sharedElement]. */ @@ -50,7 +52,26 @@ interface PaneSharedTransitionScope : boundsTransform: BoundsTransform = Defaults.DefaultBoundsTransform, placeHolderSize: PlaceHolderSize = contentSize, renderInOverlayDuringTransition: Boolean = true, - visible: Boolean? = null, + zIndexInOverlay: Float = 0f, + clipInOverlayDuringTransition: OverlayClip = Defaults.ParentClip, + ): Modifier + + /** + * Creates a shared element transition where the shared element is __NOT__ seekable + * with the transition. Instead, it always "sticks" to the "active" pane as specified by + * [PaneScope.isActive]. + * + * This is a pane specific implementation of [SharedTransitionScope.sharedElement], where + * implementations can specify extra logic for how the shared element behaves when + * rendered concurrently in multiple panes. + * + * @see [SharedTransitionScope.sharedElement]. + */ + fun Modifier.paneStickySharedElement( + sharedContentState: SharedTransitionScope.SharedContentState, + boundsTransform: BoundsTransform = Defaults.DefaultBoundsTransform, + placeHolderSize: PlaceHolderSize = contentSize, + renderInOverlayDuringTransition: Boolean = true, zIndexInOverlay: Float = 0f, clipInOverlayDuringTransition: OverlayClip = Defaults.ParentClip, ): Modifier 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 dc17bf1..5bf31ea 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 @@ -1,6 +1,7 @@ package com.tunjid.treenav.compose.moveablesharedelement import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.EnterExitState import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope.OverlayClip @@ -62,7 +63,7 @@ interface MovableSharedElementScope { * @param sharedElement A factory function to create the movable shared element if it does not * currently exist. * - * @see [SharedTransitionScope.sharedElementWithCallerManagedVisibility] + * @see [SharedTransitionScope.sharedElement] */ @OptIn(ExperimentalSharedTransitionApi::class) fun movableSharedElementOf( @@ -75,6 +76,51 @@ interface MovableSharedElementScope { alternateOutgoingSharedElement: (@Composable (T, Modifier) -> Unit)?, sharedElement: @Composable (T, Modifier) -> Unit ): @Composable (T, Modifier) -> Unit + + /** + * Creates a movable shared element that accepts a single argument [T] and a [Modifier]. + * + * This method is similar to [movableSharedElementOf], with the exception that the shared + * element transition is not seekable. Instead, the element will "stick" to where it is + * while the transition is seeking, until it begins animating to its target state. + * + * 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 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 + * 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.sharedElement] + */ + @OptIn(ExperimentalSharedTransitionApi::class) + fun movableStickySharedElementOf( + sharedContentState: SharedContentState, + boundsTransform: BoundsTransform, + placeHolderSize: PlaceHolderSize, + renderInOverlayDuringTransition: Boolean, + zIndexInOverlay: Float, + clipInOverlayDuringTransition: OverlayClip, + alternateOutgoingSharedElement: (@Composable (T, Modifier) -> Unit)?, + sharedElement: @Composable (T, Modifier) -> Unit + ): @Composable (T, Modifier) -> Unit } /** @@ -131,6 +177,60 @@ fun MovableSharedElementScope.updatedMovableSharedElementOf( modifier, ) +/** + * Convenience method for [MovableSharedElementScope.updatedMovableStickySharedElementOf] that + * invokes the movable shared element with the latest values of [state] and [modifier]. + * + * @see [MovableSharedElementScope.movableStickySharedElementOf]. + * + * @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 + * 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.updatedMovableStickySharedElementOf( + sharedContentState: SharedContentState, + state: T, + modifier: Modifier = Modifier, + boundsTransform: BoundsTransform = Defaults.DefaultBoundsTransform, + placeHolderSize: PlaceHolderSize = contentSize, + renderInOverlayDuringTransition: Boolean = true, + zIndexInOverlay: Float = 0f, + clipInOverlayDuringTransition: OverlayClip = Defaults.ParentClip, + alternateOutgoingSharedElement: (@Composable (T, Modifier) -> Unit)? = null, + sharedElement: @Composable (T, Modifier) -> Unit +) = movableStickySharedElementOf( + sharedContentState = sharedContentState, + 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 [MultiPaneDisplay]. */ @@ -220,6 +320,42 @@ class PaneMovableSharedElementScope internal construct clipInOverlayDuringTransition: OverlayClip, alternateOutgoingSharedElement: (@Composable (T, Modifier) -> Unit)?, sharedElement: @Composable (T, Modifier) -> Unit + ): @Composable (T, Modifier) -> Unit = { state, modifier -> + with(movableSharedElementHostState) { + Box( + modifier + .sharedElement( + sharedContentState = sharedContentState, + animatedVisibilityScope = paneScope, + boundsTransform = boundsTransform, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + ) + ) { + MovableElement( + sharedContentState = sharedContentState, + state = state, + isVisible = { paneScope.transition.targetState == EnterExitState.Visible }, + sharedElement = sharedElement, + alternateOutgoingSharedElement = alternateOutgoingSharedElement + ) + } + } + } + + + @OptIn(ExperimentalSharedTransitionApi::class) + override fun movableStickySharedElementOf( + sharedContentState: SharedContentState, + boundsTransform: BoundsTransform, + placeHolderSize: PlaceHolderSize, + renderInOverlayDuringTransition: Boolean, + zIndexInOverlay: Float, + clipInOverlayDuringTransition: OverlayClip, + alternateOutgoingSharedElement: (@Composable (T, Modifier) -> Unit)?, + sharedElement: @Composable (T, Modifier) -> Unit ): @Composable (T, Modifier) -> Unit = { state, modifier -> with(movableSharedElementHostState) { Box( @@ -234,31 +370,48 @@ class PaneMovableSharedElementScope internal construct clipInOverlayDuringTransition = clipInOverlayDuringTransition, ) ) { - when { - paneScope.isActive -> - createOrUpdateSharedElement( - sharedContentState = sharedContentState, - sharedElement = sharedElement - )(state, Modifier.fillMaxConstraints()) + MovableElement( + sharedContentState = sharedContentState, + state = state, + isVisible = { paneScope.isActive }, + sharedElement = sharedElement, + alternateOutgoingSharedElement = alternateOutgoingSharedElement + ) + } + } + } - // 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(sharedContentState.key) - && movableSharedElementHostState.isMatchFound(sharedContentState.key) -> Defaults.EmptyElement( - state, - Modifier.fillMaxConstraints() - ) - // The element is not being shared in its new destination, allow it run its exit - // transition - else -> (alternateOutgoingSharedElement ?: sharedElement)( - state, - Modifier.fillMaxConstraints() - ) - } - } + @Composable + private inline fun PaneMovableSharedElementScope.MovableElement( + sharedContentState: SharedContentState, + state: T, + isVisible: () -> Boolean, + noinline sharedElement: @Composable (T, Modifier) -> Unit, + noinline alternateOutgoingSharedElement: (@Composable (T, Modifier) -> Unit)? + ) = with(movableSharedElementHostState) { + when { + isVisible() -> + createOrUpdateSharedElement( + sharedContentState = sharedContentState, + sharedElement = sharedElement + )(state, Modifier.fillMaxConstraints()) + + // 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(sharedContentState.key) + && movableSharedElementHostState.isMatchFound(sharedContentState.key) -> Defaults.EmptyElement( + state, + Modifier.fillMaxConstraints() + ) + // The element is not being shared in its new destination, allow it run its exit + // transition + else -> (alternateOutgoingSharedElement ?: sharedElement)( + state, + Modifier.fillMaxConstraints() + ) } } }