本文翻译自 FLIP animation in react
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-a4YHrSjn-1604052655260)(https://github.com/aholachek/react-flip-toolkit/raw/master/example-assets/listanimations.gif)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-21L53RZZ-1604052655262)(https://github.com/aholachek/react-flip-toolkit/raw/master/example-assets/list-transition.gif)]
背景
动画将用户界面带入生活。有意设计动画也有助于可用性、减少了用户的认知负荷,并引导用户关注重要的东西。
问题与解决方案
你可能已经尝试过了改变元素的 height、width、top、left 或者除也 transform 和 opacity 之外的其他属性来实现一个动画效果。或许你已经在以往的经历(做的动效)中发现自己的实现的动效有点粗糙甚至卡顿,其实这是有一定原因的。任何触发布局变化的属性(比如 height),浏览器都会递归检查布局中的其他元素是否也因此改变,这一个过程花销是很贵的,如果这个计算比一个动画帧(16.7ms)更长,那么动画就会丢帧。
我们需要在更改 DOM 元素位置和尺寸后浏览器渲染预前计算绘制需要发生的转换,然后让它平稳运行。为了实现上述过渡,我们需要通过boundingClintRect
获取到元素的初始和最终状态,并使用 CSS transform
让过渡
步骤
对上述过程进行简短说明:
first and Last
Fist是我们转换之前的元素,Last是我们转换之后想要得到的。
我们需要存储元素的getboundingClientRect
并调整大小并其重新放置到目标位置。
Invert
这个阶段在我们需要在浏览器布局之后,浏览器绘制之前。我们可以根据getboundingClientRect
计算出差值。
Play
最后,我们让元素移动到 0,0
位置即可。 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hqxqBt6E-1604052655268)(https://makersden.io/d6ac541c409723ebb01771cd8ca95d57/play.gif)]
此时我们可以采用如下技术实现: - 使用 CSS transition - 使用 CSS keyframes + animation - 使用 Web Animation API - 使用 Anime.js
代码实现
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-z7C04Z8W-1604052655269)(https://makersden.io/c02746907fe0da4cdb1836084899fbaf/nolib.gif)]
整体代码如下:
import React, { useLayoutEffect, useRef, useState } from 'react';
interface ViewProps {
items: any[];
}
function List({ items }: ViewProps) {
const listRef = React.createRef<HTMLUListElement>();
useFlip({
root: listRef,
invert,
play,
});
return (
<ul ref={listRef}>
{items.map((item) => (
<li data-key={item} key={item}>
{item}
</li>
))}
</ul>
);
}
interface FlipProps {
root: React.RefObject<HTMLUListElement>;
invert: any;
play: any;
}
interface Rect {
top: number;
left: number;
width: number;
height: number;
}
const useFlip = ({ root, invert, play }: FlipProps) => {
const origins = useRef<{ [key: string]: ClientRect }>({});
const firstRun = useRef(true);
useLayoutEffect(() => {
if (root.current === null) return;
const list = root.current;
const children: HTMLElement[] = Array.prototype.slice.call(list.children);
for (const child of children) {
const key = child.dataset.key!;
const next = child.getBoundingClientRect();
if (!firstRun.current) {
if (key in origins.current) {
const previous = origins.current[key];
const delta = getDelta(previous, next);
if (!isZero(delta)) {
invertAndPlay(delta, child);
// invert(delta, child);
// requestAnimationFrame(() => {
// play(child)
// })
}
}
}
origins.current[key] = next;
}
firstRun.current = false;
}, [root, invert, play]);
};
const getDelta = (start: Rect, target: Rect) => ({
top: start.top - target.top,
left: start.left - target.left,
width: start.width / target.width,
height: start.height / target.height,
});
const isZero = (delta: Rect) =>
delta.left === 0 && delta.top === 0 && delta.width === 1 && delta.height === 1;
const invert = (delta: ClientRect, elem: HTMLElement) => {
elem.style.transform = `translate(${delta.left}px,${delta.top}px)`;
elem.style.transition = `transform 0s`;
};
const play = (elem: HTMLElement) => {
elem.style.transform = ``;
elem.style.transition = `transform 300ms ease`;
};
const invertAndPlay = (delta: Rect, elem: HTMLElement) => {
elem.animate(
[
{
transform: `translate(${delta.left}px,${delta.top}px)`,
},
{
transform: 'none',
},
],
{ duration: 300 },
);
};
use List
import React, { useLayoutEffect, useRef, useState } from 'react';
import Mock from 'mockjs';
import { Button, Space } from 'antd';
function shuffle([...arr]: any[]) {
let m = arr.length;
while (m) {
const i = Math.floor(Math.random() * m);
m -= 1;
[arr[m], arr[i]] = [arr[i], arr[m]];
}
return arr;
}
export default () => {
const [items, setItems] = useState<string[]>([]);
const handleAddClick = () => {
setItems((items) => [Mock.mock('@name'), ...items]);
};
const handleRemoveClick = () => {
setItems((items) => items.slice(1));
};
const handleShuffleClick = () => {
setItems((items) => shuffle(items));
};
return (
<>
<Button type="primary" onClick={handleAddClick}>
add Item
</Button>{' '}
<Space size="middle" />
<Button type="primary" onClick={handleRemoveClick}>
remove Item
</Button> <Space size="middle" />
<Button type="primary" onClick={handleShuffleClick}>
shuffle Item
</Button>
<List items={items} />
</>
);
};
代码分解
First
负责初始化,当第一次运行时 origins
记录 list
每一项。
const origins = useRef<{ [key: string]: ClientRect }>({});
const list = root.current;
const children: HTMLElement[] = Array.protoype.slice.call(list.children);
for (const child of children) {
const key = child.dataset.key!;
const next = child.getBoundingClientRect();
origins.current[child.dataset.key!] = next;
}
我依靠元素data-key
属性标识。
<li data-key={item} key={item}>
{item}
</li>
这里使用了 useRef
是为了记录行项目更新的同时,自身是不变的这样。这样当生命周期更新时不会重新创建。 文档描述:
useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变。
Last
负责计算最后元素位置属性
for (const child of children) {
const next = child.getBoundingClientRect();
}
Invert
负责计算反演的代码。
for (const child of children) {
const key = child.dataset.key!;
const next = child.getBoundingClientRect();
if (key in origins.current) {
const previous = origins.current[key];
const delta = getDelta(previous, next);
if (!isZero(delta)) {
invert(delta, child);
}
}
}
计算差值
const getDelta = (start: Rect, target: Rect) => ({
top: start.top - target.top,
left: start.left - target.left,
width: start.width / target.width,
height: start.height / target.height,
});
const isZero = (delta: Rect) =>
delta.left === 0 && delta.top === 0 && delta.width === 1 && delta.height === 1;
对于这个案例 这里使用 csstranform
来进行动画。
const invert = (delta: ClientRect, elem: HTMLElement) => {
elem.style.transform = `translate(${delta.left}px,${delta.top}px)`;
elem.style.transition = `transform 0s`;
};
Play
最后的最后让我们启动
requestAnimationFrame(() => {
play(child);
});
使用 CSS transform
反转到初始位置。
const play = (elem: HTMLElement) => {
elem.style.transform = ``;
elem.style.transition = `transform 300ms ease`;
};
技术讲解
requestAnimationFrame
注意 play 步骤的 requestAnimationFrame
调用 - 我们需要这样做,以便浏览器可以处理应用于 DOM 的反转。如果你使用 CSS transition
技术则需要它。
如果我们使用 Web Animation API 则不需要它,例如这样:
const invertAndPlay = (delta: Rect, elem: HTMLElement) => {
elem.animate(
[
{
transform: `translate(${delta.left}px,${delta.top}px)`,
},
{
transform: 'none',
},
],
{ duration: 300 },
);
};
useLayoutEffect
其函数签名与 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。尽可能使用标准的 useEffect 以避免阻塞视觉更新
因为我们需要在浏览器绘制之前,读取到 DOM 布局后的信息,所以使用它再合适不过了。
避免重复渲染
这取决与个人喜好,但通常我不喜欢在第一次渲染时进行过渡。对我来说,动画是增强体验的,而不是放慢你去获取到想要的信息。除非你需要一个漂亮的开场动画(登录页面)
为了避免首次渲染,使用 ref
作为开关。
let firstRun = useRef(true);
useLayoutEffect(() => {
const children: HTMLElement[] = Array.protoype.slice.call(list.children);
for (const child of children) {
if (!firstRun.current) {
/* do the flips */
}
}
firstRun.current = false;
}, [root, invert, play]);
代码问题
当我们快速添加项时,会出现卡顿乱序情况。如果想让他可靠,那我们我们还需要做一些处理。但是这样会复杂很多… [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AxTaff4T-1604052655271)(https://makersden.io/1a5f4a2314234f7241cfb7d3e5b57ca4/nolib-fast.gif)]
结论
如果我们想要探究技术,这个 demo 可以带你了解技术本身。如果想要生产中使用那还是直接使用react-flip-toolkit或者 react-flip-move。