前阵子,开发过程中需要用到购物车动画,所以封装了动画hooks,在此做一下总结归纳。
一、思考
首先,购物车动画的轨迹是一个抛物线效果,这个我们可以通过CSS动画来实现。其次,我们的抛物线需要一个起始点、一个目标点、一个运动小球。
然后,通过计算起始点和目标点两者之间 x 轴和 y 轴的距离,然后通过 CSS 来改变运动小球的位置和移动速度,从而实现加入购物车效果。
那么,这个抛物线动画效果如何实现?
高中物理告诉我们,当物体运动时,X轴方向上和Y轴方向上的速度不一致时,物体的运动效果就是抛物线,类似我们向外抛球,小球的运动轨迹。
所以,想要有抛物线效果,我们只需要控制运动小球,从起始点运动到目标点的过程中,X轴和Y轴方向上的速度不一致即可。
因此,我们可以通过X轴方向上的速度不变,通过Y轴方向上的速度变化。
那么,如何控制Y轴上的速度变化?
搜索前端 CSS 样式,我们可以发现,可以使用 transition-timing-function: linear|ease|ease-in|ease-out|ease-in-out|cubic-bezier(n,n,n,n);
属性来实现过渡效果的速度变化。
其中,三阶贝塞尔曲线cubic-bezier(x1, y1, x2, y2)
: 四个参数值分别在 0 到 1 之间,其中 (x1, y1)(x2, y2) 是控制曲线的变化程度。
二、基本框架
我们思考一下,想要把这个动画效果封装起来通用,我们需要传入哪些必传参数? 需要暴露哪些参数或方法给外层组件调用? 需要提供哪个参数便于个性化扩展?
- 需要起始
Dom
节点、目标Dom
节点; - 需要暴露
running
方法,用于开启动画效果; - 需要运动小球,小球包含两层,外层
flyOuter
控制X轴匀速运动,内层flyInner
控制Y轴变速运动; - 需要提供属性,支持自定义小球的内容
children
、小球内外层样式flyOuterStyle / flyInnerStyle
、小球运动时间设置runTime
、小球开始运动回调beforeRun
、小球开始运动回调afterRun
;
hook封装实现
import React, { useRef, useEffect, useImperativeHandle } from 'react';
import ReactDOM from 'react-dom';
/**
* 动画球
* @params children - 小球扩展内容
* @params flyOuterStyle - 小球外层扩展样式
* @params flyInnerStyle - 小球内层扩展样式
* @params runTime - 小球运动时间
* @params ref - 小球dom实例
*/
const flyOuter = React.forwardRef(
({ children, flyOuterStyle = {}, flyInnerStyle = {}, runTime = 0.8 }, ref) => {
const flyOuterRef = useRef();
const flyInnerRef = useRef();
useImperativeHandle(ref, () => ({ flyOuterRef, flyInnerRef }));
// 运动小球外层样式
const flyOuter_Style = Object.assign(
{
position: 'absolute',
width: '20px',
height: '20px',
transition: `transform ${runTime}s`,
display: 'none',
margin: ' -20px 0 0 -20px',
transitionTimingFunction: 'linear',
zIndex: 3,
},
flyOuterStyle,
);
// 运动小球内层样式
const flyInner_Style = Object.assign(
{
position: 'absolute',
width: '100%',
height: '100%',
borderRadius: '50%',
backgroundColor: '#FF8A2B',
color: '#ffffff',
textAlign: 'center',
lineHeight: '1',
transition: `transform ${runTime}s`,
justifyContent: 'center',
alignItems: 'center',
// transitionTimingFunction: 'cubic-bezier(.55,0,.85,.36)', // 向上抛物线的右边
transitionTimingFunction: 'cubic-bezier(0, 0, .25, 1.3)', // 向下抛物线的左边
},
flyInnerStyle,
);
return (
<div style={flyOuter_Style} ref={flyOuterRef}>
<div style={flyInner_Style} ref={flyInnerRef}>
{children}
</div>
</div>
);
},
);
/**
* 抛物线动画效果
* @params startRef - 起始点dom节点
* @params endRef - 目标点dom节点
* @params flyOuterStyle - 小球外层扩展样式
* @params flyInnerStyle - 小球内层扩展样式
* @params runTime - 小球运动时间
* @params beforeRun - 小球开始运动回调
* @params afterRun - 小球结束运动回调
* @params children - 小球扩展内容
* @returns { running } - 小球开始运动函数
*/
export default function useParabola(
{
startRef,
endRef,
flyOuterStyle,
flyInnerStyle,
runTime = 800,
beforeRun = () => {},
afterRun = () => {},
},
children,
) {
const containerRef = useRef(document.createElement('div'));
const innerRef = useRef();
let isRunning = false;
// 挂载到dom上
useEffect(() => {
const container = containerRef.current;
document.body.appendChild(container);
return () => {
document.body.removeChild(container);
};
}, []);
useEffect(() => {
if (startRef?.current && endRef?.current) {
ReactDOM.render(
React.createElement(
flyOuter,
{ ref: innerRef, flyOuterStyle, flyInnerStyle, runTime: runTime / 1000 },
children,
),
containerRef.current,
);
}
}, [startRef, endRef]); // eslint-disable-line
function running() {
if (startRef && endRef && innerRef) {
beforeRun();
const flyOuterRef = innerRef.current.flyOuterRef.current;
const flyInnerRef = innerRef.current.flyInnerRef.current;
// 现在起点距离终点的距离
const startDot = startRef.current.getBoundingClientRect();
const endDot = endRef.current.getBoundingClientRect();
// 中心点的水平垂直距离
const offsetX = endDot.left + endDot.width / 4 - (startDot.left + startDot.width / 2);
// let offsetY = endDot.top + endDot.height / 2 - (startDot.top + startDot.height / 2);
const offsetY = endDot.top + endDot.height / 4 - (startDot.top + startDot.height / 2);
// 页面滚动尺寸
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop || 0;
const scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft || 0;
if (!isRunning) {
// 初始定位
flyOuterRef.style.display = 'block';
flyOuterRef.style.left = `${
startDot.left + scrollLeft + startRef.current.clientWidth / 2
}px`;
flyOuterRef.style.top = `${startDot.top + scrollTop + startRef.current.clientHeight / 2}px`;
// 开始动画
flyOuterRef.style.transform = `translateX(${offsetX}px)`;
flyInnerRef.style.transform = `translateY(${offsetY}px)`;
// 动画标志量
isRunning = true;
setTimeout(() => {
flyOuterRef.style.display = 'none';
flyOuterRef.style.left = '';
flyOuterRef.style.top = '';
flyOuterRef.style.transform = '';
flyInnerRef.style.transform = '';
isRunning = false;
afterRun();
}, runTime);
}
}
}
return { running };
}
三、测试用例
实现效果:
购物车动画.gif
js代码
import React, { useRef, useState } from 'react';
import { Button, notification } from 'antd';
import { ShoppingCartOutlined, PayCircleOutlined } from '@ant-design/icons';
import useParabola from '@/hooks/use-parabola';
import styles from './index.less';
/*
* @Description: 购物车动画-demo
* @version: 0.0.1
* @Date: 2020-04-20 23:21:33
*/
export default React.forwardRef(() => {
const [num, setNum] = useState(1);
const startRef = useRef();
const endRef_1 = useRef();
const endRef_2 = useRef();
const endRef_3 = useRef();
const endRef_4 = useRef();
const res_1 = useParabola(
{
startRef,
endRef: endRef_1,
flyOuterStyle: {
width: '40px',
height: '40px',
transition: 'transform 3s',
margin: ' -40px 0 0 -40px',
},
flyInnerStyle: {
color: '#FF0000',
transition: 'transform 3s',
lineHeight: '40px',
},
runTime: 3000,
beforeRun: () => {
notification.warning({ message: '12号球开始运动啦啦~~' });
},
afterRun: () => {
notification.success({ message: '12号球运动结束啦啦~~' });
},
},
<span>12</span>,
);
const res_2 = useParabola(
{
startRef,
endRef: endRef_2,
flyInnerStyle: {
transitionTimingFunction: 'cubic-bezier(.55,0,.85,.36)',
},
},
'2',
);
const res_3 = useParabola(
{
startRef,
endRef: endRef_3,
flyOuterStyle: { transition: 'transform 2.5s' },
flyInnerStyle: { transition: 'transform 2.5s' },
runTime: 2500,
},
'3',
);
const res_4 = useParabola(
{
startRef,
endRef: endRef_4,
flyInnerStyle: {
transitionTimingFunction: 'cubic-bezier(.55,0,.85,.36)',
},
},
'4',
);
function startRunning() {
if (num % 4 === 1) {
res_1.running(1);
}
if (num % 4 === 2) {
res_2.running(2);
}
if (num % 4 === 3) {
res_3.running(3);
}
if (num % 4 === 0) {
res_4.running(4);
}
setNum(num + 1);
}
return (
<div className={styles['cart-animation']}>
<div className={styles.center}>
<div ref={startRef}>
<Button danger icon={<PayCircleOutlined />} onClick={startRunning}>
发射中心
</Button>
</div>
</div>
<div className={styles.left}>
<div ref={endRef_1}>
<Button type="primary" icon={<ShoppingCartOutlined />} className={styles['left-top']}>
购物车1号
</Button>
</div>
<div ref={endRef_2}>
<Button type="primary" icon={<ShoppingCartOutlined />} className={styles['left-bottom']}>
购物车2号
</Button>
</div>
</div>
<div className={styles.right}>
<div ref={endRef_3}>
<Button type="primary" icon={<ShoppingCartOutlined />} className={styles['right-top']}>
购物车3号
</Button>
</div>
<div ref={endRef_4}>
<Button type="primary" icon={<ShoppingCartOutlined />} className={styles['right-bottom']}>
购物车4号
</Button>
</div>
</div>
</div>
);
});
css代码
@import '~antd/lib/style/themes/default.less';
.cart-animation {
position: relative;
height: 300px;
.center {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.left,
.right {
position: absolute;
top: 50px;
}
.left {
left: 0;
}
.right {
right: 0;
}
.left-top,
.right-top {
margin-bottom: 200px;
}
button {
display: block;
}
}
网友评论