FLIP 动画实现详解
原生 JavaScript 实现
FLIP 是一种动画技术,由以下四个步骤组成:
- First (首位): 记录元素的初始状态
- Last (末位): 记录元素的最终状态
- Invert (反转): 计算从初始到最终状态的变化
- Play (播放): 执行动画
这种技术特别适用于需要在不同布局状态之间平滑过渡的场景,比如列表重排序、网格/列表视图切换等,为此我们可以先设计一个原生的实现,然后基于 React 封装一个组件,实现更易用的使用。 我们先定义一个通用的 FLIP 动画封装:
js
FLIP.animate({
elements: NodeList | Element[], // 要参与动画的元素
changeDOM: () => { ... }, // 在这步执行 DOM 变化(删除、插入、排序等)
options: { duration, easing }, // 动画参数
})内部执行 4 个阶段:
- First:记录所有元素的初始位置;
- Change DOM:执行 DOM 更新;
- Last:记录更新后的布局;
- 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>
);
}