cl-list-view.uvue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731
  1. <template>
  2. <view class="cl-list-view" :class="[pt.className]">
  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. :class="[pt.scroller?.className]"
  16. :scroll-top="targetScrollTop"
  17. :scroll-into-view="scrollIntoView"
  18. :scroll-with-animation="scrollWithAnimation"
  19. :show-scrollbar="showScrollbar"
  20. :refresher-triggered="refreshTriggered"
  21. :refresher-enabled="refresherEnabled"
  22. :refresher-threshold="refresherThreshold"
  23. :refresher-background="refresherBackground"
  24. refresher-default-style="none"
  25. direction="vertical"
  26. @scrolltoupper="onScrollToUpper"
  27. @scrolltolower="onScrollToLower"
  28. @scroll="onScroll"
  29. @scrollend="onScrollEnd"
  30. @refresherpulling="onRefresherPulling"
  31. @refresherrefresh="onRefresherRefresh"
  32. @refresherrestore="onRefresherRestore"
  33. @refresherabort="onRefresherAbort"
  34. >
  35. <view
  36. slot="refresher"
  37. class="cl-list-view__refresher"
  38. :class="[
  39. {
  40. 'is-pulling': refresherStatus === 'pulling',
  41. 'is-refreshing': refresherStatus === 'refreshing'
  42. },
  43. pt.refresher?.className
  44. ]"
  45. :style="{
  46. height: refresherThreshold + 'px'
  47. }"
  48. >
  49. <cl-loading
  50. v-if="refresherStatus === 'refreshing'"
  51. :size="28"
  52. :pt="{
  53. className: 'mr-2'
  54. }"
  55. ></cl-loading>
  56. <cl-text> {{ refresherText }} </cl-text>
  57. </view>
  58. <view
  59. class="cl-list-view__virtual-list"
  60. :class="[pt.list?.className]"
  61. :style="listStyle"
  62. >
  63. <view class="cl-list-view__spacer-top" :style="spacerTopStyle">
  64. <slot name="top"></slot>
  65. </view>
  66. <view
  67. v-for="(item, index) in visibleItems"
  68. :key="item.key"
  69. class="cl-list-view__virtual-item"
  70. >
  71. <view
  72. class="cl-list-view__header"
  73. :class="[
  74. {
  75. 'is-dark': isDark
  76. }
  77. ]"
  78. :style="{
  79. height: headerHeight + 'px'
  80. }"
  81. v-if="item.type == 'header'"
  82. >
  83. <slot name="header" :index="item.data.index!">
  84. <cl-text> {{ item.data.label }} </cl-text>
  85. </slot>
  86. </view>
  87. <view
  88. v-else
  89. class="cl-list-view__item"
  90. :class="[
  91. {
  92. 'is-dark': isDark
  93. },
  94. pt.item?.className
  95. ]"
  96. :style="{
  97. height: virtual ? itemHeight + 'px' : 'auto'
  98. }"
  99. @tap="onItemTap(item)"
  100. >
  101. <slot
  102. name="item"
  103. :item="item"
  104. :data="item.data"
  105. :value="item.data.value"
  106. :index="index"
  107. >
  108. <view class="cl-list-view__item-inner">
  109. <cl-text> {{ item.data.label }} </cl-text>
  110. </view>
  111. </slot>
  112. </view>
  113. </view>
  114. <view class="cl-list-view__spacer-bottom" :style="spacerBottomStyle">
  115. <slot name="bottom"></slot>
  116. </view>
  117. </view>
  118. <cl-empty v-if="noData" :fixed="false"></cl-empty>
  119. </scroll-view>
  120. <view
  121. class="cl-list-view__index"
  122. :class="[
  123. {
  124. 'is-dark': isDark
  125. }
  126. ]"
  127. :style="{ height: headerHeight + 'px' }"
  128. v-if="hasIndex"
  129. >
  130. <slot name="index" :index="indexList[activeIndex]">
  131. <cl-text> {{ indexList[activeIndex] }} </cl-text>
  132. </slot>
  133. </view>
  134. </view>
  135. </template>
  136. <script setup lang="ts">
  137. import { computed, getCurrentInstance, onMounted, ref, watch, type PropType } from "vue";
  138. import type { ClListViewItem, PassThroughProps } from "../../types";
  139. import { isApp, isDark, isEmpty, parseClass, parsePt } from "@/cool";
  140. // 定义虚拟列表项
  141. type VirtualItem = {
  142. // 每一项的唯一标识符,用于v-for的key
  143. key: string;
  144. // 项目类型:header表示分组头部,item表示列表项
  145. type: "header" | "item";
  146. // 在整个列表中的索引号
  147. index: number;
  148. // 该项距离列表顶部的像素距离
  149. top: number;
  150. // 该项的高度,header和item可以不同
  151. height: number;
  152. // 该项的具体数据
  153. data: ClListViewItem;
  154. };
  155. type Group = {
  156. index: string;
  157. children: ClListViewItem[];
  158. };
  159. defineOptions({
  160. name: "cl-list-view"
  161. });
  162. defineSlots<{
  163. // 顶部插槽
  164. top(): any;
  165. // 分组头部插槽
  166. header(props: { index: string }): any;
  167. // 列表项插槽
  168. item(props: { data: ClListViewItem; item: VirtualItem; value: any | null; index: number }): any;
  169. // 底部插槽
  170. bottom(): any;
  171. // 索引插槽
  172. index(props: { index: string }): any;
  173. }>();
  174. const props = defineProps({
  175. // 透传样式配置
  176. pt: {
  177. type: Object,
  178. default: () => ({})
  179. },
  180. // 列表数据源
  181. data: {
  182. type: Array as PropType<ClListViewItem[]>,
  183. default: () => []
  184. },
  185. // 列表项高度
  186. itemHeight: {
  187. type: Number,
  188. default: 50
  189. },
  190. // 分组头部高度
  191. headerHeight: {
  192. type: Number,
  193. default: 32
  194. },
  195. // 列表顶部预留空间高度
  196. topHeight: {
  197. type: Number,
  198. default: 0
  199. },
  200. // 列表底部预留空间高度
  201. bottomHeight: {
  202. type: Number,
  203. default: 0
  204. },
  205. // 缓冲区大小,即可视区域外预渲染的项目数量
  206. bufferSize: {
  207. type: Number,
  208. default: isApp() ? 5 : 15
  209. },
  210. // 是否启用虚拟列表渲染,当数据量大时建议开启以提升性能
  211. virtual: {
  212. type: Boolean,
  213. default: true
  214. },
  215. // 滚动到指定位置
  216. scrollIntoView: {
  217. type: String,
  218. default: ""
  219. },
  220. // 是否启用滚动动画
  221. scrollWithAnimation: {
  222. type: Boolean,
  223. default: false
  224. },
  225. // 是否显示滚动条
  226. showScrollbar: {
  227. type: Boolean,
  228. default: false
  229. },
  230. // 是否启用下拉刷新
  231. refresherEnabled: {
  232. type: Boolean,
  233. default: false
  234. },
  235. // 下拉刷新触发距离,相当于下拉内容高度
  236. refresherThreshold: {
  237. type: Number,
  238. default: 45
  239. },
  240. // 下拉刷新区域背景色
  241. refresherBackground: {
  242. type: String,
  243. default: "transparent"
  244. },
  245. // 下拉刷新默认文案
  246. refresherDefaultText: {
  247. type: String,
  248. default: "下拉刷新"
  249. },
  250. // 释放刷新文案
  251. refresherPullingText: {
  252. type: String,
  253. default: "释放立即刷新"
  254. },
  255. // 正在刷新文案
  256. refresherRefreshingText: {
  257. type: String,
  258. default: "加载中"
  259. }
  260. });
  261. const emit = defineEmits([
  262. "item-tap",
  263. "refresher-pulling",
  264. "refresher-refresh",
  265. "refresher-restore",
  266. "refresher-abort",
  267. "scrolltoupper",
  268. "scrolltolower",
  269. "scroll",
  270. "scrollend",
  271. "pull",
  272. "top",
  273. "bottom"
  274. ]);
  275. // 获取当前组件实例,用于后续DOM操作
  276. const { proxy } = getCurrentInstance()!;
  277. // 透传样式配置类型
  278. type PassThrough = {
  279. className?: string;
  280. item?: PassThroughProps;
  281. list?: PassThroughProps;
  282. indexBar?: PassThroughProps;
  283. scroller?: PassThroughProps;
  284. refresher?: PassThroughProps;
  285. };
  286. // 解析透传样式配置
  287. const pt = computed(() => parsePt<PassThrough>(props.pt));
  288. // 当前激活的索引位置,用于控制索引栏的高亮状态
  289. const activeIndex = ref(0);
  290. // 是否没有数据
  291. const noData = computed(() => {
  292. return isEmpty(props.data);
  293. });
  294. // 是否包含索引
  295. const hasIndex = computed(() => {
  296. return props.data.every((e) => e.index != null) && !noData.value;
  297. });
  298. // 计算属性:将原始数据按索引分组
  299. const data = computed<Group[]>(() => {
  300. // 初始化分组数组
  301. const group: Group[] = [];
  302. // 遍历原始数据,按index字段进行分组
  303. props.data.forEach((item) => {
  304. // 查找是否已存在相同index的分组
  305. const index = group.findIndex((group) => group.index == item.index);
  306. if (index != -1) {
  307. // 如果分组已存在,将当前项添加到该分组的列表中
  308. group[index].children.push(item);
  309. } else {
  310. // 如果分组不存在,创建新的分组
  311. group.push({
  312. index: item.index ?? "",
  313. children: [item]
  314. } as Group);
  315. }
  316. });
  317. return group;
  318. });
  319. // 计算属性:提取所有分组的索引列表,用于索引栏显示
  320. const indexList = computed<string[]>(() => {
  321. return data.value.map((item) => item.index);
  322. });
  323. // 计算属性:将分组数据扁平化为虚拟列表项数组
  324. const virtualItems = computed<VirtualItem[]>(() => {
  325. // 初始化虚拟列表数组
  326. const items: VirtualItem[] = [];
  327. // 初始化顶部位置,考虑预留空间
  328. let top = props.topHeight;
  329. // 初始化索引计数器
  330. let index = 0;
  331. // 遍历每个分组,生成虚拟列表项
  332. data.value.forEach((group, groupIndex) => {
  333. if (group.index != "") {
  334. // 添加分组头部项
  335. items.push({
  336. key: `header-${groupIndex}`,
  337. type: "header",
  338. index: index++,
  339. top,
  340. height: props.headerHeight,
  341. data: {
  342. label: group.index!,
  343. index: group.index
  344. }
  345. });
  346. // 更新top位置
  347. top += props.headerHeight;
  348. }
  349. // 添加分组内的所有列表项
  350. group.children.forEach((item, itemIndex) => {
  351. items.push({
  352. key: `item-${groupIndex}-${itemIndex}`,
  353. type: "item",
  354. index: index++,
  355. top,
  356. height: props.itemHeight,
  357. data: item
  358. });
  359. // 更新top位置
  360. top += props.itemHeight;
  361. });
  362. });
  363. return items;
  364. });
  365. // 计算属性:计算整个列表的总高度
  366. const listHeight = computed<number>(() => {
  367. return (
  368. // 所有项目高度之和
  369. virtualItems.value.reduce((total, item) => total + item.height, 0) +
  370. // 加上顶部预留空间高度
  371. props.topHeight +
  372. // 加上底部预留空间高度
  373. props.bottomHeight
  374. );
  375. });
  376. // 当前滚动位置
  377. const scrollTop = ref(0);
  378. // 目标滚动位置,用于控制滚动到指定位置
  379. const targetScrollTop = ref(0);
  380. // 滚动容器的高度
  381. const scrollerHeight = ref(0);
  382. // 计算属性:获取当前可见区域的列表项
  383. const visibleItems = computed<VirtualItem[]>(() => {
  384. // 如果虚拟列表为空,返回空数组
  385. if (isEmpty(virtualItems.value)) {
  386. return [];
  387. }
  388. // 如果未启用虚拟列表,直接返回所有项目
  389. if (!props.virtual) {
  390. return virtualItems.value;
  391. }
  392. // 计算缓冲区高度
  393. const bufferHeight = props.bufferSize * props.itemHeight;
  394. // 计算可视区域的顶部位置(包含缓冲区)
  395. const viewportTop = scrollTop.value - bufferHeight;
  396. // 计算可视区域的底部位置(包含缓冲区)
  397. const viewportBottom = scrollTop.value + scrollerHeight.value + bufferHeight;
  398. // 初始化可见项目数组
  399. const visible: VirtualItem[] = [];
  400. // 使用二分查找优化查找起始位置
  401. let startIndex = 0;
  402. let endIndex = virtualItems.value.length - 1;
  403. // 二分查找第一个可见项目的索引
  404. while (startIndex < endIndex) {
  405. const mid = Math.floor((startIndex + endIndex) / 2);
  406. const item = virtualItems.value[mid];
  407. if (item.top + item.height <= viewportTop) {
  408. startIndex = mid + 1;
  409. } else {
  410. endIndex = mid;
  411. }
  412. }
  413. // 从找到的起始位置开始,收集所有可见项目
  414. for (let i = startIndex; i < virtualItems.value.length; i++) {
  415. const item = virtualItems.value[i];
  416. // 如果项目完全超出视口下方,停止收集
  417. if (item.top >= viewportBottom) {
  418. break;
  419. }
  420. // 如果项目与视口有交集,添加到可见列表
  421. if (item.top + item.height > viewportTop) {
  422. visible.push(item);
  423. }
  424. }
  425. return visible;
  426. });
  427. // 计算属性:计算上方占位容器的高度
  428. const spacerTopHeight = computed<number>(() => {
  429. // 如果没有可见项目,返回0
  430. if (isEmpty(visibleItems.value)) {
  431. return 0;
  432. }
  433. // 返回第一个可见项目的顶部位置
  434. return visibleItems.value[0].top;
  435. });
  436. // 计算属性:计算下方占位容器的高度
  437. const spacerBottomHeight = computed<number>(() => {
  438. // 如果没有可见项目,返回0
  439. if (isEmpty(visibleItems.value)) {
  440. return 0;
  441. }
  442. // 获取最后一个可见项目
  443. const lastItem = visibleItems.value[visibleItems.value.length - 1];
  444. // 计算下方占位高度
  445. return listHeight.value - (lastItem.top + lastItem.height);
  446. });
  447. // 列表样式
  448. const listStyle = computed(() => {
  449. return {
  450. height: props.virtual ? `${listHeight.value}px` : "auto"
  451. };
  452. });
  453. // 上方占位容器样式
  454. const spacerTopStyle = computed(() => {
  455. return {
  456. height: props.virtual ? `${spacerTopHeight.value}px` : "auto"
  457. };
  458. });
  459. // 下方占位容器样式
  460. const spacerBottomStyle = computed(() => {
  461. return {
  462. height: props.virtual ? `${spacerBottomHeight.value}px` : "auto"
  463. };
  464. });
  465. // 存储每个分组头部距离顶部的位置数组
  466. const tops = ref<number[]>([]);
  467. // 计算并更新所有分组头部的位置
  468. function getTops() {
  469. // 初始化一个空数组
  470. const arr = [] as number[];
  471. // 初始化顶部位置
  472. let top = 0;
  473. // 计算每个分组的顶部位置
  474. data.value.forEach((group) => {
  475. // 将当前分组头部的位置添加到数组中
  476. arr.push(top);
  477. // 累加当前分组的总高度(头部高度+所有项目高度)
  478. top += props.headerHeight + group.children.length * props.itemHeight;
  479. });
  480. tops.value = arr;
  481. }
  482. // 下拉刷新触发标志
  483. const refreshTriggered = ref(false);
  484. // 下拉刷新相关状态
  485. const refresherStatus = ref<"default" | "pulling" | "refreshing">("default");
  486. // 下拉刷新文案
  487. const refresherText = computed(() => {
  488. switch (refresherStatus.value) {
  489. case "pulling":
  490. return props.refresherPullingText;
  491. case "refreshing":
  492. return props.refresherRefreshingText;
  493. default:
  494. return props.refresherDefaultText;
  495. }
  496. });
  497. // 停止下拉刷新
  498. function stopRefresh() {
  499. refreshTriggered.value = false;
  500. refresherStatus.value = "default";
  501. }
  502. // 滚动到顶部事件处理函数
  503. function onScrollToUpper(e: UniScrollToUpperEvent) {
  504. emit("scrolltoupper", e);
  505. emit("top");
  506. }
  507. // 滚动到底部事件处理函数
  508. function onScrollToLower(e: UniScrollToLowerEvent) {
  509. emit("scrolltolower", e);
  510. emit("bottom");
  511. }
  512. // 滚动锁定标志,用于防止滚动时触发不必要的计算
  513. let scrollLock = false;
  514. // 滚动事件处理函数
  515. function onScroll(e: UniScrollEvent) {
  516. // 更新当前滚动位置
  517. scrollTop.value = Math.floor(e.detail.scrollTop);
  518. // 如果滚动被锁定,直接返回
  519. if (scrollLock) return;
  520. // 根据滚动位置自动更新激活的索引
  521. tops.value.forEach((top, index) => {
  522. if (scrollTop.value >= top) {
  523. activeIndex.value = index;
  524. }
  525. });
  526. emit("scroll", e);
  527. }
  528. // 滚动结束事件处理函数
  529. function onScrollEnd(e: UniScrollEvent) {
  530. emit("scrollend", e);
  531. }
  532. // 行点击事件处理函数
  533. function onItemTap(item: VirtualItem) {
  534. emit("item-tap", item.data);
  535. }
  536. // 索引栏点击事件处理函数
  537. function onIndexChange(index: number) {
  538. // 锁定滚动,防止触发不必要的计算
  539. scrollLock = true;
  540. // 设置目标滚动位置为对应分组头部的位置
  541. targetScrollTop.value = tops.value[index];
  542. // 300ms后解除滚动锁定
  543. setTimeout(() => {
  544. scrollLock = false;
  545. }, 300);
  546. }
  547. // 下拉刷新事件处理函数
  548. function onRefresherPulling(e: UniRefresherEvent) {
  549. if (e.detail.dy > props.refresherThreshold * 1.5) {
  550. refresherStatus.value = "pulling";
  551. }
  552. emit("refresher-pulling", e);
  553. }
  554. function onRefresherRefresh(e: UniRefresherEvent) {
  555. refresherStatus.value = "refreshing";
  556. refreshTriggered.value = true;
  557. emit("refresher-refresh", e);
  558. emit("pull", e);
  559. }
  560. function onRefresherRestore(e: UniRefresherEvent) {
  561. refresherStatus.value = "default";
  562. emit("refresher-restore", e);
  563. }
  564. function onRefresherAbort(e: UniRefresherEvent) {
  565. refresherStatus.value = "default";
  566. emit("refresher-abort", e);
  567. }
  568. // 获取滚动容器的高度
  569. function getScrollerHeight() {
  570. setTimeout(() => {
  571. uni.createSelectorQuery()
  572. .in(proxy)
  573. .select(".cl-list-view__scroller")
  574. .boundingClientRect()
  575. .exec((res) => {
  576. if (isEmpty(res)) {
  577. return;
  578. }
  579. // 设置容器高度
  580. scrollerHeight.value = (res[0] as NodeInfo).height ?? 0;
  581. });
  582. }, 100);
  583. }
  584. // 组件挂载后的初始化逻辑
  585. onMounted(() => {
  586. // 获取容器高度
  587. getScrollerHeight();
  588. // 监听数据变化,重新计算位置信息
  589. watch(
  590. computed(() => props.data),
  591. () => {
  592. getTops();
  593. },
  594. {
  595. // 立即执行一次
  596. immediate: true
  597. }
  598. );
  599. });
  600. defineExpose({
  601. data,
  602. stopRefresh
  603. });
  604. </script>
  605. <style lang="scss" scoped>
  606. .cl-list-view {
  607. @apply h-full w-full relative;
  608. &__scroller {
  609. @apply h-full w-full;
  610. }
  611. &__virtual-list {
  612. @apply relative w-full;
  613. }
  614. &__spacer-top,
  615. &__spacer-bottom {
  616. @apply w-full;
  617. }
  618. &__index {
  619. @apply flex flex-row items-center bg-white;
  620. @apply absolute top-0 left-0 w-full px-[20rpx] z-20;
  621. &.is-dark {
  622. @apply bg-surface-600 border-none;
  623. }
  624. }
  625. &__virtual-item {
  626. @apply w-full;
  627. }
  628. &__header {
  629. @apply flex flex-row items-center relative px-[20rpx] z-10;
  630. }
  631. &__item {
  632. &-inner {
  633. @apply flex flex-row items-center px-[20rpx] h-full;
  634. }
  635. }
  636. &__refresher {
  637. @apply flex flex-row items-center justify-center w-full h-full;
  638. }
  639. }
  640. </style>