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 c2ee5bd..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
+ )
+}