一、实现目标
为元素出现在视口时添加动画,这是很常见的需求,最近在工作中遇到了,项目里面使用的是React框架。因此自己封装了一个组件。如果是Vue框架,可以封装一个自定义指令来实现,更方便一点。
二、实现思路
使用IntersectionObserver API 监听元素是否出现在视口,使用animate API为元素添加动画
三、实现过程
TypeScript
import classNames from 'classnames';
import { CSSProperties, useEffect, useRef } from 'react';
interface IntersectionWithAnimationBoxProp {
/**
* 子节点
*
* @type {JSX.Element}
* @memberOf AnimationBoxProp
*/
children: JSX.Element;
/**
* 动画效果
*
* @type {{ duration: number; fill: string }}
* @memberOf AnimationBoxProp
*/
optionalEffectTiming?: OptionalEffectTiming;
/**
* 关键帧数组
*
* @type {Keyframe[]}
* @memberOf AnimationBoxProp
*/
keyframes?: Keyframe[];
/**
* 类名
*
* @type {string}
* @memberOf AnimationBoxProp
*/
className?: string;
/**
* 样式
*
* @type {CSSProperties}
* @memberOf AnimationBoxProp
*/
style?: CSSProperties;
/**
* 默认关键帧, 可选
*
* @type {('left' | 'right' | 'top' | 'bottom')}
* @memberOf AnimationBoxProp
*/
defaultKeyFramePosition?: 'left' | 'right' | 'top' | 'bottom';
}
/**
* 给元素添加效果(元素出现在视口, 执行动画)的容器, 函数re-render阶段不会重复执行动画, 只在挂载阶段执行一次
*/
export default function IntersectionWithAnimationBox(
prop: IntersectionWithAnimationBoxProp
) {
const {
children,
optionalEffectTiming = { duration: 300, fill: 'forwards' },
keyframes,
className,
style,
defaultKeyFramePosition = 'bottom',
} = prop;
const container = useRef<HTMLDivElement>(null);
// 获取关键帧数组
const getKeyFrames = (): Keyframe[] => {
if (keyframes && !!keyframes.length) return keyframes;
switch (defaultKeyFramePosition) {
case 'bottom':
return [
{
opacity: 0,
transform: 'translateY(100%)',
},
{
opacity: 1,
transform: 'translateY(0)',
},
];
case 'top':
return [
{
opacity: 0,
transform: 'translateY(-100%)',
},
{
opacity: 1,
},
];
case 'left':
return [
{
opacity: 0,
transform: 'translateX(-100%)',
},
{
opacity: 1,
transform: 'translateX(0)',
},
];
case 'right':
return [
{
opacity: 0,
transform: 'translateX(100%)',
},
{
opacity: 1,
transform: 'translateX(0)',
},
];
}
};
const addOpacity = () => {
container.current && container.current.classList.add('opacity-100');
};
// 执行动画
const animate = (element: Element) => {
if (element instanceof HTMLElement) {
const animation = element.animate(getKeyFrames(), optionalEffectTiming);
animation.finished.then(() => {
addOpacity();
});
}
};
// 通过IntersectionObserver API 监听元素是否出现在视口
useEffect(() => {
if (!container.current) return;
const io = new IntersectionObserver((entries) => {
entries.forEach(async (item) => {
if (item.isIntersecting) {
io.unobserve(item.target);
animate(item.target);
}
});
});
io.observe(container.current);
return () => {
container.current && io.unobserve(container.current);
};
}, []);
return (
<div ref={container} className={classNames(['opacity-0', className])} style={style}>
{children}
</div>
);
}
四、如何使用
1. 单个元素场景
使用封装好的组件直接包裹就可以
TypeScript
<IntersectionWithAnimationBox optionalEffectTiming={{ duration: 2000 }}>
<div className="h-40 w-40 rounded-lg bg-theme-primary"></div>
</IntersectionWithAnimationBox>
2. 多个元素场景
一般来说,多个元素场景需要有一个动画时间的递进关系,这个只需要改变delay就可以,可以利用index动态改变deley时间
TypeScript
<div className="flex gap-x-5">
{DATA.map((data, index) => (
<IntersectionWithAnimationBox
optionalEffectTiming={{
duration: 2000,
delay: index * 150 + 150,
easing: 'cubic-bezier(0.250, 0.460, 0.450, 0.940)',
}}
className="h-40"
key={data}
>
<div className="rounded-lg bg-theme-primary p-6 text-center">{data}</div>
</IntersectionWithAnimationBox>
))}
</div>
五、总结
优点
封装为slot的形式,在使用上会方便很多,使用者无需去关心内部实现的逻辑,只需将动画传入或者选中默认的动画效果即可。
缺点
使用该组件包裹后,最终渲染会多了一个div标签。但是一般来说不会影响,如果有class的问题,可以使用prop:className将样式传递进去。