从补间到叙事:GSAP 动画实战 · 公开阅读
第15章 Flip插件
使用 Flip 实现布局切换、元素移动和尺寸变化的平滑过渡。
本章小节
15.1 Flip是什么
Flip 让页面上的卡片从旧位置平滑过渡到新位置,就像搬家时先拍照记录、再搬到新位置的过程。
Flip 不是 CSS 的 flip 翻转属性,而是四个英文单词的首字母:First(拍照)/ Last(搬家后拍照)/ Invert(算差距)/ Play(飞回去)。
把它想象成你搬家时的操作:
- First(拍照) — 搬家前,你先给每个家具拍张照片,记下它现在在哪
- Last(搬家后拍照) — 家具搬到新家后,再拍一张,标记新位置在哪
- Invert(算差距) — 算一下旧位置和新位置差多远,然后施个"魔法"让家具先保持在旧位置
- Play(飞回去) — 撤掉魔法,家具从旧位置飞向新位置,整个过程变成动画
四个字串起来就是一个完整的动画流水线:
拍照记录位置 → 改变布局 → 算出差距并"拉回来" → 放手让元素飞过去Flip 插件解决的核心问题:让 CSS 布局变化变成流畅动画。 没有 Flip 时,flex 或 grid 里的子元素重排是瞬间闪现到新位置;有了 Flip,闪现变成平滑过渡。
15.2 为什么需要Flip
CSS布局变化的"瞬移"问题
假设你点击一个按钮改变卡片的 order 属性,元素直接跳到新位置,中间没有任何过渡:
看这段代码——你点击按钮改变 order 属性,元素直接跳过去,冷冰冰的,没有任何过渡:
<style>
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.card { background: #3498db; padding: 20px; color: white; }
</style>
<div class="grid">
<div class="card">1</div>
<div class="card">2</div>
<div class="card">3</div>
</div>
<button onclick="document.querySelector('.card').style.order = 3">移动元素1到末尾</button>点击后元素1直接出现在末尾——没有动画。这背后的问题其实很本质:
| 问题 | 原因 |
|---|---|
CSS transition 不支持 order | order 是离散值,无法从1平滑过渡到3 |
| flex/grid 重排是瞬时的 | 浏览器在渲染帧之间完成重排,来不及加动画 |
| 元素瞬间出现在新位置 | 没有中间帧,相当于直接传送过去 |
Flip 插件的思路:不管浏览器怎么瞬间重排,自己算两个位置的差值,然后用 transform 补一段动画上去。 先量好"从哪里到哪里",再让 transform 把这段路走完。
15.3 Flip核心流程——搬家具四部曲
Flip 的四步工作流程,本质上就是搬家具的逻辑。
第一步:拍照(record) — 记录初始状态
搬家具之前先拍照:
// 记录所有 .card 元素的当前位置和尺寸
const state = Flip.getState(".card");Flip.getState() 给每个元素拍了一张快照,记录位置 {x, y, width, height, rotation, scale, opacity} 等信息。
第二步:搬家(改变布局)
把家具搬到新位置:
// 任意改变:切换类名、修改DOM顺序、改变flex属性
document.querySelector(".grid").classList.toggle("reordered");第三步:算差并拉回来(Invert)
// Flip.from() 内部自动计算位移差,先反向补偿
const flip = Flip.from(state, {
duration: 0.6,
ease: "power2.inOut",
});GSAP 在背后自动做了这些事:算每个元素从旧位置到新位置差了多少,然后用 translate() 反向补偿,此时元素视觉上还在原位。
第四步:松手飞(Play)
上一步的动画会自动执行:反向位移从 100% 到 0%,元素平滑移动到新位置。
完整代码示例
<div class="grid" id="grid">
<div class="card">A</div>
<div class="card">B</div>
<div class="card">C</div>
<div class="card">D</div>
</div>
<button onclick="shuffle()">随机排序</button>
<script>
function shuffle() {
// Step 1: 记录
const state = Flip.getState(".card");
// Step 2: 改变布局(随机重排DOM)
const grid = document.getElementById("grid");
const cards = Array.from(grid.children);
cards.sort(() => Math.random() - 0.5);
cards.forEach(card => grid.appendChild(card));
// Step 3 + 4: 计算并播放动画
Flip.from(state, {
duration: 0.5,
ease: "power2.inOut",
scale: true, // 同时动画缩放变化
});
}
</script>先记录旧状态,再提交目标布局,最后测量新状态并播放。直接修改 DOM 可同步完成;React setState 需要等待提交,Vue 更新后常需 nextTick。避免在过程中插入无关异步任务,让旧快照失去对应关系。
把这一节跑一遍:Flip 布局切换
布局先真正变成目标列数,Flip 再计算并应用逆变换,让视觉从旧布局回到新布局。读数和 DOM 列数可帮助区分“目标布局已提交”和“过渡还没结束”。
实验:Flip 布局切换
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
开启后已准备好一条暂停的“3列→2列”过渡,点击播放或拖动进度即可观察,不必先点击另一个按钮。此处使用 absolute:false 和固定的舞台最小高度,保留布局空间;卡片以不同颜色区分,便于追踪位置。到终点后再播放会创建反向布局过渡。
观察与验证:切换后立刻暂停在中间进度,观察元素过渡位置;再快速切换,验证旧 Flip 被终止并重新拍照。
15.4 Flip.from()参数详解
大部分时候只需要 duration 和 ease,其余按需选用。
基础参数
Flip.from(state, {
duration: 0.6, // 动画时长(秒)
ease: "power2.inOut", // 缓动函数
stagger: 0.05, // 交错延迟(秒),让元素逐个飞而不是一起飞
absolute: false, // 是否使用position:absolute("脱离地心引力"飞行)
scale: false, // 是否动画缩放变化(元素大小变了也平滑过渡)
nested: false, // 是否处理嵌套元素(父子一起飞)
targets: null, // 目标元素选择器(默认state中的所有元素)
simple: false, // 简化模式:只飞位置,不处理缩放/旋转(最省性能)
spin: false, // 旋转优化:选择最短旋转路径
onEnter: null, // 新元素入场回调
onLeave: null, // 旧元素退场回调
onComplete: null, // 动画完成回调
});scale: true — 连大小变化也做成动画
当元素在新旧位置之间大小发生变化时(比如从单列布局变成三列布局,卡片从宽变窄),打开 scale: true 就不会有突兀的"瞬间跳大小":
// 元素宽度从300px变为100px,scale:true让这个过程有动画
const state = Flip.getState(".card");
// ...改变grid-template-columns...
Flip.from(state, {
duration: 0.5,
scale: true, // 打开缩放动画
});scale 会动画处理 width/height 变化,本质上通过 transform: scale() 实现。
absolute: true — 让元素脱离文档流飞行
当你需要元素不受文档流约束地飞过去时:
Flip.from(state, {
duration: 0.6,
absolute: true, // 动画期间元素变为position:absolute
onLeave: (el) => el.classList.add("fading-out"),
onComplete: () => console.log("所有飞行完成"),
});absolute: true 的流程:
- 给元素设置
position: absolute,让它脱离文档流 - 在绝对坐标下计算 Invert 差距
- 动画结束后移除
absolute,元素回到文档流新位置
典型用途:列表项从一个容器过渡到另一个容器,两个元素可能在不同的 flex/grid 容器里,绝对定位让它们跨容器过渡而不影响其他元素。
nested: true — 父子一起飞
处理父子元素同时移动的场景:
// 父元素改变位置,子元素内部的相对位置也需要动画
Flip.from(state, {
nested: true,
duration: 0.6,
});nested 会递归处理:先处理父元素位移,再处理子元素的相对位移,最后合成最终动画。
simple: true — 无变换场景下简化测量
当目标和相关祖先没有旋转、缩放或倾斜时,可以在记录状态时设置 simple: true,简化测量。它不是“只动画位置”的开关;有复杂变换时应使用默认测量方式:
const simpleState = Flip.getState(".card", { simple: true });
// 同步修改布局后
Flip.from(simpleState, { duration: 0.3 });新旧元素的处理
列表过滤时,有些元素出现、有些元素消失。Flip 帮你优雅地处理这种情况:
| 回调 | 触发时机 | 用途 |
|---|---|---|
onEnter(elements) | 新出现在布局中的元素 | 设置入场动画初始状态 |
onLeave(elements) | 从旧布局中消失的元素 | 设置退场动画 |
Flip.from(state, {
duration: 0.5,
onEnter: (elements) => {
// 新加入的元素从透明和缩小开始
gsap.fromTo(elements,
{ opacity: 0, scale: 0.8 },
{ opacity: 1, scale: 1, duration: 0.5 }
);
},
onLeave: (elements) => {
// 离开的元素淡出消失
gsap.to(elements, { opacity: 0, scale: 0.8, duration: 0.3 });
},
});15.5 Flip.fit()
Flip.fit() 让一个元素精确对齐到另一个元素的位置和大小。常用场景:
- 点击缩略图展开详情
- 图片从列表项放大到全屏查看
- 弹出窗从触发按钮的位置展开
// 让 element 移动到 targetElement 的位置和尺寸
Flip.fit(element, targetElement, {
duration: 0.5,
ease: "power2.out",
scale: true, // 缩放匹配
fitChild: null, // 指定element的子元素作为适配对象
getVars: false, // true时返回动画参数而不执行
});实战:点击缩略图展开
<div class="gallery">
<img class="thumb" src="thumb1.jpg" data-full="full1.jpg">
<img class="thumb" src="thumb2.jpg" data-full="full2.jpg">
</div>
<div class="overlay hidden" id="overlay">
<img id="fullImage" src="">
</div>
<script>
document.querySelectorAll(".thumb").forEach(thumb => {
thumb.addEventListener("click", () => {
const overlay = document.getElementById("overlay");
const fullImg = document.getElementById("fullImage");
// 先显示叠加层并设置图片
fullImg.src = thumb.dataset.full;
overlay.classList.remove("hidden");
// fit:缩略图"飞"到全屏位置
Flip.fit(fullImg, thumb, {
duration: 0.4,
ease: "power2.out",
scale: true,
});
});
});
</script>把这一节跑一遍:Flip.fit 容器适配
fit 让当前卡片对齐目标边界,scale:true 使用缩放,因此卡片文字也缩放。布局切换与容器适配是不同用途;要重新排版内部内容,应考虑真实尺寸变化。
实验:Flip.fit 容器适配
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
卡片初始对齐区域 A,播放时移动并缩放到 B。可以停在中间比较实测宽高;完成后再次播放返回 A。
观察与验证:切换 A/B 并拖动进度,比较位置与尺寸。思考卡片文字不可缩放时是否适合 scale:true。
15.6 Flip.getState()
// 获取所有匹配元素的状态
const state = Flip.getState(".card");
// 获取单个元素的状态
const state = Flip.getState(document.getElementById("myElement"));
// 高级选项
const state = Flip.getState(".card", {
props: "transform,opacity", // 指定要记录的自定义属性
simple: false, // 简化模式
});state 是 FlipState 对象,内部记录各目标的布局和变换信息;它不是 DOM 数组,也不应当作数组直接遍历。记录的信息包括:
{
id: "card-1", // 自动生成的唯一ID
element: <div>, // DOM元素引用
bounds: { // getBoundingClientRect()
x: 100, y: 200,
width: 300, height: 150
},
style: { // 计算样式
transform: "none",
opacity: "1",
}
}
getBoundingClientRect()必要时会强制同步布局。直接写 DOM 后可测量;框架状态更新则必须确认新 DOM 已提交,不能靠“JS 执行间隙”自动保证。React 可在受控的 layout effect/同步提交后调用 Flip,Vue 可在 nextTick 后调用。
15.7 实战示例
示例1:筛选列表,卡片飞入飞出
点击"前端"按钮,只有前端相关的卡片留下,其他卡片飞走。关键是把筛选后的卡片平滑过渡到新位置,而不是瞬间跳过去:
<div class="filter-bar">
<button data-filter="all">全部</button>
<button data-filter="frontend">前端</button>
<button data-filter="backend">后端</button>
</div>
<div class="project-grid" id="projectGrid">
<div class="project" data-cat="frontend">Vue Dashboard</div>
<div class="project" data-cat="backend">Node API</div>
<div class="project" data-cat="frontend">React App</div>
<div class="project" data-cat="backend">Python Service</div>
<div class="project" data-cat="frontend">Angular Admin</div>
<div class="project" data-cat="backend">Go Microservice</div>
</div>
<script>
document.querySelectorAll(".filter-bar button").forEach(btn => {
btn.addEventListener("click", () => {
const filter = btn.dataset.filter;
const state = Flip.getState(".project");
// 显示/隐藏项目
document.querySelectorAll(".project").forEach(proj => {
const show = filter === "all" || proj.dataset.cat === filter;
proj.style.display = show ? "block" : "none";
});
Flip.from(state, {
duration: 0.4,
ease: "power2.inOut",
absolute: true, // 避免隐藏元素影响布局计算
onLeave: (elements) => gsap.to(elements, {
opacity: 0, scale: 0.5, duration: 0.3
}),
onEnter: (elements) => gsap.fromTo(elements,
{ opacity: 0, scale: 0.5 },
{ opacity: 1, scale: 1, duration: 0.3 }
),
});
});
});
</script>示例2:网格列数切换(1列→2列→3列)
切换布局列数时,卡片不仅位置会变,大小也会变。scale: true 让这个过程行云流水:
<div class="controls">
<button onclick="setColumns(1)">1列</button>
<button onclick="setColumns(2)">2列</button>
<button onclick="setColumns(3)">3列</button>
</div>
<div class="card-grid" id="cardGrid" style="grid-template-columns: repeat(3, 1fr)">
<!-- 6张卡片 -->
<div class="card">卡片1</div>
<div class="card">卡片2</div>
<div class="card">卡片3</div>
<div class="card">卡片4</div>
<div class="card">卡片5</div>
<div class="card">卡片6</div>
</div>
<script>
function setColumns(cols) {
const state = Flip.getState(".card");
document.getElementById("cardGrid").style.gridTemplateColumns =
`repeat(${cols}, 1fr)`;
Flip.from(state, {
duration: 0.5,
ease: "power2.inOut",
scale: true, // 列数变化时卡片宽高也变,需要scale动画
stagger: 0.03,
});
}
</script>示例3:拖拽排序——放手后元素飞到正确位置
这是 Flip 最经典的用法之一:拖拽排序列表,松手后其他元素平滑"让位":
<div class="sortable-list" id="sortableList">
<div class="sort-item" draggable="true">项目 Alpha</div>
<div class="sort-item" draggable="true">项目 Beta</div>
<div class="sort-item" draggable="true">项目 Gamma</div>
<div class="sort-item" draggable="true">项目 Delta</div>
</div>
<script>
const list = document.getElementById("sortableList");
let draggedItem = null;
list.addEventListener("dragstart", (e) => {
draggedItem = e.target;
e.target.style.opacity = "0.5";
});
list.addEventListener("dragover", (e) => {
e.preventDefault();
const afterElement = getDragAfterElement(list, e.clientY);
if (afterElement == null) {
list.appendChild(draggedItem);
} else {
list.insertBefore(draggedItem, afterElement);
}
});
list.addEventListener("drop", (e) => {
e.preventDefault();
const state = Flip.getState(".sort-item");
// 重新插入确保顺序正确
if (afterElement) list.insertBefore(draggedItem, afterElement);
else list.appendChild(draggedItem);
draggedItem.style.opacity = "1";
Flip.from(state, {
duration: 0.3,
ease: "power2.out",
});
draggedItem = null;
});
function getDragAfterElement(container, y) {
const items = [...container.querySelectorAll(".sort-item:not(.dragging)")];
return items.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: child };
}
return closest;
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
</script>15.8 常见陷阱
陷阱1:拍照和搬家之间插了异步操作
不要在旧状态与目标布局之间等待无关任务;等待框架提交是有目的的步骤,必须保留正确的旧快照并处理快速重复更新。
// 错误
const state = Flip.getState(".box");
await someAsyncTask(); // 布局可能已经变了!
doLayoutChange();
Flip.from(state);
// 正确
const state = Flip.getState(".box");
doLayoutChange();
Flip.from(state);原因:await 之后浏览器可能已经完成了一帧新的渲染,你拍的"旧照片"已经过时了。
陷阱2:absolute: true 后残留定位
// 如果动画中途被中断,absolute可能残留
const flip = Flip.from(state, { absolute: true });
// 解决方法:手动清理
flip.kill(); // 或
document.querySelectorAll(".card").forEach(el => el.style.position = "");推荐在
onComplete中确保清理。
陷阱3:嵌套Flip的顺序问题
// 如果父容器和子元素都有Flip动画
const state1 = Flip.getState(".parent");
const state2 = Flip.getState(".child");
changeLayout();
// 顺序很重要:先父后子
Flip.from(state1, { duration: 0.5 });
Flip.from(state2, { duration: 0.5, nested: true });使用 nested: true 可以自动处理嵌套关系,避免手动管理时序。
陷阱4:display:none 的元素无法拍照
Flip.getState() 对 display: none 的元素拍不了照(getBoundingClientRect() 获取不到尺寸)。需要先让它"露个脸"再拍:
// 错误
el.style.display = "none";
const state = Flip.getState(el); // bounds全为0
// 正确
el.style.display = "block";
const state = Flip.getState(el); // 正确获取
el.style.display = "none";陷阱5:transform 状态叠加
Flip 会计算已有的 transform,不需要另写快照回调。GSAP 3.13 没有 snapshotFunction 选项;需要额外记录并过渡颜色等 CSS 属性时,使用 Flip.getState(targets, { props: "backgroundColor,color" })。变换较复杂时不要启用 simple: true。
陷阱6:大列表中性能衰减
100个以上元素同时 Flip 时,每个元素都需要 getBoundingClientRect() 计算。优化方案:
- 仅在目标及相关祖先没有旋转、缩放或倾斜时,使用
simple: true简化测量 - 限制
stagger不要过大,避免动画重叠导致布局抖动 - 对不可见元素提前过滤掉