cl-list-view.uvue 16 KB

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