Skip to content

动效 ​

动效不是装饰,是解释:东西从哪来、去哪了、现在是什么状态。判断标准不是「好不好看」,而是「去掉这个动画,用户还能不能看懂发生了什么」。看不懂 —— 不是动画太少,是布局或状态没交代清楚,先回总览补课。

插件还有一层额外约束:你和宿主 App 并排放在用户桌面上。宿主的节奏是稳的,你的页面一进来就满天飞,用户的第一反应是「这插件卡」。所以本页的默认值是「不加」,每加一条都要能说出理由。本页只讲原理和写法,组件参数看 UI 组件库。

一、先记住这件事:动画 = 一个值 + 一条曲线 ​

Compose 里所有动画 API 做的是同一件事:把一个值从 A 平滑推到 B,界面是这个值的函数。没有「动画对象」这种东西 —— 只有 Float / Dp / Color / Offset 在变,UI 跟着那个值重画。所以「这段怎么加动画」永远拆成两个问题:

  1. 我到底在动哪个值?(位置、透明度、尺寸、颜色、圆角)
  2. 这个值被读在哪个阶段?

第二个问题决定代价,只有三档:

状态读在哪实际重跑代价典型写法
@Composable 函数体 / 参数版修饰符Composition → Layout → Draw每帧重组 + 重新测量Modifier.offset(x = animatedDp)、Modifier.graphicsLayer(translationX = x)
layout { } / lambda 版测量Layout → Draw重新量、重新摆,不跑 composableModifier.offset { IntOffset(px, 0) }
draw { } / graphicsLayer { } lambda只 Draw只重发绘制指令,换 GPU 变换矩阵Modifier.graphicsLayer { translationX = x }

位移、缩放、旋转、透明度一律用 graphicsLayer { }

Modifier.graphicsLayer { translationX = offsetX.value } 里的 lambda 在绘制阶段才执行。动画每帧改这个值,失效的只有 Draw 阶段:composable 不重跑、子节点不重新测量,只换一个变换矩阵。换成 Modifier.offset(x = animatedDp) 是另一回事:参数在 composition 阶段读,值每帧变 → 每帧重组 + 重新测量。同一段动画,代价差一个量级。

注意是 lambda 版才有这个效果:Modifier.graphicsLayer(translationX = x) 的参数版一样在 composition 读值,一样要重组。

二、时长与曲线速查表:别自己拍数字 ​

「这次动画多长」不该由你现场决定。M3 给了一整套动效 token —— 时长和曲线都是设计规范标定过的,你只负责选档。

2.1 时长:按「动的是多大一块」选档 ​

档什么时候用token值
短交互用户手底下立刻发生的事:按压反馈、勾选、图标互换、选中态、页签指示器DurationShort1 – Short450 / 100 / 150 / 200 ms
中等一个组件内部的状态变化:列表行展开折叠、卡片状态、筛选条收起、菜单浮层出现DurationMedium1 – Medium2250 / 300 ms
大范围过渡整块区域或页面级:轻提示、弹窗、底部弹层、整屏内容切换、列表到详情的大容器变形DurationMedium3 – Long2350 / 400 / 450 / 500 ms

DurationExtraLong*(700–1000ms)是整屏 morph 用的,插件里基本轮不到你。真用上了,先怀疑设计本身太复杂,而不是时长没调好。记住两句话:动的东西越大、距离越远,时长越长(一个图标位移 16dp 和整屏切换用同一时长,必然一个太慢一个太快);出场永远比进场快(进来要稳,让人看清落在哪;出去要利落,用户已经决定不要它了)。

2.2 曲线:进场、出场、状态变化,三条不通用 ​

曲线值用在哪
EmphasizedDecelerateCubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)进场:快起步、慢落定
EmphasizedAccelerateCubicBezierEasing(0.3f, 0f, 0.8f, 0.15f)出场:慢起步、快离场
EmphasizedCubicBezierEasing(0.2f, 0f, 0f, 1f)留在屏幕上的状态变化(颜色、尺寸、选中态)
LinearEasing匀速只给循环动画(旋转、扫描、进度)
FastOutSlowInEasing / LinearOutSlowInEasing / FastOutLinearInEasingM2 老命名新代码不用 —— 网上教程里满天飞的就是这三个,别照抄

进出场别用同一条曲线

都用 Emphasized,出场会显得拖;都用 Decelerate,出场会「冲出去」。这不是审美问题:进场减速是因为元素需要被看清最终位置,出场加速是因为用户已经撤回了注意力。

2.3 spring 还是 tween ​

你要什么用判据
自然、随时能被打断spring()手势驱动、跟随手指、用户可能连点或反悔
精确、一次跑完tween + token一次性过渡、需要和别人对齐(顺序编排)

spring 是可打断的:中途改了目标值,它从当前速度继续走,不重头再来。交互反馈用 spring,状态切换用 tween。

2.4 把数字收进一处 ​

kotlin
// 全项目只有这一处写时长和曲线。同一界面出现两个节奏,就是这里被抄散了。
private object Motion {
    // M3 动效 token 的值(官方名字是 MotionTokens.DurationShort4 这一套,随版本变动,
    // 能直接引用官方那套就用官方的)
    const val SHORT_2 = 100   // 小元素出现 / 消失
    const val SHORT_3 = 150   // 图标、指示器
    const val SHORT_4 = 200   // 状态变化、图标互换
    const val MEDIUM_1 = 250  // 行内展开、卡片状态
    const val LOOP = 900      // 循环节拍,不属于交互 token
    val Enter = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)   // EmphasizedDecelerate
    val Exit = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f)    // EmphasizedAccelerate
    val Change = CubicBezierEasing(0.2f, 0f, 0f, 1f)       // Emphasized
}

写 tween(Motion.SHORT_4, easing = Motion.Change),永远不写 tween(347) —— 手拍的数下一个人来调就变成 420,同一页面出现两个节奏。曲线一定要用 easing = 具名传(tween 的第二个位置参数是 delayMillis,写成位置参数会编译不过)。

主题已经装了 M3 motion scheme 的话

优先用 MaterialTheme.motionScheme.*:位置 / 尺寸这类空间变化走 defaultSpatialSpec() / fastSpatialSpec(),颜色 / 透明度这类效果变化走 defaultEffectsSpec()。两者千万别用反 —— 拿 spatial spec 去动透明度,淡入淡出会「抖」(spring 有过冲)。按你项目实际的 M3 版本和主题入口确认这套 API 在不在,不在就用上面那份 Motion 常量。

三、场景配方 ​

3.1 一个值平滑变化 → animate*AsState ​

什么时候用:只有一个值在变,而且它是普通状态(开关、选中项、展开态、进度)。你只给目标值,它自己管协程、自己管重组。

kotlin
@Composable
fun FilterRow(selected: Boolean, onToggle: () -> Unit) {
    val verticalPadding by animateDpAsState(                       // Dp:内边距随选中态变
        targetValue = if (selected) 12.dp else 8.dp,
        animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
        label = "row_padding",
    )
    val arrowRotation by animateFloatAsState(                      // Float:箭头翻转
        targetValue = if (selected) 180f else 0f,
        animationSpec = tween(durationMillis = Motion.SHORT_4, easing = Motion.Change),
        label = "arrow_rotation",
    )
    val containerColor by animateColorAsState(                     // Color:选中态底色,走语义色
        targetValue = if (selected) MaterialTheme.colorScheme.surfaceContainerHigh
                      else MaterialTheme.colorScheme.surfaceContainerLow,
        animationSpec = tween(durationMillis = Motion.SHORT_4, easing = Motion.Change),
        label = "row_container",
    )

    // 内容用组件库的行 / 项组件,圆角走 AppShapes;图标角度用 graphicsLayer { rotationZ = arrowRotation }
    Row(
        modifier = Modifier.fillMaxWidth().background(containerColor)
            .padding(vertical = verticalPadding).clickable(onClick = onToggle),
    ) { /* 组件库内容 */ }
}

坑:label 一定要给 —— 它出现在 Layout Inspector 的动画检查里,不给的话「谁在动」只能靠猜。另外 animate*AsState 是被动的(targetValue 变了它才动),别在 LaunchedEffect 里再去推它,那是 Animatable 的活(3.5)。

3.2 一整块内容的进出 → AnimatedVisibility ​

什么时候用:一块内容会「有 / 没有」(筛选条、错误提示、附加信息、底部操作条),而不是「换一个值」。

kotlin
AnimatedVisibility(
    visible = showFilterBar,
    enter = expandVertically(tween(Motion.MEDIUM_1, easing = Motion.Enter)) +
        fadeIn(tween(Motion.SHORT_3, easing = Motion.Enter)),
    exit = shrinkVertically(tween(Motion.SHORT_4, easing = Motion.Exit)) +
        fadeOut(tween(Motion.SHORT_2, easing = Motion.Exit)),
) {
    FilterBar()   // 里面是组件库组件:筛选条用 PillLabelsRow / SectionRow
}

三种动作怎么选:

这块东西是用数字怎么给
占空间的行内块(筛选条、附加说明)expandVertically / shrinkVertically让高度自己长出来,别自己算
浮在内容上的层(底部操作条、轻提示)slideInVertically / slideOutVerticallyinitialOffsetY = { it } 从下面整个滑入;{ it / 4 } 只滑四分之一,更含蓄
只是换个东西出现fadeIn / fadeOut几乎总该加一个,用来遮住布局切换的硬边

坑:同一个块里别同时用 expandVertically 和 slideInVertically —— 一个在改高度、一个在挪位置,看起来像抖。选一个主运动,另一个只用 fadeIn。同理,if (visible) { X() } 是没动画的直接跳,这是最常见的写法错误。

3.3 内容整体换掉 → Crossfade ​

什么时候用:同一块位置上内容的种类变了(加载中 → 有数据 → 空 / 出错),只要一个柔和的交叉淡化,不需要方向感。

kotlin
Crossfade(
    targetState = uiState,
    animationSpec = tween(durationMillis = Motion.SHORT_4, easing = Motion.Change),
    label = "content_crossfade",
) { state ->
    when (state) {
        UiState.Loading  -> LoadingState()                    // 组件库
        UiState.Empty    -> EmptyHint(text = "还没有内容", description = "下拉刷新试试")
        is UiState.Error -> ErrorState(message = state.msg, onRetry = ::reload)
        is UiState.Ok    -> ContentList(state.items)
    }
}

坑:Crossfade 只淡、不改尺寸 —— 两个状态高度差很多时容器会中途跳一下,装进 Modifier.animateContentSize() 的容器兜住,或改成带 SizeTransform 的 AnimatedContent(有方向感、能管尺寸,但更贵,只在真需要方向时上)。切换那几十毫秒里新旧两棵树会同时在组合里,所以别在分支里做「进页面跑一次」的副作用 —— 旧分支退场时可能又被拉起来跑一次。最后:数据没换、只是列表多了两条,那不是 Crossfade,是列表项动画(3.7)。

3.4 多个值要一起动 → updateTransition ​

什么时候用:同一个状态变化驱动好几个值(尺寸 + 圆角 + 颜色 + 箭头角度),而且必须同步。各写各的 animate*AsState 也能跑,但曲线一多就散了。

kotlin
val transition = updateTransition(targetState = expanded, label = "card")
val cornerRadius by transition.animateDp(
    transitionSpec = { tween(durationMillis = Motion.MEDIUM_1, easing = Motion.Change) },
    label = "corner",
) { if (it) 0.dp else 12.dp }
val elevation by transition.animateDp(
    transitionSpec = { tween(durationMillis = Motion.MEDIUM_1, easing = Motion.Change) },
    label = "elevation",
) { if (it) 8.dp else 0.dp }
val arrowRotation by transition.animateFloat(
    transitionSpec = { tween(durationMillis = Motion.SHORT_4, easing = Motion.Change) },
    label = "arrow",
) { if (it) 180f else 0f }
// 头行用组件库组件,箭头走 Modifier.graphicsLayer { rotationZ = arrowRotation }
// 展开出来的内容放 AnimatedVisibility(3.2)—— 「有没有这块内容」不属于 transition

坑:不是「相关的值都塞一个 transition」。targetState 一变,transition 里所有值都会跑一遍,串进去的每个值都该是「这次切换真的需要动」的。互不相关的值各用各的 animate*AsState,否则你改 A 时 B 在白跑。

3.5 需要自己控制:Animatable ​

什么时候用:你要命令式地控制动画 —— 手动取消、按顺序播放、跟随手势、从当前速度接着甩。animate*AsState 是「值变它才动」,Animatable 是「你说什么时候动、动到哪」。

kotlin
val progress = remember { Animatable(0f) }

// 顺序播放:animateTo 是挂起函数,写完一段再写下一段,天然串行
LaunchedEffect(jobId) {
    progress.snapTo(0f)                                                   // 瞬间归零,不播过程
    progress.animateTo(0.6f, tween(durationMillis = Motion.MEDIUM_1, easing = Motion.Enter))
    progress.animateTo(1f)                                                // 默认 spring,接手也顺
}
// 取消:stop() 停在当前值,之后还能从这儿继续 animateTo;
// 或者换 LaunchedEffect 的 key —— 旧协程自动取消,比手动 stop 更干净

坑:必须 remember { Animatable(0f) } —— 写成 Animatable(0f) 就是每次重组新建一个,动画永远停在初值。读值别读在 composition:graphicsLayer { translationX = progress.value },不是 Modifier.offset(x = progress.value.dp)。同一时刻只能有一个 animateTo 在跑,新的会取消旧的 —— 这正是「连点两次立刻改向」想要的行为;要顺序就串行写,不要 launch 出去再回头读中间值。snapTo 是瞬移,适合「手势跟手」和「归零重播」。

3.6 持续动画 / 加载态 → rememberInfiniteTransition ​

什么时候用:动画要一直循环、没有终点(呼吸、扫描、旋转)。

先看组件库有没有

加载态用组件库的 LoadingState / LoadingIndicator,它们已经带了自己的动画。只有要自定义内容占位这类组件库没覆盖的东西,才自己起 infinite transition。

kotlin
val infinite = rememberInfiniteTransition(label = "pulse")
val pulseAlpha by infinite.animateFloat(
    initialValue = 0.4f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = tween(durationMillis = Motion.LOOP, easing = LinearEasing),  // 循环只用匀速
        repeatMode = RepeatMode.Reverse,
    ),
    label = "pulse_alpha",
)

坑:无限动画不会自己停,组合里存在多久就转多久 —— 别放进 LazyColumn 的 item(一屏十几个同时跑),别在滚动过程中启动它。页面退到后台时帧时钟会停,不白烧电,但这不是把它撒满屏的借口。一屏里一眼可见的无限动画不超过一个。也别手写 while (true) { delay(16); angle += 6 } —— 它不走动画系统,不读系统设置,也不跟随页面生命周期。

3.7 列表项的增删和重排 ​

做法只有两件事:给 key(items(list, key = { it.id }))—— 没有 key,框架认不出「这是同一个 item 换了位置」,只能全删全建,动画无从谈起;在 item 最外层挂 item 动画修饰符 —— 新项淡入滑入、删除项淡出、剩下的项平滑移到新位置。

kotlin
LazyColumn {
    items(items, key = { it.id }) { item ->
        // ⚠️ 这个修饰符的名字随 Compose 版本变过:早期叫 animateItemPlacement,
        //    后来的版本叫 animateItem。按你项目锁定的 Compose 版本确认名字,别照抄;
        //    它还能分别配「出现 / 位移 / 消失」的 spec(参数名同样按版本确认)。
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .animateItem(),
        ) {
            // 内容用组件库的 ListItem;这层只管它在列表里的进 / 出 / 位移
            ItemContent(item)
        }
    }
}

分工:增删时剩下项的位移由 item 动画修饰符负责;新项 / 删除项的淡入淡出用修饰符自带的 fade spec,或自己 animateFloatAsState + graphicsLayer { alpha = ... };要「点了删除 → 先播一段 → 再从数据里删」,用 AnimatedVisibility 包住项内容,动画跑完回调里再改状态。

kotlin
// 「先播动画,再真删」:写在 items { } 里才有这个作用域
val itemAlpha by animateFloatAsState(
    targetValue = if (removing) 0f else 1f,
    animationSpec = tween(durationMillis = Motion.SHORT_2, easing = Motion.Exit),
    label = "item_alpha",
)
LaunchedEffect(removing) {
    if (removing) { delay(Motion.SHORT_2.toLong()); onRemove(item.id) }   // 播完才从 list 里删
}
Box(Modifier.fillMaxWidth().graphicsLayer { alpha = itemAlpha }.animateItem()) { /* … */ }

graphicsLayer 的作用域里本来就有 alpha / scaleX 这些属性:变量叫 alpha、里面又写 this.alpha = alpha,很容易看错谁是谁 —— 换个名字(itemAlpha)比 this. 前缀省心。

坑:没 key,动画全废 —— 轻则跳,重则顺序错乱、内容串行显示错数据;key 是前提,不是可选优化。别给长列表加交错入场(每项 delay(index * 80)):滚不到的项也在排 delay,用户一滚动就看到一堆「迟到」的项同时滑入 —— 交错只在「一屏内、一次、5 个以内」才成立。列表滚动过程中别再叠进出场动画,见第四节。

3.8 手势驱动:draggable / detectDragGestures + Animatable ​

什么时候用:位置由手指决定,抬手后要么归位、要么甩出去(侧滑删除、拖拽排序、上滑展开)。

kotlin
@Composable
fun SwipeToDismissRow(onDismiss: () -> Unit) {
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()
    val threshold = with(LocalDensity.current) { 96.dp.toPx() }

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .graphicsLayer { translationX = offsetX.value }   // 手指每动一下只重绘
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    scope.launch { offsetX.snapTo(offsetX.value + delta) }   // 拖动直接跟手,不走动画
                },
                onDragStopped = { velocity ->
                    if (abs(offsetX.value) > threshold) {
                        onDismiss()
                    } else {
                        // 从「当前速度」接着走,不是从头开始
                        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
                    }
                },
            ),
    ) { /* 内容用组件库的 ListItem */ }
}

两个 API 怎么选:单轴、要抬手速度、要和列表 / 滚动容器正确互让 → Modifier.draggable + rememberDraggableState;要多指、双轴、要自己掌控从按下到抬起的全过程 → Modifier.pointerInput { detectDragGestures(...) }。

kotlin
// detectDragGestures 拿到的是 pointer 级细节,规则一样:拖动 snapTo、抬手 animateTo
Modifier.pointerInput(Unit) {
    detectDragGestures(
        onDragStart = { offsetX.stop() },          // 一按下就停掉正在跑的归位动画
        onDrag = { change, drag ->
            change.consume()
            scope.launch { offsetX.snapTo(offsetX.value + drag.x) }
        },
        onDragEnd = { scope.launch { offsetX.animateTo(0f) } },
        onDragCancel = { scope.launch { offsetX.animateTo(0f) } },
    )
}

坑:拖动用 snapTo(跟手、零延迟),抬手才 animateTo —— 拖动过程用 animateTo 会「追手指」,永远差一截。抬手时必须把 velocity 传给 animateTo(initialVelocity = ...):不传,快速一甩和慢慢一拖的行为一模一样,手感立刻是假的。onDragStart 里 stop() 掉正在跑的动画,否则用户第二次按下时两个源同时写一个值。要「甩出去很远」用 animateDecay(velocity, 衰减 spec)(衰减 spec 取 rememberSplineBasedDecay<Float>());想让甩动落点有边界,用 updateBounds(lowerBound, upperBound) 划范围,否则会被夹在默认边界里。

3.9 为什么 graphicsLayer { } 能做到「不重组」 ​

  • Modifier.graphicsLayer { } 把 lambda 挂在绘制前的 apply 阶段,状态读发生在 lambda 执行的那一刻(Draw),而不是 composable 函数体(Composition)。
  • 动画每帧改值 → 只有 Draw 失效 → 重发绘制指令;composable 没重跑(没重组),子节点没量过(没 layout)。
  • 变换矩阵存在渲染节点上,改矩阵不改变子节点的尺寸和约束,所以不牵连测量。
  • 边界:尺寸真的变了,graphicsLayer 救不了。内容从两行变四行是 layout 的事,用 animateContentSize();graphicsLayer 只管「同一个尺寸下的位置、缩放、旋转、透明度」。
kotlin
Box(Modifier.graphicsLayer { translationX = offsetX.value })              // ✅ Draw:不重组、不重测
Box(Modifier.offset { IntOffset(offsetX.value.roundToInt(), 0) })         // ⚠️ Layout:不重组,但要重新量、重新摆
Box(Modifier.offset(x = offsetX.value.dp))                                // ❌ Composition:每帧重组 + 重新测量

顺序是「先正确、再便宜」

别一开始就纠结阶段归属。先按语义把动画写对,掉帧了再用这张表定位:函数体里的动画值 → 往 lambda 里挪。

四、什么时候不该加动画 ​

这一节比上面所有配方都重要。插件里的动效预算很小,加之前先过一遍。

4.1 数据加载中不要花式动画 ​

用户此刻只要两件事:知道在加载、界面别跳。这两件事组件库已经解决了 —— LoadingState / LoadingIndicator。再叠上骨架屏 shimmer、交错入场、呼吸灯三层,观感只会变成「慢」。

  • 加载中的容器高度尽量和加载后一致(留位),别让内容一到位就把整页顶下去。
  • 加载态到内容态,一次 Crossfade 就够(3.3),不要再加位移和缩放。
  • 真正拖慢感知的是等待时间本身,不是动画不够多。

4.2 列表滚动中不要加动画 ​

滚动本身就是动画,而且是手指直接驱动的。在它上面再挂动画,等于两套运动打架:

  • 滚动时 item 的进出场动画会和滚动位置叠加:item 一边被滚走、一边自己往里滑,看起来像「粘住」。
  • 每项都带进场动画 → 滑动时每帧都在给新进屏的项起动画,这是掉帧最典型的来源。
  • 交错入场、视差(parallax)慎用;要视差就放在固定高度的小区域,可折叠标题栏用组件库 CollapsingTitleScaffold。列表项动画只保留「增 / 删 / 重排」这一种语义(3.7),滚动本身交给 LazyColumn。

4.3 不要为了炫技拖慢交互 ​

只有一条线:反馈必须在 100ms 内出现。 用户按下就立刻变,动画可以继续播,但不能让用户等它。

  • 别让动画成为动作的前置条件:不要在 onClick 里 delay(300) 再执行,不要在动画结束回调里发请求。
  • 出场比进场快(见 2.1)。用户已经决定离开,多留他 200ms 是纯粹的敌意。
  • 别给导航 / 返回加长过渡。整屏切换 400ms 已是上限,500ms 以上就是「卡」。
  • 别做「排队式」动画:用户连点两下,第二下必须能立刻打断第一下 —— 这就是 spring(可打断)和长 tween 的差别。
  • 动画不是功能:它会掉帧、会被用户关掉、会在低端机上变成一卡一停。凡是靠动画才看得懂的交互,设计就是错的。

4.4 尊重系统的「减弱动效」 ​

Android 的「动画时长缩放 / 移除动画」不只是给开发者用的:前庭功能障碍的用户开着它会难受。三条规则:

① Compose 内置动画不用你管。 animate*AsState / AnimatedVisibility / Transition 都会读系统的动画时长缩放系数,用户关掉动画时它们直接落在终态、不播过程。这也是「少手写动画」的另一个理由。

② 自己写的循环和手写协程动画要自己接开关:

kotlin
val animatorsEnabled = ValueAnimator.areAnimatorsEnabled()   // Android 26+
val pulseAlpha = if (animatorsEnabled) {
    val infinite = rememberInfiniteTransition(label = "pulse")
    infinite.animateFloat(
        initialValue = 0.4f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = Motion.LOOP, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse,
        ),
        label = "pulse_alpha",
    ).value
} else 1f   // 动画被关掉:直接给稳定态

这个开关只在组合时读一次,运行中改系统设置要重进插件才生效;要实时跟随就别自己造循环。另外,系统把动画缩放到 0 时循环动画不会安静下来(时长被压成 0,值会每帧来回跳),所以这个判断不是可选项。

③ 状态不能只靠动画表达。 动画可以关,文字和图标关不掉 —— 加载 / 成功 / 失败这类状态必须同时有静态可读的信号(见无障碍)。也别拿「震动反馈」当动效的替代品,有些用户同样把它关了。

五、交付前过一遍 ​

  • [ ] 时长的数字只有一处来源(Motion 常量或官方 token),没有 tween(347) 这种手拍值
  • [ ] 进场用 Decelerate、出场用 Accelerate,且出场时长 ≤ 进场时长
  • [ ] 位移 / 缩放 / 旋转 / 透明度全部走 Modifier.graphicsLayer { } lambda,不是 offset(x = dp)
  • [ ] 一块内容的进出用 AnimatedVisibility,不是 if (visible) { … }
  • [ ] 列表项动画挂了 key,且修饰符名字按项目锁定的 Compose 版本确认过
  • [ ] 一条链上没有同时用 expandVertically 和 slideInVertically 这类互相打架的运动
  • [ ] 无限动画:一屏不超过一个,不在列表项里,且接了系统「减弱动效」开关
  • [ ] 加载 / 空 / 出错用组件库 LoadingState / EmptyHint / ErrorState,没有自己叠多层动画
  • [ ] 系统关掉动画后界面依然看得懂(状态不靠动效单独表达)
  • [ ] 长列表滚动过程中没有挂进出场动画

相关页:性能(动效掉帧怎么定位)、列表、滚动与自动加载更多(滚动与 item 动画)、无障碍(减弱动效、状态可感知)、结构反模式。