退出、布局、共享元素与高频数值
来源:motion-react/SKILL.md、motion-react/RECIPES.md、animate/framer-motion.md。示例使用 motion 包的 motion/react 入口;API 关键边界经 Context7 与官方文档核对,日期 2026-10-23。示例是教学片段,省略产品样式时会明确说明。
3.1 什么时候引入
CSS 已能合理完成的 hover、循环、小幅过渡,不必再装库。真实弹簧、React 退出生命周期、布局变化和共享元素更值得用 Motion。先读 package.json 和锁文件,复用已安装能力;“原生 CSS 不能做、原生 JS 也不能做”是错误理解,库主要是在封装生命周期、测量和驱动。
3.2 Initial / animate / exit
Initial 是进入起点,animate 是当前目标,exit 是退出目标。传入明确 transition,避免把库的默认选型误认成自己的设计。每帧 Motion value 更新不需要 React 每帧 render,但动画仍可能有 JS 和测量成本。
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export function NoticeExample() {
const [open, setOpen] = useState(false);
const reduce = useReducedMotion();
const offset = reduce ? 0 : 8;
return (
<section>
<button type="button" onClick={() => setOpen(v => !v)}>
{open ? "关闭提示" : "显示提示"}
</button>
<AnimatePresence initial={false}>
{open && (
<motion.p key="notice" role="status"
initial={{ opacity: 0, y: offset }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: offset }}
transition={{ duration: 0.18, ease: "easeOut" }}>
操作已完成
</motion.p>
)}
</AnimatePresence>
</section>
);
}
这是提示,不是完整模态框;模态框还需要焦点管理、语义、背景隔离与关闭行为。
3.3 Presence 的生命周期
AnimatePresence 保持挂载,才能让退出动画完成。它本身应在条件外,并通过直接子节点身份识别添加与移除。列表/状态替换使用稳定、唯一 key;不是每次 render 随机生成 key。缺 key 是常见原因,但 presence 本身被卸载、子组件未把 exit 传到 motion 节点等也会导致失败。
3.4 三种 mode
| mode | 行为 | 适合 |
|---|---|---|
| sync(默认) | 进入和退出独立同步发生 | 已自行处理重叠/布局的情况 |
| wait | 旧元素退出后,新元素进入 | 单个图标、单个信息状态;一次只支持一个子项 |
| popLayout | 退出节点退出布局流,周围立即重排 | 列表移除,结合 layout 的状态变化 |
不要把 popLayout 当通用修复开关:定位上下文、尺寸和重叠策略仍要正确。直接子节点是自定义组件时,需要按 React/库版本把 ref 传到目标 DOM。官方边界。
initial={false} 仅跳过首次存在子项的初始入场,后续新增节点仍可入场。
3.5 退出元素为何读到旧数据
移出的元素不再正常接收新 props。方向、退出尺度等需要通过 AnimatePresence 的 custom 提供 presence 数据,再由动态 variants 消费。原配方在 Presence 和子节点都设置 custom;理解数据流比机械多写一次 prop 更重要。
const variants = {
enter: d => ({ x: `${d * 100}%`, opacity: 0 }),
center: { x: "0%", opacity: 1 },
exit: d => ({ x: `${-d * 100}%`, opacity: 0 }),
};
更新步骤时同时记录方向,前进 +1、返回 -1;reduce 版本改成 opacity-only。旧内容退出期间还需避免可聚焦控件重复参与交互。
3.6 Spring 与配置作用域
常用起点:{ type: "spring", duration: 0.3, bounce: 0 };需要物理控制时用 stiffness/damping/mass。设置物理参数时不要期待 duration/bounce 仍按另一套模型生效。MotionConfig 可提供子树默认 transition,组件必要时覆盖;品牌节奏统一不等于所有数值完全相同。
3.7 Layout 的机制
改变真实 class/style 布局,Motion 测量前后矩形,用 transform 表现视觉过渡。它没有让 flex-direction 本身变成连续可插值属性。邻居需要协同移动时也加 layout;仅位置变化可考虑 position 类型的布局动画;滚动容器/固定容器测量还需要相应配置。
3.8 LayoutId:两份 DOM,一个视觉身份
缩略图与大图、按钮与表单容器、不同 Tab 下的高亮块,可以共享 layoutId。多个独立组件组要避免 ID 冲突,可用 LayoutGroup 命名空间。两份元素同时存在时,可能涉及交叉淡化,而非必须先卸载一份。
缩放可能拉伸文字、圆角与阴影。原文推荐内联像素圆角作为稳妥起点;官方要求相关样式可由 Motion 通过 style 读取,不宜把“仅像素、任何其他写法都失败”当永久规则。布局与纠偏。
3.9 动态高度
同样写着 auto 的元素,内部内容变高,不一定产生一个可识别的 auto→auto 动画目标。稳定做法是测量内层自然高度,动画外层高度;padding 计入测量层。
import useMeasure from "react-use-measure";
import { motion, useReducedMotion } from "motion/react";
export function MeasuredPanel({ children }) {
const [ref, bounds] = useMeasure();
const reduce = useReducedMotion();
return (
<motion.div
animate={{ height: reduce || bounds.height === 0 ? "auto" : bounds.height }}
transition={{ duration: reduce ? 0 : 0.2, ease: "easeOut" }}
style={{ overflow: "hidden" }}>
<div ref={ref} style={{ padding: 16 }}>{children}</div>
</motion.div>
);
}
依赖 react-use-measure;也可用 ResizeObserver。测量层与动画层相同会形成反馈,初次 0 需避免塌陷;但 0 也可能是真实高度,不应一律当“尚未测量”。图片加载、字体替换、文本换行、容器 resize 都要验收。
原文自适应时长 clamp(abs(newHeight-oldHeight)/500, 0.15, 0.27),保存 previousHeight,并分别决定高度和透明度是否使用同一时长。
3.10 高频数据的四个工具
| 工具 | 用途 | 典型坑 |
|---|---|---|
| useMotionValue | 原始连续数值,直接跟手 | 每次 pointermove 改 React state |
| useSpring | 向目标弹性追随 | 拖拽接触阶段被拖出滞后 |
| useTransform | 输入范围映射、派生值 | 多个独立 state 相互漂移 |
| useMotionTemplate | 拼接可响应字符串 | 普通模板字符串只得到一次快照 |
例:useTransform(y, [0, 300], [1, 0]) 将拖拽映射到透明度;useMotionTemplate 生成 inset/filter 字符串;计数可由 transform 派生显示文本。改变数字用 tabular-nums,避免字宽抖动。
3.11 手势与指针跟随
Drag 支持约束、弹性和释放动量;简单重新定位可关闭 dragMomentum。直接操控阶段不加追随弹簧;装饰性的 cursor follower 可以用 spring。坐标用 clientX/clientY 减元素 bounding rect;若祖先缩放/旋转,需要正确坐标转换,不能仅做相减。原文 mass:0.1 是跟随场景的起点,不是所有拖拽的默认。
3.12 命令式编排
UseAnimate 适合跨事件多步骤时间线;声明式适合根据状态得目标。命令式实现要保留控制句柄、取消旧动画、处理卸载清理,并明确哪些 Promise 需要等待。times 的长度应与关键帧数对应;不要为了“更高级”把简单 state swap 改成命令式。
3.13 配方:按钮与多步骤表单
按钮 idle→loading→success:稳定容器、keyed label、Presence 管理交接,必要时禁重复提交。原文标签上入下出形成滚轮感;本质是位置关系服务状态变化。表单前进/返回:方向 variants+测量高度+按钮 layout+统一 transition。功能与错误状态不能等动画完成才可用。
3.14 配方:Feedback Popover
按钮与弹层共享 wrapper ID,按钮文案与展示用提示文案共享 title ID,造成标签延展到表单的错觉。展示文案 aria-hidden;真实输入需要 label,可选 placeholder 不能替代 label。Escape 关闭、Cmd/Ctrl+Enter 提交、退出后焦点回触发按钮;无障碍由真实表单与弹层结构承担。
3.15 配方:图片进垃圾桶
网格图片与桶内图片共享每张图片唯一 ID;父级额外 y 位移可以叠加下落轨迹。未选图片快速淡出,桶前面板通过层级/时序形成遮挡。73px、0.13s、0.175s 是特定场景的配方数值,复现时须按实际几何重新量。删除应有确定业务状态,动画只是表现。
3.16 配方:交互图与 App Store 卡片
交互图是裁切揭示,不是真正逐点画线;指针位置映射到右裁切比例,再用 spring 追随。离开后延迟恢复,重新进入清除计时器,卸载时也清理。恢复与跟随可采用不同参数。
卡片→详情:图片、标题、描述、按钮分别配对 ID,真实 fixed 布局变化由 layout 系统测量;遮罩退出可以更快,长描述迅速离开,关闭恢复滚动、焦点和触发位置。原示例的 50ms 遮罩退出是局部参数。
3.17 配方:Dynamic Island 与导航菜单
有限状态表记录每对状态的尺寸、scale、方向、bounce,子内容用 wait,数字等宽;原文 idle:0.5 等 bounce 是明确的品牌选择。对于有限状态,显式映射比抽象猜测更可控。
宽导航菜单可由原语暴露 viewport 尺寸和 data-motion 来驱动 CSS;大容器初始 scale 接近 1、方向随项目切换。不要硬写跨库的 data attribute,先看使用版本的原语文档。
3.18 Debug 检查表
退出不播→Presence 是否仍在、身份 key、exit 是否能到节点;方向错→custom/presence 数据;高度冻结→测量层;邻居跳动→布局协同;字符串不动→响应模板;圆角扭曲→缩放纠偏;首次乱动→initial=false。原文建议降到 11.0.10 绕旧 bug,不能作为当前通用方案,应按最小复现和项目版本核对修复记录。
自测与答案
-
Layout 能让 flex-direction 插值吗?它测量布局结果并变换视觉,不是让该属性本身连续化。
-
Wait 能同时管理一排独立新旧子项吗?不适合,官方限定一次一个子项。
-
所有 pointermove 都用 useSpring 吗?直接跟手用原始值,装饰追随再用弹簧。
-
Placeholder 是输入框的可访问名称吗?不能替代 label。
完成标志:能实现可快速反向操作的状态切换,解释其挂载、布局测量与高频值更新路径。
来自作者已整理的 Skills 中文自学资料。数字和配方是调试起点,库与浏览器的行为需结合实际版本验证;教材中的原演示验收记录不等同于你的项目已通过测试。
技术校正与版本边界 ↗完整来源说明 ↗