Lzh on GitHub
<template>
  <div>
    <div>
      <button @click="rowVirtualizer.scrollToIndex(40)">
        Scroll to index 40
      </button>
      <button @click="rowVirtualizer.scrollToIndex(20)">
        Then scroll to index 20
      </button>
    </div>

    <br>
    <br>

    <div
      ref="parentRef"
      class="List"
      style="height: 200px; width: 400px; overflow: auto"
    >
      <table :style="{ height: `${totalSize}px`, width: '100%' }">
        <thead ref="theadRef">
          <tr>
            <th>Index</th>
            <th>Key</th>
          </tr>
        </thead>
        <tbody>
          <tr
            v-for="virtualRow in virtualRows"
            :key="virtualRow.index"
            :class="virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'"
            :style="{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`
            }"
          >
            <td>#{{ virtualRow.index }}</td>
            <td>{{ virtualRow.key }}</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { useElementSize } from '@vueuse/core'
import { useVirtualizer } from '@tanstack/vue-virtual'

const parentRef = ref<HTMLElement | null>(null)

const theadRef = ref<HTMLElement | null>(null)

const { height } = useElementSize(theadRef)

const rowVirtualizerOptions = computed(() => {
  return {
    count: 10000,
    getScrollElement: () => parentRef.value,
    estimateSize: () => 35,
    overscan: 5,
    paddingStart: height.value ?? 0,
    scrollPaddingStart: height.value ?? 0
  }
})

const rowVirtualizer = useVirtualizer(rowVirtualizerOptions)

const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems())

const totalSize = computed(() => rowVirtualizer.value.getTotalSize())
</script>

<style scoped>
.List table {
  background-color: #fff;
  border: 1px solid #e6e4dc;
  max-width: 100%;
  border-collapse: collapse;

  display: flex;
  flex-direction: column;
  align-items: stretch;
  position: relative;
}

.List thead {
  display: flex;
  flex-direction: column;
  background-color: #fff;

  position: sticky;
  top: 0;
  z-index: 1;
}

.List thead tr {
  height: 70px;
}

.List tr {
  display: flex;
  flex-direction: row;
}

.List td,
.List th {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  width: 180px;
}

.ListItemEven {
  background-color: #e6e4dc;
}
</style>

Scroll Padding

Scroll Padding(滚动填充)是 TanStack Virtual (以及其他一些虚拟化库) 中一个非常有用的概念,它允许你在滚动到特定元素时,在滚动容器的起始或结束位置额外留出一些空间

什么是 Scroll Padding?

简单来说,当你使用 scrollToIndex()scrollToOffset() 方法让虚拟器滚动到某个项目时,Scroll Padding 会在目标项目和滚动容器边缘之间添加一个“空白区域”。

这个概念在两个选项中体现:

  1. scrollPaddingStart: 在滚动容器的起始边缘(例如,垂直滚动时是顶部,水平滚动时是左侧)添加的填充。
  2. scrollPaddingEnd: 在滚动容器的结束边缘(例如,垂直滚动时是底部,水平滚动时是右侧)添加的填充。

为什么需要 Scroll Padding?

Scroll Padding 解决了一些常见的 UI/UX 问题,尤其是在具有固定头部或底部导航的页面中:

  1. 避免内容被遮挡
  • 固定头部/导航栏: 很多网页设计会在页面顶部有一个固定不动的导航栏或工具栏。如果你滚动到某个项目,而这个项目恰好位于视口的顶部,它可能会被固定头部遮挡一部分。scrollPaddingStart 就可以在项目上方留出足够的空间,确保它完全可见,不会被遮挡。
  • 固定底部/工具栏: 类似地,如果页面底部有固定的元素,scrollPaddingEnd 可以防止滚动到的项目被底部元素遮挡。
  1. 改善用户体验:
  • 居中或偏离: 有时你可能不希望项目正好紧贴着滚动容器的边缘显示,而是希望它在滚动后能稍微居中或至少距离边缘有一定距离,看起来更舒服。
  • 视觉指示: 额外的填充可以为用户提供更好的视觉指示,让他们清楚地看到目标项目,而不会显得过于局促。

如何工作?

当你调用 scrollToIndex(index, { align: 'start' }) 时,虚拟器会计算使 index 处的项目对齐到滚动容器起始边缘所需的偏移量。如果设置了 scrollPaddingStart,虚拟器会在这个计算出的偏移量上额外加上 scrollPaddingStart 的值,从而在目标项目和容器边缘之间留出空白。

示例场景

假设你的页面有一个高度为 60px 的固定顶部导航栏:

const virtualizer = useVirtualizer({
  // ...其他选项
  getScrollElement: () => document.getElementById('my-scroll-container'),
  scrollPaddingStart: 60, // 留出60px,防止被顶部导航遮挡
  // ...
});

// 当你滚动到某个项目时
virtualizer.scrollToIndex(someIndex, { align: 'start' });

这样,当你通过 scrollToIndex 将某个项目滚动到可视区域顶部时,这个项目将不会紧贴着容器顶部,而是会向下偏移 60px,从而避免被你的固定导航栏遮挡。