# virtual-seamless-scrolling

## 介绍

基于 **Vue 3** 的**虚拟列表 + 无缝自动滚动**组件：在只渲染可视区域附近若干行的前提下，通过 **`transform`** 位移与 **GSAP** 动画实现连续滚动；适合公告、榜单、大屏等需要循环展示大量数据的场景。

> 说明：当前仓库实现为 **Vue 3** 版本。若需 Vue 2，请自行拉取源码改造。

---

## 技术栈与依赖

| 项 | 说明 |
|----|------|
| Vue | 3.x（组合式 API） |
| 动画 | [GSAP](https://greensock.com/gsap/) 3.x，用于行级位移动画与行间停顿计时 |
| 工具 | [@vueuse/core](https://vueuse.org/)（尺寸监听、元素/文档可见性等） |

发布包中会**打包**上述运行时依赖；业务项目只需安装本包与 **Vue 3**。

**本地开发 / 构建**建议使用 **Node.js `^20.19.0` 或 `>=22.12.0`**（与 Vite 8 要求一致）。

---

## 安装

```bash
npm install virtual-seamless-scrolling -S
```

```bash
pnpm add virtual-seamless-scrolling
```

也可从 Gitee 安装：

```bash
npm install git+https://gitee.com/strivelei/virtual-list-scroll.git -S
```

### 引入样式

组件样式需单独引入（若未引入，列表容器可能无高度或布局异常）：

```ts
import 'virtual-seamless-scrolling/dist/style.css';
```

### 按需引入组件

```ts
import { VirtualListScroll, ListHeader } from 'virtual-seamless-scrolling';
import type { TitltListItem } from 'virtual-seamless-scrolling';
```

---

## 架构与原理（简要）

1. **虚拟窗口**：根据容器高度与首行测量高度估算 `maxCount`，只渲染 `startIndex ~ endIndex` 区间的数据行。
2. **无缝带**：在可视列表下方再渲染一段「头部数据」副本，视觉上形成循环；滚动到底后内部会将位移重置并触发 `scroll-end`，由业务决定是否追加数据等。
3. **位移方式**：列表在 **`overflow: hidden`** 容器内通过外层 **`translate3d`** 移动，避免高频修改 `scrollTop`；**鼠标滚轮**通过监听 `wheel` 手动累加内部位移，与自动滚动共用同一套 `scrollPixel` 逻辑。
4. **自动滚动**：使用 **GSAP** 的 `gsap.to` 对位移做插值；**`transitionDuration`** 控制「滚过一行高度」所需时间；**`interval`** 控制「每滚完一段后的静止等待」时间（详见下文）。

---

## VirtualListScroll 组件

### Props

| 属性 | 说明 | 类型 | 默认值 | 必填 |
|------|------|------|--------|------|
| `dataKey` | 数据源对象上用作 `v-for` `:key` 的字段名，需在每条数据中唯一 | `string` | `'id'` | 是（建议始终传入业务主键） |
| `dataSource` | 列表数据数组，每项为对象且包含 `dataKey` 对应字段 | `Array` | — | 是 |
| `loading` | 加载中。为 **`true` 时停止自动滚动**；为 **`false` 且未悬停**时才会自动滚 | `boolean` | — | 是 |
| `interval` | **行间停顿**时长（**毫秒**）。为 `0` 时连续滚动、**不触发** `line-scroll-end`；大于 `0` 时，每滚完一段后静止 `interval` 再滚下一段，并在满足条件时触发 `line-scroll-end` | `number` | `0` | 否 |
| `transitionDuration` | **单行过渡动画**基准时长（**毫秒**）。表示滚过「**一整行行高**」所用的动画时间；若本段实际位移不足一行（例如滚轮停在半行后先「补到行首」），动画时长会按距离比例缩短，以保持线速度一致 | `number` | `1000` | 否 |
| `refresh` | `true`：数据源变化时**清空内部累积列表**再按新数据渲染；`false`：在原有基础上**追加**新条目（适合分页追加） | `boolean` | `false` | 否 |

#### `interval` 与 `transitionDuration` 的区别

- **`transitionDuration`**：内容**在动**的时间。内部对应 GSAP 对 `scrollPixel` 的补间时长，按 `(实际像素位移 / 行高) × transitionDuration` 计算。
- **`interval`**：内容**停住不动**的时间。在 `interval > 0` 时，每段滚动结束后会再执行一次「仅占位、不位移」的 `gsap.to`，时长为 `interval`，用于间隔下一行滚动。

二者**数值可以不同**，分别控制「动多快」与「停多久」。

#### `interval > 0` 时的额外行为

- **首次**开始自动滚动前，会先**静止等待一个完整的 `interval`**，再开始第一段位移动画（与「每行滚完再停」的节奏一致）。
- 将 `refresh` 设为 `true` 并清空/替换数据时，会重置「首段等待」状态，新数据加载后仍会先等 `interval` 再滚。

### 事件

| 事件名 | 说明 | 触发时机 |
|--------|------|----------|
| `scroll-end` | 列表在内部逻辑中判定「滚到本轮末尾」并回到起点时触发 | 虚拟窗口滑出数据末尾、`scrollPixel` 被重置为 `0` 时；可用于请求下一页、拼接数据等 |
| `line-scroll-end` | **单行（整行高）滚动完成** | 仅当 **`interval > 0`**，且本段动画位移**约等于一行高**时触发；**补行首的短距离**段**不会**触发，避免与下一整行连续触发两次 |

在模板中使用 **kebab-case**：

```vue
<VirtualListScroll
  @scroll-end="onScrollEnd"
  @line-scroll-end="onLineScrollEnd"
/>
```

### 插槽

| 插槽名 | 作用域参数 | 说明 |
|--------|------------|------|
| `item` | `{ item }` | 渲染**一行**内容；`item` 为 `dataSource` 中对应数据对象 |

### 交互行为

| 行为 | 说明 |
|------|------|
| 鼠标进入列表区域 | **暂停**自动滚动 |
| 鼠标离开 | **`loading` 为 false** 时**恢复**自动滚动 |
| 鼠标滚轮 | 在列表上滚动时**阻止默认冒泡**，改为更新内部位移；非悬停且非 loading 时会**打断当前 GSAP 动画并重新排队**自动滚动链 |
| 元素离开视口 / 页签隐藏 | 会按内部逻辑暂停；再次可见时尝试恢复（与 `mouseenter` 状态叠加时以组件内判断为准） |

### 样式与布局建议

- 组件根节点使用 **`height: 100%`**，请保证**父级有明确高度**（如固定 `height` 或 flex 子项拉伸），否则可视高度为 0 会导致无法正确测量行高。
- 列表项建议 **`box-sizing: border-box`**，且同一列表内各行**高度一致**时测量最准确（当前实现按首行高度估算虚拟行高）。

---

## ListHeader（可选）

与示例配套的表头组件，通过 `titleList` 配置列标题与宽度，类型为 `TitltListItem[]`。具体字段见源码 `src/components/ListHeader/data.d.ts`。

---

## 完整示例

更完整的交互可参考仓库内 **`src/examples/App.vue`**。

```vue
<template>
  <div class="virtual-list-content">
    <ListHeader :title-list="titleList" />
    <VirtualListScroll
      data-key="project_id"
      :data-source="dataSource"
      :loading="loading"
      :interval="3000"
      :transition-duration="1000"
      :refresh="false"
      class="virtual-list"
      @scroll-end="scrollEnd"
      @line-scroll-end="lineScrollEnd"
    >
      <template #item="{ item }">
        <div class="virtual-list-item">
          <span>{{ item.name }}</span>
        </div>
      </template>
    </VirtualListScroll>
  </div>
</template>

<script setup lang="ts">
import { VirtualListScroll, ListHeader } from 'virtual-seamless-scrolling';
import type { TitltListItem } from 'virtual-seamless-scrolling';
import 'virtual-seamless-scrolling/dist/style.css';
import { ref } from 'vue';

const titleList: TitltListItem[] = [
  { label: '项目名', width: '20%' },
  // ...
];

const loading = ref(true);
const dataSource = ref<Array<{ project_id: number; name: string }>>([]);

setTimeout(() => {
  for (let i = 0; i < 35; i++) {
    dataSource.value.push({ project_id: i, name: 'Item ' + i });
  }
  loading.value = false;
}, 100);

function scrollEnd() {
  console.log('列表本轮滚到底并复位，可在此拉取更多数据');
}

function lineScrollEnd() {
  console.log('完成一整行位移后的停顿前回调（需 interval > 0）');
}
</script>

<style scoped>
.virtual-list-content {
  display: flex;
  flex-direction: column;
  height: 500px;
}
.virtual-list {
  flex: 1;
  min-height: 0;
}
</style>
```

---

## 本地开发

```bash
pnpm install
pnpm dev
```

## 构建库

```bash
pnpm run build
```

产物在 **`dist/`**（含 ES / UMD 与 `style.css`）。

---

## 常见问题（FAQ）

**Q：`loading` 已经 `false` 了，为什么不滚？**  
A：请确认已有数据且容器已布局完成（行高能测到）。组件会在量完行高后排队启动；若仍异常，检查父级高度是否为 0。

**Q：`line-scroll-end` 为什么不触发？**  
A：需要 **`interval > 0`**，且本段为**约一整行高**的滚动；`interval === 0` 时不会发该事件。

**Q：`transitionDuration` 和 `interval` 能写成一样吗？**  
A：可以，但语义不同：前者是**动画时长**，后者是**停顿时长**；相同数值表示「动多久、就停多久」的节奏感。

---

## 反馈与贡献

有其他需求或 Bug，欢迎在 **Gitee** 提 Issue，后续会持续迭代优化。
