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