Add AnimateBounds Modifier API Adds a new Modifier to animate Layout bounds based on LookaheadScope coordinate changes. Operates using ApproachLayoutModifierNode, capturing target size/position and animating accordingly. To decide the animation, it uses `BoundsTransform` (used in shared element transitions), so that users may calculate their desired AnimationSpec based on initial and target bounds. By default, it ignores changes in position under `LayoutCoordinates.introducesMotionFrameOfReference`, as they are expected to be frequent (in small, continuous changes), which are typically not needed to animate. This is mostly to account for scrolling. Relnote: "Adds `Modifier.animateBounds(...)` Modifier, to animate Layout changes under LookaheadScope." Bug: 330540544 Test: AnimateBoundsTest Change-Id: I8ac4562168a09de981cf163c140b3bc77681466d
diff --git a/compose/animation/animation/api/current.txt b/compose/animation/animation/api/current.txt index e3b1efc..05c53bd 100644 --- a/compose/animation/animation/api/current.txt +++ b/compose/animation/animation/api/current.txt
@@ -5,6 +5,10 @@ method @Deprecated @androidx.compose.runtime.Composable public static androidx.compose.animation.core.DecayAnimationSpec<java.lang.Float> defaultDecayAnimationSpec(); } + public final class AnimateBoundsModifierKt { + method @SuppressCompatibility @androidx.compose.animation.ExperimentalSharedTransitionApi public static androidx.compose.ui.Modifier animateBounds(androidx.compose.ui.Modifier, androidx.compose.ui.layout.LookaheadScope lookaheadScope, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.animation.BoundsTransform boundsTransform, optional boolean animateMotionFrameOfReference); + } + public final class AnimatedContentKt { method @androidx.compose.runtime.Composable public static <S> void AnimatedContent(androidx.compose.animation.core.Transition<S>, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.animation.AnimatedContentTransitionScope<S>,androidx.compose.animation.ContentTransform> transitionSpec, optional androidx.compose.ui.Alignment contentAlignment, optional kotlin.jvm.functions.Function1<? super S,? extends java.lang.Object?> contentKey, kotlin.jvm.functions.Function2<? super androidx.compose.animation.AnimatedContentScope,? super S,kotlin.Unit> content); method @androidx.compose.runtime.Composable public static <S> void AnimatedContent(S targetState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.animation.AnimatedContentTransitionScope<S>,androidx.compose.animation.ContentTransform> transitionSpec, optional androidx.compose.ui.Alignment contentAlignment, optional String label, optional kotlin.jvm.functions.Function1<? super S,? extends java.lang.Object?> contentKey, kotlin.jvm.functions.Function2<? super androidx.compose.animation.AnimatedContentScope,? super S,kotlin.Unit> content);
diff --git a/compose/animation/animation/api/restricted_current.txt b/compose/animation/animation/api/restricted_current.txt index e3b1efc..05c53bd 100644 --- a/compose/animation/animation/api/restricted_current.txt +++ b/compose/animation/animation/api/restricted_current.txt
@@ -5,6 +5,10 @@ method @Deprecated @androidx.compose.runtime.Composable public static androidx.compose.animation.core.DecayAnimationSpec<java.lang.Float> defaultDecayAnimationSpec(); } + public final class AnimateBoundsModifierKt { + method @SuppressCompatibility @androidx.compose.animation.ExperimentalSharedTransitionApi public static androidx.compose.ui.Modifier animateBounds(androidx.compose.ui.Modifier, androidx.compose.ui.layout.LookaheadScope lookaheadScope, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.animation.BoundsTransform boundsTransform, optional boolean animateMotionFrameOfReference); + } + public final class AnimatedContentKt { method @androidx.compose.runtime.Composable public static <S> void AnimatedContent(androidx.compose.animation.core.Transition<S>, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.animation.AnimatedContentTransitionScope<S>,androidx.compose.animation.ContentTransform> transitionSpec, optional androidx.compose.ui.Alignment contentAlignment, optional kotlin.jvm.functions.Function1<? super S,? extends java.lang.Object?> contentKey, kotlin.jvm.functions.Function2<? super androidx.compose.animation.AnimatedContentScope,? super S,kotlin.Unit> content); method @androidx.compose.runtime.Composable public static <S> void AnimatedContent(S targetState, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.animation.AnimatedContentTransitionScope<S>,androidx.compose.animation.ContentTransform> transitionSpec, optional androidx.compose.ui.Alignment contentAlignment, optional String label, optional kotlin.jvm.functions.Function1<? super S,? extends java.lang.Object?> contentKey, kotlin.jvm.functions.Function2<? super androidx.compose.animation.AnimatedContentScope,? super S,kotlin.Unit> content);
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/AnimationDemos.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/AnimationDemos.kt index c8b339c..4f00d4f 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/AnimationDemos.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/AnimationDemos.kt
@@ -38,7 +38,9 @@ import androidx.compose.animation.demos.layoutanimation.ScreenTransitionDemo import androidx.compose.animation.demos.layoutanimation.ShrineCartDemo import androidx.compose.animation.demos.lookahead.AnimateBoundsModifierDemo +import androidx.compose.animation.demos.lookahead.AnimateBoundsOnFloatingToolbarDemo import androidx.compose.animation.demos.lookahead.CraneDemo +import androidx.compose.animation.demos.lookahead.LookaheadInScrollingColumn import androidx.compose.animation.demos.lookahead.LookaheadLayoutWithAlignmentLinesDemo import androidx.compose.animation.demos.lookahead.LookaheadSamplesDemo import androidx.compose.animation.demos.lookahead.LookaheadWithAnimatedContentSize @@ -144,6 +146,10 @@ }, ComposableDemo("Lookahead With Tab Row") { LookaheadWithTabRowDemo() }, ComposableDemo("Lookahead With Scaffold") { LookaheadWithScaffold() }, + ComposableDemo("Lookahead With Scroll") { LookaheadInScrollingColumn() }, + ComposableDemo("Floating Toolbar w/ AnimateBounds") { + AnimateBoundsOnFloatingToolbarDemo() + }, ) ), DemoCategory(
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/AnimateBoundsModifier.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/AnimateBoundsModifier.kt deleted file mode 100644 index 87e9e5b..0000000 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/AnimateBoundsModifier.kt +++ /dev/null
@@ -1,179 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * 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 androidx.compose.animation.demos.lookahead - -import androidx.compose.animation.core.AnimationVector2D -import androidx.compose.animation.core.DeferredTargetAnimation -import androidx.compose.animation.core.ExperimentalAnimatableApi -import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.VectorConverter -import androidx.compose.animation.core.spring -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.layout.LookaheadScope -import androidx.compose.ui.layout.Placeable -import androidx.compose.ui.layout.approachLayout -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 kotlinx.coroutines.CoroutineScope - -context(LookaheadScope) -@OptIn(ExperimentalAnimatableApi::class) -fun Modifier.animateBounds( - modifier: Modifier = Modifier, - sizeAnimationSpec: FiniteAnimationSpec<IntSize> = - spring(Spring.DampingRatioNoBouncy, Spring.StiffnessMediumLow), - positionAnimationSpec: FiniteAnimationSpec<IntOffset> = - spring(Spring.DampingRatioNoBouncy, Spring.StiffnessMediumLow), - debug: Boolean = false, -) = composed { - val outerOffsetAnimation = remember { DeferredTargetAnimation(IntOffset.VectorConverter) } - val outerSizeAnimation = remember { DeferredTargetAnimation(IntSize.VectorConverter) } - - val offsetAnimation = remember { DeferredTargetAnimation(IntOffset.VectorConverter) } - val sizeAnimation = remember { DeferredTargetAnimation(IntSize.VectorConverter) } - - val coroutineScope = rememberCoroutineScope() - - // The measure logic in `approachLayout` is skipped in the lookahead pass, as - // approachLayout is expected to produce intermediate stages of a layout transform. - // When the measure block is invoked after lookahead pass, the lookahead size of the - // child will be accessible as a parameter to the measure block. - this.drawWithContent { - drawContent() - if (debug) { - // val offset = outerOffsetAnimation.pendingTarget!! - - // outerOffsetAnimation.value!! - // translate( - // offset.x.toFloat(), offset.y.toFloat() - // ) { - // drawRect(Color.Black.copy(alpha = 0.5f), style = Stroke(10f)) - // } - } - } - .approachLayout( - isMeasurementApproachInProgress = { - outerSizeAnimation.updateTarget(it, coroutineScope, sizeAnimationSpec) - !outerSizeAnimation.isIdle - }, - isPlacementApproachInProgress = { - val target = lookaheadScopeCoordinates.localLookaheadPositionOf(it) - outerOffsetAnimation.updateTarget( - target.round(), - coroutineScope, - positionAnimationSpec - ) - !outerOffsetAnimation.isIdle - } - ) { measurable, constraints -> - val (w, h) = - outerSizeAnimation.updateTarget( - lookaheadSize, - coroutineScope, - sizeAnimationSpec, - ) - measurable.measure(constraints).run { - layout(w, h) { - with(coroutineScope) { - val (x, y) = - outerOffsetAnimation.updateTargetBasedOnCoordinates( - positionAnimationSpec - ) - place(x, y) - } - } - } - } - .then(modifier) - .drawWithContent { - drawContent() - if (debug) { - // val offset = offsetAnimation.pendingTarget!! - - // offsetAnimation.value!! - // translate( - // offset.x.toFloat(), offset.y.toFloat() - // ) { - // drawRect(Color.Green.copy(alpha = 0.5f), style = Stroke(10f)) - // } - } - } - .approachLayout( - isMeasurementApproachInProgress = { - sizeAnimation.updateTarget(it, coroutineScope, sizeAnimationSpec) - !sizeAnimation.isIdle - }, - isPlacementApproachInProgress = { - val target = lookaheadScopeCoordinates.localLookaheadPositionOf(it) - offsetAnimation.updateTarget(target.round(), coroutineScope, positionAnimationSpec) - !offsetAnimation.isIdle - } - ) { measurable, _ -> - // When layout changes, the lookahead pass will calculate a new final size for the - // child modifier. This lookahead size can be used to animate the size - // change, such that the animation starts from the current size and gradually - // change towards `lookaheadSize`. - val (width, height) = - sizeAnimation.updateTarget( - lookaheadSize, - coroutineScope, - sizeAnimationSpec, - ) - // Creates a fixed set of constraints using the animated size - val animatedConstraints = Constraints.fixed(width, height) - // Measure child/children with animated constraints. - val placeable = measurable.measure(animatedConstraints) - layout(placeable.width, placeable.height) { - val (x, y) = - with(coroutineScope) { - offsetAnimation.updateTargetBasedOnCoordinates( - positionAnimationSpec, - ) - } - placeable.place(x, y) - } - } -} - -context(LookaheadScope, Placeable.PlacementScope, CoroutineScope) -@OptIn(ExperimentalAnimatableApi::class) -internal fun DeferredTargetAnimation<IntOffset, AnimationVector2D>.updateTargetBasedOnCoordinates( - animationSpec: FiniteAnimationSpec<IntOffset>, -): IntOffset { - coordinates?.let { coordinates -> - with(this@PlacementScope) { - val targetOffset = lookaheadScopeCoordinates.localLookaheadPositionOf(coordinates) - val animOffset = - updateTarget( - targetOffset.round(), - this@CoroutineScope, - animationSpec, - ) - val current = - lookaheadScopeCoordinates.localPositionOf(coordinates, Offset.Zero).round() - return (animOffset - current) - } - } - - return IntOffset.Zero -}
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/AnimateBoundsModifierDemo.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/AnimateBoundsModifierDemo.kt index 5ebb842..787045d 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/AnimateBoundsModifierDemo.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/AnimateBoundsModifierDemo.kt
@@ -16,6 +16,8 @@ package androidx.compose.animation.demos.lookahead +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -39,6 +41,7 @@ import androidx.compose.ui.unit.dp import kotlin.random.Random +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun AnimateBoundsModifierDemo() { var height by remember { mutableIntStateOf(200) } @@ -63,18 +66,27 @@ Box(Modifier.fillMaxHeight(0.5f).fillMaxSize()) { Box( Modifier.background(Color.Gray) - .animateBounds(Modifier.padding(left.dp, top.dp, right.dp, bottom.dp)) + .animateBounds( + this@LookaheadScope, + Modifier.padding(left.dp, top.dp, right.dp, bottom.dp) + ) .background(Color.Red) .fillMaxSize() ) } Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { Box( - Modifier.animateBounds(Modifier.weight(weight).height(height.dp)) + Modifier.animateBounds( + this@LookaheadScope, + Modifier.weight(weight).height(height.dp) + ) .background(Color(0xffa2d2ff), RoundedCornerShape(5.dp)) ) Box( - Modifier.animateBounds(Modifier.weight(1f).height(height.dp)) + Modifier.animateBounds( + this@LookaheadScope, + Modifier.weight(1f).height(height.dp) + ) .background(Color(0xfffff3b0)) ) }
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/AnimateBoundsOnFloatingToolbar.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/AnimateBoundsOnFloatingToolbar.kt new file mode 100644 index 0000000..0221499 --- /dev/null +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/AnimateBoundsOnFloatingToolbar.kt
@@ -0,0 +1,228 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * 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 androidx.compose.animation.demos.lookahead + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.demos.visualaid.EasingItemDemo +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material.icons.outlined.Share +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentWithReceiverOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.coerceAtLeast +import androidx.compose.ui.unit.dp + +/** + * Example using [animateBounds] with nested movable content. + * + * Animates an Icon component from a Toolbar to a FAB position, the toolbar is also animated to hide + * it under the FAB. + */ +@Preview +@Composable +fun AnimateBoundsOnFloatingToolbarDemo() { + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { + val sampleText = remember { LoremIpsum().values.first() } + Text( + text = "Click on the Toolbar to animate", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.h6 + ) + Text(text = sampleText) + } + FloatingFabToolbar( + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(8.dp) + .padding(bottom = 24.dp) + ) + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun FloatingFabToolbar(modifier: Modifier = Modifier) { + var mode by remember { mutableStateOf(FabToolbarMode.Toolbar) } + + val animationDuration = 600 + val animEasing = EasingItemDemo.EmphasizedEasing.function + + val editIconPadding by + animateDpAsState( + targetValue = if (mode == FabToolbarMode.Fab) 12.dp else 0.dp, + animationSpec = tween(animationDuration, easing = animEasing), + label = "Edit Icon Padding" + ) + + val myEditIcon = remember { + movableContentWithReceiverOf<LookaheadScope, Modifier> { iconModifier -> + Box( + modifier = + iconModifier + .let { + if (mode == FabToolbarMode.Toolbar) { + it.fillMaxSize() + } else { + it.aspectRatio(1f, matchHeightConstraintsFirst = true) + } + } + .animateBounds( + lookaheadScope = this, + modifier = Modifier, + boundsTransform = { _, _ -> + tween(animationDuration, easing = animEasing) + }, + ), + ) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = null, + tint = MaterialTheme.colors.onPrimary, + modifier = + Modifier.background(MaterialTheme.colors.primary, RoundedCornerShape(16.dp)) + .fillMaxSize() + .padding(editIconPadding.coerceAtLeast(0.dp)) + ) + } + } + } + + // Toolbar container + Toolbar + val myToolbar = remember { + movableContentWithReceiverOf<LookaheadScope, Modifier> { toolbarMod -> + // Toolbar container + Box( + modifier = + toolbarMod + .animateBounds( + lookaheadScope = this, + boundsTransform = { _, _ -> + tween(animationDuration, easing = animEasing) + }, + ) + .background(MaterialTheme.colors.background, RoundedCornerShape(50)) + .let { + if (mode == FabToolbarMode.Toolbar) { + // Respect toolbar content size when in Toolbar mode + it.wrapContentSize().padding(8.dp) + } else { + // Resize the container so that it doesn't go beyond the Fab box, + // clipping the toolbar as needed + it.fillMaxWidth().wrapContentHeight().padding(8.dp) + } + } + ) { + // Toolbar - Fixed Size + Row( + modifier = Modifier.align(Alignment.Center), + horizontalArrangement = Arrangement.spacedBy(26.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val iconSize = DpSize(30.dp, 20.dp) + Icon( + imageVector = Icons.Outlined.Share, + contentDescription = "Share", + modifier = Modifier.size(iconSize) + ) + Icon( + imageVector = Icons.Outlined.FavoriteBorder, + contentDescription = "Favorite", + modifier = Modifier.size(iconSize) + ) + Box(modifier = Modifier.size(iconSize)) { + // Slot for the Edit Icon when position on the toolbar + if (mode == FabToolbarMode.Toolbar) { + myEditIcon(Modifier.align(Alignment.Center)) + } + } + } + } + } + } + + LookaheadScope { + Box( + modifier.clickable { + mode = + if (mode == FabToolbarMode.Fab) { + FabToolbarMode.Toolbar + } else { + FabToolbarMode.Fab + } + } + ) { + Box( + Modifier.align(Alignment.Center), + ) { + // Slot 0 - Toolbar position + if (mode == FabToolbarMode.Toolbar) { + // The Toolbar container should also place the Edit Icon at this state + myToolbar(Modifier.align(Alignment.Center)) + } + } + Box(Modifier.size(80.dp).align(Alignment.CenterEnd)) { + // Slot 1 - Fab position + if (mode == FabToolbarMode.Fab) { + // We pull out the Edit Icon in this state + myToolbar(Modifier.align(Alignment.Center)) + myEditIcon(Modifier.align(Alignment.Center)) + } + } + } + } +} + +enum class FabToolbarMode { + Fab, + Toolbar +}
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadInScrollingColumn.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadInScrollingColumn.kt new file mode 100644 index 0000000..69173ea --- /dev/null +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadInScrollingColumn.kt
@@ -0,0 +1,123 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * 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 androidx.compose.animation.demos.lookahead + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.demos.layoutanimation.turquoiseColors +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentWithReceiverOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex + +/** + * A simple example showing how [animateBounds] behaves when animating from/to a scrolling layout. + * + * Note that despite the items position changing due to the scroll, it does not affect or trigger an + * animation. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +@Preview +fun LookaheadInScrollingColumn() { + var displayInScroller by remember { mutableStateOf(false) } + val movableContent = remember { + movableContentWithReceiverOf<LookaheadScope> { + Box( + Modifier.zIndex(1f) + .let { + if (displayInScroller) { + it.height(80.dp).fillMaxWidth() + } else { + it.size(150.dp) + } + } + .animateBounds( + lookaheadScope = this@movableContentWithReceiverOf, + boundsTransform = { _, _ -> + spring(stiffness = 50f, visibilityThreshold = Rect.VisibilityThreshold) + } + ) + .clickable { displayInScroller = !displayInScroller } + .background(color, RoundedCornerShape(10.dp)) + ) + } + } + + Box(Modifier.fillMaxSize()) { + LookaheadScope { + Column( + modifier = + Modifier.fillMaxSize().verticalScroll(rememberScrollState(0)).padding(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text("Click Yellow box to animate to/from scrolling list.") + repeat(6) { + Box( + Modifier.fillMaxWidth() + .background(turquoiseColors[it % 6], RoundedCornerShape(10.dp)) + .height(80.dp) + ) + } + if (displayInScroller) { + movableContent() + } + repeat(6) { + Box( + Modifier.animateBounds(lookaheadScope = this@LookaheadScope) + .background(turquoiseColors[it % 6], RoundedCornerShape(10.dp)) + .height(80.dp) + .fillMaxWidth() + ) + } + } + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd) { + if (!displayInScroller) { + movableContent() + } + } + } + } +} + +private val color = Color(0xffffcc5c)
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadSamplesDemo.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadSamplesDemo.kt index f0375d7..8af930f 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadSamplesDemo.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadSamplesDemo.kt
@@ -16,17 +16,56 @@ package androidx.compose.animation.demos.lookahead +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.ExperimentalAnimatableApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LookaheadScope import androidx.compose.ui.samples.LookaheadLayoutCoordinatesSample -import androidx.compose.ui.samples.approachLayoutSample import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp @Preview @Composable fun LookaheadSamplesDemo() { Column { - approachLayoutSample() + ApproachLayoutSample0() LookaheadLayoutCoordinatesSample() } } + +@OptIn(ExperimentalAnimatableApi::class, ExperimentalSharedTransitionApi::class) +@Composable +public fun ApproachLayoutSample0() { + var fullWidth by remember { mutableStateOf(false) } + LookaheadScope { + Row( + (if (fullWidth) Modifier.fillMaxWidth() else Modifier.width(100.dp)) + .height(200.dp) + // Use the custom modifier created above to animate the constraints passed + // to the child, and therefore resize children in an animation. + .animateBounds(this@LookaheadScope) + .clickable { fullWidth = !fullWidth } + ) { + Box( + Modifier.weight(1f).fillMaxHeight().background(Color(0xffff6f69)), + ) + Box(Modifier.weight(2f).fillMaxHeight().background(Color(0xffffcc5c))) + } + } +}
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithAnimatedContentSize.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithAnimatedContentSize.kt index 36f8fec..34c468d 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithAnimatedContentSize.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithAnimatedContentSize.kt
@@ -16,6 +16,8 @@ package androidx.compose.animation.demos.lookahead +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds import androidx.compose.animation.animateContentSize import androidx.compose.animation.demos.gesture.pastelColors import androidx.compose.foundation.background @@ -34,6 +36,7 @@ import androidx.compose.ui.zIndex import kotlinx.coroutines.delay +@OptIn(ExperimentalSharedTransitionApi::class) @Preview @Composable fun LookaheadWithAnimatedContentSize() { @@ -56,7 +59,12 @@ Box(Modifier.fillMaxWidth().height(200.dp).background(Color.White)) } } - Box(Modifier.animateBounds().fillMaxWidth().height(100.dp).background(pastelColors[1])) + Box( + Modifier.animateBounds(this@LookaheadScope) + .fillMaxWidth() + .height(100.dp) + .background(pastelColors[1]) + ) } } }
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithBoxWithConstraints.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithBoxWithConstraints.kt index 24f929f..48ffde5 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithBoxWithConstraints.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithBoxWithConstraints.kt
@@ -16,6 +16,8 @@ package androidx.compose.animation.demos.lookahead +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds import androidx.compose.animation.demos.gesture.pastelColors import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -46,6 +48,7 @@ import androidx.compose.ui.layout.LookaheadScope import androidx.compose.ui.unit.dp +@OptIn(ExperimentalSharedTransitionApi::class) @Suppress("UnusedBoxWithConstraintsScope") @Composable fun LookaheadWithBoxWithConstraints() { @@ -59,6 +62,7 @@ Column( Modifier.fillMaxHeight() .animateBounds( + this@LookaheadScope, if (halfSize) Modifier.fillMaxSize(0.5f) else Modifier.fillMaxWidth() ) .background(pastelColors[2]), @@ -96,7 +100,10 @@ BoxWithConstraints { Column( if (animate) { - Modifier.animateBounds(Modifier.fillMaxWidth()) + Modifier.animateBounds( + lookaheadScope = this@LookaheadScope, + Modifier.fillMaxWidth() + ) } else { Modifier.fillMaxWidth() }
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithFlowRowDemo.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithFlowRowDemo.kt index 6e8296d..d3dd741 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithFlowRowDemo.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithFlowRowDemo.kt
@@ -14,8 +14,12 @@ * limitations under the License. */ +@file:OptIn(ExperimentalSharedTransitionApi::class) + package androidx.compose.animation.demos.lookahead +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background @@ -76,17 +80,26 @@ ) { Box( Modifier.height(50.dp) - .animateBounds(Modifier.fillMaxWidth(if (isHorizontal) 0.4f else 1f)) + .animateBounds( + lookaheadScope = this@LookaheadScope, + Modifier.fillMaxWidth(if (isHorizontal) 0.4f else 1f) + ) .background(colors[0], RoundedCornerShape(10)) ) Box( Modifier.height(50.dp) - .animateBounds(Modifier.fillMaxWidth(if (isHorizontal) 0.2f else 0.4f)) + .animateBounds( + lookaheadScope = this@LookaheadScope, + Modifier.fillMaxWidth(if (isHorizontal) 0.2f else 0.4f) + ) .background(colors[1], RoundedCornerShape(10)) ) Box( Modifier.height(50.dp) - .animateBounds(Modifier.fillMaxWidth(if (isHorizontal) 0.2f else 0.4f)) + .animateBounds( + lookaheadScope = this@LookaheadScope, + Modifier.fillMaxWidth(if (isHorizontal) 0.2f else 0.4f) + ) .background(colors[2], RoundedCornerShape(10)) ) } @@ -136,12 +149,19 @@ var expanded by remember { mutableStateOf(false) } Box( modifier = - Modifier.animateBounds(Modifier.widthIn(max = 600.dp)).background(Color.Red) + Modifier.animateBounds( + lookaheadScope = this@LookaheadScope, + Modifier.widthIn(max = 600.dp) + ) + .background(Color.Red) ) { val height = animateDpAsState(targetValue = if (expanded) 500.dp else 300.dp) Box( modifier = - Modifier.animateBounds(Modifier.fillMaxWidth().height(height.value)) + Modifier.animateBounds( + lookaheadScope = this@LookaheadScope, + Modifier.fillMaxWidth().height(height.value) + ) .clickable { expanded = !expanded } ) } @@ -155,8 +175,8 @@ modifier = Modifier.size(200.dp) .animateBounds( + lookaheadScope = this@LookaheadScope, Modifier.wrapContentWidth().heightIn(min = 156.dp), - debug = true ) .background(Color.Blue) ) { @@ -166,8 +186,8 @@ modifier = Modifier.size(200.dp) .animateBounds( + lookaheadScope = this@LookaheadScope, Modifier.wrapContentWidth().heightIn(min = 156.dp), - debug = true ) .background(Color.Yellow) ) {
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithIntrinsicsDemo.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithIntrinsicsDemo.kt index c359741..a38e88b 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithIntrinsicsDemo.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithIntrinsicsDemo.kt
@@ -16,6 +16,8 @@ package androidx.compose.animation.demos.lookahead +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -43,6 +45,7 @@ import androidx.compose.ui.layout.LookaheadScope import androidx.compose.ui.unit.dp +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun LookaheadWithIntrinsicsDemo() { Column { @@ -64,6 +67,7 @@ ) { Box( Modifier.animateBounds( + lookaheadScope = this@LookaheadScope, if (isWide) Modifier.width(300.dp) else Modifier.width(150.dp) ) .height(50.dp)
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithLazyColumn.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithLazyColumn.kt index 6f6dc17b..0c0acba 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithLazyColumn.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithLazyColumn.kt
@@ -17,8 +17,11 @@ package androidx.compose.animation.demos.lookahead import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring import androidx.compose.animation.demos.R import androidx.compose.animation.demos.gesture.pastelColors @@ -47,6 +50,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LookaheadScope @@ -54,6 +58,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +@OptIn(ExperimentalSharedTransitionApi::class) @Preview @Composable fun LookaheadWithLazyColumn() { @@ -74,10 +79,7 @@ LookaheadScope { val title = remember { movableContentOf { - Text( - names[index], - Modifier.padding(20.dp).animateBounds(Modifier) - ) + Text(names[index], Modifier.padding(20.dp).animateBounds(this)) } } val image = remember { @@ -89,9 +91,16 @@ modifier = Modifier.padding(10.dp) .animateBounds( + this, if (expanded) Modifier.fillMaxWidth() else Modifier.size(80.dp), - spring(stiffness = Spring.StiffnessLow) + { _, _ -> + spring( + Spring.DampingRatioNoBouncy, + Spring.StiffnessLow, + Rect.VisibilityThreshold + ) + } ) .clip(RoundedCornerShape(5.dp)), contentScale = @@ -108,10 +117,17 @@ modifier = Modifier.padding(10.dp) .animateBounds( + lookaheadScope = this, if (expanded) Modifier.fillMaxWidth().aspectRatio(1f) else Modifier.size(80.dp), - spring(stiffness = Spring.StiffnessLow) + { _, _ -> + spring( + Spring.DampingRatioNoBouncy, + Spring.StiffnessLow, + Rect.VisibilityThreshold + ) + } ) .background( Color.LightGray,
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithMovableContentDemo.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithMovableContentDemo.kt index 2e82042..a02fc9a 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithMovableContentDemo.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithMovableContentDemo.kt
@@ -16,6 +16,8 @@ package androidx.compose.animation.demos.lookahead +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds import androidx.compose.animation.core.DeferredTargetAnimation import androidx.compose.animation.core.ExperimentalAnimatableApi import androidx.compose.animation.core.VectorConverter @@ -58,6 +60,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round +@OptIn(ExperimentalSharedTransitionApi::class) @Preview @Composable fun LookaheadWithMovableContentDemo() { @@ -91,7 +94,7 @@ Modifier.padding(15.dp) .height(80.dp) .fillMaxWidth(weight) - .animateBoundsInScope() + .animateBounds(lookaheadScope = this@movableContentWithReceiverOf) .background(color, RoundedCornerShape(20)), contentAlignment = Alignment.Center ) {
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithPopularBoxWithConstraintsUsage.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithPopularBoxWithConstraintsUsage.kt index 7181a1b..f38a073 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithPopularBoxWithConstraintsUsage.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithPopularBoxWithConstraintsUsage.kt
@@ -16,6 +16,8 @@ package androidx.compose.animation.demos.lookahead +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.demos.R import androidx.compose.animation.demos.gesture.pastelColors @@ -50,6 +52,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay +@OptIn(ExperimentalSharedTransitionApi::class) @Preview @Composable fun LookaheadWithPopularBoxWithConstraintsUsage() { @@ -67,7 +70,7 @@ LookaheadScope { Box( Modifier.fillMaxSize() - .animateBounds(Modifier.padding(padding)) + .animateBounds(this, Modifier.padding(padding)) .background(pastelColors[3]) ) { DetailsContent()
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithScaffold.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithScaffold.kt index c2ee5bd4..08f1c8a 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithScaffold.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithScaffold.kt
@@ -16,6 +16,8 @@ package androidx.compose.animation.demos.lookahead +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.TweenSpec import androidx.compose.foundation.background @@ -77,6 +79,7 @@ private val colors = listOf(Color(0xffff6f69), Color(0xffffcc5c), Color(0xff264653), Color(0xff2a9d84)) +@OptIn(ExperimentalSharedTransitionApi::class) @Preview @Composable fun LookaheadWithScaffold() { @@ -91,7 +94,10 @@ Box( Modifier.fillMaxHeight() .background(Color.Gray) - .animateBounds(if (hasPadding) Modifier.padding(bottom = 300.dp) else Modifier) + .animateBounds( + this@LookaheadScope, + if (hasPadding) Modifier.padding(bottom = 300.dp) else Modifier + ) ) { var state by remember { mutableIntStateOf(0) } val titles =
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithSubcompose.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithSubcompose.kt index 098f6a2..2ea3bd2 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithSubcompose.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithSubcompose.kt
@@ -16,6 +16,8 @@ package androidx.compose.animation.demos.lookahead +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -107,10 +109,11 @@ } context(LookaheadScope) +@OptIn(ExperimentalSharedTransitionApi::class) private fun Modifier.conditionallyAnimateBounds( shouldAnimate: Boolean, modifier: Modifier = Modifier -) = if (shouldAnimate) this.animateBounds(modifier) else this.then(modifier) +) = if (shouldAnimate) this.animateBounds(this@LookaheadScope, modifier) else this.then(modifier) private val colors = listOf(Color(0xffff6f69), Color(0xffffcc5c), Color(0xff2a9d84), Color(0xff264653))
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithTabRowDemo.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithTabRowDemo.kt index 07df45b..a83f31c 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithTabRowDemo.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithTabRowDemo.kt
@@ -16,6 +16,8 @@ package androidx.compose.animation.demos.lookahead +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -50,6 +52,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay +@OptIn(ExperimentalSharedTransitionApi::class) @Preview @Composable fun LookaheadWithTabRowDemo() { @@ -63,7 +66,10 @@ } Column( Modifier.fillMaxWidth() - .animateBounds(if (isWide) Modifier else Modifier.padding(end = 100.dp)) + .animateBounds( + this@LookaheadScope, + if (isWide) Modifier else Modifier.padding(end = 100.dp) + ) .fillMaxHeight() .background(Color(0xFFfffbd0)) ) {
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/SceneHostExperiment.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/SceneHostExperiment.kt index 1d877f5..1b94fc1 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/SceneHostExperiment.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/SceneHostExperiment.kt
@@ -19,6 +19,7 @@ import androidx.compose.animation.core.AnimationVector2D import androidx.compose.animation.core.DeferredTargetAnimation import androidx.compose.animation.core.ExperimentalAnimatableApi +import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.spring @@ -36,6 +37,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.approachLayout import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset @@ -43,6 +45,7 @@ import androidx.compose.ui.unit.round import androidx.compose.ui.unit.toOffset import androidx.compose.ui.unit.toSize +import kotlinx.coroutines.CoroutineScope @Composable fun SceneHost(modifier: Modifier = Modifier, content: @Composable SceneScope.() -> Unit) { @@ -149,3 +152,26 @@ } } } + +context(LookaheadScope, Placeable.PlacementScope, CoroutineScope) +@OptIn(ExperimentalAnimatableApi::class) +internal fun DeferredTargetAnimation<IntOffset, AnimationVector2D>.updateTargetBasedOnCoordinates( + animationSpec: FiniteAnimationSpec<IntOffset>, +): IntOffset { + coordinates?.let { coordinates -> + with(this@PlacementScope) { + val targetOffset = lookaheadScopeCoordinates.localLookaheadPositionOf(coordinates) + val animOffset = + updateTarget( + targetOffset.round(), + this@CoroutineScope, + animationSpec, + ) + val current = + lookaheadScopeCoordinates.localPositionOf(coordinates, Offset.Zero).round() + return (animOffset - current) + } + } + + return IntOffset.Zero +}
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/ScreenSizeChangeDemo.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/ScreenSizeChangeDemo.kt index 2717e49..497f9b2 100644 --- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/ScreenSizeChangeDemo.kt +++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/ScreenSizeChangeDemo.kt
@@ -16,6 +16,8 @@ package androidx.compose.animation.demos.lookahead +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -137,11 +139,13 @@ } } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun Root(state: DisplayState) { SceneHost { Row( Modifier.animateBounds( + this, if (state == DisplayState.Compact) { Modifier.wrapContentSize(align = Alignment.TopStart, unbounded = true) .requiredWidth(800.dp) @@ -322,10 +326,12 @@ } } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun SceneScope.NavRail(state: DisplayState) { Column( Modifier.animateBounds( + this, if (state == DisplayState.Tablet) Modifier.width(200.dp) else Modifier.width(IntrinsicSize.Min) )
diff --git a/compose/animation/animation/samples/build.gradle b/compose/animation/animation/samples/build.gradle index a3be858..66c33e58 100644 --- a/compose/animation/animation/samples/build.gradle +++ b/compose/animation/animation/samples/build.gradle
@@ -36,11 +36,11 @@ compileOnly(project(":annotation:annotation-sampled")) implementation(project(":compose:animation:animation")) - implementation("androidx.compose.foundation:foundation:1.2.1") - implementation("androidx.compose.material:material:1.2.1") - implementation("androidx.compose.material:material-icons-core:1.6.7") - implementation("androidx.compose.runtime:runtime:1.2.1") - implementation("androidx.compose.ui:ui-text:1.2.1") + implementation("androidx.compose.foundation:foundation:1.6.8") + implementation("androidx.compose.material:material:1.6.8") + implementation("androidx.compose.material:material-icons-core:1.6.8") + implementation("androidx.compose.runtime:runtime:1.6.8") + implementation("androidx.compose.ui:ui-text:1.6.8") } androidx {
diff --git a/compose/animation/animation/samples/src/main/java/androidx/compose/animation/samples/AnimateBoundsModifierSample.kt b/compose/animation/animation/samples/src/main/java/androidx/compose/animation/samples/AnimateBoundsModifierSample.kt new file mode 100644 index 0000000..70dbcfa --- /dev/null +++ b/compose/animation/animation/samples/src/main/java/androidx/compose/animation/samples/AnimateBoundsModifierSample.kt
@@ -0,0 +1,351 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * 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 androidx.compose.animation.samples + +import androidx.annotation.Sampled +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.keyframesWithSpline +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentWithReceiverOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round +import androidx.compose.ui.util.fastForEach + +@OptIn(ExperimentalSharedTransitionApi::class) +@Sampled +@Composable +private fun AnimateBounds_animateOnContentChange() { + // Example where the change in content triggers the layout change on the item with animateBounds + val textShort = remember { "Foo ".repeat(10) } + val textLong = remember { "Bar ".repeat(50) } + + var toggle by remember { mutableStateOf(true) } + + LookaheadScope { + Box( + modifier = Modifier.fillMaxSize().clickable { toggle = !toggle }, + contentAlignment = Alignment.Center + ) { + Text( + text = if (toggle) textShort else textLong, + modifier = + Modifier.fillMaxWidth(0.7f) + .background(Color.LightGray) + .animateBounds(this@LookaheadScope) + .padding(10.dp), + ) + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Sampled +@Composable +private fun AnimateBounds_withLayoutModifier() { + // Example showing the difference between providing a Layout Modifier as a parameter of + // `animateBounds` and chaining the Layout Modifier. + + // We use `padding` in this example, as it provides an immediate change in layout to its child, + // but not the parent, which sees the same resulting layout. The difference can be seen in the + // Text (content under padding) and an accompanying Cyan Box (a sibling, under the same Row + // parent). + LookaheadScope { + val boundsTransform = remember { + BoundsTransform { _, _ -> + spring(stiffness = 50f, visibilityThreshold = Rect.VisibilityThreshold) + } + } + + var toggleAnimation by remember { mutableStateOf(true) } + + Column(Modifier.clickable { toggleAnimation = !toggleAnimation }) { + Text( + "See the difference in animation when the Layout Modifier is a parameter of animateBounds. Padding, in this example." + ) + Spacer(Modifier.height(12.dp)) + Text("Layout Modifier as a parameter.") + Row(Modifier.fillMaxWidth()) { + Box( + Modifier.animateBounds( + lookaheadScope = this@LookaheadScope, + modifier = + // By providing this Modifier as a parameter of `animateBounds`, + // both content and parent see a gradual/animated change in Layout. + Modifier.padding( + horizontal = if (toggleAnimation) 10.dp else 50.dp + ), + boundsTransform = boundsTransform + ) + .background(Color.Red, RoundedCornerShape(12.dp)) + .height(50.dp) + ) { + Text("Layout Content", Modifier.align(Alignment.Center)) + } + Box(Modifier.size(50.dp).background(Color.Cyan, RoundedCornerShape(12.dp))) + } + Spacer(Modifier.height(12.dp)) + Text("Layout Modifier after AnimateBounds.") + Row(Modifier.fillMaxWidth()) { + Box( + Modifier.animateBounds( + lookaheadScope = this@LookaheadScope, + boundsTransform = boundsTransform + ) + // The content is able to animate the change in padding, but since the + // parent Layout sees no difference, the change in position is immediate. + .padding(horizontal = if (toggleAnimation) 10.dp else 50.dp) + .background(Color.Red, RoundedCornerShape(12.dp)) + .height(50.dp) + ) { + Text("Layout Content", Modifier.align(Alignment.Center)) + } + Box(Modifier.size(50.dp).background(Color.Cyan, RoundedCornerShape(12.dp))) + } + Spacer(Modifier.height(12.dp)) + Text("Layout Modifier before AnimateBounds.") + Row(Modifier.fillMaxWidth()) { + Box( + Modifier + // The parent is able to see the change in position and the animated size, + // so it can smoothly place both its children, but the content of the Box + // cannot see the gradual changes so it remains constant. + .padding(horizontal = if (toggleAnimation) 10.dp else 50.dp) + .animateBounds( + lookaheadScope = this@LookaheadScope, + boundsTransform = boundsTransform + ) + .background(Color.Red, RoundedCornerShape(12.dp)) + .height(50.dp) + ) { + Text("Layout Content", Modifier.align(Alignment.Center)) + } + Box(Modifier.size(50.dp).background(Color.Cyan, RoundedCornerShape(12.dp))) + } + } + } +} + +@OptIn( + ExperimentalLayoutApi::class, + ExperimentalSharedTransitionApi::class, +) +@Sampled +@Composable +private fun AnimateBounds_inFlowRowSample() { + var itemRowCount by remember { mutableIntStateOf(1) } + val colors = remember { listOf(Color.Cyan, Color.Magenta, Color.Yellow, Color.Green) } + + // A case showing `animateBounds` being used to animate layout changes driven by a parent Layout + LookaheadScope { + Column(Modifier.clickable { itemRowCount = if (itemRowCount != 2) 2 else 1 }) { + Text("Click to toggle animation.") + FlowRow( + modifier = + Modifier.fillMaxWidth() + // Note that the wrap content size changes for FlowRow as the content + // adjusts + // to one or two lines, we can simply use `animateContentSize()` to make + // sure + // all items are visible during their animation. + .animateContentSize(), + // Try changing the arrangement as well! + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + // We use the maxItems parameter to change the layout of the FlowRow at different + // states + maxItemsInEachRow = itemRowCount + ) { + colors.fastForEach { + Box( + Modifier.animateBounds(this@LookaheadScope) + // Note the modifier order, we declare the background after + // `animateBounds` to make sure it animates with the rest of the content + .background(it, RoundedCornerShape(12.dp)) + .weight(weight = 1f, fill = true) + .height(100.dp) + ) + } + } + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Sampled +@Composable +private fun AnimateBounds_usingKeyframes() { + var toggle by remember { mutableStateOf(true) } + + // Example using BoundsTransform to calculate an animation using keyframes with splines. + LookaheadScope { + Box(Modifier.fillMaxSize().clickable { toggle = !toggle }) { + Text( + text = "Hello, World!", + textAlign = TextAlign.Center, + modifier = + Modifier.align(if (toggle) Alignment.TopStart else Alignment.TopEnd) + .animateBounds( + lookaheadScope = this@LookaheadScope, + boundsTransform = { initialBounds, targetBounds -> + // We'll use a keyframe to emphasize the animation in position and + // size. + keyframesWithSpline { + durationMillis = 1200 + + // Emphasize with an increase in size + val size = targetBounds.size.times(2f) + + // Emphasize the path with a slight curve at the halfway point + val position = + targetBounds.topLeft + .plus(initialBounds.topLeft) + .times(0.5f) + .plus( + Offset( + // Consider the increase in size (from the + // center, + // to keep the Layout aligned at the keyframe) + x = -(size.width - targetBounds.width) * 0.5f, + // Emphasize the path with a vertical offset + y = size.height * 0.5f + ) + ) + + // Only need to define the intermediate keyframe, initial and + // target are implicit. + Rect(position, size).atFraction(0.5f).using(LinearEasing) + } + } + ) + .background(Color.LightGray, RoundedCornerShape(50)) + .padding(10.dp) + // Text is laid out with the animated fixed Constraints, relax constraints + // back to wrap content to be able to center Align vertically. + .wrapContentSize(Alignment.Center) + ) + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Sampled +@Composable +private fun AnimateBounds_withMovableContent() { + // Example showing how to animate a Layout that can be presented on different Layout Composables + // as the state changes using `movableContent`. + var position by remember { mutableIntStateOf(-1) } + + val movableContent = remember { + // To animate a Layout that can be presented in different Composables, we can use + // `animateBounds` with `movableContent`. + movableContentWithReceiverOf<LookaheadScope> { + Box( + Modifier.animateBounds( + lookaheadScope = this@movableContentWithReceiverOf, + boundsTransform = { _, _ -> + spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessVeryLow, + visibilityThreshold = Rect.VisibilityThreshold + ) + } + ) + // Our movableContent can always fill its container in this example. + .fillMaxSize() + .background(Color.Cyan, RoundedCornerShape(8.dp)) + ) + } + } + + LookaheadScope { + Box(Modifier.fillMaxSize()) { + // Initial container of our Layout, at the center of the screen. + Box( + Modifier.size(200.dp) + .border(3.dp, Color.Red, RoundedCornerShape(8.dp)) + .align(Alignment.Center) + .clickable { position = -1 } + ) { + if (position < 0) { + movableContent() + } + } + + repeat(4) { index -> + // Four additional Boxes where our content may be move to. + Box( + Modifier.size(100.dp) + .border(2.dp, Color.Blue, RoundedCornerShape(8.dp)) + .align { size, space, _ -> + val horizontal = if (index % 2 == 0) 0.15f else 0.85f + val vertical = if (index < 2) 0.15f else 0.85f + + Offset( + x = (space.width - size.width) * horizontal, + y = (space.height - size.height) * vertical + ) + .round() + } + .clickable { position = index } + ) { + if (position == index) { + // The call to movable content will trigger `Modifier.animateBounds()` to + // animate the content's position and size from its previous state. + movableContent() + } + } + } + } + } +}
diff --git a/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimateBoundsTest.kt b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimateBoundsTest.kt new file mode 100644 index 0000000..6159e8d --- /dev/null +++ b/compose/animation/animation/src/androidInstrumentedTest/kotlin/androidx/compose/animation/AnimateBoundsTest.kt
@@ -0,0 +1,559 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * 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 androidx.compose.animation + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentWithReceiverOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.lerp +import androidx.compose.ui.unit.round +import androidx.compose.ui.util.fastRoundToInt +import androidx.compose.ui.util.lerp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import kotlin.math.roundToInt +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalSharedTransitionApi::class) +@RunWith(AndroidJUnit4::class) +@MediumTest +class AnimateBoundsTest { + + @get:Rule val rule = createComposeRule() + + @Test + fun animatePosition() = + with(rule.density) { + val frames = 14 // Even number to reliably test at half duration + val durationMillis = frames * 16 + val rootSizePx = 100 + val boxSizePX = 20 + + var boxPosition = IntOffset.Zero + + var isAtStart by mutableStateOf(true) + + rule.setContent { + Box(modifier = Modifier.size(rootSizePx.toDp())) { + LookaheadScope { + Box( + modifier = + Modifier.align( + if (isAtStart) Alignment.TopStart else Alignment.BottomEnd + ) + .size(boxSizePX.toDp()) + .animateBounds( + lookaheadScope = this@LookaheadScope, + boundsTransform = { _, _ -> + tween(durationMillis, easing = LinearEasing) + } + ) + .drawBehind { drawRect(Color.LightGray) } + .onGloballyPositioned { + boxPosition = it.positionInParent().round() + } + ) + } + } + } + rule.waitForIdle() + + // At TopStart (0, 0) + assertEquals(IntOffset.Zero, boxPosition) + + // AutoAdvance off to test animation at different points + rule.mainClock.autoAdvance = false + isAtStart = false + rule.waitForIdle() + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + + // Advance to the middle of the animation + rule.mainClock.advanceTimeBy(durationMillis / 2L) + + val expectedPosPx = (rootSizePx - boxSizePX) * 0.5f + val expectedIntOffset = Offset(expectedPosPx, expectedPosPx).round() + assertEquals(expectedIntOffset, boxPosition) + + // AutoAdvance ON to finish the animation + rule.mainClock.autoAdvance = true + rule.waitForIdle() + + // At BottomEnd (parentSize - boxSize, parentSize - boxSize) + val expectedFinalPos = rootSizePx - boxSizePX + assertEquals(IntOffset(expectedFinalPos, expectedFinalPos), boxPosition) + } + + @Test + fun animateSize() = + with(rule.density) { + val frames = 14 // Even number to reliable test at half duration + val durationMillis = frames * 16 + val rootSizePx = 400 + val boxSizeSmallPx = rootSizePx * 0.25f + val boxSizeLargePx = rootSizePx * 0.5f + + val expectedLargeSize = Size(boxSizeLargePx, boxSizeLargePx) + val expectedSmallSize = Size(boxSizeSmallPx, boxSizeSmallPx) + + var boxSize = IntSize.Zero + + var isExpanded by mutableStateOf(false) + + rule.setContent { + Box(Modifier.size(rootSizePx.toDp())) { + LookaheadScope { + Box( + Modifier.size( + if (isExpanded) boxSizeLargePx.toDp() else boxSizeSmallPx.toDp() + ) + .animateBounds( + lookaheadScope = this, + boundsTransform = { _, _ -> + tween( + durationMillis = durationMillis, + easing = LinearEasing + ) + } + ) + .drawBehind { drawRect(Color.LightGray) } + .onGloballyPositioned { boxSize = it.size } + ) + } + } + } + rule.waitForIdle() + assertEquals(expectedSmallSize.round(), boxSize) + + // AutoAdvance off to test animation at different points + rule.mainClock.autoAdvance = false + isExpanded = true + rule.waitForIdle() + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + + // 1-frame latency. TODO: Can we fix this? + rule.mainClock.advanceTimeByFrame() + + // Advance to approx. the middle of the animation + rule.mainClock.advanceTimeBy(durationMillis / 2L) + + val expectedMidIntSize = (expectedLargeSize + expectedSmallSize).times(0.5f).round() + assertEquals(expectedMidIntSize, boxSize) + + // AutoAdvance ON to finish the animation + rule.mainClock.autoAdvance = true + rule.waitForIdle() + + assertEquals(expectedLargeSize.round(), boxSize) + } + + @Test + fun animateBounds() = + with(rule.density) { + val frames = 14 // Even number to reliable test at half duration + val durationMillis = frames * 16 + val rootSizePx = 400 + val boxSizeSmallPx = rootSizePx * 0.25f + val boxSizeLargePx = rootSizePx * 0.5f + + val expectedLargeSize = Size(boxSizeLargePx, boxSizeLargePx) + val expectedSmallSize = Size(boxSizeSmallPx, boxSizeSmallPx) + val expectedFinalPos = rootSizePx - boxSizeLargePx + + var boxBounds = Rect(Offset.Zero, Size.Zero) + + var toggle by mutableStateOf(false) + + rule.setContent { + Box(Modifier.size(rootSizePx.toDp())) { + LookaheadScope { + Box( + Modifier.then( + if (toggle) { + Modifier.align(Alignment.BottomEnd) + .size(boxSizeLargePx.toDp()) + } else { + Modifier.align(Alignment.TopStart) + .size(boxSizeSmallPx.toDp()) + } + ) + .animateBounds( + lookaheadScope = this, + boundsTransform = { _, _ -> + tween( + durationMillis = durationMillis, + easing = LinearEasing + ) + } + ) + .drawBehind { drawRect(Color.Yellow) } + .onGloballyPositioned { boxBounds = it.boundsInParent() } + ) + } + } + } + rule.waitForIdle() + assertEquals(Rect(Offset.Zero, expectedSmallSize), boxBounds) + + // AutoAdvance off to test animation at different points + rule.mainClock.autoAdvance = false + toggle = true + rule.waitForIdle() + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + + // Advance to the middle of the animation + rule.mainClock.advanceTimeBy(durationMillis / 2L) + rule.waitForIdle() + + // Calculate expected bounds + val expectedMidSize = (expectedLargeSize + expectedSmallSize).times(0.5f) + val expectedMidPosition = (rootSizePx - boxSizeLargePx) * 0.5f + val expectedMidOffset = Offset(expectedMidPosition, expectedMidPosition) + val expectedMidBounds = Rect(expectedMidOffset, expectedMidSize) + + assertEquals(expectedMidBounds, boxBounds) + + // AutoAdvance ON to finish the animation + rule.mainClock.autoAdvance = true + rule.waitForIdle() + + assertEquals( + Rect(Offset(expectedFinalPos, expectedFinalPos), expectedLargeSize), + boxBounds + ) + } + + @Test + fun animateBounds_withIntermediateModifier() = + with(rule.density) { + val durationMillis = 10 * 16 + + var toggleAnimation by mutableStateOf(true) + + val rootWidthPx = 100 + val padding1Px = 10 + val padding2Px = 20 + + var positionA = IntOffset(-1, -1) + var positionB = IntOffset(-1, 1) + + // Change the padding on state change to trigger the animation + fun Modifier.applyPadding(): Modifier = + this.padding( + horizontal = + if (toggleAnimation) { + padding1Px.toDp() + } else { + padding2Px.toDp() + } + ) + + rule.setContent { + // Based on sample `AnimateBounds_withLayoutModifier` + LookaheadScope { + Column(Modifier.width(rootWidthPx.toDp())) { + Row(Modifier.fillMaxWidth()) { + Box( + Modifier.animateBounds( + lookaheadScope = this@LookaheadScope, + modifier = Modifier.applyPadding(), + boundsTransform = { _, _, + -> + tween(durationMillis, easing = LinearEasing) + } + ) + ) { + Box( + Modifier.onGloballyPositioned { + positionA = it.positionInRoot().round() + } + ) + } + } + Row(Modifier.fillMaxWidth()) { + Box( + Modifier.animateBounds( + lookaheadScope = this@LookaheadScope, + boundsTransform = { _, _, + -> + tween(durationMillis, easing = LinearEasing) + } + ) + .applyPadding() + ) { + Box( + Modifier.onGloballyPositioned { + positionB = it.positionInRoot().round() + } + ) + } + } + } + } + } + rule.waitForIdle() + + assertEquals(positionA, IntOffset(padding1Px, 0)) + assertEquals(positionB, IntOffset(padding1Px, 0)) + + rule.mainClock.autoAdvance = false + toggleAnimation = !toggleAnimation + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + + // We measure at the first animated frame + rule.mainClock.advanceTimeByFrame() + + // Box A has a continuous change in value from having the Modifier as a parameter + val expectedPosA = + lerp(padding1Px.toFloat(), padding2Px.toFloat(), 16f / durationMillis) + assertEquals(positionA, IntOffset(expectedPosA.fastRoundToInt(), 0)) + // Box B has an immediate change in value from chaining the Modifier + assertEquals(positionB, IntOffset(padding2Px, 0)) + } + + @Test + fun animateBounds_usingMovableContent() = + with(rule.density) { + val frames = 14 // Even number to reliable test at half duration + val durationMillis = frames * 16 + + val itemASizePx = 30 + val itemAOffset = IntOffset(70, 70) + + val itemBSizePx = 50 + val itemBOffset = IntOffset(110, 110) + + var isBoxAtSlotA by mutableStateOf(true) + + var boxPosition = IntOffset.Zero + var boxSize = IntSize.Zero + + rule.setContent { + val movableBox = remember { + movableContentWithReceiverOf<LookaheadScope> { + Box( + modifier = + Modifier.fillMaxSize() + .animateBounds( + lookaheadScope = this, + boundsTransform = { _, _ -> + tween( + durationMillis = durationMillis, + easing = LinearEasing + ) + } + ) + .onGloballyPositioned { + boxPosition = it.positionInRoot().round() + boxSize = it.size + } + ) + } + } + + LookaheadScope { + Box { + Box(Modifier.offset { itemAOffset }.size(itemASizePx.toDp())) { + // Slot A + if (isBoxAtSlotA) { + movableBox() + } + } + Box(Modifier.offset { itemBOffset }.size(itemBSizePx.toDp())) { + // Slot B + if (!isBoxAtSlotA) { + movableBox() + } + } + } + } + } + rule.waitForIdle() + + // Initial conditions + assertEquals(itemAOffset, boxPosition) + assertEquals(IntSize(itemASizePx, itemASizePx), boxSize) + + // AutoAdvance off to test animation at different points + rule.mainClock.autoAdvance = false + isBoxAtSlotA = false + rule.waitForIdle() + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + + // Advance to the middle of the animation + rule.mainClock.advanceTimeBy(durationMillis / 2L) + rule.waitForIdle() + + // Evaluate with expected values at half the animation + val sizeAtHalfDuration = (itemASizePx + itemBSizePx) / 2 + assertEquals((itemAOffset + itemBOffset).div(2f), boxPosition) + assertEquals(IntSize(sizeAtHalfDuration, sizeAtHalfDuration), boxSize) + + // AutoAdvance ON to finish the animation + rule.mainClock.autoAdvance = true + rule.waitForIdle() + + assertEquals(itemBOffset, boxPosition) + assertEquals(IntSize(itemBSizePx, itemBSizePx), boxSize) + } + + @Test + fun animateBounds_scrollBehavior() = + with(rule.density) { + val itemSizePx = 30f + val keyFrameOffset = itemSizePx * 5 + + var isAnimateScroll by mutableStateOf(false) + val scrollState = ScrollState(0) + + var item0Position = IntOffset(-1, -1) + + rule.setContent { + LookaheadScope { + Column(Modifier.size(itemSizePx.toDp()).verticalScroll(scrollState)) { + repeat(2) { index -> + Box( + modifier = + Modifier.size(itemSizePx.toDp()) + .animateBounds( + lookaheadScope = this@LookaheadScope, + boundsTransform = { initial, _ -> + // Drive the start position to a specific value, by + // default + // the animation should not happen, and so we should + // never + // be able to read that value. + keyframes { + Rect(Offset(0f, keyFrameOffset), initial.size) + .at(0) + .using(LinearEasing) + } + }, + animateMotionFrameOfReference = isAnimateScroll + ) + .onGloballyPositioned { + if (index == 0) { + item0Position = it.positionInRoot().round() + } + } + ) + } + } + } + } + // First test without animating scroll, note that we still handle the clock, as to allow + // any animation to play after we change the scroll. + rule.waitForIdle() + rule.mainClock.autoAdvance = false + + runBlocking { scrollState.scrollBy(itemSizePx) } + + // Let animations play for the first frame + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + + // Expected position should immediately reflect scroll changes since we are not + // animating it + assertEquals(IntOffset(0, -itemSizePx.fastRoundToInt()), item0Position) + + // Finish any pending animations + rule.mainClock.autoAdvance = true + rule.waitForIdle() + + // Enable scroll animation + isAnimateScroll = true + rule.waitForIdle() + rule.mainClock.autoAdvance = false + + // Not sure why, but we need to run this scroll within a runOnIdle to complete the test + // consistently across devices. + rule.runOnIdle { + runBlocking { + // Scroll back into starting position + scrollState.scrollBy(-itemSizePx) + } + } + + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + + // Position should correspond to the exaggerated keyframe offset. + // Note that the keyframe is actually defined around the item's center + assertEquals( + Offset( + // Center position at x = 0 + x = 0f, + // keyframeOffset - (previousScrollOffset) + itemCenterY + y = keyFrameOffset + ) + .round(), + item0Position + ) + } + + private fun Size.round(): IntSize = IntSize(width.roundToInt(), height.roundToInt()) + + private operator fun Size.plus(other: Size) = Size(width + other.width, height + other.height) + + private operator fun Size.minus(other: Size) = Size(width - other.width, height - other.height) + + private operator fun IntSize.minus(other: IntSize) = + IntSize(width - other.width, height - other.height) + + private operator fun Rect.minus(other: Rect) = + Rect(offset = this.topLeft - other.topLeft, size = this.size - other.size) +}
diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimateBoundsModifier.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimateBoundsModifier.kt new file mode 100644 index 0000000..388b244 --- /dev/null +++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/AnimateBoundsModifier.kt
@@ -0,0 +1,428 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * 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 androidx.compose.animation + +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 + +/** + * [Modifier] to animate layout changes (position and/or size) that occur within a [LookaheadScope]. + * + * So, the given [lookaheadScope] defines the coordinate space considered to trigger an animation. + * For example, if [lookaheadScope] was defined at the root of the app hierarchy, then any layout + * changes visible within the screen will trigger an animation, if it, in contrast was defined + * within a scrolling parent, then, as long the [LookaheadScope] scrolls with is content, no + * animation will be triggered, as there will be no changes within its coordinate space. + * + * The animation is driven with a [FiniteAnimationSpec] produced by the given [BoundsTransform] + * function, which you may use to customize the animations based on the initial and target bounds. + * + * Do note that certain Layout Modifiers when chained with [animateBounds], may only cause an + * immediate observable change to either the child or the parent Layout which can result in + * undesired behavior. For those cases you can instead provide it to the [modifier] parameter. This + * allows [animateBounds] to envelop the size and constraints change and propagate them gradually to + * both its parent and child Layout. + * + * You may see the difference when supplying a Layout Modifier in [modifier] on the following + * example: + * + * @sample androidx.compose.animation.samples.AnimateBounds_withLayoutModifier + * + * By default, changes in position under [LayoutCoordinates.introducesMotionFrameOfReference] are + * excluded from the animation and are instead immediately applied, as they are expected to be + * frequent/continuous (to handle Layouts under Scroll). You may change this behavior by passing + * [animateMotionFrameOfReference] as `true`. Keep in mind, doing that under a scroll may result in + * the Layout "chasing" the scroll offset, as it will constantly animate to the latest position. + * + * A basic use-case is animating a layout based on content changes, such as the String changing on a + * Text: + * + * @sample androidx.compose.animation.samples.AnimateBounds_animateOnContentChange + * + * It also provides an easy way to animate layout changes of a complex Composable Layout: + * + * @sample androidx.compose.animation.samples.AnimateBounds_inFlowRowSample + * + * Since [BoundsTransform] is called when initiating an animation, you may also use it to calculate + * a keyframe based animation: + * + * @sample androidx.compose.animation.samples.AnimateBounds_usingKeyframes + * + * It may also be used together with [movableContent][androidx.compose.runtime.movableContentOf] as + * long as the given [LookaheadScope] is in a common place within the Layout hierarchy of the slots + * presenting the `movableContent`: + * + * @sample androidx.compose.animation.samples.AnimateBounds_withMovableContent + * @param lookaheadScope The scope from which this [animateBounds] will calculate its animations + * from. This implies that as long as you're expecting an animation the reference of the given + * [LookaheadScope] shouldn't change, otherwise you may get unexpected behavior. + * @param modifier Optional intermediate Modifier, may be used in cases where otherwise immediate + * layout changes are perceived as gradual by both the parent and child Layout. + * @param boundsTransform Produce a customized [FiniteAnimationSpec] based on the initial and target + * bounds, called when an animation is triggered. + * @param animateMotionFrameOfReference When `true`, changes under + * [LayoutCoordinates.introducesMotionFrameOfReference] (for continuous positional changes, such + * as Scroll Offset) are included when calculating an animation. `false` by default, where the + * changes are instead applied directly into the layout without triggering an animation. + * @see ApproachLayoutModifierNode + * @see LookaheadScope + */ +@ExperimentalSharedTransitionApi // Depends on BoundsTransform +public 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<BoundsAnimationModifierNode>() { + 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<Rect, AnimationVector4D>? = 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<LayoutCoordinates>? = 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 + ) +}