案例动画: 支付宝-会员-积分球列表领取
动画演示
积分球与列表UI结构,以及动画轨迹/样式变化路径:

CSS实现
核心都是基于css的transition属性,来实现各种属性的动画效果:
1、球领取中,触发单球飞起的动画,手动计算单球与终点的位移transform,然后设置transform中属性的动画效果:
js
// 计算动画偏移量
ballEl.style.transition = 'transform 0.7s cubic-bezier(.52,.2,.13,.97), opacity 0.7s';
ballEl.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(0.2)`;
ballEl.style.opacity = '0';
2、列表中,球飞起同时,其占位元素的宽度、边距等属性变化,触发flex布局的列表中其他球的位移(transform):
关键动画属性:
- 在flex布局中,每一个球的位置自适应: flex: 0 0 auto;
- 下一个球的补位动画实现,主要由消失球的width等变化,和下一个球的位移(transform)变化组合实现
js
.ball-item {
width: 118px;
/* 关键1、在flex布局中,每一个球的位置和尺寸自适应 */
flex: 0 0 auto;
/* 关键2、球补位动画的实现变化,主要由消失球的width等UI变化,和下一个球的transform变化组合实现 */
transition: width 0.7s, margin-left 0.7s, opacity 0.7s, transform 0.7s;
z-index: 1;
/* 消失球的UI变化 */
&.removing,
&.batch-removing {
// 平滑变小,飞出动画
width: 0 !important;
margin-left: 0 !important;
opacity: 0;
.ball-content {
opacity: 0;
}
}
}
完整代码
js
import { delay } from 'lodash';
import { useAsyncEffect } from 'ahooks';
import React, { useRef, useMemo } from 'react';
import cn from 'classnames';
import './index.less';
import { IPointCertInfoItemProps } from '../../../store/initialState';
type BallItemProps = {
ball: IPointCertInfoItemProps;
onClick?: () => void;
targetId: string;
children: React.ReactNode;
id: string;
};
export const BallItem = ({ ball, targetId, id, children, onClick }: BallItemProps) => {
const ballRef = useRef<HTMLDivElement>(null);
const animationDuration = useMemo(() => {
return `${3 + Math.random()}s`;
}, []);
const animationDelay = useMemo(() => {
return `${Math.random()}s`;
}, []);
useAsyncEffect(async () => {
const ballEl = ballRef.current;
const targetEl = document.getElementById(targetId);
// "领取"或"批量领取"状态响应动画
if ((ball.state === 'removing' || ball.state === 'batch-removing') && ballEl && targetEl) {
const ballRect = ballEl.getBoundingClientRect();
const targetRect = targetEl.getBoundingClientRect();
const dx = targetRect.left - ballRect.left;
const dy = targetRect.top - ballRect.top;
// 计算动画偏移量
ballEl.style.transition = 'transform 0.7s cubic-bezier(.52,.2,.13,.97), opacity 0.7s';
ballEl.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(0.2)`;
ballEl.style.opacity = '0';
// 清理动画,方便下一次复用
await delay(700);
if (ballEl) {
ballEl.style.transition = '';
ballEl.style.transform = '';
ballEl.style.opacity = '';
}
}
}, [ball.state]);
return (
<div
id={id}
className={cn('ball-item', {
removing: ball.state === 'removing',
'batch-removing': ball.state === 'batch-removing',
})}
onClick={onClick}
>
<div
className={cn('ball-content', {
float: !ball.state,
})}
style={{
animationDuration,
animationDelay,
}}
ref={ballRef}
>
{children}
</div>
</div>
);
};
less
.ball-item {
width: 118px;
flex: 0 0 auto;
transition: width 0.7s, margin-left 0.7s, opacity 0.7s, transform 0.7s;
z-index: 1;
.ball-content {
width: 118px;
height: 118px;
transition: opacity 0.7s, transform 0.7s;
&.float {
animation: upDown 0.8s ease-in-out infinite;
}
}
&.removing,
&.batch-removing {
// 平滑变小,飞出动画
width: 0 !important;
margin-left: 0 !important;
opacity: 0;
.ball-content {
opacity: 0;
}
}
}
@keyframes upDown {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
100% {
transform: translateY(0);
}
}