最简单的 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 之间、在不同缓动曲线之间做选择,其实就是在回答一个问题:你希望这个元素"动起来像什么"?是轻柔落地,还是弹跳着到位,还是匀速滑过去?

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

相关推荐
stevenzqzq2 小时前
app性能日志工具
android
DB哥讲数据库2 小时前
【最新】MySQL9.7安装教程:图文详解(附MySQL安装包)
android·mysql·adb
IT乐手3 小时前
Android 写本地日志工具类
android·java
非典型Android程序员4 小时前
Launcher学习总结
android
Coffeeee4 小时前
Android功耗优化,哪些代码会让你的机子发热又耗电
android·架构·kotlin
Android打工仔4 小时前
Kotlin 编译器是如何把 suspend 函数变成状态机的?
kotlin
Android打工仔4 小时前
一个 suspend 函数,为什么会变成一个普通函数?
kotlin
xianrenli386 小时前
Android14自动时区更新
android·学习·framework·aosp14·自动时区
心中有国也有家6 小时前
AtomGit Flutter 鸿蒙客户端:零外部资源的应用打包策略
android·javascript·flutter·华为·harmonyos
-SOLO-6 小时前
解决gralde下载太慢的问题
android