外观
动效
动效不是装饰,是解释:东西从哪来、去哪了、现在是什么状态。判断标准不是「好不好看」,而是「去掉这个动画,用户还能不能看懂发生了什么」。看不懂 —— 不是动画太少,是布局或状态没交代清楚,先回总览补课。
插件还有一层额外约束:你和宿主 App 并排放在用户桌面上。宿主的节奏是稳的,你的页面一进来就满天飞,用户的第一反应是「这插件卡」。所以本页的默认值是「不加」,每加一条都要能说出理由。本页只讲原理和写法,组件参数看 UI 组件库。
一、先记住这件事:动画 = 一个值 + 一条曲线
Compose 里所有动画 API 做的是同一件事:把一个值从 A 平滑推到 B,界面是这个值的函数。没有「动画对象」这种东西 —— 只有 Float / Dp / Color / Offset 在变,UI 跟着那个值重画。所以「这段怎么加动画」永远拆成两个问题:
- 我到底在动哪个值?(位置、透明度、尺寸、颜色、圆角)
- 这个值被读在哪个阶段?
第二个问题决定代价,只有三档:
| 状态读在哪 | 实际重跑 | 代价 | 典型写法 |
|---|---|---|---|
@Composable 函数体 / 参数版修饰符 | Composition → Layout → Draw | 每帧重组 + 重新测量 | Modifier.offset(x = animatedDp)、Modifier.graphicsLayer(translationX = x) |
layout { } / lambda 版测量 | Layout → Draw | 重新量、重新摆,不跑 composable | Modifier.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 – Short4 | 50 / 100 / 150 / 200 ms |
| 中等 | 一个组件内部的状态变化:列表行展开折叠、卡片状态、筛选条收起、菜单浮层出现 | DurationMedium1 – Medium2 | 250 / 300 ms |
| 大范围过渡 | 整块区域或页面级:轻提示、弹窗、底部弹层、整屏内容切换、列表到详情的大容器变形 | DurationMedium3 – Long2 | 350 / 400 / 450 / 500 ms |
DurationExtraLong*(700–1000ms)是整屏 morph 用的,插件里基本轮不到你。真用上了,先怀疑设计本身太复杂,而不是时长没调好。记住两句话:动的东西越大、距离越远,时长越长(一个图标位移 16dp 和整屏切换用同一时长,必然一个太慢一个太快);出场永远比进场快(进来要稳,让人看清落在哪;出去要利落,用户已经决定不要它了)。
2.2 曲线:进场、出场、状态变化,三条不通用
| 曲线 | 值 | 用在哪 |
|---|---|---|
| EmphasizedDecelerate | CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) | 进场:快起步、慢落定 |
| EmphasizedAccelerate | CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f) | 出场:慢起步、快离场 |
| Emphasized | CubicBezierEasing(0.2f, 0f, 0f, 1f) | 留在屏幕上的状态变化(颜色、尺寸、选中态) |
| LinearEasing | 匀速 | 只给循环动画(旋转、扫描、进度) |
FastOutSlowInEasing / LinearOutSlowInEasing / FastOutLinearInEasing | M2 老命名 | 新代码不用 —— 网上教程里满天飞的就是这三个,别照抄 |
进出场别用同一条曲线
都用 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 / slideOutVertically | initialOffsetY = { 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 动画)、无障碍(减弱动效、状态可感知)、结构反模式。