一个合理的动画是良好用户体验中必不可少的一部分。我们平常是怎样写动画的?CSS 中的 animation
和 transition
,还有 requestAnimationFrame
?相信大家写动画的时候心里也是在万马奔腾。今天我们从一个另辟蹊径的角度来探索一个动画实现。
请看下面的示例:
这是一个可添加的数字的随机乱序列表。首先想一想,我们第一直觉可能会这样做:将这些数字的 DOM 节点用绝对定位来布局,数字变化后计算 top
、left
的值,再配合 transition
实现该动画。这种方式看似简单,其实内部要维护各种位置信息,所有坐标都需要手动管理,相当繁杂,非常不利于后期扩展。如果这些节点换成高度不固定的图片,那计算量可想而知。
那有没有一种更好的方式实现呢?肯定的,接下来介绍一个金光闪闪的概念:FLIP
。
提前预览:
https://minjieliu.github.io/react-flip-demo[1]
FLIP
其实是几个单词的缩写:即 First、Last 、Invert 、Play。
让我们分解一下:
First
涉及动画的元素的初始状态(比如位置、缩放、透明等)。
Last
涉及动画的元素的最终状态。
Invert
这一步为核心,即找出这个元素是如何变化的。例如该元素在 First 和 Last 之间向右移动了 50px,你就需要在 X 方向 translateX(-50px)
,使元素看起来在 First 位置。
这里有一个知识点值得注意,DOM 元素属性的改变(比如 left
、right
、transform
等),会被集中起来延迟到浏览器下一帧统一渲染,所以我们可以得到一个这样的中间时间点:DOM 位置信息改变了,而浏览器还没渲染[2]。也就意味着在一定的时间内,我们能获取 DOM 改变后的位置,但在浏览器中位置还未改变。经测试,这个过程超过 10ms 就显得不稳定了。因此 setTimeout(fn, 0)
、 React useEffect
和 Vue $nextTick
都可以实现 Invert 过程。
Play
即从 Invert 回到最终状态,有了两个点的位置信息,中间的过渡动画就可以使用 transition
实现。本文采用 Web Animation API[3] 实现,动画执行过程中不会添加 CSS 到 DOM 上,相当干净。
这里主要使用 React 方式实现该效果,其他框架原理都一样可参考。
一个列表,将子元素 5 列为一行:
.list {
display: flex;
flex-wrap: wrap;
width: 400px;
}
.item {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border: 1px solid #eee;
}
function ListShuffler() {
const [data, setData] = useState([0, 1, 2, 3, 4, 5]);
const listRef = useRef<HTMLDivElement>(null);
return (
<div className={styles.list} ref={listRef}>
{data.map((item) => (
<div key={item} className={styles.item}>
{item}
</div>
))}
</div>
);
}
首先,我们需要记录 First
和 Last
的位置信息,并用来计算 Invert
偏移差,因此用 Map
对象来存储最合适不过了,有了这个方法,我们就可以用它来生成前后快照:
function createChildElementRectMap(nodes: HTMLElement | null | undefined) {
if (!nodes) {
return new Map();
}
const elements = Array.from(nodes.childNodes) as HTMLElement[];
// 使用节点作为 Map 的 key 存储当前快照,下次直接用 node 引用取值,相当方便
return new Map(elements.map((node) => [node, node.getBoundingClientRect()]));
}
点击添加的时候记录 First 快照:
// 使用 ref 存储 DOM 之前的位置信息
const lastRectRef = useRef<Map<HTMLElement, DOMRect>>(new Map());
function handleAdd() {
// 添加一条到顶部,让后面节点运动
setData((prev) => [prev.length, ...prev]);
// 并存储改变前的 DOM 快照
lastRectRef.current = createChildElementRectMap(listRef.current);
}
接下来 DOM 更新后还需要改变后的快照,在 React 中,无论是 useEffect
还是 useLayoutEffect
这里都可以拿到:
useLayoutEffect(() => {
// 改变后的 DOM 快照,此时 UI 并未更新
const currentRectMap = createChildElementRectMap(listRef.current);
}, [data]);
现在,我们就可以把之前的快照进行遍历,实现 Invert
并 Play
:
// 遍历之前的快照
lastRectRef.current.forEach((prevRect, node) => {
// 前后快照的 DOM 引用一样,可以直接获取
const currentRect = currentRectMap.get(node);
// Invert
const invert = {
left: prevRect.left - currentRect.left,
top: prevRect.top - currentRect.top,
};
const keyframes = [
{
transform: `translate(${invert.left}px, ${invert.top}px)`,
},
{ transform: 'translate(0, 0)' },
];
// Play 执行动画
node.animate(keyframes, {
duration: 800,
easing: 'cubic-bezier(0.25, 0.8, 0.25, 1)',
});
});
大功告成!这里每个节点有单独的动画,各个节点之间互不冲突。也就是说无论节点位置多么复杂,处理起来都能从容应对。
比如图片乱序只需要从 lodash
引入 shuffle
修改数据就可以完美实现展现。
import { shuffle } from 'lodash-es';
function shuffleList() {
setData(shuffle);
// 并存储改变前的 DOM 快照
lastRectRef.current = createChildElementRectMap(listRef.current);
}
以上总体思路就是 First -> Last -> Invert -> Play 的一个变换过程。预览下:
你发现没有,每次做完操作都需要手动更新快照,作为开发者不能忍,我们要懒到极致,好好封装一下。
直白需求:
开干!
在 React 更新模型中,执行顺序为:setState -> render -> layoutEffect。因此可以把 setState
生成快照的步骤放到 render
中,从而与操作解耦。(如果放到 useLayoutEffect
中动画频繁会出现位置计算不准确的问题)
useMemo(() => {
// render 时立即执行
lastRectRef.current.forEach((item) => {
item.rect = item.node.getBoundingClientRect();
});
}, [data]);
加上之前 useLayoutEffect
那部分逻辑,我们可以抽到一个独立组件中(Flipper
),用 flipKey
来控制,只要 flipKey
变化就执行动画,即实现 1、2 两点。
Flipper.tsx
export default function Flipper({ flipKey, children }: FlipperProps) {
const lastRectRef = useRef<Map<number, FlipItemType>>(new Map());
const uniqueIdRef = useRef(0);
// 通过 ref 创建函数,传递 context 避免引起穿透渲染
const fnRef = useRef<IFlipContext>({
add(flipItem) {
lastRectRef.current.set(flipItem.flipId, flipItem);
},
remove(flipId) {
lastRectRef.current.delete(flipId);
},
nextId() {
return (uniqueIdRef.current += 1);
},
});
useMemo(() => {
lastRectRef.current.forEach((item) => {
item.rect = item.node.getBoundingClientRect();
});
}, [flipKey]);
useLayoutEffect(() => {
const currentRectMap = new Map<number, DOMRect>();
lastRectRef.current.forEach((item) => {
currentRectMap.set(item.flipId, item.node.getBoundingClientRect());
});
lastRectRef.current.forEach(() => {
// 之前的 FLIP 代码
});
}, [flipKey]);
return <FlipContext.Provider value={fnRef}>{children}</FlipContext.Provider>;
}
最开始的方式是通过原生方法遍历 DOM,因此我们只能限制子节点一个层级,并且操作方式也脱离的 React 的编写模型,加以改进可以使用 Context
来通信存储:
FlipContext.ts
import React, { createContext } from 'react';
export type FlipItemType = {
// 子组件的唯一标识
flipId: number;
// 子组件通过 ref 获取的节点
node: HTMLElement;
// 子组件的位置快照
rect?: DOMRect;
};
export interface IFlipContext {
// mount 后执行 add
add: (item: FlipItemType) => void;
// unout 后执行 remove
remove: (flipId: number) => void;
// 自增唯一 id
nextId: () => number;
}
export const FlipContext = createContext(
undefined as unknown as React.MutableRefObject<IFlipContext>,
);
最后则是要实现采集每个动画元素的节点。将动画的节点使用自定义组件 Flipped
包裹并 cloneElement(children { ref })
劫持 ref,mount
时将子组件 ref
添加到 Context
,unmount
时则移除。react-photo-view[4] 的封装方式也是如此。即实现 3、4 两点。
Flipped.tsx
import React, {
cloneElement,
memo,
useContext,
useLayoutEffect,
useRef,
} from 'react';
import { FlipContext } from './FlipContext';
export interface FlippedProps {
children: React.ReactElement;
innerRef?: React.RefObject<HTMLElement>;
}
function Flipped({ children, innerRef }: FlippedProps) {
// Flipper.tsx 将 ref 通过 Context 传递,避免穿透渲染
const ctxRef = useContext(FlipContext);
const ref = useRef<HTMLElement>(null);
const currentRef = innerRef || ref;
useLayoutEffect(() => {
const ctx = ctxRef.current;
const node = currentRef.current;
// 生成唯一 ID
const flipId = ctx.nextId();
if (node) {
// mount 后添加节点
ctx.add({ flipId, node });
}
return () => {
// unmout 后删除节点
ctx.remove(flipId);
};
}, []);
return cloneElement(children, { ref: currentRef });
}
export default memo(Flipped);
好了,看一下如何使用,一共就两个 API,从原本的 JSX 只需包裹一下就有动画了:
<Flipper flipKey={data}>
<div className={styles.list}>
{data.map((item) => (
<Flipped key={item}>
<div className={styles.item}>{item}</div>
</Flipped>
))}
</div>
</Flipper>
是不是超简单!最后,还剩性能问题一个非常重要的指标。因为每个节点都是独立的动画,数据量大了之后渲染肯定卡顿。经过测试,5000 个 DIV 节点的数字数组的随机动画完成更新时间为大约 2 秒,这是很不能接受的。我们可以只允许屏幕内的节点有动画,其他节点就跳过,只需要稍微判断一下两个状态都不在屏幕内就好了,这可以节约 2 / 3 的时间:
const isLastRectOverflow =
rect.right < 0 ||
rect.left > innerWidth ||
rect.bottom < 0 ||
rect.top > innerHeight;
const isCurrentRectOverflow =
currentRect.right < 0 ||
currentRect.left > innerWidth ||
currentRect.bottom < 0 ||
currentRect.top > innerHeight;
if (isLastRectOverflow && isCurrentRectOverflow) {
return;
}
// node.animate() ...
记得之前 react-beautiful-dnd[5] 库刚出来的时候拖拽动画迷倒了不少人。但是现在有了 FLIP 再配合 react-dnd[6] 就可以轻松实现此类动画,功能上就更是属于碾压状态。而 react-motion[7] 之类的动画库实现该动画就繁杂很多,因为它用的是绝对定位控制的类型。下面的例子仅仅用刚封装的 Flipper
包裹了一下:
以下是源码:
https://github.com/MinJieLiu/react-flip-demo[8] 其中里面的 Flipper
组件目录可以直接拷贝到项目中使用,100 来行代码相当轻量 。
注意:Web Animation
只兼容 Chrome 75
以上,兼容古董浏览器可以考虑 Web Animations API polyfill[9]。
什么?你有更复杂的动画需求,自己不想动手,可以看看这个,支持更多特性
react-flip-toolkit[10] 一款有 3.4K Star FLIP 的库。实现了你所能想到的功能。
交错效果:
嵌套比例变换:
路由动画:
以及更多
相信这种动画思路肯定能大幅度简化编写动画的门槛,想起自己以前傻傻的用绝对定位计算位置,真是可笑可笑~
[1]https://minjieliu.github.io/react-flip-demo: https://minjieliu.github.io/react-flip-demo
[2]DOM 位置信息改变了,而浏览器还没渲染: https://juejin.cn/post/6844904165462769678
[3]Web Animation API: https://developer.mozilla.org/zh-CN/docs/Web/API/Animation
[4]react-photo-view: https://react-photo-view.vercel.app
[5]react-beautiful-dnd: https://react-beautiful-dnd.netlify.app
[6]react-dnd: https://react-dnd.github.io/react-dnd/examples/sortable/simple
[7]react-motion: http://chenglou.github.io/react-motion/demos/demo8-draggable-list
[8]https://github.com/MinJieLiu/react-flip-demo: https://github.com/MinJieLiu/react-flip-demo
[9]Web Animations API polyfill: https://github.com/web-animations/web-animations-js
[10]react-flip-toolkit: https://github.com/aholachek/react-flip-toolkit
[11]Guitar 商城: https://react-flip-toolkit-demos.surge.sh/guitar
[12]React-flip-toolkit logo: https://codepen.io/aholachek/pen/ERRpEj
[13]使用 Portals: https://react-flip-toolkit-demos.surge.sh/portal
[14]Vue 实现: https://juejin.cn/post/6844904179572424711
本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/wpz8CFjYMik6peZEFwMwBw
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。