cl-list-view.uvue 17 KB

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