diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/configurations/AnimatePaneBoundsConfiguration.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/configurations/AnimatePaneBoundsConfiguration.kt new file mode 100644 index 0000000..6290a99 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/configurations/AnimatePaneBoundsConfiguration.kt @@ -0,0 +1,74 @@ +/* + * 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.configurations + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LookaheadScope +import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.PaneScope +import com.tunjid.treenav.compose.PanedNavHostConfiguration +import com.tunjid.treenav.compose.delegated +import com.tunjid.treenav.compose.paneStrategy +import com.tunjid.treenav.compose.utilities.AnimatedBoundsState +import com.tunjid.treenav.compose.utilities.AnimatedBoundsState.Companion.animateBounds +import com.tunjid.treenav.compose.utilities.DefaultBoundsTransform + +/** + * A [PanedNavHostConfiguration] that animates the bounds of each [Pane] displayed within it. + * This is useful for scenarios where the panes move within a layout hierarchy to accommodate + * other panes. + * + * @param lookaheadScope the root [LookaheadScope] where the panes are rendered in. + * @param paneBoundsTransform a lambda providing the [BoundsTransform] for each [Pane]. + * @param shouldAnimatePane a lambda for toggling when the pane can be animated. It allows for + * skipping an animation in progress. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +fun PanedNavHostConfiguration< + Pane, + NavigationState, + Destination + >.animatePaneBoundsConfiguration( + lookaheadScope: LookaheadScope, + paneBoundsTransform: PaneScope.() -> BoundsTransform = { DefaultBoundsTransform }, + shouldAnimatePane: PaneScope.() -> Boolean = { true }, +): PanedNavHostConfiguration = delegated { + val originalTransform = strategyTransform(it) + paneStrategy( + transitions = originalTransform.transitions, + paneMapping = originalTransform.paneMapper, + render = render@{ destination -> + Box( + modifier = Modifier.animateBounds( + state = remember { + AnimatedBoundsState( + lookaheadScope = lookaheadScope, + boundsTransform = paneBoundsTransform(), + inProgress = { shouldAnimatePane() } + ) + } + ) + ) { + originalTransform.render(this@render, destination) + } + } + ) +} \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/BoundsTransformDeferredAnimation.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/BoundsTransformDeferredAnimation.kt deleted file mode 100644 index 09b1c8e..0000000 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/BoundsTransformDeferredAnimation.kt +++ /dev/null @@ -1,180 +0,0 @@ -package com.tunjid.treenav.compose.moveablesharedelement - - -import androidx.compose.animation.BoundsTransform -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector4D -import androidx.compose.animation.core.VectorConverter -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.geometry.isSpecified -import androidx.compose.ui.geometry.isUnspecified -import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.layout.LookaheadScope -import androidx.compose.ui.layout.Placeable -import androidx.compose.ui.layout.positionInParent -import androidx.compose.ui.unit.round -import androidx.compose.ui.unit.roundToIntSize -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.launch - -@OptIn(ExperimentalSharedTransitionApi::class) -internal class BoundsTransformDeferredAnimation { - private var animatable: Animatable? = null - - private var targetSize: Size = Size.Unspecified - private var targetOffset: Offset = Offset.Unspecified - - private var isPending = false - - /** - * Captures lookahead size, updates current size for the first pass and marks the animation as - * pending. - */ - fun updateTargetSize(size: Size) { - if (targetSize.isSpecified && size.roundToIntSize() != targetSize.roundToIntSize()) { - // Change in target, animation is pending - isPending = true - } - targetSize = size - - if (currentSize.isUnspecified) { - currentSize = size - } - } - - /** - * Captures lookahead position, updates current position for the first pass and marks the - * animation as pending. - */ - private fun updateTargetOffset(offset: Offset) { - if (targetOffset.isSpecified && offset.round() != targetOffset.round()) { - isPending = true - } - targetOffset = offset - - if (currentPosition.isUnspecified) { - currentPosition = offset - } - } - - // We capture the current bounds parameters individually to avoid unnecessary Rect allocations - private var currentPosition: Offset = Offset.Unspecified - var currentSize: Size = Size.Unspecified - - val currentBounds: Rect? - get() { - val size = currentSize - val position = currentPosition - return if (position.isSpecified && size.isSpecified) { - Rect(position, size) - } else { - null - } - } - - fun updateCurrentBounds(position: Offset, size: Size) { - currentPosition = position - currentSize = size - } - - val isIdle: Boolean - get() = !isPending && animatable?.isRunning != true - - var animatedValue: Rect? by mutableStateOf(null) - private set - -// val value: Rect? -// get() = if (isIdle) null else animatedValue - - private var directManipulationParents: MutableList? = null - private var additionalOffset: Offset = Offset.Zero - - fun updateTargetOffsetAndAnimate( - lookaheadScope: LookaheadScope, - placementScope: Placeable.PlacementScope, - coroutineScope: CoroutineScope, - includeMotionFrameOfReference: Boolean, - boundsTransform: BoundsTransform, - ) { - placementScope.coordinates?.let { coordinates -> - with(lookaheadScope) { - val lookaheadScopeCoordinates = placementScope.lookaheadScopeCoordinates - - var delta = Offset.Zero - if (!includeMotionFrameOfReference) { - // As the Layout changes, we need to keep track of the accumulated offset up - // the hierarchy tree, to get the proper Offset accounting for scrolling. - val parents = directManipulationParents ?: mutableListOf() - var currentCoords = coordinates - var index = 0 - - // Find the given lookahead coordinates by traversing up the tree - while (currentCoords.toLookaheadCoordinates() != lookaheadScopeCoordinates) { - if (currentCoords.introducesMotionFrameOfReference) { - if (parents.size == index) { - parents.add(currentCoords) - delta += currentCoords.positionInParent() - } else if (parents[index] != currentCoords) { - delta -= parents[index].positionInParent() - parents[index] = currentCoords - delta += currentCoords.positionInParent() - } - index++ - } - currentCoords = currentCoords.parentCoordinates ?: break - } - - for (i in parents.size - 1 downTo index) { - delta -= parents[i].positionInParent() - parents.removeAt(parents.size - 1) - } - directManipulationParents = parents - } - additionalOffset += delta - - val targetOffset = - lookaheadScopeCoordinates.localLookaheadPositionOf( - sourceCoordinates = coordinates, - includeMotionFrameOfReference = includeMotionFrameOfReference - ) - updateTargetOffset(targetOffset + additionalOffset) - - animatedValue = - animate(coroutineScope = coroutineScope, boundsTransform = boundsTransform) - .translate(-(additionalOffset)) - } - } - } - - private fun animate( - coroutineScope: CoroutineScope, - boundsTransform: BoundsTransform, - ): Rect { - if (targetOffset.isSpecified && targetSize.isSpecified) { - // Initialize Animatable when possible, we might not use it but we need to have it - // instantiated since at the first pass the lookahead information will become the - // initial bounds when we actually need an animation. - val target = Rect(targetOffset, targetSize) - val anim = animatable ?: Animatable(target, Rect.VectorConverter) - animatable = anim - - // This check should avoid triggering an animation on the first pass, as there would not - // be enough information to have a distinct current and target bounds. - if (isPending) { - isPending = false - coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { - // Dispatch right away to make sure approach callbacks are accurate on `isIdle` - anim.animateTo(target, boundsTransform.transform(currentBounds!!, target)) - } - } - } - return animatable?.value ?: Rect.Zero - } -} 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 13f8cf0..92bdbc4 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 @@ -12,30 +12,21 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState -import androidx.compose.runtime.rememberCoroutineScope 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.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.isUnspecified 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.layout.approachLayout -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.round -import androidx.compose.ui.unit.roundToIntSize import androidx.compose.ui.unit.toOffset -import androidx.compose.ui.unit.toSize -import androidx.compose.ui.util.fastRoundToInt 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 @@ -46,7 +37,7 @@ internal class MovableSharedElementState( sharedTransitionScope: SharedTransitionScope, sharedElement: @Composable (State, Modifier) -> Unit, onRemoved: () -> Unit, - private val boundsTransform: BoundsTransform, + boundsTransform: BoundsTransform, private val canAnimateOnStartingFrames: PaneState.() -> Boolean ) : SharedElementOverlay, SharedTransitionScope by sharedTransitionScope { @@ -55,20 +46,21 @@ internal class MovableSharedElementState( private var inCount by mutableIntStateOf(0) private var layer: GraphicsLayer? = null - private var targetOffset by mutableStateOf(IntOffset.Zero) var animInProgress by mutableStateOf(false) private set private val canDrawInOverlay get() = animInProgress private val panesKeysToSeenCount = mutableStateMapOf() - private val animatedBounds: Rect? - get() = if (animInProgress) boundsAnimation.animatedValue else null - - private val boundsAnimation = BoundsTransformDeferredAnimation() + private val animatedBoundsState = AnimatedBoundsState( + lookaheadScope = this, + boundsTransform = boundsTransform, + inProgress = { animInProgress } + ) val moveableSharedElement: @Composable (Any?, Modifier) -> Unit = movableContentOf { state, modifier -> + animInProgress = isInProgress() @Suppress("UNCHECKED_CAST") sharedElement( // The shared element composable will be created by the first screen and reused by @@ -91,7 +83,7 @@ internal class MovableSharedElementState( override fun ContentDrawScope.drawInOverlay() { if (!canDrawInOverlay) return val overlayLayer = layer ?: return - val (x, y) = targetOffset.toOffset() + val (x, y) = animatedBoundsState.targetOffset.toOffset() translate(x, y) { drawLayer(overlayLayer) } @@ -117,75 +109,11 @@ internal class MovableSharedElementState( internal fun Modifier.movableSharedElement( state: MovableSharedElementState<*, Pane, Destination>, ): Modifier { - val coroutineScope = rememberCoroutineScope() - state.isInProgress().also { state.animInProgress = it } val layer = rememberGraphicsLayer().also { state.layer = it } - return approachLayout( - isMeasurementApproachInProgress = { lookaheadSize -> - // Update target size, it will serve to know if we expect an approach in progress - state.boundsAnimation.updateTargetSize(lookaheadSize.toSize()) - state.animInProgress - }, - isPlacementApproachInProgress = { - state.boundsAnimation.updateTargetOffsetAndAnimate( - lookaheadScope = state, - placementScope = this, - coroutineScope = coroutineScope, - includeMotionFrameOfReference = true, - boundsTransform = state.boundsTransform, - ) - state.animInProgress - }, - approachMeasure = { measurable, _ -> - // The animated value is null on the first frame as we don't get the full bounds - // information until placement, so we can safely use the current Size. - val fallbackSize = - // When using Intrinsics, we may get measured before getting the approach check - if (state.boundsAnimation.currentSize.isUnspecified) lookaheadSize.toSize() - else state.boundsAnimation.currentSize - - val (animatedWidth, animatedHeight) = - (state.animatedBounds?.size ?: fallbackSize).roundToIntSize() - - // For the target Layout, pass the animated size as Constraints. - val placeable = measurable.measure( - Constraints.fixed( - width = animatedWidth, - height = animatedHeight, - ) - ) - layout(animatedWidth, animatedHeight) { - val animatedBounds = state.animatedBounds - val currentCoordinates = coordinates ?: return@layout placeable.place( - x = 0, - y = 0 - ) - val positionInScope = with(state) { - lookaheadScopeCoordinates.localPositionOf( - sourceCoordinates = currentCoordinates, - relativeToSource = Offset.Zero, - includeMotionFrameOfReference = true, - ) - } - - val topLeft = - if (animatedBounds != null) { - state.boundsAnimation.updateCurrentBounds( - animatedBounds.topLeft, - animatedBounds.size - ) - animatedBounds.topLeft - } else { - state.boundsAnimation.currentBounds?.topLeft ?: Offset.Zero - } - state.targetOffset = topLeft.round() - - val (x, y) = topLeft - positionInScope - placeable.place(x.fastRoundToInt(), y.fastRoundToInt()) - } - } + return animateBounds( + state = state.animatedBoundsState ) .drawWithContent { layer.record { @@ -197,7 +125,6 @@ internal class MovableSharedElementState( } } - @Composable private fun MovableSharedElementState<*, Pane, Destination>.isInProgress(): Boolean { val paneState = paneScope.paneState.also(::updatePaneStateSeen) @@ -213,7 +140,7 @@ internal class MovableSharedElementState( paneState.key, paneState.canAnimateOnStartingFrames() ) - value = snapshotFlow { boundsAnimation.isIdle } + value = snapshotFlow { animatedBoundsState.isIdle } .debounce { if (it) 10 else 0 } .first(true::equals) .let { value.first to false } 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 f6021d4..d067719 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 @@ -20,6 +20,7 @@ import com.tunjid.treenav.Node import com.tunjid.treenav.compose.PanedNavHost import com.tunjid.treenav.compose.PaneScope import com.tunjid.treenav.compose.PaneState +import com.tunjid.treenav.compose.utilities.DefaultBoundsTransform internal interface SharedElementOverlay { fun ContentDrawScope.drawInOverlay() @@ -161,13 +162,4 @@ internal class AdaptiveMovableSharedElementScope( } } -private val EmptyElement: @Composable (Any?, Modifier) -> Unit = { _, _ -> } - -@OptIn(ExperimentalSharedTransitionApi::class) -private val DefaultBoundsTransform = BoundsTransform { _, _ -> - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - visibilityThreshold = Rect.VisibilityThreshold - ) -} \ No newline at end of file +private val EmptyElement: @Composable (Any?, Modifier) -> Unit = { _, _ -> } \ No newline at end of file diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt index 28349c4..8b28abb 100644 --- a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/threepane/ThreePane.kt @@ -128,7 +128,10 @@ fun threePaneListDetailStrategy( ) } - else -> NoTransition + else -> PaneScope.Transitions( + enter = enterTransition(), + exit = exitTransition() + ) } }, render = render 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 722055e..b5eded0 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 @@ -19,7 +19,6 @@ import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementHost import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementScope import com.tunjid.treenav.compose.threepane.ThreePane - /** * An [PanedNavHostConfiguration] that applies semantics of movable shared elements to * [ThreePane] layouts. diff --git a/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/utilities/AnimatedBoundsModifier.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/utilities/AnimatedBoundsModifier.kt new file mode 100644 index 0000000..87c330c --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/utilities/AnimatedBoundsModifier.kt @@ -0,0 +1,355 @@ +/* + * 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.utilities + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector4D +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isSpecified +import androidx.compose.ui.geometry.isUnspecified +import androidx.compose.ui.layout.ApproachLayoutModifierNode +import androidx.compose.ui.layout.ApproachMeasureScope +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.round +import androidx.compose.ui.unit.roundToIntSize +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.util.fastRoundToInt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch + +@ExperimentalSharedTransitionApi // Depends on BoundsTransform +internal class AnimatedBoundsState( + val lookaheadScope: LookaheadScope, + boundsTransform: BoundsTransform = DefaultBoundsTransform, + animateMotionFrameOfReference: Boolean = false, + private val inProgress: (() -> Boolean)? = null, +) { + var targetOffset by mutableStateOf(IntOffset.Zero) + var boundsTransform by mutableStateOf(boundsTransform) + var animateMotionFrameOfReference by mutableStateOf(animateMotionFrameOfReference) + + val isInProgress get() = inProgress?.invoke() ?: !boundsAnimation.isIdle + val isIdle get() = boundsAnimation.isIdle + + private val boundsAnimation = BoundsTransformDeferredAnimation() + + companion object { + /** + * A copy of the bounds transform in the compose library that allows for reading the state + * and overriding when the approach is in progress. + */ + @ExperimentalSharedTransitionApi // Depends on BoundsTransform + internal fun Modifier.animateBounds( + state: AnimatedBoundsState, + ): Modifier = + this then BoundsAnimationElement( + state = state, + resolveMeasureConstraints = { animatedSize, _ -> + // For the target Layout, pass the animated size as Constraints. + Constraints.fixed(animatedSize.width, animatedSize.height) + }, + ) + + @ExperimentalSharedTransitionApi + internal data class BoundsAnimationElement( + val resolveMeasureConstraints: (animatedSize: IntSize, constraints: Constraints) -> Constraints, + val state: AnimatedBoundsState, + ) : ModifierNodeElement() { + override fun create(): BoundsAnimationModifierNode { + return BoundsAnimationModifierNode( + state = state, + onChooseMeasureConstraints = resolveMeasureConstraints, + ) + } + + override fun update(node: BoundsAnimationModifierNode) { + node.onChooseMeasureConstraints = resolveMeasureConstraints + } + + override fun InspectorInfo.inspectableProperties() { + name = "boundsAnimation" + properties["onChooseMeasureConstraints"] = resolveMeasureConstraints + properties["state"] = state + } + } + + @ExperimentalSharedTransitionApi + internal class BoundsAnimationModifierNode( + var onChooseMeasureConstraints: + (animatedSize: IntSize, constraints: Constraints) -> Constraints, + val state: AnimatedBoundsState, + ) : ApproachLayoutModifierNode, Modifier.Node() { + + override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean { + // Update target size, it will serve to know if we expect an approach in progress + state.boundsAnimation.updateTargetSize(lookaheadSize.toSize()) + + return state.isInProgress + } + + override fun Placeable.PlacementScope.isPlacementApproachInProgress( + lookaheadCoordinates: LayoutCoordinates + ): Boolean { + // Once we can capture size and offset we may also start the animation + state.boundsAnimation.updateTargetOffsetAndAnimate( + lookaheadScope = state.lookaheadScope, + placementScope = this, + coroutineScope = coroutineScope, + includeMotionFrameOfReference = state.animateMotionFrameOfReference, + boundsTransform = state.boundsTransform, + ) + return state.isInProgress + } + + override fun ApproachMeasureScope.approachMeasure( + measurable: Measurable, + constraints: Constraints + ): MeasureResult { + // The animated value is null on the first frame as we don't get the full bounds + // information until placement, so we can safely use the current Size. + val fallbackSize = + if (state.boundsAnimation.currentSize.isUnspecified) { + // When using Intrinsics, we may get measured before getting the approach check + lookaheadSize.toSize() + } else { + state.boundsAnimation.currentSize + } + val animatedSize = + (state.boundsAnimation.value?.size ?: fallbackSize).roundToIntSize() + + val chosenConstraints = onChooseMeasureConstraints(animatedSize, constraints) + + val placeable = measurable.measure(chosenConstraints) + + return layout(animatedSize.width, animatedSize.height) { + val animatedBounds = state.boundsAnimation.value + val positionInScope = + with(state.lookaheadScope) { + coordinates?.let { coordinates -> + lookaheadScopeCoordinates.localPositionOf( + sourceCoordinates = coordinates, + relativeToSource = Offset.Zero, + includeMotionFrameOfReference = state.animateMotionFrameOfReference + ) + } + } + + val topLeft = + if (animatedBounds != null) { + state.boundsAnimation.updateCurrentBounds( + animatedBounds.topLeft, + animatedBounds.size + ) + animatedBounds.topLeft + } else { + state.boundsAnimation.currentBounds?.topLeft ?: Offset.Zero + } + state.targetOffset = topLeft.round() + val (x, y) = positionInScope?.let { topLeft - it } ?: Offset.Zero + placeable.place(x.fastRoundToInt(), y.fastRoundToInt()) + } + } + } + } +} + +/** Helper class to keep track of the BoundsAnimation state for [ApproachLayoutModifierNode]. */ +@OptIn(ExperimentalSharedTransitionApi::class) +internal class BoundsTransformDeferredAnimation { + private var animatable: Animatable? = null + + private var targetSize: Size = Size.Unspecified + private var targetOffset: Offset = Offset.Unspecified + + private var isPending = false + + /** + * Captures lookahead size, updates current size for the first pass and marks the animation as + * pending. + */ + fun updateTargetSize(size: Size) { + if (targetSize.isSpecified && size.roundToIntSize() != targetSize.roundToIntSize()) { + // Change in target, animation is pending + isPending = true + } + targetSize = size + + if (currentSize.isUnspecified) { + currentSize = size + } + } + + /** + * Captures lookahead position, updates current position for the first pass and marks the + * animation as pending. + */ + private fun updateTargetOffset(offset: Offset) { + if (targetOffset.isSpecified && offset.round() != targetOffset.round()) { + isPending = true + } + targetOffset = offset + + if (currentPosition.isUnspecified) { + currentPosition = offset + } + } + + // We capture the current bounds parameters individually to avoid unnecessary Rect allocations + private var currentPosition: Offset = Offset.Unspecified + var currentSize: Size = Size.Unspecified + + val currentBounds: Rect? + get() { + val size = currentSize + val position = currentPosition + return if (position.isSpecified && size.isSpecified) { + Rect(position, size) + } else { + null + } + } + + fun updateCurrentBounds(position: Offset, size: Size) { + currentPosition = position + currentSize = size + } + + val isIdle: Boolean + get() = !isPending && animatable?.isRunning != true + + private var animatedValue: Rect? by mutableStateOf(null) + + val value: Rect? + get() = if (isIdle) null else animatedValue + + private var directManipulationParents: MutableList? = null + private var additionalOffset: Offset = Offset.Zero + + fun updateTargetOffsetAndAnimate( + lookaheadScope: LookaheadScope, + placementScope: Placeable.PlacementScope, + coroutineScope: CoroutineScope, + includeMotionFrameOfReference: Boolean, + boundsTransform: BoundsTransform, + ) { + placementScope.coordinates?.let { coordinates -> + with(lookaheadScope) { + val lookaheadScopeCoordinates = placementScope.lookaheadScopeCoordinates + + var delta = Offset.Zero + if (!includeMotionFrameOfReference) { + // As the Layout changes, we need to keep track of the accumulated offset up + // the hierarchy tree, to get the proper Offset accounting for scrolling. + val parents = directManipulationParents ?: mutableListOf() + var currentCoords = coordinates + var index = 0 + + // Find the given lookahead coordinates by traversing up the tree + while (currentCoords.toLookaheadCoordinates() != lookaheadScopeCoordinates) { + if (currentCoords.introducesMotionFrameOfReference) { + if (parents.size == index) { + parents.add(currentCoords) + delta += currentCoords.positionInParent() + } else if (parents[index] != currentCoords) { + delta -= parents[index].positionInParent() + parents[index] = currentCoords + delta += currentCoords.positionInParent() + } + index++ + } + currentCoords = currentCoords.parentCoordinates ?: break + } + + for (i in parents.size - 1 downTo index) { + delta -= parents[i].positionInParent() + parents.removeAt(parents.size - 1) + } + directManipulationParents = parents + } + additionalOffset += delta + + val targetOffset = + lookaheadScopeCoordinates.localLookaheadPositionOf( + sourceCoordinates = coordinates, + includeMotionFrameOfReference = includeMotionFrameOfReference + ) + updateTargetOffset(targetOffset + additionalOffset) + + animatedValue = + animate(coroutineScope = coroutineScope, boundsTransform = boundsTransform) + .translate(-(additionalOffset)) + } + } + } + + private fun animate( + coroutineScope: CoroutineScope, + boundsTransform: BoundsTransform, + ): Rect { + if (targetOffset.isSpecified && targetSize.isSpecified) { + // Initialize Animatable when possible, we might not use it but we need to have it + // instantiated since at the first pass the lookahead information will become the + // initial bounds when we actually need an animation. + val target = Rect(targetOffset, targetSize) + val anim = animatable ?: Animatable(target, Rect.VectorConverter) + animatable = anim + + // This check should avoid triggering an animation on the first pass, as there would not + // be enough information to have a distinct current and target bounds. + if (isPending) { + isPending = false + coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { + // Dispatch right away to make sure approach callbacks are accurate on `isIdle` + anim.animateTo(target, boundsTransform.transform(currentBounds!!, target)) + } + } + } + return animatable?.value ?: Rect.Zero + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +internal val DefaultBoundsTransform = BoundsTransform { _, _ -> + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Rect.VisibilityThreshold + ) +} \ No newline at end of file diff --git a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt index 99152e6..72ce554 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt @@ -66,6 +66,13 @@ fun MultiStackNav.switch(toIndex: Int): MultiStackNav = copy( indexHistory = (indexHistory - toIndex) + toIndex ) +fun MultiStackNav.popToRoot(indexToPop: Int = currentIndex) = copy( + stacks = stacks.mapIndexed { index: Int, stackNav: StackNav -> + if (index == indexToPop) stackNav.popToRoot() + else stackNav + } +) + /** * Performs the given [operation] with the [StackNav] at [MultiStackNav.currentIndex] */ diff --git a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt index ad5dfe1..7e0f6cc 100644 --- a/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt +++ b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt @@ -48,6 +48,10 @@ fun StackNav.pop(popLast: Boolean = false) = when { else -> copy(children = children.dropLast(1)) } +fun StackNav.popToRoot() = copy( + children = children.take(1) +) + /** * Indicates if there's a [Node] available to pop up to */ 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 17ab663..8782a08 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 @@ -18,15 +18,14 @@ package com.tunjid.demo.common.ui import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold -import androidx.compose.material3.adaptive.layout.PaneAdaptedValue -import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue -import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -44,20 +43,22 @@ import com.tunjid.demo.common.ui.chat.chatPaneStrategy import com.tunjid.demo.common.ui.chatrooms.chatRoomPaneStrategy import com.tunjid.demo.common.ui.data.NavigationRepository import com.tunjid.demo.common.ui.data.SampleDestination -import com.tunjid.demo.common.ui.profile.profilePaneStrategy import com.tunjid.demo.common.ui.me.mePaneStrategy -import com.tunjid.treenav.compose.moveablesharedelement.MovableSharedElementHostState +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.PaneState import com.tunjid.treenav.compose.SavedStatePanedNavHostState +import com.tunjid.treenav.compose.configurations.animatePaneBoundsConfiguration +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.threePanedMovableSharedElementConfiguration import com.tunjid.treenav.compose.threepane.configurations.threePanedNavHostConfiguration import com.tunjid.treenav.current +import com.tunjid.treenav.popToRoot import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -98,6 +99,20 @@ fun SampleApp( .threePanedMovableSharedElementConfiguration( movableSharedElementHostState = movableSharedElementHostState ) + .animatePaneBoundsConfiguration( + lookaheadScope = this@SharedTransitionScope, + shouldAnimatePane = { + when (paneState.pane) { + ThreePane.Primary, + ThreePane.Secondary, + ThreePane.Tertiary -> true + + null, + ThreePane.Overlay, + ThreePane.TransientPrimary -> false + } + } + ) }, modifier = Modifier .fillMaxSize() @@ -105,30 +120,30 @@ fun SampleApp( windowWidthDp.value = (it.width / density.density).roundToInt() } ) { - ListDetailPaneScaffold( + Row( modifier = Modifier .fillMaxSize() then movableSharedElementHostState.modifier then sharedTransitionModifier, - directive = calculatePaneScaffoldDirective(currentWindowAdaptiveInfo()), - value = ThreePaneScaffoldValue( - primary = if (nodeFor(ThreePane.Primary) == null) PaneAdaptedValue.Hidden else PaneAdaptedValue.Expanded, - secondary = if (nodeFor(ThreePane.Secondary) == null) PaneAdaptedValue.Hidden else PaneAdaptedValue.Expanded, - tertiary = if (nodeFor(ThreePane.Tertiary) == null) PaneAdaptedValue.Hidden else PaneAdaptedValue.Expanded, - ), - listPane = { - if (nodeFor(ThreePane.Tertiary) == null) Destination(ThreePane.Secondary) - else Destination(ThreePane.Tertiary) - }, - detailPane = { - if (nodeFor(ThreePane.Tertiary) == null) Destination(ThreePane.Primary) - else Destination(ThreePane.Secondary) - }, - extraPane = { - if (nodeFor(ThreePane.Tertiary) == null) Destination(ThreePane.Tertiary) - else Destination(ThreePane.Primary) + ) { + val order = remember { + listOf( + ThreePane.Tertiary, + ThreePane.Secondary, + ThreePane.Primary, + ) } - ) + order.forEach { pane -> + if (nodeFor(pane) == null) Spacer(Modifier) + else Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) { + Destination(pane) + } + } + } } } } @@ -150,7 +165,8 @@ class SampleAppState( fun setTab(destination: SampleDestination.NavTabs) { navigationRepository.navigate { - it.copy(currentIndex = destination.ordinal) + if (it.currentIndex == destination.ordinal) it.popToRoot() + else it.copy(currentIndex = destination.ordinal) } } 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 3783786..3982f7f 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 @@ -129,9 +129,12 @@ private fun ProfilePhoto( } @Composable -private fun ProfileDetails(state: State) { +private fun ProfileDetails( + state: State, + modifier: Modifier = Modifier, +) { Column( - modifier = Modifier + modifier = modifier .padding(horizontal = 16.dp) .verticalScroll(rememberScrollState()) ) {