| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228 |
- <template>
- <view
- class="virtual-list"
- :style="containerStyle"
- >
- <slot name="inner" />
- <scroll-view
- class="virtual-list-scroll"
- :scroll-y="direction === 'vertical'"
- :scroll-x="direction === 'horizontal'"
- @scroll="handleScroll"
- >
- <slot name="prefix" />
- <!-- 占位容器,用于撑开滚动区域 -->
- <view
- class="virtual-list-placeholder"
- :style="placeholderStyle"
- ></view>
-
- <!-- 可见条目容器 -->
- <view
- class="virtual-list-content"
- :style="contentStyle"
- >
- <view
- v-for="(item, index) in visibleItems"
- :key="dataKey ? ((item as Record<string, any>)[dataKey] ?? index) : index"
- :style="itemStyle"
- class="virtual-list-item"
- >
- <slot name="item" :item="item" :index="startIndex + index"></slot>
- </view>
- </view>
- <slot v-if="data.length === 0" name="empty" />
- <slot name="suffix" />
- </scroll-view>
- </view>
- </template>
- <script setup lang="ts" generic="T">
- import { ref, onMounted, computed, nextTick, getCurrentInstance, type PropType } from 'vue';
- import { useTheme } from '../theme/ThemeDefine';
- import { waitTimeOut } from '@imengyu/imengyu-utils';
- const props = defineProps({
- /**
- * 条目高度(垂直滚动)或宽度(水平滚动)px
- */
- itemSize: {
- type: Number,
- required: true
- },
- /**
- * 数据源
- */
- data: {
- type: Array as PropType<T[]>,
- required: true,
- default: () => []
- },
- /**
- * 数据项的唯一键名,用于vue循环优化
- */
- dataKey: {
- type: String,
- default: undefined
- },
- /**
- * 滚动方向
- */
- direction: {
- type: String,
- default: 'vertical',
- validator: (val: string) => ['vertical', 'horizontal'].includes(val)
- },
- /**
- * 缓冲区大小(可视区域外额外渲染的条目数量)
- * @default 5
- */
- bufferSize: {
- type: Number,
- default: 5
- },
- /**
- * 容器样式
- * @default { height: '100%', width: '100%' }
- */
- containerStyle: {
- type: Object,
- default: () => ({ height: '100%', width: '100%' })
- }
- });
- const emit = defineEmits([ 'scroll' ]);
- const scrollPosition = ref(0);
- const containerSize = ref(0);
- const startIndex = ref(0);
- const visibleCount = ref(0);
- const theme = useTheme();
- const instance = getCurrentInstance();
- onMounted(() => {
- updateContainerSizeAndFlush();
- });
- let flushLock = false;
- async function updateContainerSize() {
- return new Promise<number>((resolve) => {
- const query = uni.createSelectorQuery().in(instance);
- query.select('.virtual-list-scroll').boundingClientRect((data) => {
- if (data && !(data instanceof Array))
- containerSize.value = (props.direction === 'vertical' ? data.height : data.width) || 0;
- else
- containerSize.value = 0;
- resolve(containerSize.value);
- }).exec();
- })
- }
- async function updateContainerSizeAndFlush() {
- flushLock = true;
- await nextTick();
- const size = await updateContainerSize();
- if (size === 0) {
- await waitTimeOut(200);
- await updateContainerSize();
- }
- flushLock = false;
- calculateVisibleItems();
- }
- function handleScroll(e: any) {
- scrollPosition.value = props.direction === 'vertical'
- ? e.detail.scrollTop
- : e.detail.scrollLeft;
- calculateVisibleItems();
- emit('scroll', e);
- }
- function calculateVisibleItems() {
- if (containerSize.value === 0) {
- if (!flushLock)
- updateContainerSizeAndFlush();
- return;
- }
- const itemSize = props.itemSize;
-
- // 计算起始索引(减去缓冲区)
- const newStartIndex = Math.max(0,
- Math.floor(scrollPosition.value / itemSize) - props.bufferSize
- );
-
- // 计算可见条目数量(容器尺寸/条目尺寸 + 2倍缓冲区)
- const newVisibleCount = Math.ceil(containerSize.value / itemSize) + props.bufferSize * 2;
-
- startIndex.value = newStartIndex;
- visibleCount.value = newVisibleCount;
- }
- // 可见数据
- const visibleItems = computed(() => {
- const endIndex = Math.min(
- props.data.length,
- startIndex.value + visibleCount.value
- );
- return props.data.slice(startIndex.value, endIndex);
- });
- // 占位容器样式(总滚动区域)
- const placeholderStyle = computed(() => {
- const size = `${props.data.length * props.itemSize}px`;
- return props.direction === 'vertical'
- ? { height: size, width: '100%' }
- : { width: size, height: '100%' };
- });
- // 内容容器偏移量
- const contentStyle = computed(() => {
- const offset = `${startIndex.value * props.itemSize}px`;
- return props.direction === 'vertical'
- ? { transform: `translateY(${offset})`, width: '100%' }
- : { transform: `translateX(${offset})`, height: '100%' };
- });
- // 条目样式
- const itemStyle = computed(() => {
- const size = `${props.itemSize}px`;
- return props.direction === 'vertical'
- ? { height: size, width: '100%' }
- : { width: size, height: '100%' };
- });
- defineExpose({
- updateContainerSizeAndFlush,
- })
- </script>
- <style scoped>
- .virtual-list {
- position: relative;
- overflow: hidden;
- }
- .virtual-list-scroll {
- position: relative;
- overflow: hidden;
- width: 100%;
- height: 100%;
- }
- .virtual-list-placeholder {
- position: absolute;
- top: 0;
- left: 0;
- pointer-events: none;
- }
- .virtual-list-content {
- position: absolute;
- top: 0;
- left: 0;
- }
- .virtual-list-item {
- box-sizing: border-box;
- }
- </style>
|