cl-list-view.uvue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. <template>
  2. <view class="cl-list-view">
  3. <cl-index-bar
  4. v-if="hasIndex"
  5. v-model="activeIndex"
  6. :list="indexList"
  7. :pt="{
  8. className: parseClass([pt.indexBar?.className])
  9. }"
  10. @change="onIndexChange"
  11. >
  12. </cl-index-bar>
  13. <scroll-view
  14. class="cl-list-view__scroller"
  15. :scroll-top="targetScrollTop"
  16. :show-scrollbar="false"
  17. direction="vertical"
  18. @scroll="onScroll"
  19. >
  20. <view
  21. class="cl-list-view__virtual-list"
  22. :style="{ height: virtual ? listHeight + 'px' : 'auto' }"
  23. >
  24. <view class="cl-list-view__spacer-top" :style="{ height: spacerTopHeight + 'px' }">
  25. <slot name="top"></slot>
  26. </view>
  27. <view
  28. v-for="item in visibleItems"
  29. :key="item.key"
  30. class="cl-list-view__virtual-item"
  31. >
  32. <view
  33. class="cl-list-view__header"
  34. :class="[
  35. {
  36. 'is-dark': isDark
  37. }
  38. ]"
  39. :style="{
  40. height: headerHeight + 'px'
  41. }"
  42. v-if="item.type == 'header'"
  43. >
  44. <slot name="header" :index="item.data.index!">
  45. <cl-text> {{ item.data.label }} </cl-text>
  46. </slot>
  47. </view>
  48. <view
  49. v-else
  50. class="cl-list-view__item"
  51. :class="[
  52. {
  53. 'is-dark': isDark
  54. },
  55. pt.item?.className
  56. ]"
  57. :style="{
  58. height: virtual ? itemHeight + 'px' : 'auto'
  59. }"
  60. @tap="onItemTap(item)"
  61. >
  62. <slot name="item" :item="item" :data="item.data" :value="item.data.value">
  63. <view class="cl-list-view__item-inner">
  64. <cl-text> {{ item.data.label }} </cl-text>
  65. </view>
  66. </slot>
  67. </view>
  68. </view>
  69. <view
  70. class="cl-list-view__spacer-bottom"
  71. :style="{ height: spacerBottomHeight + 'px' }"
  72. >
  73. <slot name="bottom"></slot>
  74. </view>
  75. </view>
  76. <cl-empty v-if="noData" :fixed="false"></cl-empty>
  77. </scroll-view>
  78. <view
  79. class="cl-list-view__index"
  80. :class="[
  81. {
  82. 'is-dark': isDark
  83. }
  84. ]"
  85. :style="{ height: headerHeight + 'px' }"
  86. v-if="hasIndex"
  87. >
  88. <slot name="index" :index="indexList[activeIndex]">
  89. <cl-text> {{ indexList[activeIndex] }} </cl-text>
  90. </slot>
  91. </view>
  92. </view>
  93. </template>
  94. <script setup lang="ts">
  95. import { computed, getCurrentInstance, onMounted, ref, watch, type PropType } from "vue";
  96. import type { ClListViewItem, PassThroughProps } from "../../types";
  97. import { isApp, isDark, isEmpty, parseClass, parsePt } from "@/cool";
  98. // 定义虚拟列表项
  99. type VirtualItem = {
  100. // 每一项的唯一标识符,用于v-for的key
  101. key: string;
  102. // 项目类型:header表示分组头部,item表示列表项
  103. type: "header" | "item";
  104. // 在整个列表中的索引号
  105. index: number;
  106. // 该项距离列表顶部的像素距离
  107. top: number;
  108. // 该项的高度,header和item可以不同
  109. height: number;
  110. // 该项的具体数据
  111. data: ClListViewItem;
  112. };
  113. type Group = {
  114. index: string;
  115. children: ClListViewItem[];
  116. };
  117. defineOptions({
  118. name: "cl-list-view"
  119. });
  120. defineSlots<{
  121. // 顶部插槽
  122. top(): any;
  123. // 分组头部插槽
  124. header(props: { index: string }): any;
  125. // 列表项插槽
  126. item(props: { data: ClListViewItem; item: VirtualItem; value: any | null }): any;
  127. // 底部插槽
  128. bottom(): any;
  129. // 索引插槽
  130. index(props: { index: string }): any;
  131. }>();
  132. const props = defineProps({
  133. // 透传样式配置
  134. pt: {
  135. type: Object,
  136. default: () => ({})
  137. },
  138. // 列表数据源
  139. data: {
  140. type: Array as PropType<ClListViewItem[]>,
  141. default: () => []
  142. },
  143. // 列表项高度
  144. itemHeight: {
  145. type: Number,
  146. default: 50
  147. },
  148. // 分组头部高度
  149. headerHeight: {
  150. type: Number,
  151. default: 32
  152. },
  153. // 列表顶部预留空间高度
  154. topHeight: {
  155. type: Number,
  156. default: 0
  157. },
  158. // 列表底部预留空间高度
  159. bottomHeight: {
  160. type: Number,
  161. default: 0
  162. },
  163. // 缓冲区大小,即可视区域外预渲染的项目数量
  164. bufferSize: {
  165. type: Number,
  166. default: isApp() ? 5 : 15
  167. },
  168. // 是否启用虚拟列表渲染,当数据量大时建议开启以提升性能
  169. virtual: {
  170. type: Boolean,
  171. default: true
  172. }
  173. });
  174. const emit = defineEmits(["item-tap"]);
  175. // 获取当前组件实例,用于后续DOM操作
  176. const { proxy } = getCurrentInstance()!;
  177. type PassThrough = {
  178. className?: string;
  179. item?: PassThroughProps;
  180. indexBar?: PassThroughProps;
  181. };
  182. // 解析透传样式配置
  183. const pt = computed(() => parsePt<PassThrough>(props.pt));
  184. // 当前激活的索引位置,用于控制索引栏的高亮状态
  185. const activeIndex = ref(0);
  186. // 是否没有数据
  187. const noData = computed(() => {
  188. return isEmpty(props.data);
  189. });
  190. // 是否包含索引
  191. const hasIndex = computed(() => {
  192. return props.data.every((e) => e.index != null) && !noData.value;
  193. });
  194. // 计算属性:将原始数据按索引分组
  195. const data = computed<Group[]>(() => {
  196. // 初始化分组数组
  197. const group: Group[] = [];
  198. // 遍历原始数据,按index字段进行分组
  199. props.data.forEach((item) => {
  200. // 查找是否已存在相同index的分组
  201. const index = group.findIndex((group) => group.index == item.index);
  202. if (index != -1) {
  203. // 如果分组已存在,将当前项添加到该分组的列表中
  204. group[index].children.push(item);
  205. } else {
  206. // 如果分组不存在,创建新的分组
  207. group.push({
  208. index: item.index ?? "",
  209. children: [item]
  210. } as Group);
  211. }
  212. });
  213. return group;
  214. });
  215. // 计算属性:提取所有分组的索引列表,用于索引栏显示
  216. const indexList = computed<string[]>(() => {
  217. return data.value.map((item) => item.index);
  218. });
  219. // 计算属性:将分组数据扁平化为虚拟列表项数组
  220. const virtualItems = computed<VirtualItem[]>(() => {
  221. // 初始化虚拟列表数组
  222. const items: VirtualItem[] = [];
  223. // 初始化顶部位置,考虑预留空间
  224. let top = props.topHeight;
  225. // 初始化索引计数器
  226. let index = 0;
  227. // 遍历每个分组,生成虚拟列表项
  228. data.value.forEach((group, groupIndex) => {
  229. if (group.index != "") {
  230. // 添加分组头部项
  231. items.push({
  232. key: `header-${groupIndex}`,
  233. type: "header",
  234. index: index++,
  235. top,
  236. height: props.headerHeight,
  237. data: {
  238. label: group.index!,
  239. index: group.index
  240. }
  241. });
  242. // 更新top位置
  243. top += props.headerHeight;
  244. }
  245. // 添加分组内的所有列表项
  246. group.children.forEach((item, itemIndex) => {
  247. items.push({
  248. key: `item-${groupIndex}-${itemIndex}`,
  249. type: "item",
  250. index: index++,
  251. top,
  252. height: props.itemHeight,
  253. data: item
  254. });
  255. // 更新top位置
  256. top += props.itemHeight;
  257. });
  258. });
  259. return items;
  260. });
  261. // 计算属性:计算整个列表的总高度
  262. const listHeight = computed<number>(() => {
  263. return (
  264. // 所有项目高度之和
  265. virtualItems.value.reduce((total, item) => total + item.height, 0) +
  266. // 加上顶部预留空间高度
  267. props.topHeight +
  268. // 加上底部预留空间高度
  269. props.bottomHeight
  270. );
  271. });
  272. // 当前滚动位置
  273. const scrollTop = ref(0);
  274. // 目标滚动位置,用于控制滚动到指定位置
  275. const targetScrollTop = ref(0);
  276. // 滚动容器的高度
  277. const scrollerHeight = ref(0);
  278. // 计算属性:获取当前可见区域的列表项
  279. const visibleItems = computed<VirtualItem[]>(() => {
  280. // 如果虚拟列表为空,返回空数组
  281. if (isEmpty(virtualItems.value)) {
  282. return [];
  283. }
  284. // 如果未启用虚拟列表,直接返回所有项目
  285. if (!props.virtual) {
  286. return virtualItems.value;
  287. }
  288. // 计算缓冲区高度
  289. const bufferHeight = props.bufferSize * props.itemHeight;
  290. // 计算可视区域的顶部位置(包含缓冲区)
  291. const viewportTop = scrollTop.value - bufferHeight;
  292. // 计算可视区域的底部位置(包含缓冲区)
  293. const viewportBottom = scrollTop.value + scrollerHeight.value + bufferHeight;
  294. // 初始化可见项目数组
  295. const visible: VirtualItem[] = [];
  296. // 使用二分查找优化查找起始位置
  297. let startIndex = 0;
  298. let endIndex = virtualItems.value.length - 1;
  299. // 二分查找第一个可见项目的索引
  300. while (startIndex < endIndex) {
  301. const mid = Math.floor((startIndex + endIndex) / 2);
  302. const item = virtualItems.value[mid];
  303. if (item.top + item.height <= viewportTop) {
  304. startIndex = mid + 1;
  305. } else {
  306. endIndex = mid;
  307. }
  308. }
  309. // 从找到的起始位置开始,收集所有可见项目
  310. for (let i = startIndex; i < virtualItems.value.length; i++) {
  311. const item = virtualItems.value[i];
  312. // 如果项目完全超出视口下方,停止收集
  313. if (item.top >= viewportBottom) {
  314. break;
  315. }
  316. // 如果项目与视口有交集,添加到可见列表
  317. if (item.top + item.height > viewportTop) {
  318. visible.push(item);
  319. }
  320. }
  321. return visible;
  322. });
  323. // 计算属性:计算上方占位容器的高度
  324. const spacerTopHeight = computed<number>(() => {
  325. // 如果没有可见项目,返回0
  326. if (isEmpty(visibleItems.value)) {
  327. return 0;
  328. }
  329. // 如果未启用虚拟列表,返回0
  330. if (!props.virtual) {
  331. return 0;
  332. }
  333. // 返回第一个可见项目的顶部位置
  334. return visibleItems.value[0].top;
  335. });
  336. // 计算属性:计算下方占位容器的高度
  337. const spacerBottomHeight = computed<number>(() => {
  338. // 如果没有可见项目,返回0
  339. if (isEmpty(visibleItems.value)) {
  340. return 0;
  341. }
  342. // 如果未启用虚拟列表,返回0
  343. if (!props.virtual) {
  344. return 0;
  345. }
  346. // 获取最后一个可见项目
  347. const lastItem = visibleItems.value[visibleItems.value.length - 1];
  348. // 计算下方占位高度
  349. return listHeight.value - (lastItem.top + lastItem.height);
  350. });
  351. // 存储每个分组头部距离顶部的位置数组
  352. const tops = ref<number[]>([]);
  353. // 计算并更新所有分组头部的位置
  354. function getTops() {
  355. // 初始化一个空数组
  356. const arr = [] as number[];
  357. // 初始化顶部位置
  358. let top = 0;
  359. // 计算每个分组的顶部位置
  360. data.value.forEach((group) => {
  361. // 将当前分组头部的位置添加到数组中
  362. arr.push(top);
  363. // 累加当前分组的总高度(头部高度+所有项目高度)
  364. top += props.headerHeight + group.children.length * props.itemHeight;
  365. });
  366. tops.value = arr;
  367. }
  368. // 滚动锁定标志,用于防止滚动时触发不必要的计算
  369. let scrollLock = false;
  370. // 滚动事件处理函数
  371. function onScroll(e: UniScrollEvent) {
  372. // 更新当前滚动位置
  373. scrollTop.value = Math.floor(e.detail.scrollTop);
  374. // 如果滚动被锁定,直接返回
  375. if (scrollLock) return;
  376. // 根据滚动位置自动更新激活的索引
  377. tops.value.forEach((top, index) => {
  378. if (scrollTop.value >= top) {
  379. activeIndex.value = index;
  380. }
  381. });
  382. }
  383. // 行点击事件处理函数
  384. function onItemTap(item: VirtualItem) {
  385. emit("item-tap", item.data);
  386. }
  387. // 索引栏点击事件处理函数
  388. function onIndexChange(index: number) {
  389. // 锁定滚动,防止触发不必要的计算
  390. scrollLock = true;
  391. // 设置目标滚动位置为对应分组头部的位置
  392. targetScrollTop.value = tops.value[index];
  393. // 300ms后解除滚动锁定
  394. setTimeout(() => {
  395. scrollLock = false;
  396. }, 300);
  397. }
  398. // 获取滚动容器的高度
  399. function getScrollerHeight() {
  400. setTimeout(() => {
  401. uni.createSelectorQuery()
  402. .in(proxy)
  403. .select(".cl-list-view__scroller")
  404. .boundingClientRect()
  405. .exec((res) => {
  406. if (isEmpty(res)) {
  407. return;
  408. }
  409. // 设置容器高度
  410. scrollerHeight.value = (res[0] as NodeInfo).height ?? 0;
  411. });
  412. }, 100);
  413. }
  414. // 组件挂载后的初始化逻辑
  415. onMounted(() => {
  416. // 获取容器高度
  417. getScrollerHeight();
  418. // 监听数据变化,重新计算位置信息
  419. watch(
  420. computed(() => props.data),
  421. () => {
  422. getTops();
  423. },
  424. {
  425. // 立即执行一次
  426. immediate: true
  427. }
  428. );
  429. });
  430. defineExpose({
  431. data
  432. });
  433. </script>
  434. <style lang="scss" scoped>
  435. .cl-list-view {
  436. @apply h-full w-full relative;
  437. &__scroller {
  438. @apply h-full w-full;
  439. }
  440. &__virtual-list {
  441. @apply relative w-full;
  442. }
  443. &__spacer-top,
  444. &__spacer-bottom {
  445. @apply w-full;
  446. }
  447. &__index {
  448. @apply flex flex-row items-center bg-white;
  449. @apply absolute top-0 left-0 w-full;
  450. top: 0px;
  451. padding: 0 20rpx;
  452. z-index: 11;
  453. &.is-dark {
  454. @apply bg-surface-600 border-none;
  455. }
  456. }
  457. &__virtual-item {
  458. @apply w-full;
  459. }
  460. &__header {
  461. @apply flex flex-row items-center;
  462. padding: 0 20rpx;
  463. position: relative;
  464. z-index: 10;
  465. }
  466. &__item {
  467. &-inner {
  468. @apply flex flex-row items-center px-[20rpx] h-full;
  469. }
  470. }
  471. }
  472. </style>