From 440c80311c629b7db913a146c518d7b7a52f99c3 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Thu, 17 Oct 2024 10:15:20 -0400 Subject: [PATCH 1/7] Experimenting with bounds transforms for panes --- .../AnimatePaneBoundsConfiguration.kt | 51 +++ .../treenav/compose/threepane/ThreePane.kt | 5 +- .../utilities/AnimatedBoundsModifier.kt | 369 ++++++++++++++++++ .../com/tunjid/treenav/MultiStackNav.kt | 7 + .../kotlin/com/tunjid/treenav/StackNav.kt | 4 + .../com/tunjid/demo/common/ui/DemoApp.kt | 64 +-- 6 files changed, 471 insertions(+), 29 deletions(-) create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/configurations/AnimatePaneBoundsConfiguration.kt create mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/utilities/AnimatedBoundsModifier.kt 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..5838de2 --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/configurations/AnimatePaneBoundsConfiguration.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.compose.configurations + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LookaheadScope +import com.tunjid.treenav.Node +import com.tunjid.treenav.compose.PanedNavHostConfiguration +import com.tunjid.treenav.compose.delegated +import com.tunjid.treenav.compose.paneStrategy +import com.tunjid.treenav.compose.threepane.ThreePane +import com.tunjid.treenav.compose.utilities.animateBounds + + +@OptIn(ExperimentalSharedTransitionApi::class) +fun PanedNavHostConfiguration< + ThreePane, + NavigationState, + Destination + >.animatePaneBoundsConfiguration( + lookaheadScope: LookaheadScope, +): PanedNavHostConfiguration = delegated { + val originalTransform = strategyTransform(it) + paneStrategy( + transitions = originalTransform.transitions, + paneMapping = originalTransform.paneMapper, + render = render@{ + Box( + modifier = Modifier.animateBounds(lookaheadScope) + ) { + originalTransform.render(this@render, it) + } + } + ) +} \ 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/utilities/AnimatedBoundsModifier.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/utilities/AnimatedBoundsModifier.kt new file mode 100644 index 0000000..70d0c7f --- /dev/null +++ b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/utilities/AnimatedBoundsModifier.kt @@ -0,0 +1,369 @@ +/* + * 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.FiniteAnimationSpec +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.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 fun Modifier.animateBounds( + lookaheadScope: LookaheadScope, + modifier: Modifier = Modifier, + boundsTransform: BoundsTransform = DefaultBoundsTransform, + animateMotionFrameOfReference: Boolean = false, +): Modifier = + this.then( + BoundsAnimationElement( + lookaheadScope = lookaheadScope, + boundsTransform = boundsTransform, + // Measure with original constraints. + // The layout of this element will still be the animated lookahead size. + resolveMeasureConstraints = { _, constraints -> constraints }, + animateMotionFrameOfReference = animateMotionFrameOfReference, + ) + ) + .then(modifier) + .then( + BoundsAnimationElement( + lookaheadScope = lookaheadScope, + boundsTransform = boundsTransform, + resolveMeasureConstraints = { animatedSize, _ -> + // For the target Layout, pass the animated size as Constraints. + Constraints.fixed(animatedSize.width, animatedSize.height) + }, + animateMotionFrameOfReference = animateMotionFrameOfReference, + ) + ) + +@ExperimentalSharedTransitionApi +internal data class BoundsAnimationElement( + val lookaheadScope: LookaheadScope, + val boundsTransform: BoundsTransform, + val resolveMeasureConstraints: (animatedSize: IntSize, constraints: Constraints) -> Constraints, + val animateMotionFrameOfReference: Boolean, +) : ModifierNodeElement() { + override fun create(): BoundsAnimationModifierNode { + return BoundsAnimationModifierNode( + lookaheadScope = lookaheadScope, + boundsTransform = boundsTransform, + onChooseMeasureConstraints = resolveMeasureConstraints, + animateMotionFrameOfReference = animateMotionFrameOfReference, + ) + } + + override fun update(node: BoundsAnimationModifierNode) { + node.lookaheadScope = lookaheadScope + node.boundsTransform = boundsTransform + node.onChooseMeasureConstraints = resolveMeasureConstraints + node.animateMotionFrameOfReference = animateMotionFrameOfReference + } + + override fun InspectorInfo.inspectableProperties() { + name = "boundsAnimation" + properties["lookaheadScope"] = lookaheadScope + properties["boundsTransform"] = boundsTransform + properties["onChooseMeasureConstraints"] = resolveMeasureConstraints + properties["animateMotionFrameOfReference"] = animateMotionFrameOfReference + } +} + +/** + * [Modifier.Node] implementation that handles the bounds animation with + * [ApproachLayoutModifierNode]. + * + * @param lookaheadScope The [LookaheadScope] to animate from. + * @param boundsTransform Callback to produce [FiniteAnimationSpec] at every triggered animation + * @param onChooseMeasureConstraints Callback to decide whether to measure the Modifier Layout with + * the current animated size value or the incoming constraints. This reflects on the + * [MeasureResult] of this Modifier Layout as well. + * @param animateMotionFrameOfReference Whether to include changes under + * [LayoutCoordinates.introducesMotionFrameOfReference] to trigger animations. + */ +@ExperimentalSharedTransitionApi +internal class BoundsAnimationModifierNode( + var lookaheadScope: LookaheadScope, + var boundsTransform: BoundsTransform, + var onChooseMeasureConstraints: + (animatedSize: IntSize, constraints: Constraints) -> Constraints, + var animateMotionFrameOfReference: Boolean, +) : ApproachLayoutModifierNode, Modifier.Node() { + private val boundsAnimation = BoundsTransformDeferredAnimation() + + override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean { + // Update target size, it will serve to know if we expect an approach in progress + boundsAnimation.updateTargetSize(lookaheadSize.toSize()) + + return !boundsAnimation.isIdle + } + + override fun Placeable.PlacementScope.isPlacementApproachInProgress( + lookaheadCoordinates: LayoutCoordinates + ): Boolean { + // Once we can capture size and offset we may also start the animation + boundsAnimation.updateTargetOffsetAndAnimate( + lookaheadScope = lookaheadScope, + placementScope = this, + coroutineScope = coroutineScope, + includeMotionFrameOfReference = animateMotionFrameOfReference, + boundsTransform = boundsTransform, + ) + return !boundsAnimation.isIdle + } + + 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 (boundsAnimation.currentSize.isUnspecified) { + // When using Intrinsics, we may get measured before getting the approach check + lookaheadSize.toSize() + } else { + boundsAnimation.currentSize + } + val animatedSize = (boundsAnimation.value?.size ?: fallbackSize).roundToIntSize() + + val chosenConstraints = onChooseMeasureConstraints(animatedSize, constraints) + + val placeable = measurable.measure(chosenConstraints) + return layout(animatedSize.width, animatedSize.height) { + val animatedBounds = boundsAnimation.value + val positionInScope = + with(lookaheadScope) { + coordinates?.let { coordinates -> + lookaheadScopeCoordinates.localPositionOf( + sourceCoordinates = coordinates, + relativeToSource = Offset.Zero, + includeMotionFrameOfReference = animateMotionFrameOfReference + ) + } + } + + val topLeft = + if (animatedBounds != null) { + boundsAnimation.updateCurrentBounds(animatedBounds.topLeft, animatedBounds.size) + animatedBounds.topLeft + } else { + boundsAnimation.currentBounds?.topLeft ?: Offset.Zero + } + 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) +private 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..edeaab1 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width 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 @@ -39,25 +38,28 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp import com.tunjid.demo.common.ui.SampleAppState.Companion.rememberPanedNavHostState 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 +100,9 @@ fun SampleApp( .threePanedMovableSharedElementConfiguration( movableSharedElementHostState = movableSharedElementHostState ) + .animatePaneBoundsConfiguration( + lookaheadScope = this@SharedTransitionScope + ) }, modifier = Modifier .fillMaxSize() @@ -105,30 +110,32 @@ 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 -> + Box( + Modifier + .run { + if (nodeFor(pane) == null) width(0.dp) + else weight(1f) + } + .fillMaxHeight() + ) { + Destination(pane) + } + } + } } } } @@ -150,7 +157,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) } } From 7cab080dcb8c6a089e04f1f03f240b8764c60d67 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Thu, 17 Oct 2024 11:27:28 -0400 Subject: [PATCH 2/7] Use a single animatBounds modifier for movable shared elements and animated pane bounds --- .../AnimatePaneBoundsConfiguration.kt | 13 +- .../BoundsTransformDeferredAnimation.kt | 180 ------------- .../MovableSharedElementState.kt | 99 +------ .../MovableSharedElements.kt | 12 +- .../utilities/AnimatedBoundsModifier.kt | 244 +++++++++--------- 5 files changed, 140 insertions(+), 408 deletions(-) delete mode 100644 library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/moveablesharedelement/BoundsTransformDeferredAnimation.kt 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 index 5838de2..3e6950a 100644 --- 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 @@ -18,6 +18,7 @@ package com.tunjid.treenav.compose.configurations 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 @@ -25,8 +26,8 @@ import com.tunjid.treenav.compose.PanedNavHostConfiguration import com.tunjid.treenav.compose.delegated import com.tunjid.treenav.compose.paneStrategy import com.tunjid.treenav.compose.threepane.ThreePane -import com.tunjid.treenav.compose.utilities.animateBounds - +import com.tunjid.treenav.compose.utilities.AnimatedBoundsState +import com.tunjid.treenav.compose.utilities.AnimatedBoundsState.Companion.animateBounds @OptIn(ExperimentalSharedTransitionApi::class) fun PanedNavHostConfiguration< @@ -42,7 +43,13 @@ fun PanedNavHostConfiguration< paneMapping = originalTransform.paneMapper, render = render@{ Box( - modifier = Modifier.animateBounds(lookaheadScope) + modifier = Modifier.animateBounds( + state = remember { + AnimatedBoundsState( + lookaheadScope = lookaheadScope, + ) + } + ) ) { originalTransform.render(this@render, it) } 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/utilities/AnimatedBoundsModifier.kt b/library/compose/src/commonMain/kotlin/com/tunjid/treenav/compose/utilities/AnimatedBoundsModifier.kt index 70d0c7f..87c330c 100644 --- 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 @@ -20,7 +20,6 @@ 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.FiniteAnimationSpec import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.VisibilityThreshold @@ -45,6 +44,7 @@ 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 @@ -54,152 +54,138 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.launch - @ExperimentalSharedTransitionApi // Depends on BoundsTransform -internal fun Modifier.animateBounds( - lookaheadScope: LookaheadScope, - modifier: Modifier = Modifier, +internal class AnimatedBoundsState( + val lookaheadScope: LookaheadScope, boundsTransform: BoundsTransform = DefaultBoundsTransform, animateMotionFrameOfReference: Boolean = false, -): Modifier = - this.then( - BoundsAnimationElement( - lookaheadScope = lookaheadScope, - boundsTransform = boundsTransform, - // Measure with original constraints. - // The layout of this element will still be the animated lookahead size. - resolveMeasureConstraints = { _, constraints -> constraints }, - animateMotionFrameOfReference = animateMotionFrameOfReference, - ) - ) - .then(modifier) - .then( - BoundsAnimationElement( - lookaheadScope = lookaheadScope, - boundsTransform = boundsTransform, + 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) }, - animateMotionFrameOfReference = animateMotionFrameOfReference, ) - ) -@ExperimentalSharedTransitionApi -internal data class BoundsAnimationElement( - val lookaheadScope: LookaheadScope, - val boundsTransform: BoundsTransform, - val resolveMeasureConstraints: (animatedSize: IntSize, constraints: Constraints) -> Constraints, - val animateMotionFrameOfReference: Boolean, -) : ModifierNodeElement() { - override fun create(): BoundsAnimationModifierNode { - return BoundsAnimationModifierNode( - lookaheadScope = lookaheadScope, - boundsTransform = boundsTransform, - onChooseMeasureConstraints = resolveMeasureConstraints, - animateMotionFrameOfReference = animateMotionFrameOfReference, - ) - } - - override fun update(node: BoundsAnimationModifierNode) { - node.lookaheadScope = lookaheadScope - node.boundsTransform = boundsTransform - node.onChooseMeasureConstraints = resolveMeasureConstraints - node.animateMotionFrameOfReference = animateMotionFrameOfReference - } + @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 InspectorInfo.inspectableProperties() { - name = "boundsAnimation" - properties["lookaheadScope"] = lookaheadScope - properties["boundsTransform"] = boundsTransform - properties["onChooseMeasureConstraints"] = resolveMeasureConstraints - properties["animateMotionFrameOfReference"] = animateMotionFrameOfReference - } -} + override fun update(node: BoundsAnimationModifierNode) { + node.onChooseMeasureConstraints = resolveMeasureConstraints + } -/** - * [Modifier.Node] implementation that handles the bounds animation with - * [ApproachLayoutModifierNode]. - * - * @param lookaheadScope The [LookaheadScope] to animate from. - * @param boundsTransform Callback to produce [FiniteAnimationSpec] at every triggered animation - * @param onChooseMeasureConstraints Callback to decide whether to measure the Modifier Layout with - * the current animated size value or the incoming constraints. This reflects on the - * [MeasureResult] of this Modifier Layout as well. - * @param animateMotionFrameOfReference Whether to include changes under - * [LayoutCoordinates.introducesMotionFrameOfReference] to trigger animations. - */ -@ExperimentalSharedTransitionApi -internal class BoundsAnimationModifierNode( - var lookaheadScope: LookaheadScope, - var boundsTransform: BoundsTransform, - var onChooseMeasureConstraints: - (animatedSize: IntSize, constraints: Constraints) -> Constraints, - var animateMotionFrameOfReference: Boolean, -) : ApproachLayoutModifierNode, Modifier.Node() { - private val boundsAnimation = BoundsTransformDeferredAnimation() + override fun InspectorInfo.inspectableProperties() { + name = "boundsAnimation" + properties["onChooseMeasureConstraints"] = resolveMeasureConstraints + properties["state"] = state + } + } - override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean { - // Update target size, it will serve to know if we expect an approach in progress - boundsAnimation.updateTargetSize(lookaheadSize.toSize()) + @ExperimentalSharedTransitionApi + internal class BoundsAnimationModifierNode( + var onChooseMeasureConstraints: + (animatedSize: IntSize, constraints: Constraints) -> Constraints, + val state: AnimatedBoundsState, + ) : ApproachLayoutModifierNode, Modifier.Node() { - return !boundsAnimation.isIdle - } + 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()) - override fun Placeable.PlacementScope.isPlacementApproachInProgress( - lookaheadCoordinates: LayoutCoordinates - ): Boolean { - // Once we can capture size and offset we may also start the animation - boundsAnimation.updateTargetOffsetAndAnimate( - lookaheadScope = lookaheadScope, - placementScope = this, - coroutineScope = coroutineScope, - includeMotionFrameOfReference = animateMotionFrameOfReference, - boundsTransform = boundsTransform, - ) - return !boundsAnimation.isIdle - } + 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 (boundsAnimation.currentSize.isUnspecified) { - // When using Intrinsics, we may get measured before getting the approach check - lookaheadSize.toSize() - } else { - boundsAnimation.currentSize + 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 } - val animatedSize = (boundsAnimation.value?.size ?: fallbackSize).roundToIntSize() - - val chosenConstraints = onChooseMeasureConstraints(animatedSize, constraints) - - val placeable = measurable.measure(chosenConstraints) - return layout(animatedSize.width, animatedSize.height) { - val animatedBounds = boundsAnimation.value - val positionInScope = - with(lookaheadScope) { - coordinates?.let { coordinates -> - lookaheadScopeCoordinates.localPositionOf( - sourceCoordinates = coordinates, - relativeToSource = Offset.Zero, - includeMotionFrameOfReference = animateMotionFrameOfReference - ) + + 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) { - boundsAnimation.updateCurrentBounds(animatedBounds.topLeft, animatedBounds.size) - animatedBounds.topLeft - } else { - boundsAnimation.currentBounds?.topLeft ?: Offset.Zero + 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()) } - val (x, y) = positionInScope?.let { topLeft - it } ?: Offset.Zero - placeable.place(x.fastRoundToInt(), y.fastRoundToInt()) + } } } } @@ -360,7 +346,7 @@ internal class BoundsTransformDeferredAnimation { } @OptIn(ExperimentalSharedTransitionApi::class) -private val DefaultBoundsTransform = BoundsTransform { _, _ -> +internal val DefaultBoundsTransform = BoundsTransform { _, _ -> spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow, From 643f7f5bfb1fc82ee52f1917dbc5e6fb0389d406 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Thu, 17 Oct 2024 11:47:51 -0400 Subject: [PATCH 3/7] Add more options to animatePaneBoundsConfiguration --- .../AnimatePaneBoundsConfiguration.kt | 15 +++++++++++---- .../kotlin/com/tunjid/demo/common/ui/DemoApp.kt | 12 +++++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) 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 index 3e6950a..cb89818 100644 --- 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 @@ -16,6 +16,7 @@ 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 @@ -25,18 +26,20 @@ import com.tunjid.treenav.Node import com.tunjid.treenav.compose.PanedNavHostConfiguration import com.tunjid.treenav.compose.delegated import com.tunjid.treenav.compose.paneStrategy -import com.tunjid.treenav.compose.threepane.ThreePane import com.tunjid.treenav.compose.utilities.AnimatedBoundsState import com.tunjid.treenav.compose.utilities.AnimatedBoundsState.Companion.animateBounds +import com.tunjid.treenav.compose.utilities.DefaultBoundsTransform @OptIn(ExperimentalSharedTransitionApi::class) -fun PanedNavHostConfiguration< - ThreePane, +fun PanedNavHostConfiguration< + Pane, NavigationState, Destination >.animatePaneBoundsConfiguration( lookaheadScope: LookaheadScope, -): PanedNavHostConfiguration = delegated { + paneBoundsTransform: (Pane) -> BoundsTransform = { DefaultBoundsTransform }, + canAnimatePane: (Pane) -> Boolean = { true }, +): PanedNavHostConfiguration = delegated { val originalTransform = strategyTransform(it) paneStrategy( transitions = originalTransform.transitions, @@ -47,6 +50,10 @@ fun PanedNavHostConfiguration< state = remember { AnimatedBoundsState( lookaheadScope = lookaheadScope, + boundsTransform = paneState.pane + ?.let(paneBoundsTransform) + ?: DefaultBoundsTransform, + inProgress = { paneState.pane?.let(canAnimatePane) ?: false } ) } ) 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 edeaab1..5c3dbba 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 @@ -101,7 +101,17 @@ fun SampleApp( movableSharedElementHostState = movableSharedElementHostState ) .animatePaneBoundsConfiguration( - lookaheadScope = this@SharedTransitionScope + lookaheadScope = this@SharedTransitionScope, + canAnimatePane = { pane -> + when (pane) { + ThreePane.Primary, + ThreePane.Secondary, + ThreePane.Tertiary -> true + + ThreePane.Overlay, + ThreePane.TransientPrimary -> false + } + } ) }, modifier = Modifier From 25d2307af060754f552ce18bbff5b580fc43a765 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Thu, 17 Oct 2024 12:04:03 -0400 Subject: [PATCH 4/7] Use a spacer for non existent panes --- .../AnimatePaneBoundsConfiguration.kt | 10 ++++++++++ .../MovableSharedElementConfiguration.kt | 1 - .../kotlin/com/tunjid/demo/common/ui/DemoApp.kt | 13 +++++-------- 3 files changed, 15 insertions(+), 9 deletions(-) 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 index cb89818..d045b88 100644 --- 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 @@ -30,6 +30,16 @@ 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 canAnimatePane a lambda for toggling when the pane can be animated. It allows for + * skipping an animation in progress. + */ @OptIn(ExperimentalSharedTransitionApi::class) fun PanedNavHostConfiguration< Pane, 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/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 5c3dbba..6ab2019 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 @@ -20,9 +20,9 @@ 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.foundation.layout.width import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @@ -38,7 +38,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp import com.tunjid.demo.common.ui.SampleAppState.Companion.rememberPanedNavHostState import com.tunjid.demo.common.ui.chat.chatPaneStrategy import com.tunjid.demo.common.ui.chatrooms.chatRoomPaneStrategy @@ -134,12 +133,10 @@ fun SampleApp( ) } order.forEach { pane -> - Box( - Modifier - .run { - if (nodeFor(pane) == null) width(0.dp) - else weight(1f) - } + if (nodeFor(pane) == null) Spacer(Modifier) + else Box( + modifier = Modifier + .weight(1f) .fillMaxHeight() ) { Destination(pane) From 16ce7371de75254f8316f277110098fe6245b18d Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Thu, 17 Oct 2024 17:49:01 -0400 Subject: [PATCH 5/7] Pass modifier to ProfileDetails --- .../com/tunjid/demo/common/ui/profile/ProfileScreen.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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()) ) { From 38a5d991cc90159c2642b7a74b69ac7e13325e68 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Fri, 18 Oct 2024 05:41:37 -0400 Subject: [PATCH 6/7] Pass entire PaneScope in lambdas for animatePaneBoundsConfiguration --- .../AnimatePaneBoundsConfiguration.kt | 17 ++++++++--------- .../kotlin/com/tunjid/demo/common/ui/DemoApp.kt | 2 +- 2 files changed, 9 insertions(+), 10 deletions(-) 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 index d045b88..6290a99 100644 --- 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 @@ -23,6 +23,7 @@ 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 @@ -37,7 +38,7 @@ import com.tunjid.treenav.compose.utilities.DefaultBoundsTransform * * @param lookaheadScope the root [LookaheadScope] where the panes are rendered in. * @param paneBoundsTransform a lambda providing the [BoundsTransform] for each [Pane]. - * @param canAnimatePane a lambda for toggling when the pane can be animated. It allows for + * @param shouldAnimatePane a lambda for toggling when the pane can be animated. It allows for * skipping an animation in progress. */ @OptIn(ExperimentalSharedTransitionApi::class) @@ -47,28 +48,26 @@ fun PanedNavHostConfiguration Destination >.animatePaneBoundsConfiguration( lookaheadScope: LookaheadScope, - paneBoundsTransform: (Pane) -> BoundsTransform = { DefaultBoundsTransform }, - canAnimatePane: (Pane) -> Boolean = { true }, + paneBoundsTransform: PaneScope.() -> BoundsTransform = { DefaultBoundsTransform }, + shouldAnimatePane: PaneScope.() -> Boolean = { true }, ): PanedNavHostConfiguration = delegated { val originalTransform = strategyTransform(it) paneStrategy( transitions = originalTransform.transitions, paneMapping = originalTransform.paneMapper, - render = render@{ + render = render@{ destination -> Box( modifier = Modifier.animateBounds( state = remember { AnimatedBoundsState( lookaheadScope = lookaheadScope, - boundsTransform = paneState.pane - ?.let(paneBoundsTransform) - ?: DefaultBoundsTransform, - inProgress = { paneState.pane?.let(canAnimatePane) ?: false } + boundsTransform = paneBoundsTransform(), + inProgress = { shouldAnimatePane() } ) } ) ) { - originalTransform.render(this@render, it) + originalTransform.render(this@render, destination) } } ) 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 6ab2019..8d0f4a4 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 @@ -101,7 +101,7 @@ fun SampleApp( ) .animatePaneBoundsConfiguration( lookaheadScope = this@SharedTransitionScope, - canAnimatePane = { pane -> + shouldAnimatePane = { pane -> when (pane) { ThreePane.Primary, ThreePane.Secondary, From 1520b53a84141997b91391d4409098e82a355f69 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Fri, 18 Oct 2024 05:43:42 -0400 Subject: [PATCH 7/7] Use exhaustive when --- .../commonMain/kotlin/com/tunjid/demo/common/ui/DemoApp.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 8d0f4a4..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 @@ -101,12 +101,13 @@ fun SampleApp( ) .animatePaneBoundsConfiguration( lookaheadScope = this@SharedTransitionScope, - shouldAnimatePane = { pane -> - when (pane) { + shouldAnimatePane = { + when (paneState.pane) { ThreePane.Primary, ThreePane.Secondary, ThreePane.Tertiary -> true + null, ThreePane.Overlay, ThreePane.TransientPrimary -> false }