最简单的 Compose 动画 — animateDpAsState

各位 Compose 彭于晏,早上好。

在之前讲解 Compose 的文章里,我们花了不少篇幅探讨 Compose 的绘制流程、布局原理和重组机制------这些确实是理解 Compose 如何工作的基础。

但一个界面真正"活"起来,往往靠的是动画。动画不是锦上添花,而是让用户感知到状态变化、建立空间认知的关键手段。

所以后续我会连续更新几篇文章,让我们把目光转向 Compose 动画。

作为开篇,我们先从最基础、最常用的 animateDpAsState 入手。

这个 API 用起来非常简单:你告诉它"目标是多大",它就返回一个会自己变化的尺寸值。当目标改变时,它会自动在旧值和新值之间平滑过渡。

但在这简单用法的背后,动画系统到底是怎么工作的?为什么有时候动画感觉流畅自然,有时候却显得生硬?持续时间和缓动曲线这些参数,究竟怎样影响动画的"手感"?

本文通过一个可展开卡片的实例,带你深入理解 Compose 尺寸动画的方方面面,重点讲解 tween 动画规格的使用技巧。

整体结构

界面上是一个标题行和一张 Card,卡片内容由正文文本和一个小箭头指示器组成。

点击卡片会切换大小状态,这个状态实际上是一个布尔值管理的,这个布尔值就是驱动动画的唯一状态。

UI 的字体、表面颜色、内边距等其余属性全部是静态的。

我们先来看看完整的示例代码(篇幅问题,只保留了主要代码)

Kotlin 复制代码
// 两种形态的宽高
private val COLLAPSED_HEIGHT: Dp = 160.dp
private val EXPANDED_HEIGHT: Dp = 330.dp

// 动画时长
private const val EXPAND_DURATION_MS = 400

// 缓动曲线选项
private data class EasingOption(
    val name: String,
    val easing: androidx.compose.animation.core.Easing,
)

private val easingOptions = listOf(
    EasingOption("FastOutSlowIn", FastOutSlowInEasing),
    EasingOption("Linear", LinearEasing),
    EasingOption("FastOutLinearIn", FastOutLinearInEasing),
)

// 示例文字
private val sampleText = buildString {
    appendLine("XXX")
}

@Composable
fun AnimateDpPage(
    onNavigate: (Any) -> Unit,
    onBack: () -> Unit,
) {
    var isExpanded by remember { mutableStateOf(false) }
    var easingIndex by remember { mutableIntStateOf(0) }
    val currentEasing by remember { derivedStateOf { easingOptions[easingIndex] }  }
    
    // 动画
    val animatedHeight by animateDpAsState(
        targetValue = if (isExpanded) EXPANDED_HEIGHT else COLLAPSED_HEIGHT,
        animationSpec = tween(
            durationMillis = EXPAND_DURATION_MS,
            easing = currentEasing.easing,
        ),
        label = "cardHeight",
    )

     Card(
        modifier = Modifier
            .fillMaxWidth()
            .height(animatedHeight) // 使用动画变更高度
            .clickable { isExpanded = !isExpanded },
        //...
    ) {
        //...
        Text(
            text = sampleText,
            fontSize = 14.sp,
            lineHeight = 20.sp,
            modifier = Modifier.padding(top = 12.dp),
        )
    }
}

动画不追踪滚动位置或拖拽偏移量,它只关注 isExpanded 这一个布尔值:true 就去展开高度,false 就回折叠高度。animateDpAsState 会自动在当前值和目标值之间做平滑过渡。

Card 的高度直接绑定到 animatedHeight,每一帧都会用动画系统算出来的最新值来测量和布局。

代码里定义了三个关键常量:

  • COLLAPSED_HEIGHTEXPANDED_HEIGHT:卡片折叠和展开时的高度,也就是动画的起点和终点。
  • EXPAND_DURATION_MS:动画从头到尾需要多少毫秒。

EasingOption 中的 easing(缓动曲线)则控制动画在不同时间段的快慢节奏。

简单来说,就是决定动画是匀速运动,还是先快后慢、先慢后快。

动画调用解析

kotlin 复制代码
val animatedHeight by animateDpAsState(
  targetValue = if (isExpanded) EXPANDED_HEIGHT else COLLAPSED_HEIGHT,
  animationSpec = tween(
    durationMillis = EXPAND_DURATION_MS,
    easing = currentEasing.easing,
  ),
  label = "cardHeight",
)

有三个要点值得注意:

  1. targetValue 在每次重组时根据 isExpanded 计算。当布尔值从 false 变成 true(或反过来),animateDpAsState 会检测到目标值变了,随即开始一段新的动画,从当前值平滑过渡到新目标。
  2. tween 是基于持续时间的动画,不是物理模拟。它不会模拟惯性、弹跳或摩擦,而是严格按照给定的时间和曲线,算出每一帧应该显示什么值------结果是完全可预测的。
  3. label 只是给调试工具(如 Animation Preview)用的标签,对动画效果没有任何影响。

因为 tween 完全由时间和曲线决定,所以 EXPAND_DURATION_MSeasing 的组合就决定了动画的节奏。

两个端点高度的变化只影响动画要走多远,不会改变动画要花多长时间------这是两个独立的维度。

三种 Easing 对比

FastOutSlowInEasing 是 Material Design 的默认缓动曲线。打个比方:它就像一辆车起步时猛踩油门快速提速,快到目的地时提前松油门平稳刹停。体现在卡片上,就是前半段展开很快,后半段逐渐放慢、轻柔地停下来,不会有任何生硬的"撞墙感"。

我们还提供了两种替代方案:

  • LinearEasing:匀速运动,每一帧移动的距离完全相同。听起来很"完美",但实际效果却很机械------因为现实中任何物体的启动和停止都有加减速过程,匀速运动反而让人觉得不自然。
  • FastOutLinearInEasing :起步和 FastOutSlowIn 一样快速,但后半段不会减速,而是保持匀速冲到终点。就像一辆车猛踩油门起步,到了目的地却不刹车,直接撞上终点线------对于要飞出屏幕的元素来说很合适,但对于需要"停稳"的元素就显得粗暴了。

对于一个打开后需要停在原地的卡片,FastOutSlowInEasing 是最佳选择。它收尾时的减速恰好给用户一种"卡片已经到位、稳稳落定"的感觉。反过来,如果元素要离开屏幕(比如关闭动画),那就应该选收尾时加速的曲线,让它"加速离开"。

缓动曲线不只是让动画"好看",它其实是在传达一种物理直觉------让用户通过动画的节奏感,直觉地理解元素正在做什么。

各常量对感觉的影响

逐一分析每个常量对动画感受的具体影响:

  • EXPAND_DURATION_MS :设为 400 时,展开动画从容而不拖沓。降到 150 左右,运动变得干脆利落、几乎瞬间完成,适合功能性面板,但会丧失"正在打开"的感知。推到 700 以上,卡片开始显得迟钝,尤其在用户连续点击、需要等待上一次动画完成时。
  • 缓动曲线 :换成 LinearEasing,卡片以恒定速度打开,运动感觉僵硬。换成 FastOutLinearInEasing,卡片以全速到达展开高度,显得突兀。保持 FastOutSlowInEasing 则产生柔和的落定效果,与 Material Motion 整体风格一致。
  • COLLAPSED_HEIGHT :设为 160.dp 时,折叠卡片显示标题行和几行正文。降到约 80.dp,只有标题行可见,折叠状态变成了一个头部概览。提高到接近 EXPANDED_HEIGHT,动画几乎没有距离可走,展开变成轻微推动而非明确手势。
  • EXPANDED_HEIGHT :设为 330.dp,卡片舒适地显示全部文本内容,内边距充裕。减小它,底部内容会被卡片边缘裁剪,尽管布局系统仍认为它们存在。增大到远超内容尺寸,卡片底部变成空白区域。
    最好的做法,高度应该由内容驱动,而非固定 Dp 值。

一点想法

本文通过一个可展开卡片的实例,讲解了 animateDpAsState 配合 tween 规格的用法,核心内容包括:

  • 决定动画效果的四个量:持续时间、缓动曲线、折叠高度、展开高度
  • targetValue 如何响应布尔值变化并触发动画
  • FastOutSlowInEasingLinearEasingFastOutLinearInEasing 在驱动相同高度变化时的差异

理解这些原理之后,你就不会把动画参数当成"随便填个默认值"了。

animateDpAsState 并不是什么魔法------它的本质很简单:接收一个目标值和一条缓动曲线,然后在每一帧算出当前应该显示多少。

掌握了这个核心,在 tweenspring 之间、在不同缓动曲线之间做选择,其实就是在回答一个问题:你希望这个元素"动起来像什么"?是轻柔落地,还是弹跳着到位,还是匀速滑过去?

无论你正在构建内联展开的卡片、滑入视图的底部弹出框,还是放大为主图的缩略图,模式都是一样的:选择起点和终点、选择持续时间、选择与元素行为匹配的缓动曲线。

相关推荐
黄林晴1 小时前
Android Show I/O 2026:开发者该关注这几件事
android
问心无愧05131 小时前
ctf show web 入门46
android·前端·笔记
凛_Lin~~2 小时前
lifecycle源码解析 (版本2.5.1)
android·java·安卓·lifecycle
唐诺2 小时前
Android 与 iOS 核心差异
android·ios
UXbot2 小时前
Vibecoding 工具如何一次性生成 Web + iOS + Android 三端 APP?功能架构深度解读
android·前端·ui·ios·交互·软件构建·ai编程
鹏晨互联2 小时前
Jetpack Compose vs XML:fillMaxSize、fillMaxHeight、fillMaxWidth 全面对比
android·xml
Android小码家2 小时前
ptrace 内存追踪
android
三少爷的鞋2 小时前
为什么 Android 不用接口做 Activity 通信?
android
恋猫de小郭2 小时前
2026 Android I/O ,全新 AI 手机、 Android PC 和车载驾驶
android·前端·flutter