Vue3 实现无缝无限滚动组件:多方向、交错布局、图片墙
使用 Vue3 封装一个无缝无限滚动组件,支持水平/垂直(正向/反向)多方向滚动、自定义尺寸与间距、悬停暂停及多行交错布局。
1. 无缝滚动实现
核心是双组内容拼接 + 动画循环:
- 组件内部将数据列表渲染为两组完全相同的内容(
wall-group
) - 通过 CSS 动画控制内容组的位移,当第一组内容完全滚动出视野时,第二组内容恰好衔接上
- 由于两组内容完全一致,用户视觉上感知为“无限循环”,实现无缝效果
/* 核心动画示例(水平正向滚动) */
@keyframes scroll-right {
from { transform: translateX(var(--horizontal-end)); } /* 起始位置:第二组内容衔接处 */
to { transform: translateX(var(--horizontal-start)); } /* 结束位置:第一组内容完全移出 */
}
.wall.horizontal .wall-group {
animation: scroll-right var(--duration) linear infinite; /* 应用无限循环动画 */
}
2. 数据分组逻辑
当需要多行展示时,通过 splitIntoParts
函数将原始数据均匀分配到各行:
- 计算每行基础数据量与剩余数据,优先将剩余数据分配给前几行
- 确保每行数据量尽可能均匀,避免某行内容过少导致滚动效果不连贯
- 生成的二维数组对应多行数据,每行独立滚动形成整体效果
// 数据分割核心逻辑
function splitIntoParts<T>(array: T[], num: number): T[][] {
const baseSize = Math.floor(array.length / num); // 基础分片大小
let remainder = array.length % num; // 剩余项数量
let startIndex = 0;
const result: T[][] = [];
for (let i = 0; i < num; i++) {
const currentSize = baseSize + (remainder > 0 ? 1 : 0); // 分配剩余项
remainder = Math.max(0, remainder - 1);
result.push(array.slice(startIndex, startIndex + currentSize));
startIndex += currentSize;
}
return result;
}
3. 动态样式与交互控制
-
CSS 变量驱动:通过计算属性动态生成
--item-width
、--gap
、--duration
等 CSS 变量,实现样式与配置的联动。例如通过:style="cssVars"
将组件 props 中定义的尺寸、间距等配置转换为 CSS 变量,使样式能直接响应配置变化。 -
动态动画时长计算:通过
:style="
–duration: ${(wall.length * props.duration) / 8}s;"
实现不同数据量的滚动行动画速度平衡。根据每行数据量动态调整动画时长,确保数据量不同的行保持一致的视觉滚动速度,避免出现“数据少的行滚动过快”或“数据多的行滚动过慢”的问题。 -
悬停暂停:利用
animation-play-state
CSS 属性,通过.wall.pause-on-hover:hover .wall-group
选择器在 hover 时将动画状态设为paused
,实现鼠标悬停时暂停滚动、离开后恢复的交互效果,提升用户浏览体验。 -
交错布局:通过调整偶数行的
margin
实现交错视觉效果,如.wall.horizontal.staggered:nth-child(even)
选择器为水平方向的偶数行设置margin-left: calc(var(--item-width) / -2 + var(--gap) / -2)
,使多行内容呈现交错排列,增强展示层次感。
4. 基础使用
<template>
<div class="example-container">
<!-- 水平滚动示例 -->
<ScrollWall
:dataList="productList"
direction="horizontal"
:duration="40"
:itemWidth="240"
:itemHeight="120"
>
<template #item="{ item }">
<div class="product-card">
<img :src="item.image" class="product-img" />
<h3 class="product-name">{{ item.name }}</h3>
<p class="product-price">¥{{ item.price }}</p>
</div>
</template>
</ScrollWall>
<!-- 多行交错示例 -->
<ScrollWall
:dataList="newsList"
direction="horizontal"
:rows="2"
stagger
:duration="50"
>
<template #item="{ item }">
<div class="news-item">{{ item.title }}</div>
</template>
</ScrollWall>
</div>
</template>
<script setup>
import ScrollWall from './ScrollWall.vue';
// 模拟数据
const productList = Array(15).fill(0).map((_, i) => ({
id: i,
name: `精选商品 ${i+1}`,
price: (Math.random() * 200 + 100).toFixed(2),
image: `https://picsum.photos/240/120?random=${i}`
}));
const newsList = Array(20).fill(0).map((_, i) => ({
id: i,
title: `最新资讯标题 ${i+1} - 热点新闻摘要`
}));
</script>
5. 配置说明
参数名 | 类型 | 说明 | 默认值 |
---|---|---|---|
dataList | any[] | 滚动数据列表 | 必传 |
direction | ScrollDirection | 滚动方向(horizontal/horizontal-reverse/vertical/vertical-reverse) | horizontal |
duration | number | 完整滚动周期(秒) | 60 |
itemWidth | number | 项目宽度 | 226 |
itemHeight | number | 项目高度 | 115 |
gap | number | null | 项目间距,为null时自动计算 | null |
pauseOnHover | boolean | 是否悬停暂停 | true |
rows | number | 展示行数 | 1 |
stagger | boolean | 是否启用交错布局 | false |
6. ScrollWall.vue 组件完整代码
<script setup lang="ts">
import { computed } from 'vue';
type ScrollDirection =
| 'horizontal'
| 'horizontal-reverse'
| 'vertical'
| 'vertical-reverse';
interface Props {
// 数据列表
dataList: any[];
// 滚动方向
direction?: ScrollDirection;
// 完成一次完整滚动所需时间(秒)
duration?: number;
// 项目宽度
itemWidth?: number;
// 项目高度
itemHeight?: number;
// 项目间距,如果为null则自动计算
gap?: number | null;
// 是否启用悬停暂停
pauseOnHover?: boolean;
// 单位
unit?: string;
// 行或列
rows?: number;
// 是否显示两行交错效果
stagger?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
direction: 'horizontal',
duration: 60,
itemWidth: 226,
itemHeight: 115,
gap: null,
pauseOnHover: true,
unit: 'px',
rows: 1,
stagger: false,
});
const computedData = computed(() => {
return splitIntoParts(props.dataList, props.rows);
});
// 计算间距
const computedGap = computed(() => {
return props.gap !== null ? props.gap : Math.round(props.itemWidth / 14);
});
// 计算CSS变量对象
const cssVars = computed(() => {
const vars: Record<string, string> = {
'--item-width': `${props.itemWidth}${props.unit}`,
'--item-height': `${props.itemHeight}${props.unit}`,
'--gap': `${computedGap.value}${props.unit}`,
'--duration': `${props.duration}s`,
'--horizontal-start': '0',
'--horizontal-end': `calc(-100% - var(--gap))`,
'--vertical-start': '0',
'--vertical-end': `calc(-100% - var(--gap))`,
};
return vars;
});
const wallWrapperClasses = computed(() => {
return [props.direction];
});
// 墙的类名
const wallClasses = computed(() => {
return [
props.direction,
{
staggered: props.stagger,
'pause-on-hover': props.pauseOnHover,
},
];
});
// 分割
function splitIntoParts<T>(array: T[], num: number): T[][] {
if (!Array.isArray(array)) {
throw new Error('First argument must be an array');
}
if (!Number.isInteger(num) || num <= 0) {
throw new Error('Second argument must be a positive integer');
}
if (array.length === 0 || num === 1) {
return [array.slice()];
}
if (num >= array.length) {
return array.map((item) => [item]);
}
const result: T[][] = [];
const totalLength = array.length;
const baseSize = Math.floor(totalLength / num);
let remainder = totalLength % num;
let startIndex = 0;
for (let i = 0; i < num; i++) {
const currentSize = baseSize + (remainder > 0 ? 1 : 0);
remainder = Math.max(0, remainder - 1);
result.push(array.slice(startIndex, startIndex + currentSize));
startIndex += currentSize;
}
return result;
}
</script>
<template>
<div class="wall-wrapper" :class="wallWrapperClasses" :style="cssVars">
<div
v-for="(wall, wallIndex) in computedData"
:key="wallIndex"
class="wall"
:class="wallClasses"
:style="`--duration: ${(wall.length * props.duration) / 8}s;`"
>
<!-- 第一个内容组 -->
<ul class="wall-group">
<li
v-for="(item, index) in wall"
:key="`origin-${index}`"
class="wall-item"
>
<slot name="item" :item="item"></slot>
</li>
</ul>
<!-- 第二个内容组(用于无缝循环) -->
<ul class="wall-group">
<li
v-for="(item, index) in wall"
:key="`duplicate-${index}`"
class="wall-item"
>
<slot name="item" :item="item"></slot>
</li>
</ul>
</div>
</div>
</template>
<style scoped>
/* 动画定义 */
@keyframes scroll-left {
from {
transform: translateX(var(--horizontal-start));
}
to {
transform: translateX(var(--horizontal-end));
}
}
@keyframes scroll-right {
from {
transform: translateX(var(--horizontal-end));
}
to {
transform: translateX(var(--horizontal-start));
}
}
@keyframes scroll-down {
from {
transform: translateY(var(--vertical-start));
}
to {
transform: translateY(var(--vertical-end));
}
}
@keyframes scroll-up {
from {
transform: translateY(var(--vertical-end));
}
to {
transform: translateY(var(--vertical-start));
}
}
/* 墙包装容器基础样式 */
.wall-wrapper {
position: relative;
display: flex;
flex-direction: column;
gap: var(--gap);
margin: auto;
overflow: hidden;
}
/* 墙容器基础样式 */
.wall {
display: flex;
gap: var(--gap);
overflow: hidden;
user-select: none;
}
/* 悬停暂停效果 */
.wall.pause-on-hover:hover .wall-group {
animation-play-state: paused;
}
/* 项目组基础样式 */
.wall-group {
display: flex;
flex-shrink: 0;
gap: var(--gap);
align-items: center;
will-change: transform;
}
.wall-item {
width: var(--item-width);
height: var(--item-height);
}
/* 水平交错效果 */
.wall.horizontal.staggered:nth-child(even),
.wall.horizontal-reverse.staggered:nth-child(even) {
margin-left: calc(var(--item-width) / -2 + var(--gap) / -2);
}
/* 垂直交错效果 */
.wall.vertical.staggered:nth-child(even),
.wall.vertical-reverse.staggered:nth-child(even) {
margin-top: calc(var(--item-height) / -2 + var(--gap) / -2);
}
/* 水平滚动配置 */
/* 垂直滚动配置 */
.wall-wrapper.vertical,
.wall-wrapper.vertical-reverse {
flex-direction: row;
}
.wall.vertical,
.wall.vertical-reverse {
flex-direction: column;
}
.wall.vertical .wall-group,
.wall.vertical-reverse .wall-group {
flex-direction: column;
}
/* 应用动画 */
.wall.horizontal .wall-group {
animation: scroll-right var(--duration) linear infinite;
}
.wall.horizontal-reverse .wall-group {
animation: scroll-left var(--duration) linear infinite;
}
.wall.vertical .wall-group {
animation: scroll-down var(--duration) linear infinite;
}
.wall.vertical-reverse .wall-group {
animation: scroll-up var(--duration) linear infinite;
}
</style>