FixedVirtualList.vue 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. <template>
  2. <view
  3. class="virtual-list"
  4. :style="containerStyle"
  5. >
  6. <slot name="inner" />
  7. <scroll-view
  8. class="virtual-list-scroll"
  9. :scroll-y="direction === 'vertical'"
  10. :scroll-x="direction === 'horizontal'"
  11. @scroll="handleScroll"
  12. >
  13. <slot name="prefix" />
  14. <!-- 占位容器,用于撑开滚动区域 -->
  15. <view
  16. class="virtual-list-placeholder"
  17. :style="placeholderStyle"
  18. ></view>
  19. <!-- 可见条目容器 -->
  20. <view
  21. class="virtual-list-content"
  22. :style="contentStyle"
  23. >
  24. <view
  25. v-for="(item, index) in visibleItems"
  26. :key="dataKey ? ((item as Record<string, any>)[dataKey] ?? index) : index"
  27. :style="itemStyle"
  28. class="virtual-list-item"
  29. >
  30. <slot name="item" :item="item" :index="startIndex + index"></slot>
  31. </view>
  32. </view>
  33. <slot v-if="data.length === 0" name="empty" />
  34. <slot name="suffix" />
  35. </scroll-view>
  36. </view>
  37. </template>
  38. <script setup lang="ts" generic="T">
  39. import { ref, onMounted, computed, nextTick, getCurrentInstance, type PropType } from 'vue';
  40. import { useTheme } from '../theme/ThemeDefine';
  41. import { waitTimeOut } from '@imengyu/imengyu-utils';
  42. const props = defineProps({
  43. /**
  44. * 条目高度(垂直滚动)或宽度(水平滚动)px
  45. */
  46. itemSize: {
  47. type: Number,
  48. required: true
  49. },
  50. /**
  51. * 数据源
  52. */
  53. data: {
  54. type: Array as PropType<T[]>,
  55. required: true,
  56. default: () => []
  57. },
  58. /**
  59. * 数据项的唯一键名,用于vue循环优化
  60. */
  61. dataKey: {
  62. type: String,
  63. default: undefined
  64. },
  65. /**
  66. * 滚动方向
  67. */
  68. direction: {
  69. type: String,
  70. default: 'vertical',
  71. validator: (val: string) => ['vertical', 'horizontal'].includes(val)
  72. },
  73. /**
  74. * 缓冲区大小(可视区域外额外渲染的条目数量)
  75. * @default 5
  76. */
  77. bufferSize: {
  78. type: Number,
  79. default: 5
  80. },
  81. /**
  82. * 容器样式
  83. * @default { height: '100%', width: '100%' }
  84. */
  85. containerStyle: {
  86. type: Object,
  87. default: () => ({ height: '100%', width: '100%' })
  88. }
  89. });
  90. const emit = defineEmits([ 'scroll' ]);
  91. const scrollPosition = ref(0);
  92. const containerSize = ref(0);
  93. const startIndex = ref(0);
  94. const visibleCount = ref(0);
  95. const theme = useTheme();
  96. const instance = getCurrentInstance();
  97. onMounted(() => {
  98. updateContainerSizeAndFlush();
  99. });
  100. let flushLock = false;
  101. async function updateContainerSize() {
  102. return new Promise<number>((resolve) => {
  103. const query = uni.createSelectorQuery().in(instance);
  104. query.select('.virtual-list-scroll').boundingClientRect((data) => {
  105. if (data && !(data instanceof Array))
  106. containerSize.value = (props.direction === 'vertical' ? data.height : data.width) || 0;
  107. else
  108. containerSize.value = 0;
  109. resolve(containerSize.value);
  110. }).exec();
  111. })
  112. }
  113. async function updateContainerSizeAndFlush() {
  114. flushLock = true;
  115. await nextTick();
  116. const size = await updateContainerSize();
  117. if (size === 0) {
  118. await waitTimeOut(200);
  119. await updateContainerSize();
  120. }
  121. flushLock = false;
  122. calculateVisibleItems();
  123. }
  124. function handleScroll(e: any) {
  125. scrollPosition.value = props.direction === 'vertical'
  126. ? e.detail.scrollTop
  127. : e.detail.scrollLeft;
  128. calculateVisibleItems();
  129. emit('scroll', e);
  130. }
  131. function calculateVisibleItems() {
  132. if (containerSize.value === 0) {
  133. if (!flushLock)
  134. updateContainerSizeAndFlush();
  135. return;
  136. }
  137. const itemSize = props.itemSize;
  138. // 计算起始索引(减去缓冲区)
  139. const newStartIndex = Math.max(0,
  140. Math.floor(scrollPosition.value / itemSize) - props.bufferSize
  141. );
  142. // 计算可见条目数量(容器尺寸/条目尺寸 + 2倍缓冲区)
  143. const newVisibleCount = Math.ceil(containerSize.value / itemSize) + props.bufferSize * 2;
  144. startIndex.value = newStartIndex;
  145. visibleCount.value = newVisibleCount;
  146. }
  147. // 可见数据
  148. const visibleItems = computed(() => {
  149. const endIndex = Math.min(
  150. props.data.length,
  151. startIndex.value + visibleCount.value
  152. );
  153. return props.data.slice(startIndex.value, endIndex);
  154. });
  155. // 占位容器样式(总滚动区域)
  156. const placeholderStyle = computed(() => {
  157. const size = `${props.data.length * props.itemSize}px`;
  158. return props.direction === 'vertical'
  159. ? { height: size, width: '100%' }
  160. : { width: size, height: '100%' };
  161. });
  162. // 内容容器偏移量
  163. const contentStyle = computed(() => {
  164. const offset = `${startIndex.value * props.itemSize}px`;
  165. return props.direction === 'vertical'
  166. ? { transform: `translateY(${offset})`, width: '100%' }
  167. : { transform: `translateX(${offset})`, height: '100%' };
  168. });
  169. // 条目样式
  170. const itemStyle = computed(() => {
  171. const size = `${props.itemSize}px`;
  172. return props.direction === 'vertical'
  173. ? { height: size, width: '100%' }
  174. : { width: size, height: '100%' };
  175. });
  176. defineExpose({
  177. updateContainerSizeAndFlush,
  178. })
  179. </script>
  180. <style scoped>
  181. .virtual-list {
  182. position: relative;
  183. overflow: hidden;
  184. }
  185. .virtual-list-scroll {
  186. position: relative;
  187. overflow: hidden;
  188. width: 100%;
  189. height: 100%;
  190. }
  191. .virtual-list-placeholder {
  192. position: absolute;
  193. top: 0;
  194. left: 0;
  195. pointer-events: none;
  196. }
  197. .virtual-list-content {
  198. position: absolute;
  199. top: 0;
  200. left: 0;
  201. }
  202. .virtual-list-item {
  203. box-sizing: border-box;
  204. }
  205. </style>