Vue3 实现无缝滚动组件:多方向、交错布局、图片墙

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. 配置说明

参数名类型说明默认值
dataListany[]滚动数据列表必传
directionScrollDirection滚动方向(horizontal/horizontal-reverse/vertical/vertical-reverse)horizontal
durationnumber完整滚动周期(秒)60
itemWidthnumber项目宽度226
itemHeightnumber项目高度115
gapnumber | null项目间距,为null时自动计算null
pauseOnHoverboolean是否悬停暂停true
rowsnumber展示行数1
staggerboolean是否启用交错布局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>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

沙漠举重的萝卜

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值