Skip to content

FLIP 动画实现详解 ​

原生 JavaScript 实现 ​

FLIP 是一种动画技术,由以下四个步骤组成:

  • First (首位): 记录元素的初始状态
  • Last (末位): 记录元素的最终状态
  • Invert (反转): 计算从初始到最终状态的变化
  • Play (播放): 执行动画

这种技术特别适用于需要在不同布局状态之间平滑过渡的场景,比如列表重排序、网格/列表视图切换等,为此我们可以先设计一个原生的实现,然后基于 React 封装一个组件,实现更易用的使用。 我们先定义一个通用的 FLIP 动画封装:

js
FLIP.animate({
  elements: NodeList | Element[],   // 要参与动画的元素
  changeDOM: () => { ... },         // 在这步执行 DOM 变化(删除、插入、排序等)
  options: { duration, easing },    // 动画参数
})

内部执行 4 个阶段:

  1. First:记录所有元素的初始位置;
  2. Change DOM:执行 DOM 更新;
  3. Last:记录更新后的布局;
  4. Invert + Play:对比位置差异并播放动画。

封装核心逻辑(纯原生 JS)

js
class FLIP {
  static animate({ elements, changeDOM, options = {} }) {
    const els = Array.from(elements);
    const duration = options.duration || 400;
    const easing = options.easing || 'ease';

    // 1️⃣ 记录 First(初始位置)
    const firstRects = new Map();
    els.forEach(el => {
      firstRects.set(el, el.getBoundingClientRect());
    });

    // 2️⃣ 执行 DOM 变更
    changeDOM?.();

    // 3️⃣ 记录 Last(最终位置)
    const lastRects = new Map();
    els.forEach(el => {
      lastRects.set(el, el.getBoundingClientRect());
    });

    // 4️⃣ Invert + Play
    requestAnimationFrame(() => {
      els.forEach(el => {
        const first = firstRects.get(el);
        const last = lastRects.get(el);
        if (!first || !last) return;

        const dx = first.left - last.left;
        const dy = first.top - last.top;
        const ds = first.width / last.width;

        if (dx || dy || ds !== 1) {
          el.animate(
            [
              { transform: `translate(${dx}px, ${dy}px) scale(${ds})` },
              { transform: 'translate(0, 0) scale(1)' }
            ],
            { duration, easing }
          );
        }
      });
    })
  }
}

常见FLIP 动画案例整合

html
<!DOCTYPE html>
<html lang="zh">

<head>
  <meta charset="UTF-8">
  <title>FLIP 动画综合示例</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      font-family: sans-serif;
      background: #f3f4f6;
      padding: 20px;
    }

    h1 {
      text-align: center;
      margin-bottom: 16px;
    }

    .tabs {
      display: flex;
      justify-content: center;
      gap: 12px;
      margin-bottom: 24px;
    }

    .tabs button {
      background: #579ef8;
      color: #fff;
      border: none;
      padding: 8px 16px;
      border-radius: 6px;
      cursor: pointer;
    }

    .tabs button.active {
      background: #2563eb;
    }

    .demo {
      display: none;
    }

    .demo.active {
      display: block;
    }

    /* 通用样式 */
    .btns {
      text-align: center;
      margin-bottom: 16px;
    }

    .btns button {
      background: #579ef8;
      color: #fff;
      border: none;
      padding: 8px 12px;
      border-radius: 6px;
      cursor: pointer;
      margin: 0 4px;
    }

    .btns button:hover {
      opacity: .8;
    }

    /* 随机排序 */
    .container {
      width: 520px;
      margin: 0 auto;
      display: flex;
      flex-wrap: wrap;
    }

    .item {
      width: 50px;
      height: 50px;
      margin: 5px;
      background: #eef5fe;
      border: 1px solid #ddebfd;
      text-align: center;
      line-height: 50px;
      border-radius: 6px;
    }

    /* 删除卡片 / 添加卡片 */
    .grid {
      width: 900px;
      display: grid;
      grid-template-columns: repeat(4, 1fr);
      gap: 16px;
      margin: 0 auto;
    }

    .card {
      background: #fff;
      border-radius: 10px;
      padding: 16px;
      box-shadow: 0 6px 18px rgba(2, 6, 23, 0.08);
      position: relative;
      min-height: 100px;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: box-shadow .2s ease;
    }

    .card:hover {
      box-shadow: 0 10px 28px rgba(2, 6, 23, 0.12);
    }

    .close {
      position: absolute;
      top: 8px;
      right: 8px;
      background: #ef4444;
      color: #fff;
      border: none;
      border-radius: 50%;
      width: 28px;
      height: 28px;
      cursor: pointer;
    }

    .removing {
      opacity: 0;
      transform: scale(.9);
      transition: opacity .25s ease, transform .25s ease;
    }

    /* 切换布局 */
    .layout-container {
      width: 800px;
      margin: 0 auto;
      display: grid;
      gap: 10px;
      transition: all .3s ease;
    }

    .layout-container.grid {
      grid-template-columns: repeat(4, 1fr);
    }

    .layout-container.list {
      grid-template-columns: 1fr;
    }

    .layout-item {
      background: #e0f2fe;
      border: 1px solid #bae6fd;
      padding: 16px;
      text-align: center;
      border-radius: 8px;
    }
  </style>
</head>

<body>
  <h1>✨ FLIP 动画综合示例</h1>

  <div class="tabs">
    <button class="tab-btn active" data-target="demo-sort">随机排序</button>
    <button class="tab-btn" data-target="demo-remove">删除卡片</button>
    <button class="tab-btn" data-target="demo-add">添加卡片</button>
    <button class="tab-btn" data-target="demo-reverse">反转顺序</button>
    <button class="tab-btn" data-target="demo-layout">切换布局</button>
  </div>

  <!-- Demo 1: 随机排序 -->
  <div class="demo active" id="demo-sort">
    <div class="btns"><button id="sort">随机排序</button></div>
    <div class="container" id="container"></div>
  </div>

  <!-- Demo 2: 删除卡片 -->
  <div class="demo" id="demo-remove">
    <div class="grid" id="grid-remove"></div>
  </div>

  <!-- Demo 3: 添加卡片 -->
  <div class="demo" id="demo-add">
    <div class="btns"><button id="add-card">添加卡片</button></div>
    <div class="grid" id="grid-add"></div>
  </div>

  <!-- Demo 4: 反转顺序 -->
  <div class="demo" id="demo-reverse">
    <div class="btns"><button id="reverse">反转列表</button></div>
    <div class="container" id="container-reverse"></div>
  </div>

  <!-- Demo 5: 切换布局 -->
  <div class="demo" id="demo-layout">
    <div class="btns"><button id="toggle-layout">切换布局</button></div>
    <div class="layout-container grid" id="layout-container"></div>
  </div>

  <script>
    /**********************
     * Demo 1: 随机排序
     **********************/
    const container = document.getElementById('container');
    for (let i = 1; i <= 50; i++) {
      const div = document.createElement('div');
      div.className = 'item';
      div.textContent = i;
      container.appendChild(div);
    }
    document.getElementById('sort').onclick = () => {
      const items = Array.from(container.children);
      FLIP.animate({
        elements: items,
        changeDOM: () => {
          for (let i = items.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            container.insertBefore(items[j], items[i]);
          }
        },
        options: { duration: 600, easing: 'ease-out' }
      });
    };

    /**********************
     * Demo 2: 删除卡片
     **********************/
    const gridRemove = document.getElementById('grid-remove');
    for (let i = 1; i <= 20; i++) {
      const card = document.createElement('div');
      card.className = 'card';
      card.innerHTML = `<button class="close">×</button><div>Card ${i}</div>`;
      gridRemove.appendChild(card);
    }
    gridRemove.addEventListener('click', e => {
      if (!e.target.classList.contains('close')) return;
      const card = e.target.closest('.card');
      card.classList.add('removing');
      card.addEventListener('transitionend', () => {
        const cards = Array.from(gridRemove.children);
        FLIP.animate({
          elements: cards,
          changeDOM: () => card.remove(),
          options: { duration: 400, easing: 'ease-out' }
        });
      }, { once: true });
    });

    /**********************
     * Demo 3: 添加卡片
     **********************/
    const gridAdd = document.getElementById('grid-add');
    let addCount = 1;
    document.getElementById('add-card').onclick = () => {
      const cards = Array.from(gridAdd.children);
      FLIP.animate({
        elements: cards,
        changeDOM: () => {
          const newCard = document.createElement('div');
          newCard.className = 'card';
          newCard.innerHTML = `<div>New Card ${addCount++}</div>`;
          gridAdd.insertBefore(newCard, gridAdd.firstChild);
        },
        options: { duration: 400, easing: 'ease-out' }
      });
    };

    /**********************
     * Demo 4: 反转顺序
     **********************/
    const containerReverse = document.getElementById('container-reverse');
    for (let i = 1; i <= 12; i++) {
      const div = document.createElement('div');
      div.className = 'item';
      div.textContent = i;
      containerReverse.appendChild(div);
    }
    document.getElementById('reverse').onclick = () => {
      const items = Array.from(containerReverse.children);
      FLIP.animate({
        elements: items,
        changeDOM: () => items.reverse().forEach(el => containerReverse.appendChild(el)),
        options: { duration: 600, easing: 'ease-in-out' }
      });
    };

    /**********************
     * Demo 5: 切换布局
     **********************/
    const layoutContainer = document.getElementById('layout-container');
    for (let i = 1; i <= 8; i++) {
      const div = document.createElement('div');
      div.className = 'layout-item';
      div.textContent = `Item ${i}`;
      layoutContainer.appendChild(div);
    }
    let isGrid = true;
    document.getElementById('toggle-layout').onclick = () => {
      const items = Array.from(layoutContainer.children);
      FLIP.animate({
        elements: items,
        changeDOM: () => {
          layoutContainer.classList.toggle('grid', !isGrid);
          layoutContainer.classList.toggle('list', isGrid);
          isGrid = !isGrid;
        },
        options: { duration: 600, easing: 'ease-in-out' }
      });
    };

    /**********************
     * Tab 切换逻辑
     **********************/
    document.querySelectorAll('.tab-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        const target = btn.dataset.target;
        document.querySelectorAll('.demo').forEach(d => d.classList.remove('active'));
        document.getElementById(target).classList.add('active');
      });
    });
  </script>
</body>

</html>

React hook - useFLIP 封装实现 ​

1. useFLIP ​

javascript
import React, { useLayoutEffect, useRef, useState } from "react";

/**
 * 通用 FLIP 动画 Hook
 * @param {Array} deps - 依赖数组(当变化时触发动画)
 * @param {Object} options - 动画选项
 */
function useFLIP(deps, options = {}) {
  const containerRef = useRef(null);
  const prevRectsRef = useRef(new Map());
  const duration = options.duration || 500;
  const easing = options.easing || "ease";

  // 核心动画逻辑
  useLayoutEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const elements = Array.from(container.children);
    const prevRects = prevRectsRef.current;
    const nextRects = new Map();

    // 计算更新后的位置信息
    elements.forEach(el => nextRects.set(el, el.getBoundingClientRect()));

    // 播放动画
    requestAnimationFrame(() => {
      elements.forEach(el => {
        const first = prevRects.get(el);
        const last = nextRects.get(el);
        if (!first || !last) return;
        const dx = first.left - last.left;
        const dy = first.top - last.top;
        const dw = first.width / last.width;
        const dh = first.height / last.height;

        if (dx || dy || dw !== 1 || dh !== 1) {
          el.animate(
            [
              { transform: `translate(${dx}px, ${dy}px) scale(${dw}, ${dh})` },
              { transform: "translate(0, 0) scale(1, 1)" }
            ],
            { duration, easing }
          );
        }
      });
    })


    // 更新保存当前布局
    prevRectsRef.current = new Map();
    elements.forEach(el => prevRectsRef.current.set(el, el.getBoundingClientRect()));
  }, deps);

  // 初始化时记录初始布局
  useLayoutEffect(() => {
    const container = containerRef.current;
    if (container) {
      const elements = Array.from(container.children);
      prevRectsRef.current = new Map();
      elements.forEach(el => prevRectsRef.current.set(el, el.getBoundingClientRect()));
    }
  }, []);

  return containerRef;
}

2. useFLIP 使用示例 ​

javascript

/** 示例 1:随机排序 */
export function FlipSortDemo() {
  const [items, setItems] = useState([...Array(20).keys()].map(i => i + 1));
  const containerRef = useFLIP([items]);

  const shuffle = () => {
    setItems([...items].sort(() => Math.random() - 0.5));
  };

  return (
    <div className="text-center">
      <button
        onClick={shuffle}
        className="bg-blue-500 text-white px-3 py-1 rounded mb-4"
      >
        随机排序
      </button>

      <div
        ref={containerRef}
        className="flex flex-wrap justify-center w-[520px] mx-auto"
      >
        {items.map(i => (
          <div
            key={i}
            className="w-12 h-12 m-1 flex items-center justify-center bg-blue-100 border border-blue-200 rounded"
          >
            {i}
          </div>
        ))}
      </div>
    </div>
  );
}

/** 示例 2:删除卡片 */
export function FlipRemoveDemo() {
  const [cards, setCards] = useState([...Array(12).keys()].map(i => i + 1));
  const containerRef = useFLIP([cards]);

  return (
    <div className="grid grid-cols-4 gap-4 w-[800px] mx-auto">
      <div ref={containerRef} className="contents">
        {cards.map(c => (
          <div
            key={c}
            className="relative bg-white p-4 rounded shadow text-center"
          >
            <button
              className="absolute top-1 right-2 bg-red-500 text-white w-6 h-6 rounded-full"
              onClick={() => setCards(cards.filter(x => x !== c))}
            >
              ×
            </button>
            Card {c}
          </div>
        ))}
      </div>
    </div>
  );
}

三方库的使用 ​

react-flip-toolkit ​

react-flip-toolkit 是一个用于实现 FLIP 动画的 React 组件库,他的包体积小,专注实现 FLIP 动画,因此使用起来比较方便。但是注意:元素已有 transform 时:react-flip-toolkit 可能直接覆盖 transform,需先读取并合并原 transform(或用 wrapper node)。framer-motion 用矩阵方式能更好保留原有 transform。

js
import React, { useState } from "react";
import { Flipper, Flipped } from "react-flip-toolkit";

/** 示例 1:随机排序 */
export function FlipToolkitSortDemo() {
  const [items, setItems] = useState([...Array(20).keys()].map(i => i + 1));

  const shuffle = () => {
    setItems([...items].sort(() => Math.random() - 0.5));
  };

  return (
    <div className="text-center">
      <button
        onClick={shuffle}
        className="bg-blue-500 text-white px-3 py-1 rounded mb-4"
      >
        随机排序
      </button>

      <Flipper flipKey={items.join(",")} spring={{ stiffness: 280, damping: 24 }}>
        <div className="flex flex-wrap justify-center w-[520px] mx-auto">
          {items.map(i => (
            <Flipped key={i} flipId={`item-${i}`}>
              <div className="w-12 h-12 m-1 flex items-center justify-center bg-blue-100 border border-blue-200 rounded">
                {i}
              </div>
            </Flipped>
          ))}
        </div>
      </Flipper>
    </div>
  );
}

/** 示例 2:删除卡片 */
export function FlipToolkitRemoveDemo() {
  const [cards, setCards] = useState([...Array(12).keys()].map(i => i + 1));

  const onAppear = (el, index) => {
    el.style.opacity = "0";
    setTimeout(() => {
      el.style.opacity = "1";
    }, index * 50);
  };

  return (
    <Flipper
      flipKey={cards.join(",")}
      spring={{ stiffness: 280, damping: 24 }}
      decisionData={cards}
    >
      <div className="grid grid-cols-4 gap-4 w-[800px] mx-auto">
        {cards.map((c, index) => (
          <Flipped
            key={c}
            flipId={c}
            onAppear={onAppear}
            onExit={(el, index, removeElement) => {
              el.style.opacity = "0";
              setTimeout(removeElement, 300);
            }}
          >
            <div className="relative bg-white p-4 rounded shadow text-center transition-opacity duration-300">
              <Flipped inverseFlipId={c}>
                <button
                  className="absolute top-1 right-2 bg-red-500 text-white w-6 h-6 rounded-full"
                  onClick={() => setCards(cards.filter(x => x !== c))}
                >
                  ×
                </button>
              </Flipped>
              <Flipped inverseFlipId={c}>
                <div>Card {c}</div>
              </Flipped>
            </div>
          </Flipped>
        ))}
      </div>
    </Flipper>
  );
}

framer-motion ​

如果你的应用有大量复杂布局动画、shared element 跨路由/跨层级需求、或者你需要与手势/variants/exit 等其它动画组合,优先选择 framer-motion(更稳、更全面)。

js
import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";

/** 示例 1:随机排序 */
export function FramerSortDemo() {
  const [items, setItems] = useState([...Array(20).keys()].map(i => i + 1));

  const shuffle = () => {
    setItems([...items].sort(() => Math.random() - 0.5));
  };

  return (
    <div className="text-center">
      <button
        onClick={shuffle}
        className="bg-blue-500 text-white px-3 py-1 rounded mb-4"
      >
        随机排序
      </button>

      <div className="flex flex-wrap justify-center w-[520px] mx-auto">
        {items.map(i => (
          <motion.div
            key={i}
            layout // 这个属性会自动处理 FLIP 动画
            transition={{ duration: 0.5, ease: "easeOut" }}
            className="w-12 h-12 m-1 flex items-center justify-center bg-blue-100 border border-blue-200 rounded"
          >
            {i}
          </motion.div>
        ))}
      </div>
    </div>
  );
}

/** 示例 2:删除卡片 */
export function FramerRemoveDemo() {
  const [cards, setCards] = useState([...Array(12).keys()].map(i => i + 1));

  return (
    <div className="grid grid-cols-4 gap-4 w-[800px] mx-auto">
      <AnimatePresence>
        {cards.map(c => (
          <motion.div
            key={c}
            layout // 处理位置变化的动画
            initial={{ scale: 0, opacity: 0 }} // 初始状态
            animate={{ scale: 1, opacity: 1 }} // 进入状态
            exit={{ scale: 0, opacity: 0 }} // 退出状态
            transition={{ duration: 0.3 }}
            className="relative bg-white p-4 rounded shadow text-center"
          >
            <button
              className="absolute top-1 right-2 bg-red-500 text-white w-6 h-6 rounded-full"
              onClick={() => setCards(cards.filter(x => x !== c))}
            >
              ×
            </button>
            Card {c}
          </motion.div>
        ))}
      </AnimatePresence>
    </div>
  );
}