返回目录

从补间到叙事:GSAP 动画实战 · 公开阅读

第15章 Flip插件

使用 Flip 实现布局切换、元素移动和尺寸变化的平滑过渡。

本章小节

15.1 Flip是什么

Flip 让页面上的卡片从旧位置平滑过渡到新位置,就像搬家时先拍照记录、再搬到新位置的过程。

Flip 不是 CSS 的 flip 翻转属性,而是四个英文单词的首字母:First(拍照)/ Last(搬家后拍照)/ Invert(算差距)/ Play(飞回去)。

把它想象成你搬家时的操作:

  1. First(拍照) — 搬家前,你先给每个家具拍张照片,记下它现在在哪
  2. Last(搬家后拍照) — 家具搬到新家后,再拍一张,标记新位置在哪
  3. Invert(算差距) — 算一下旧位置和新位置差多远,然后施个"魔法"让家具先保持在旧位置
  4. 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 不支持 orderorder 是离散值,无法从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 的流程:

  1. 给元素设置 position: absolute,让它脱离文档流
  2. 在绝对坐标下计算 Invert 差距
  3. 动画结束后移除 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 不要过大,避免动画重叠导致布局抖动
  • 对不可见元素提前过滤掉