淡出穿过与共享轴:用 Motion 实现 Material 的两种内容切换
交互演示: 打开「淡出穿过与共享轴」演示,在底部标签之间快速连点,再在步骤之间前进后退,切换 X / Y / Z 三种轴向并用慢放观察新旧内容的交接。
在容器变形过渡那篇文章里提到过,Container Transform 是 Material Design 运动体系中的四种过渡模式之一。另外两种同样常见,而且更"日常":
- 在底部标签之间切换,旧内容很快淡出,新内容一边淡入、一边从略小一点放大到原尺寸。这叫淡出穿过(Fade Through)。
- 在向导的步骤之间前进或后退,新旧内容沿同一条轴一起移动,后退时方向倒过来;顶部的进度条一直留在原地,只是变长或变短。这叫共享轴(Shared Axis)。
它们都发生在同一块区域里,看上去只是"换一下内容",但做得随意时很容易出现几种别扭的情况:快速连点时动画排起长队、后退时旧内容仍朝前进方向离开、进度条跟着内容一起闪一下、切换瞬间布局上下跳。这篇文章把这两种模式拆开讲,最后给出一份可以直接对照的验收清单。
一、先按内容关系选模式
选哪种过渡,取决于新旧内容之间是什么关系,而不是哪种看起来更酷:
两个反例最常见:用淡出穿过处理前后步骤,会丢掉"我在往前走"的方向感;给毫无关系的标签加左右滑,又会制造一种并不存在的"标签排成一排"的空间关系。
二、淡出穿过:先清场,再上场
淡出穿过的节奏只有一句话:旧内容很快淡出,新内容随后淡入并从 92% 放大到 100%。 新旧内容没有位移,因为它们之间没有方向;那一点点放大让新内容"落"进来,而不是生硬地出现。
参照 Material 的默认时长 300ms:旧内容在前 90ms 用加速曲线淡出,新内容延迟 90ms 后用标准曲线淡入 210ms,同时完成放大:
这个节奏还有一个好处:快速连点也能接住。 我在 Chromium 里以 40ms 的间隔连点三个标签,逐帧记录舞台里每块内容的透明度:中间两个标签还没来得及淡入(它们有 90ms 的延迟)就已经开始退出,整个过程中真正可见过的只有起点内容和最后一次点击的内容,约 520ms 后稳定。用户看到的是"直接到了",而不是一串闪烁。
三、共享轴:沿同一条轴一起移动
共享轴的关键词是"一起":旧内容离开和新内容进入同时发生,而且沿同一条轴、朝同一个方向运动。 前进时,旧内容向左(或向上)移出 30px 并淡出,新内容从右边(或下方)30px 处移入并淡入;后退时整组方向倒过来。
Z 轴把"沿轴移动"换成"沿纵深缩放":前进时旧内容放大到 110% 并淡出,像被推到眼前然后散开;新内容从 80% 长到 100%,像从远处走来。后退时反过来。
位移只有 30px,不是整屏滑动。它是一个"往这边走了"的暗示,配合透明度错峰,新旧内容几乎不会同时清晰可见;需要整屏跟着手指滑动的,是轮播或页面手势,不属于这里。
方向通过 custom 传入,而且要同时传给 AnimatePresence 和子元素:退出中的元素已经不在 React 树里了,只能从 AnimatePresence 的 custom 读到最新的方向。只传给子元素,后退时旧内容就会继续朝前进方向离开。
四、共享优先:进度条留在原地
向导顶部的进度条、页面的标题栏、底部的标签栏,在切换前后都存在。它们和容器变形过渡里的标题、图标字形是同一类元素:两端都有,就应该共享,而不是跟着内容一起淡出再淡入。
做法很简单:把它们放在 AnimatePresence 之外,作为常驻元素,只更新自身的状态——进度条动画宽度,标签栏移动选中指示器。只有随步骤变化的那部分内容才进入 keyed 子树。放反了,进度条就会在每次切换时被卸载重建,跟着内容一起闪一下,用户看到的是"整块被替换"。
五、叠放,而不是排队
上面的切换区有两个容易被忽略的决定:AnimatePresence 用默认的 sync 模式,新旧内容叠放在同一个 grid 格子里(col-start-1 row-start-1)。
很多人的第一反应是 mode="wait":等旧内容走完再让新内容进来,"不会重叠"。对共享轴来说这恰恰是错的——它把"一起沿轴移动"变成了"先走一个,再来一个",连续感随之消失。叠放 + sync 才能让两者同时运动,又因为叠在同一个格子里,切换瞬间布局不会上下跳。(也可以用 mode="popLayout",让退出元素脱离文档流。)
淡出穿过对 wait 更宽容:旧内容本来就只有 90ms 的退出,实测 wait 只比 sync 晚约 20ms 稳定。但统一用叠放写法,两种模式可以共用一套结构。
叠放 + sync 还有一个很实用的性质:中途反向不会跳。 我在 X 轴上点"下一步"、60ms 后立刻点"上一步",逐帧记录两块内容的位移:仍在退出中的旧内容被重新选中,Motion 会从它当前的位置和透明度转回进入态;刚进来一半的新内容则掉头,按后退方向离开。整个过程中任一帧的位移变化都不超过 6.8px,也就是正常的运动速度,没有任何一帧"跳到某处"。Z 轴同理,单帧缩放变化不超过 0.05。
六、高度不一样怎么办
叠放的格子高度取新旧内容中较高的那个。如果两步内容高度不同,旧内容退出完成、被卸载的那一帧,容器高度会突然跳到新内容的高度。
这时需要让容器高度也参与过渡:内容行数变化不大时,可以给容器加 layout 并配合 mode="popLayout";变化较大、文字较多时,按测量式 auto-height的做法测量内层高度、动画外层数值高度;如果下方还有元素需要连续让位,参考连续布局过渡。
七、减少动态效果
开启"减少动态效果"时,去掉位移与缩放,只保留约 150ms 的交叉淡入淡出;常驻的进度条、标签栏照常更新。方向信息不必通过运动表达——步骤编号、进度条本身已经说明了"我在第几步"。
八、验收清单
- 模式与关系一致: 无关内容没有方向,前后 / 层级内容方向正确,后退时反向。
- 共享元素常驻: 进度条、标题栏、标签栏不随内容闪烁,只更新自身状态。
- 同时进退: 共享轴的新旧内容一起运动;切换瞬间布局不跳。
- 快速连点: 只看到起点与最终内容,中间项不闪现,最终停在最后一次点击的内容。
- 中途反向: 退出中的内容平滑转回,逐帧位移无跳变。
- 高度: 内容高度不同时容器平滑伸缩。
- 首屏与无障碍: 首屏无入场动画(
initial={false}),减少动态效果时只剩短时长淡入淡出。
逐帧数据比肉眼可靠:在页面里挂一个 requestAnimationFrame 循环,每帧记录舞台里挂载了哪些内容、各自的透明度与变换。连点时统计"出现过可见透明度的内容",反向时统计"每帧位移或缩放变化的最大值",比慢放截图更容易发现那一两帧的跳变。
九、结语
淡出穿过和共享轴都很短,300ms 左右就结束了,但它们回答的是同一个问题:这两块内容是什么关系? 有前后,就沿着一条轴走;有层级,就沿着纵深进出;没有关系,就干脆利落地清场再上场。
剩下的工程细节——叠放而不是排队、方向同时交给 AnimatePresence 和子元素、共有的元素留在原地——都是为了让这个关系在快速连点、中途反向这些真实操作下依然成立。