From bb979cb52d826134de2fcc96898b0c692c597cdf Mon Sep 17 00:00:00 2001
From: Artem Chepurnoy <artemchep@gmail.com>
Date: Sat, 28 Dec 2024 17:22:37 +0200
Subject: [PATCH] ui: Only animate height where possible to smooth out
 animation when you are resizing the window

---
 .../attachments/compose/ItemAttachment.kt     |   1 -
 .../feature/auth/login/LoginScreen.kt         |   4 -
 .../feature/barcodetype/BarcodeTypeScreen.kt  |   4 +-
 .../confirmation/ConfirmationScreen.kt        |   3 -
 .../OrganizationConfirmationScreen.kt         |   1 -
 .../feature/generator/GeneratorScreen.kt      |  34 ++-
 .../home/vault/component/VaultViewInfoItem.kt |   5 +-
 .../home/vault/component/VaultViewTotpItem.kt |   3 -
 .../vault/component/VaultViewValueItem.kt     |  48 ++--
 .../keyguard/feature/sync/SyncScreen.kt       |   1 -
 .../artemchep/keyguard/ui/AutofillWindow.kt   |   4 +-
 .../ui/animation/ContentSizeAnimation.kt      | 215 ++++++++++++++++++
 12 files changed, 259 insertions(+), 64 deletions(-)
 create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/ui/animation/ContentSizeAnimation.kt

diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/compose/ItemAttachment.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/compose/ItemAttachment.kt
index 5825adbd..362ca661 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/compose/ItemAttachment.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/compose/ItemAttachment.kt
@@ -2,7 +2,6 @@ package com.artemchep.keyguard.feature.attachments.compose
 
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.Crossfade
-import androidx.compose.animation.ExperimentalAnimationApi
 import androidx.compose.animation.animateContentSize
 import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.animation.fadeIn
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/auth/login/LoginScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/auth/login/LoginScreen.kt
index 727cabdb..db244fd4 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/auth/login/LoginScreen.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/auth/login/LoginScreen.kt
@@ -7,7 +7,6 @@ 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.RowScope
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
@@ -21,7 +20,6 @@ import androidx.compose.foundation.text.KeyboardOptions
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.outlined.Login
 import androidx.compose.material.icons.outlined.Add
-import androidx.compose.material.icons.outlined.AppRegistration
 import androidx.compose.material.icons.outlined.Security
 import androidx.compose.material3.CircularProgressIndicator
 import androidx.compose.material3.DropdownMenu
@@ -31,7 +29,6 @@ import androidx.compose.material3.Icon
 import androidx.compose.material3.LocalContentColor
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
@@ -86,7 +83,6 @@ import com.artemchep.keyguard.ui.UrlFlatTextField
 import com.artemchep.keyguard.ui.icons.ChevronIcon
 import com.artemchep.keyguard.ui.icons.DropdownIcon
 import com.artemchep.keyguard.ui.icons.IconBox
-import com.artemchep.keyguard.ui.icons.icon
 import com.artemchep.keyguard.ui.skeleton.SkeletonText
 import com.artemchep.keyguard.ui.skeleton.SkeletonTextField
 import com.artemchep.keyguard.ui.theme.Dimens
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/barcodetype/BarcodeTypeScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/barcodetype/BarcodeTypeScreen.kt
index 325489ff..7460ba97 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/barcodetype/BarcodeTypeScreen.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/barcodetype/BarcodeTypeScreen.kt
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.outlined.QrCode
-import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
@@ -44,6 +43,7 @@ import com.artemchep.keyguard.res.*
 import com.artemchep.keyguard.ui.FlatDropdown
 import com.artemchep.keyguard.ui.FlatItemTextContent
 import com.artemchep.keyguard.ui.KeepScreenOnEffect
+import com.artemchep.keyguard.ui.animation.animateContentHeight
 import com.artemchep.keyguard.ui.icons.DropdownIcon
 import com.artemchep.keyguard.ui.icons.icon
 import org.jetbrains.compose.resources.stringResource
@@ -120,7 +120,7 @@ private fun BarcodeTypeContent(
                     BarcodeImage(
                         modifier = Modifier
                             .fillMaxWidth()
-                            .animateContentSize(),
+                            .animateContentHeight(),
                         imageModel = {
                             imageRequest
                                 ?.copy(
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationScreen.kt
index 9da8b5b5..db49225b 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationScreen.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/ConfirmationScreen.kt
@@ -2,7 +2,6 @@ package com.artemchep.keyguard.feature.confirmation
 
 import androidx.compose.animation.animateColorAsState
 import androidx.compose.animation.animateContentSize
-import androidx.compose.animation.core.animateSizeAsState
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -42,7 +41,6 @@ import com.artemchep.keyguard.feature.auth.common.VisibilityState
 import com.artemchep.keyguard.feature.auth.common.VisibilityToggle
 import com.artemchep.keyguard.feature.dialog.Dialog
 import com.artemchep.keyguard.feature.filepicker.FilePickerEffect
-import com.artemchep.keyguard.feature.filepicker.humanReadableByteCountBin
 import com.artemchep.keyguard.feature.filepicker.humanReadableByteCountSI
 import com.artemchep.keyguard.feature.navigation.LocalNavigationController
 import com.artemchep.keyguard.feature.navigation.NavigationIntent
@@ -57,7 +55,6 @@ import com.artemchep.keyguard.ui.FlatItemLayout
 import com.artemchep.keyguard.ui.FlatTextField
 import com.artemchep.keyguard.ui.MediumEmphasisAlpha
 import com.artemchep.keyguard.ui.PasswordStrengthBadge
-import com.artemchep.keyguard.ui.icons.IconBox
 import com.artemchep.keyguard.ui.skeleton.SkeletonItem
 import com.artemchep.keyguard.ui.theme.Dimens
 import com.artemchep.keyguard.ui.theme.combineAlpha
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationScreen.kt
index 711a2940..d0a734e2 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationScreen.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationScreen.kt
@@ -1,7 +1,6 @@
 package com.artemchep.keyguard.feature.confirmation.organization
 
 import androidx.compose.animation.animateContentSize
-import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/GeneratorScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/GeneratorScreen.kt
index 2f8275f2..c4d49088 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/GeneratorScreen.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/GeneratorScreen.kt
@@ -106,6 +106,7 @@ import com.artemchep.keyguard.ui.MediumEmphasisAlpha
 import com.artemchep.keyguard.ui.OptionsButton
 import com.artemchep.keyguard.ui.PasswordStrengthBadge
 import com.artemchep.keyguard.ui.ScaffoldColumn
+import com.artemchep.keyguard.ui.animation.animateContentHeight
 import com.artemchep.keyguard.ui.collectIsInteractedWith
 import com.artemchep.keyguard.ui.colorizePassword
 import com.artemchep.keyguard.ui.icons.DropdownIcon
@@ -556,7 +557,7 @@ fun ColumnScope.GeneratorValue(
                 Crossfade(
                     targetState = value.password,
                     modifier = Modifier
-                        .animateContentSize(),
+                        .animateContentHeight(),
                 ) { password ->
                     AutoResizeText(
                         text = colorizePasswordOrEmpty(password),
@@ -1189,23 +1190,18 @@ fun AutoResizeText(
     minFontSize: Float = 14f,
     maxLines: Int = Int.MAX_VALUE,
 ) {
-    Box(
-        modifier = modifier,
-    ) {
-        val fontSize = kotlin.run {
-            val ratio = 1f - (text.length / 128f)
-                .coerceAtMost(1f)
-            minFontSize + (maxFontSize - minFontSize) * ratio
-        }
-        Text(
-            modifier = Modifier
-                .animateContentSize(),
-            text = text,
-            fontFamily = monoFontFamily,
-            fontSize = fontSize.sp,
-            maxLines = maxLines,
-            overflow = TextOverflow.Ellipsis,
-            style = MaterialTheme.typography.titleLarge,
-        )
+    val fontSize = run {
+        val ratio = 1f - (text.length / 128f)
+            .coerceAtMost(1f)
+        minFontSize + (maxFontSize - minFontSize) * ratio
     }
+    Text(
+        modifier = modifier,
+        text = text,
+        fontFamily = monoFontFamily,
+        fontSize = fontSize.sp,
+        maxLines = maxLines,
+        overflow = TextOverflow.Ellipsis,
+        style = MaterialTheme.typography.titleLarge,
+    )
 }
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewInfoItem.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewInfoItem.kt
index f518ffc0..7488c056 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewInfoItem.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewInfoItem.kt
@@ -1,7 +1,6 @@
 package com.artemchep.keyguard.feature.home.vault.component
 
 import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.animateContentSize
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 import androidx.compose.foundation.background
@@ -19,7 +18,6 @@ import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.outlined.ArrowDownward
-import androidx.compose.material.icons.outlined.ArrowDropDown
 import androidx.compose.material.icons.outlined.ErrorOutline
 import androidx.compose.material3.ColorScheme
 import androidx.compose.material3.Icon
@@ -47,6 +45,7 @@ import com.artemchep.keyguard.res.*
 import com.artemchep.keyguard.ui.DisabledEmphasisAlpha
 import com.artemchep.keyguard.ui.ExpandedIfNotEmpty
 import com.artemchep.keyguard.ui.MediumEmphasisAlpha
+import com.artemchep.keyguard.ui.animation.animateContentHeight
 import com.artemchep.keyguard.ui.theme.Dimens
 import com.artemchep.keyguard.ui.theme.combineAlpha
 import com.artemchep.keyguard.ui.theme.info
@@ -114,7 +113,7 @@ fun VaultViewInfoItem(
                             .fillMaxWidth()
                             .padding(top = 4.dp)
                             .padding(horizontal = 8.dp)
-                            .animateContentSize(),
+                            .animateContentHeight(),
                         text = message,
                         style = MaterialTheme.typography.bodySmall,
                         color = LocalContentColor.current
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewTotpItem.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewTotpItem.kt
index 781fe26c..93fd56a5 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewTotpItem.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewTotpItem.kt
@@ -28,7 +28,6 @@ import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.LocalContentColor
 import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.State
@@ -39,8 +38,6 @@ import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.isSpecified
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
 import arrow.core.getOrElse
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewValueItem.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewValueItem.kt
index 3505ac0e..ca91814c 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewValueItem.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultViewValueItem.kt
@@ -1,7 +1,7 @@
 package com.artemchep.keyguard.feature.home.vault.component
 
-import androidx.compose.animation.animateContentSize
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.ColumnScope
 import androidx.compose.foundation.layout.ExperimentalLayoutApi
 import androidx.compose.foundation.layout.FlowRow
@@ -35,12 +35,11 @@ import com.artemchep.keyguard.ui.FlatDropdown
 import com.artemchep.keyguard.ui.FlatItemAction
 import com.artemchep.keyguard.ui.MediumEmphasisAlpha
 import com.artemchep.keyguard.ui.animatedConcealedText
+import com.artemchep.keyguard.ui.animation.animateContentHeight
 import com.artemchep.keyguard.ui.colorizePassword
 import com.artemchep.keyguard.ui.theme.combineAlpha
 import com.artemchep.keyguard.ui.theme.monoFontFamily
 import org.jetbrains.compose.resources.stringResource
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlin.Int
 
 @OptIn(ExperimentalLayoutApi::class)
 @Composable
@@ -83,28 +82,27 @@ fun VaultViewValueItem(
                 } else {
                     null
                 },
-                text = if (shownValue.isNotBlank()) {
-                    // composable
-                    {
-                        Text(
-                            modifier = Modifier
-                                .animateContentSize(),
-                            text = shownValue,
-                            fontFamily = if (item.monospace) monoFontFamily else null,
-                            maxLines = item.maxLines,
-                            overflow = TextOverflow.Ellipsis,
-                        )
-                    }
-                } else {
-                    // composable
-                    {
-                        Text(
-                            modifier = Modifier
-                                .animateContentSize()
-                                .alpha(MediumEmphasisAlpha),
-                            text = stringResource(Res.string.empty_value),
-                            fontFamily = if (item.monospace) monoFontFamily else null,
-                        )
+                text = {
+                    Box(
+                        modifier = Modifier
+                            .animateContentHeight(),
+                    ) {
+                        val fontFamily = if (item.monospace) monoFontFamily else null
+                        if (shownValue.isNotBlank()) {
+                            Text(
+                                text = shownValue,
+                                fontFamily = fontFamily,
+                                maxLines = item.maxLines,
+                                overflow = TextOverflow.Ellipsis,
+                            )
+                        } else {
+                            Text(
+                                modifier = Modifier
+                                    .alpha(MediumEmphasisAlpha),
+                                text = stringResource(Res.string.empty_value),
+                                fontFamily = fontFamily,
+                            )
+                        }
                     }
                 },
             )
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/sync/SyncScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/sync/SyncScreen.kt
index 1b499dec..e90b6292 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/sync/SyncScreen.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/sync/SyncScreen.kt
@@ -19,7 +19,6 @@ import androidx.compose.material3.Icon
 import androidx.compose.material3.LocalContentColor
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.mutableStateOf
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/AutofillWindow.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/AutofillWindow.kt
index 46bfb9ba..c33100c0 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/AutofillWindow.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/AutofillWindow.kt
@@ -1,7 +1,6 @@
 package com.artemchep.keyguard.ui
 
 import androidx.compose.animation.Crossfade
-import androidx.compose.animation.animateContentSize
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
@@ -52,6 +51,7 @@ import com.artemchep.keyguard.feature.home.vault.component.Section
 import com.artemchep.keyguard.feature.localization.wrap
 import com.artemchep.keyguard.res.Res
 import com.artemchep.keyguard.res.*
+import com.artemchep.keyguard.ui.animation.animateContentHeight
 import com.artemchep.keyguard.ui.icons.icon
 import com.artemchep.keyguard.ui.skeleton.SkeletonItem
 import com.artemchep.keyguard.ui.theme.combineAlpha
@@ -320,7 +320,7 @@ private fun ColumnScope.GeneratorValue2(
                 Crossfade(
                     targetState = value.password,
                     modifier = Modifier
-                        .animateContentSize(),
+                        .animateContentHeight(),
                 ) { password ->
                     AutoResizeText(
                         text = if (password.isEmpty()) {
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/animation/ContentSizeAnimation.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/animation/ContentSizeAnimation.kt
new file mode 100644
index 00000000..ca009dd9
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/animation/ContentSizeAnimation.kt
@@ -0,0 +1,215 @@
+package com.artemchep.keyguard.ui.animation
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationEndReason
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.AnimationVector2D
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.layout.IntrinsicMeasurable
+import androidx.compose.ui.layout.IntrinsicMeasureScope
+import androidx.compose.ui.layout.LayoutModifier
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.node.LayoutModifierNode
+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.constrain
+import kotlinx.coroutines.launch
+
+fun Modifier.animateContentHeight(
+    animationSpec: FiniteAnimationSpec<IntSize> =
+        spring(
+            stiffness = Spring.StiffnessMediumLow,
+            visibilityThreshold = IntSize.VisibilityThreshold,
+        ),
+    finishedListener: ((initialValue: IntSize, targetValue: IntSize) -> Unit)? = null,
+): Modifier =
+    this.clipToBounds() then
+            SizeAnimationModifierElement(animationSpec, Alignment.TopStart, finishedListener)
+
+fun Modifier.animateContentHeight(
+    animationSpec: FiniteAnimationSpec<IntSize> =
+        spring(
+            stiffness = Spring.StiffnessMediumLow,
+            visibilityThreshold = IntSize.VisibilityThreshold,
+        ),
+    alignment: Alignment = Alignment.TopStart,
+    finishedListener: ((initialValue: IntSize, targetValue: IntSize) -> Unit)? = null,
+): Modifier =
+    this.clipToBounds() then
+            SizeAnimationModifierElement(animationSpec, alignment, finishedListener)
+
+private data class SizeAnimationModifierElement(
+    val animationSpec: FiniteAnimationSpec<IntSize>,
+    val alignment: Alignment,
+    val finishedListener: ((initialValue: IntSize, targetValue: IntSize) -> Unit)?,
+) : ModifierNodeElement<SizeAnimationModifierNode>() {
+    override fun create(): SizeAnimationModifierNode =
+        SizeAnimationModifierNode(animationSpec, alignment, finishedListener)
+
+    override fun update(node: SizeAnimationModifierNode) {
+        node.animationSpec = animationSpec
+        node.listener = finishedListener
+        node.alignment = alignment
+    }
+
+    override fun InspectorInfo.inspectableProperties() {
+        name = "animateContentSize"
+        properties["animationSpec"] = animationSpec
+        properties["alignment"] = alignment
+        properties["finishedListener"] = finishedListener
+    }
+}
+
+internal val InvalidSize = IntSize(Int.MIN_VALUE, Int.MIN_VALUE)
+internal val IntSize.isValid: Boolean
+    get() = this != InvalidSize
+
+/**
+ * This class creates a [LayoutModifier] that measures children, and responds to children's size
+ * change by animating to that size. The size reported to parents will be the animated size.
+ */
+private class SizeAnimationModifierNode(
+    var animationSpec: AnimationSpec<IntSize>,
+    var alignment: Alignment = Alignment.TopStart,
+    var listener: ((startSize: IntSize, endSize: IntSize) -> Unit)? = null,
+) : LayoutModifierNodeWithPassThroughIntrinsics() {
+    private var lookaheadSize: IntSize = InvalidSize
+    private var lookaheadConstraints: Constraints = Constraints()
+        set(value) {
+            field = value
+            lookaheadConstraintsAvailable = true
+        }
+
+    private var lookaheadConstraintsAvailable: Boolean = false
+
+    private fun targetConstraints(default: Constraints) =
+        if (lookaheadConstraintsAvailable) {
+            lookaheadConstraints
+        } else {
+            default
+        }
+
+    data class AnimData(val anim: Animatable<IntSize, AnimationVector2D>, var startSize: IntSize)
+
+    var animData: AnimData? by mutableStateOf(null)
+
+    override fun onReset() {
+        super.onReset()
+        // Reset is an indication that the node may be re-used, in such case, animData becomes stale
+        animData = null
+    }
+
+    override fun onAttach() {
+        super.onAttach()
+        // When re-attached, we may be attached to a tree without lookahead scope.
+        lookaheadSize = InvalidSize
+        lookaheadConstraintsAvailable = false
+    }
+
+    override fun MeasureScope.measure(
+        measurable: Measurable,
+        constraints: Constraints,
+    ): MeasureResult {
+        val placeable =
+            if (isLookingAhead) {
+                lookaheadConstraints = constraints
+                measurable.measure(constraints)
+            } else {
+                // Measure with lookahead constraints when available, to avoid unnecessary relayout
+                // in child during the lookahead animation.
+                measurable.measure(targetConstraints(constraints))
+            }
+        val measuredSize = IntSize(placeable.width, placeable.height)
+        val (width, height) =
+            if (isLookingAhead) {
+                lookaheadSize = measuredSize
+                measuredSize
+            } else {
+                animateTo(if (lookaheadSize.isValid) lookaheadSize else measuredSize).let {
+                    // Constrain the measure result to incoming constraints, so that parent doesn't
+                    // force center this layout.
+                    constraints.constrain(it)
+                }
+            }
+        return layout(width, height) {
+            val offset =
+                alignment.align(
+                    size = measuredSize,
+                    space = IntSize(width, height),
+                    layoutDirection = this@measure.layoutDirection,
+                )
+            placeable.placeRelative(offset)
+        }
+    }
+
+    fun animateTo(targetSize: IntSize): IntSize {
+        val data =
+            animData?.apply {
+                val axisTargetSize = IntSize(
+                    width = 1,
+                    height = targetSize.height,
+                )
+                // TODO(b/322878517): Figure out a way to seamlessly continue the animation after
+                //  re-attach. Note that in some cases restarting the animation is the correct
+                // behavior.
+                val wasInterrupted = (axisTargetSize != anim.value && !anim.isRunning)
+
+                if (axisTargetSize != anim.targetValue || wasInterrupted) {
+                    startSize = anim.value
+                    coroutineScope.launch {
+                        val result = anim.animateTo(axisTargetSize, animationSpec)
+                        if (result.endReason == AnimationEndReason.Finished) {
+                            listener?.invoke(startSize, result.endState.value)
+                        }
+                    }
+                }
+            }
+                ?: AnimData(
+                    Animatable(targetSize, IntSize.VectorConverter, IntSize(1, 1)),
+                    targetSize,
+                )
+
+        animData = data
+        return IntSize(
+            width = targetSize.width,
+            height = data.anim.value.height,
+        )
+    }
+}
+
+internal abstract class LayoutModifierNodeWithPassThroughIntrinsics :
+    LayoutModifierNode, Modifier.Node() {
+    override fun IntrinsicMeasureScope.minIntrinsicWidth(
+        measurable: IntrinsicMeasurable,
+        height: Int,
+    ) = measurable.minIntrinsicWidth(height)
+
+    override fun IntrinsicMeasureScope.minIntrinsicHeight(
+        measurable: IntrinsicMeasurable,
+        width: Int,
+    ) = measurable.minIntrinsicHeight(width)
+
+    override fun IntrinsicMeasureScope.maxIntrinsicWidth(
+        measurable: IntrinsicMeasurable,
+        height: Int,
+    ) = measurable.maxIntrinsicWidth(height)
+
+    override fun IntrinsicMeasureScope.maxIntrinsicHeight(
+        measurable: IntrinsicMeasurable,
+        width: Int,
+    ) = measurable.maxIntrinsicHeight(width)
+}