手把手带你实现两个基本的轮播动画

882 阅读5分钟

前言

在现代的前端开发中,轮播组件是一种常见的交互效果,用于内容切换,相信每个前端同学在刚开始学习前端开发时都实现过或多或少的轮播效果。今天笔者带大家实现两个基本的轮播动画效果——头像轮播与弹幕轮播,设计思路是利用 React 的状态管理和定时器,结合 CSS 动画来实现轮播效果。相关代码已上传到仓库carousel-animation

头像轮播动画

头像轮播动画.gif

可以看到,头像是自动轮播的,并且左边出现和右边轮播会有一个渐变效果。

首先实现这个效果至少需要5张图片,每个图片DOM对应一个类:

	const [avatarList, setAvatarList] = useState<string[]>([
		'https://img.alicdn.com/bao/uploaded/i2/O1CN01TEnjbR1jxGQKMfnG3_!!0-mtopupload.jpg',
		'https://img.alicdn.com/bao/uploaded/i4/O1CN01au1IiC2GwhHQRo1M8_!!0-mtopupload.jpg',
		'https://img.alicdn.com/bao/uploaded/i4/O1CN01BeSG5i1mUMJCJvXAx_!!4611686018427385981-0-mtopupload.jpg',
		'https://gtms03.alicdn.com/tps/i3/TB1LFGeKVXXXXbCaXXX07tlTXXX-200-200.png',
		'https://img.alicdn.com/bao/uploaded/i3/O1CN01v4wLrP1P7h7r8BMwe_!!0-fleamarket.jpg'
	]);
	const [avatarClass, setAvatarClass] = useState<string[]>([
		'fadeInAvatar',
		'firstAvatar',
		'secondAvatar',
		'thirdAvatar',
		'fadeOutAvatar'
	]);

其中,每个类的CSS如下;

	.avatarList {
		width: 68px;
		height: 28px;
		position: relative;
		overflow: hidden;
		margin: 10px 0;
		.avatar {
			width: 28px;
			height: 28px;
			border-radius: 50%;
			border: 2px solid #fff;
			box-sizing: border-box;
			position: absolute;
			transition: all 0.4s;
		}
		.firstAvatar {
			transform: translateX(0px);
			opacity: 1;
			z-index: 1;
		}
		.secondAvatar {
			transform: translateX(20px);
			opacity: 1;
			z-index: 2;
		}
		.thirdAvatar {
			transform: translateX(40px);
			opacity: 1;
			z-index: 3;
		}
		.fadeInAvatar {
			transform: translateX(-20px);
			opacity: 0;
			z-index: 2;
		}
		.fadeOutAvatar {
			transform: translateX(60px);
			opacity: 0;
			z-index: 2;
		}
	}

实现动画的关键就是动态改变图片DOM的类名,触发对应类名的css动画:

	useEffect(() => {
		// 头像轮播思路:动态改变类名,触发对应类名的css动画
		// avatarList的最前面添加最后一个元素,最后面添加第一个元素,方便轮播
		const tempAvatarList = (avatarList as string[]) || [];
		// 如果头像数量小于5,则用兜底图填充
		if (tempAvatarList.length < 5) {
			const avatarsNeeded = 5 - tempAvatarList.length;
			for (let i = 0; i < avatarsNeeded; i++) {
				tempAvatarList.push(
					'https://gtms03.alicdn.com/tps/i3/TB1LFGeKVXXXXbCaXXX07tlTXXX-200-200.png'
				);
			}
		}
		setAvatarList([...tempAvatarList]);
		// 轮播动画,将第一个类名移动到最后一个
		const next = () => {
			setAvatarClass(prevAvatarClass => {
				if (prevAvatarClass.length > 0) {
					return [...prevAvatarClass.slice(1), prevAvatarClass[0]];
				}
				return prevAvatarClass;
			});
		};
		avatarTimerRef.current = setInterval(next, 1600);
		// 组件销毁时清除定时器
		return () => clearInterval(avatarTimerRef.current);
	}, []);

弹幕轮播动画

弹幕轮播动画.gif

受评论区大佬的指导得知,这个效果可以用纯 CSS 实现,而且极其简单,文章后面的内容可以不看了,参考CSS-only infinite scrolling carousel animation · Logto blog

这个弹幕轮播动画实现起来比上面的头像轮播复杂一点,但核心原理都是利用CSS实现滚动,滚动到指定位置后复位重新开始滚动即可。

组件的DOM结构如下:

	return (
		<div className={styles.barrageCarouselContainer}>
			<h3>弹幕轮播动画</h3>
			<div className={styles.BarrageListContainer} ref={BarrageListContainerRef}>
				<div
					className={styles.barrageRow}
					ref={moveBarPreRef}
					style={{
						transform: `translateX(${0}px)`
					}}
				>
					<BarrageRow barrageRowData={barrageArrayData} rowIndex={0} />
				</div>
				<div
					className={styles.barrageRow}
					ref={moveBarNextRef}
					style={{
						transform: `translateX(${PARENT_WIDTH}px)`
					}}
				>
					<BarrageRow barrageRowData={barrageArrayData} rowIndex={1} />
				</div>
			</div>
		</div>
	);

其中,BarrageRow 组件表示一个弹幕容器,实现弹幕滚动的原理就是设置一前一后两个容器,前一个容器显示完后,后一个容器开始显示,同时前一个容器重置位置。两个容器的初始位置不同:

image.png

image.png BarrageRow组件代码如下:

	// 弹幕列
	const BarrageRow = (props: { barrageRowData: BarrageItemDTO[]; rowIndex: number }) => {
		const { barrageRowData, rowIndex } = props;
		return (
			<>
				{barrageRowData.map(item => {
					return (
						<div className={styles.barrageItem} key={`${item.id}-${rowIndex}`}>
							{item.content}
						</div>
					);
				})}
			</>
		);
	};

实现动画的一些初始化设置如下:

const PARENT_WIDTH = 400; // 弹幕容器宽度,单位px
const BARRAGE_SPEED = 80; // 弹幕速度(px/s)

	const barrageList: BarrageItemDTO[] = [
		{ content: '弹幕111', id: '1' },
		{ content: '弹幕22222', id: '2' },
		{ content: '弹幕33333', id: '3' },
		{ content: '弹幕4444444', id: '4' },
		{ content: '弹幕555555', id: '5' },
		{ content: '弹幕666666666666666666666666666', id: '6' },
		{ content: '弹幕7777777777777', id: '7' },
		{ content: '弹幕8', id: '8' }
	];
	const speed = BARRAGE_SPEED; // 弹幕速度,单位px/s

	const BarrageListContainerRef = useRef<HTMLDivElement>(null); // 弹幕容器,用于获取宽度
	const destoryRef = useRef(false); // 是否销毁
	const moveBarPreRef = useRef<HTMLDivElement>(null); // 前一个容器
	const moveBarNextRef = useRef<HTMLDivElement>(null); // 后一个容器

	// 弹幕数量不足,重复弹幕,确保展示的弹幕能够超过容器宽度(必须满足,否则时间会有问题)
	// 只会导致key重复,不会有其他问题
	const handleBarrage = (barrageArray: BarrageItemDTO[]) => {
		if (barrageArray.length < 8) {
			const tempArray = [...barrageArray];
			while (tempArray.length < 8) {
				tempArray.push(...barrageArray);
			}
			return tempArray;
		} else {
			return barrageArray;
		}
	};
	const barrageArrayData =
		!barrageList || barrageList.length === 0 ? [] : handleBarrage(barrageList);

实现动画的核心逻辑如下,可以概括为:

  1. 第一个容器一开始就开始向左移动,并计算两个时间:容器右移动到外部盒子右边的时间(此时第二个容器开始移动)和容器右移动到外部盒子左边的时间(此时第一个容器要复位)
  2. 对第二个容器进行相同的计算,并且一直循环下去。
	// 动画初始化
	const initAnimate = () => {
		const BarrageListContainerWidth = (BarrageListContainerRef.current?.offsetWidth as number) || 0;
		const preOffset = (moveBarPreRef.current?.offsetWidth as number) || 0;
		const nextOffset = (moveBarNextRef.current?.offsetWidth as number) || 0;

		if (destoryRef.current) {
			return;
		}

		// 计算前一个容器的移动时间,*1000是为了转换成ms
		const preMoveTime = (preOffset * 1000) / speed;
		// 设置前一个容器的移动时间和移动距离,实现移动动画
		if (moveBarPreRef?.current?.style) {
			moveBarPreRef.current.style.transition = `all ${preMoveTime}ms linear`;
			moveBarPreRef.current.style.transform = `translateX(-${preOffset}px)`;
		}
		// 动画完成自动reset
		setTimeout(() => {
			moveReset(moveBarPreRef);
		}, preMoveTime + 50);

		// 前一个容器的宽度大于容器宽度,需要等待一段时间再移动,使其完全进入视线
		const waitTime = ((preOffset - BarrageListContainerWidth) * 1000) / speed;

		setTimeout(() => {
			// 计算后一个容器的移动时间,*1000是为了转换成ms
			const nextMoveTime = ((nextOffset + BarrageListContainerWidth) * 1000) / speed;
			// 设置后一个容器的移动时间和移动距离,实现移动动画
			if (moveBarNextRef?.current?.style) {
				moveBarNextRef.current.style.transition = `all ${nextMoveTime}ms linear`;
				moveBarNextRef.current.style.transform = `translateX(-${nextOffset}px)`;
			}
			// 动画完成自动reset
			setTimeout(() => {
				moveReset(moveBarNextRef);
			}, nextMoveTime + 50);

			// 当后一个容器已经完全进入视线时,前一个容器及时开启动画(此时前一个容器已经reset)
			setTimeout(
				() => {
					moveAction(moveBarPreRef, moveBarNextRef);
				},
				(nextOffset * 1000) / speed
			);
		}, waitTime);
	};

	// 重置已经移动结束的容器,凭借到容器最右侧等待下一次移动
	const moveReset = (tRef: any) => {
		if (destoryRef.current) {
			return;
		}
		const BarrageListContainerWidth = (BarrageListContainerRef.current?.offsetWidth as number) || 0;
		const element = tRef.current;
		if (!element) return;
		element.style.transition = '';
		element.style.transform = `translateX(${BarrageListContainerWidth}px)`; // 记得使用px单位
	};

	// 循环移动
	const moveAction = (tRef: any, nRef: any) => {
		if (destoryRef.current) {
			return;
		}
		// 开启第二次循环动画
		const tElement = tRef.current;
		const BarrageListContainerWidth = (BarrageListContainerRef.current?.offsetWidth as number) || 0;
		const nowOffset = (tElement?.offsetWidth as number) || 0;
		const moveTime = ((nowOffset + BarrageListContainerWidth) * 1000) / speed;
		tElement.style.transition = `all ${moveTime}ms linear`;
		tElement.style.transform = `translateX(-${nowOffset}px)`;

		setTimeout(() => {
			moveReset(tRef);
		}, moveTime + 50);

		// 递归调用,实现无限循环
		setTimeout(
			() => {
				moveAction(nRef, tRef);
			},
			(nowOffset * 1000) / speed
		);
	};

注意:本弹幕动画的实现由于使用了计时器,且实现较为简陋,无法手动控制弹幕的播放与暂停,当页面有大量弹幕时可能会遇到性能问题,建议不渲染的弹幕组件及时销毁掉,防止页面卡顿。