帧动画功能
以 抖音前端技术官网 的首页加载动画为例,看看这个动画下究竟发生了什么?
首先打开控制台的 network,使用 performance 来录制首页加载的过程,为了能更清晰查看,适当降低 CPU 的性能,调整为 4 x slowdown。
点击控制台左上角的 ⚪,然后刷新页面,可以得到下面的逐帧列表:
从下面的加载图中可以判断出,这个动画总的执行时长为 1.36 s,然后上面的列表中有具体页面加载过程的帧动画变化图,通过按帧查看,可以大概看出这个动画的执行顺序是这样的。
按照从小序列到大序列的顺序,每个元素分别执行了从下往上的平移操作,以及一个透明度从 0 到 1 的过程,加上上面看到每个动画的时长分析都是 1.3s,所以只是对每个元素推迟了不同的动画平移时间,但是它们享有相同的动画时长,针对这个场景应该怎么去实现呢?
针对现在的首页,把 dom 元素简单拆分为 8 个区域,总动画时长定成 1s,其中 1s 的时间可以分为 9 个时间帧,每个区域从对应序列的时间帧开始执行相同的动画效果,最后把所有的帧连起来就是一个完整的帧动画。
定义对应的样式进行绑定,以 fadeInDown1 举例,@keyframes 指向动画的逐帧状态,其中 0% 和 11 % 都是一样的内容,这时候区域处于 y 轴 40px 的位置,然后末尾状态是无区域状态和 1 透明度,这个动画的效果会使得动画从整体时间的 11% 开始执行,到 100 % 完成最终的变化。
这个 11% 是从哪里来的呢?上面提到为每个动画延迟一个帧频率执行,8 个区域,共 9 帧,所以 1 帧的占比为 11% 的总动画时长,每个动画的起始时间(第二个状态值)都比上一个高出 1 帧的比例,这样就可以将整体帧动画串联起来了。
styles/Home.module.scss
.withAnimation {
.title {
animation: fadeInDown1 1s;
}
.description {
animation: fadeInDown2 1s;
}
.card:nth-of-type(1) {
animation: fadeInDown3 1s;
}
.card:nth-of-type(2) {
animation: fadeInDown4 1s;
}
.card:nth-of-type(3) {
animation: fadeInDown5 1s;
}
.card:nth-of-type(4) {
animation: fadeInDown6 1s;
}
.card:nth-of-type(5) {
animation: fadeInDown7 1s;
}
.card:nth-of-type(6) {
animation: fadeInDown8 1s;
}
}
@keyframes fadeInDown1 {
0% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
11% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
100% {
-webkit-transform: none;
transform: none;
opacity: 1;
}
}
@keyframes fadeInDown2 {
0% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
22% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
100% {
-webkit-transform: none;
transform: none;
opacity: 1;
}
}
@keyframes fadeInDown3 {
0% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
33% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
100% {
-webkit-transform: none;
transform: none;
opacity: 1;
}
}
@keyframes fadeInDown4 {
0% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
44% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
100% {
-webkit-transform: none;
transform: none;
opacity: 1;
}
}
@keyframes fadeInDown5 {
0% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
55% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
100% {
-webkit-transform: none;
transform: none;
opacity: 1;
}
}
@keyframes fadeInDown6 {
0% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
66% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
100% {
-webkit-transform: none;
transform: none;
opacity: 1;
}
}
@keyframes fadeInDown7 {
0% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
77% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
100% {
-webkit-transform: none;
transform: none;
opacity: 1;
}
}
@keyframes fadeInDown8 {
0% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
88% {
transform: translate3d(0, 40px, 0);
opacity: 0;
}
100% {
-webkit-transform: none;
transform: none;
opacity: 1;
}
}
改造首页 (index.tsx) Dom 类,专门定义一个动画类来存放动画相关的样式,避免对基础样式造成污染。
import {useRef} from 'react';
import type { NextPage } from 'next';
import classNames from 'classnames';
import styles from '@/styles/Home.module.scss';
interface IHomeProps {
title: string;
description: string;
list: {
label: string;
info: string;
link: string;
}[];
}
const Home: NextPage<IHomeProps> = ({
title, description, list
}) => {
const mainRef = useRef<HTMLDivElement>(null);
return (
<div className={styles.container}>
<main className={classNames([styles.main, styles.withAnimation])} ref={mainRef}>
<h1 className={styles.title}>{title}</h1>
<p className={styles.description}>{description}</p>
<div className={styles.grid}>
{
list?.map((item, index) => {
return (
<div key={index} className={styles.card} onClick={(): void => {
window.open(
item?.link,
"blank",
"noopener=yes,noreferrer=yes"
);
}}>
<h2>{item?.label}</h2>
<p>{item?.info}</p>
</div>
)
})
}
</div>
</main>
</div>
)
}
Home.getInitialProps = (context) => {
return {
title: "Hello SSR!",
description: "A Demo for 官网开发实战",
list: [
{
label: "文章 1",
info: "A test for article1",
link: "http://localhost:3000/article/1",
},
{
label: "文章 2",
info: "A test for article2",
link: "http://localhost:3000/article/2",
},
{
label: "文章 3",
info: "A test for article3",
link: "http://localhost:3000/article/3",
},
{
label: "文章 4",
info: "A test for article4",
link: "http://localhost:3000/article/4",
},
{
label: "文章 5",
info: "A test for article5",
link: "http://localhost:3000/article/5",
},
{
label: "文章 6",
info: "A test for article6",
link: "http://localhost:3000/article/6",
},
],
};
}
export default Home;
然后查看一下效果。
主动触发动画重新播放
在切换主题时,希望能再执行一次加载动画,可以通过 requestAnimationFrame 来实现,它会返回一个回调,强制浏览器在重绘前调用指定的函数来进行动画的更新。
使用这个来改造一下首页,加一个 useEffect 的钩子。
import {useRef, useContext, useEffect} from 'react';
import type { NextPage } from 'next';
import classNames from 'classnames';
import { ThemeContext } from '@/stores/theme';
import styles from '@/styles/Home.module.scss';
interface IHomeProps {
title: string;
description: string;
list: {
label: string;
info: string;
link: string;
}[];
}
const Home: NextPage<IHomeProps> = ({
title, description, list
}) => {
const mainRef = useRef<HTMLDivElement>(null);
const { theme } = useContext(ThemeContext);
useEffect(() => {
mainRef.current?.classList.remove(styles.withAnimation);
window.requestAnimationFrame(() => {
mainRef.current?.classList.add(styles.withAnimation);
});
}, [theme]);
return (
<div className={styles.container}>
<main className={classNames([styles.main, styles.withAnimation])} ref={mainRef}>
<h1 className={styles.title}>{title}</h1>
<p className={styles.description}>{description}</p>
<div className={styles.grid}>
{
list?.map((item, index) => {
return (
<div key={index} className={styles.card} onClick={(): void => {
window.open(
item?.link,
"blank",
"noopener=yes,noreferrer=yes"
);
}}>
<h2>{item?.label}</h2>
<p>{item?.info}</p>
</div>
)
})
}
</div>
</main>
</div>
)
}
Home.getInitialProps = (context) => {
return {
title: "Hello SSR!",
description: "A Demo for 官网开发实战",
list: [
{
label: "文章 1",
info: "A test for article1",
link: "http://localhost:3000/article/1",
},
{
label: "文章 2",
info: "A test for article2",
link: "http://localhost:3000/article/2",
},
{
label: "文章 3",
info: "A test for article3",
link: "http://localhost:3000/article/3",
},
{
label: "文章 4",
info: "A test for article4",
link: "http://localhost:3000/article/4",
},
{
label: "文章 5",
info: "A test for article5",
link: "http://localhost:3000/article/5",
},
{
label: "文章 6",
info: "A test for article6",
link: "http://localhost:3000/article/6",
},
],
};
}
export default Home;
在每次 theme 发生变化的时候,主动移除对应的动画类,再通过 requestAnimationFrame 对动画类进重新绑定,达到主动触发动画刷新的效果,现在来看一下最终成品。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论