sticky
<template>
<div>
<div
ref="parentRef"
class="List"
style="height: 300px; width: 400px; overflow: auto"
>
<div
:style="{
height: `${totalSize}px`,
width: '100%',
position: 'relative'
}"
>
<div
v-for="virtualRow in virtualRows"
:key="virtualRow.index"
:class="['ListItem', { Sticky: isSticky(virtualRow.index) }]"
:style="{
...(isSticky(virtualRow.index)
? {
background: '#fff',
borderBottom: '1px solid #ddd',
zIndex: 1
}
: {}),
...(isActiveSticky(virtualRow.index)
? { position: 'sticky' }
: {
position: 'absolute',
transform: `translateY(${virtualRow.start}px)`
}),
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`
}"
>
{{ rows[virtualRow.index] }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { faker } from '@faker-js/faker'
import { findIndex, groupBy } from 'lodash'
import { useVirtualizer, defaultRangeExtractor } from '@tanstack/vue-virtual'
// 使用 faker 生成1000个随机名字,并按字母顺序排序后按首字母分组
// groupBy 会创建一个对象,键为首字母,值为对应首字母的名字数组
const groupedNames = groupBy(
Array.from({ length: 1000 }) // 创建长度为1000的数组
.map(() => faker.person.firstName()) // 生成1000个随机名字
.sort(), // 按字母顺序排序
(name: string[]) => name[0] // 按名字的首字母分组
)
// 获取所有分组的首字母键(如 ['A', 'B', 'C', ...])
const groups = Object.keys(groupedNames)
// 将分组数据扁平化为一维数组,格式为:[分组标题, 分组内名字1, 分组内名字2, ..., 下一个分组标题, ...]
// 例如: ['A', 'Alice', 'Amy', 'Andrew', 'B', 'Bob', 'Ben', ...]
const rows = groups.reduce<string[]>(
(acc, k) => [...acc, k, ...groupedNames[k]], // 将每个分组的标题和内容依次添加到数组中
[]
)
const parentRef = ref<HTMLElement | null>(null)
// 创建用于存储当前激活的粘性头部索引的响应式变量
// 初始值为0,表示第一个粘性头部处于激活状态
const activeStickyIndexRef = ref(0)
// 计算属性:获取所有粘性头部的索引位置
// 通过在 rows 数组中查找每个分组名称的位置来确定粘性头部的索引
const stickyIndexes = computed(() =>
groups.map(gn => findIndex(rows, (n: string) => n === gn))
)
// 判断指定索引是否为粘性头部
// @param index - 要检查的行索引
// @returns 如果是粘性头部返回 true,否则返回 false
const isSticky = (index: number) => stickyIndexes.value.includes(index)
// 判断指定索引是否为当前激活的粘性头部
// @param index - 要检查的行索引
// @returns 如果是当前激活的粘性头部返回 true,否则返回 false
const isActiveSticky = (index: number) => activeStickyIndexRef.value === index
const rowVirtualizer = useVirtualizer({
// 数据总条数,等于处理后的行数组长度
count: rows.length,
// 估算每行的高度为50像素
estimateSize: () => 50,
// 获取滚动容器元素的回调函数
getScrollElement: () => parentRef.value,
// 自定义范围提取器,用于确保粘性头部始终可见
rangeExtractor: (range) => {
// 从后往前查找,找到第一个 startIndex 大于等于当前索引的粘性头部
// 并将其设置为当前激活的粘性头部
activeStickyIndexRef.value = [...stickyIndexes.value]
.reverse() // 反转数组以便从后往前查找
.find(index => range.startIndex >= index) // 找到合适的粘性头部索引
// 创建一个新的集合,包含:
// 1. 当前激活的粘性头部索引
// 2. 默认范围提取器返回的索引列表
const next = new Set([
activeStickyIndexRef.value,
...defaultRangeExtractor(range)
])
// 将索引按升序排列并返回
return [...next].sort((a, b) => a - b)
}
})
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems())
const totalSize = computed(() => rowVirtualizer.value.getTotalSize())
</script>
<style scoped>
.List {
border: 1px solid #e6e4dc;
max-width: 100%;
}
.ListItemEven,
.ListItemOdd {
display: flex;
align-items: center;
justify-content: center;
}
.ListItemEven {
background-color: #e6e4dc;
}
button {
border: 1px solid gray;
}
</style>