动效不是装饰,是因果
这一卷讲界面上唯一会「回话」的部分。而动效是全书最容易做成噪音的一项——因为它是唯一一个「加了就能看见效果」的维度,于是所有人都忍不住多加一点。
一个当场能感觉到的对照
下面这个 Demo 里,两种模式的内容完全一样。点一行,再切换模式点一次:
没有动效的那次,你会有大约半秒钟的空白:这个面板是从哪来的?关掉之后我会回到哪?
有动效的那次,这两个问题根本不会出现——因为那个面板是从你刚点的那一行长出来的,你的眼睛全程跟着它。
这个新出现的东西,和我刚才那个动作是什么关系。
这就是判断「该不该加动效」的唯一标准:它有没有在解释一个因果关系。
有 —— 加。
没有 —— 那它就是延迟。而延迟是纯粹的成本,用户每次都要付。
四种该做
| 场合 | 它在解释什么 | 典型时长 |
|---|---|---|
| 从哪来 / 去哪了 | 新界面和你刚点的那个东西的空间关系 | 300–500ms |
| 点了有反应 | 「我收到了」——涟漪、按下的凹陷、状态层 | 100ms 以内 |
| 东西变了 | 列表里插入/删除了一项,数字从 3 变成 4 | 200–300ms |
| 我在忙 | 加载中——它在说「没死机,还在跑」 | 循环 |
「点了有反应」是所有动效里最重要的一个,而且它的时长要求和别的完全不同:必须在 100ms 以内。
因为人对「我的动作引起了这个结果」的感知窗口大约就是 100ms。超过之后,因果感开始断裂;超过 300ms,用户会以为自己没点到,然后再点一次。
这也是第 4 章那个「硬」的主要来源——不是动效不够花哨,是压根没有按下反馈。
四种不该做
- 入场动画——页面加载完,元素一个个淡入、依次上滑。它解释了什么因果?没有。用户已经在等加载了,你又让他多等 400ms。
- 装饰性循环——一直在呼吸的按钮、一直在飘的图标。它们持续消耗注意力,而注意力是这个界面最稀缺的资源。
- 过长的转场——超过 500ms 的转场,第一次觉得高级,第五次觉得慢,第五十次想卸载。你的用户会用这个 App 五百次。
- 让人等的确认动画——点了「保存」之后一个 800ms 的对勾动画。保存已经成功了,为什么还要拦着我?
你自己在开发时,会不会想把这个动画跳过?
你每天要看这个界面几十次。如果你已经开始觉得某个动画烦了——用户在第十次的时候就会有同样的感觉,只是他们不会告诉你,他们会直接觉得这 App「有点慢」。
顺带:「感觉慢」和「实际慢」是两件事,而动效能同时影响两者。一个 300ms 的转场会让操作真的慢 300ms;但一个恰当的加载动画能让 2 秒的等待感觉像 1 秒。区别在于前者是在你等的时候挡着你,后者是在你本来就得等的时候陪着你。
尊重「减弱动态效果」
一件很多人不知道的事:有些人会因为界面动效而头晕、恶心。前庭功能障碍者在系统里打开了「减弱动态效果」,你必须听。
// 读系统设置。这不是可选项 —— 这是无障碍要求。
@Composable
fun rememberReduceMotion(): Boolean {
val ctx = LocalContext.current
return remember {
Settings.Global.getFloat(
ctx.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE, 1f
) == 0f
}
}
@Composable
fun SheetTransition(content: @Composable () -> Unit) {
val reduce = rememberReduceMotion()
AnimatedVisibility(
visible = true,
// 减弱模式下:保留淡入(不会引起眩晕),去掉位移和缩放
enter = if (reduce) fadeIn(tween(100))
else slideInVertically { it / 2 } + fadeIn(tween(300))
) { content() }
}
注意做法不是「关掉所有动画」——那会丢掉因果信息。而是把「位移和缩放」换成「淡入淡出」:因果关系还在,眩晕的来源(大面积运动)没了。
动效的层次
最后给一个很实用的排序,用来决定「有限的精力先花在哪」:
animateItem()大多数人的精力分配正好是倒过来的:花两天做一个共享元素转场,而按钮至今没有 pressed 态。
- 「这儿要不要加个动画」——问它有没有在解释因果。没有就不加。入场动画、装饰性循环一律不做。
- 「动效要不要考虑无障碍」——要,而且是必须。读系统的动画缩放设置,减弱模式下把位移换成淡入淡出。