| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761 |
- <template>
- <view class="cl-list-view" :class="[pt.className]">
- <!-- 滚动容器 -->
- <scroll-view
- class="cl-list-view__scroller"
- :class="[pt.scroller?.className]"
- :scroll-top="targetScrollTop"
- :scroll-into-view="scrollIntoView"
- :scroll-with-animation="scrollWithAnimation"
- :show-scrollbar="showScrollbar"
- :refresher-triggered="refreshTriggered"
- :refresher-enabled="refresherEnabled"
- :refresher-threshold="refresherThreshold"
- :refresher-background="refresherBackground"
- refresher-default-style="none"
- direction="vertical"
- @scrolltoupper="onScrollToUpper"
- @scrolltolower="onScrollToLower"
- @scroll="onScroll"
- @scrollend="onScrollEnd"
- @refresherpulling="onRefresherPulling"
- @refresherrefresh="onRefresherRefresh"
- @refresherrestore="onRefresherRestore"
- @refresherabort="onRefresherAbort"
- >
- <!-- 下拉刷新 -->
- <!-- #ifndef APP-HARMONY -->
- <view
- slot="refresher"
- class="cl-list-view__refresher"
- :class="[
- {
- 'is-pulling': refresherStatus === 'pulling',
- 'is-refreshing': refresherStatus === 'refreshing'
- },
- pt.refresher?.className
- ]"
- :style="{
- height: refresherThreshold + 'px'
- }"
- >
- <slot name="refresher" :status="refresherStatus" :text="refresherText">
- <cl-loading
- v-if="refresherStatus === 'refreshing'"
- :size="28"
- :pt="{
- className: 'mr-2'
- }"
- ></cl-loading>
- <cl-text> {{ refresherText }} </cl-text>
- </slot>
- </view>
- <!-- #endif -->
- <!-- 列表 -->
- <view
- class="cl-list-view__virtual-list"
- :class="[pt.list?.className]"
- :style="listStyle"
- >
- <!-- 顶部占位 -->
- <view class="cl-list-view__spacer-top" :style="spacerTopStyle">
- <slot name="top"></slot>
- </view>
- <!-- 列表项 -->
- <view
- v-for="(item, index) in visibleItems"
- :key="item.key"
- class="cl-list-view__virtual-item"
- >
- <view
- class="cl-list-view__header"
- :class="[
- {
- 'is-dark': isDark
- }
- ]"
- :style="{
- height: headerHeight + 'px'
- }"
- v-if="item.type == 'header'"
- >
- <slot name="header" :index="item.data.index!">
- <cl-text> {{ item.data.label }} </cl-text>
- </slot>
- </view>
- <view
- v-else
- class="cl-list-view__item"
- :class="[
- {
- 'is-dark': isDark
- },
- pt.item?.className
- ]"
- :hover-class="pt.itemHover?.className"
- :style="{
- height: virtual ? itemHeight + 'px' : 'auto'
- }"
- @tap="onItemTap(item)"
- >
- <slot
- name="item"
- :item="item"
- :data="item.data"
- :value="item.data.value"
- :index="index"
- >
- <view class="cl-list-view__item-inner">
- <cl-text> {{ item.data.label }} </cl-text>
- </view>
- </slot>
- </view>
- </view>
- <!-- 底部占位 -->
- <view class="cl-list-view__spacer-bottom" :style="spacerBottomStyle">
- <slot name="bottom"></slot>
- </view>
- </view>
- <!-- 空状态 -->
- <cl-empty v-if="noData" :fixed="false"></cl-empty>
- </scroll-view>
- <!-- 右侧索引栏 -->
- <cl-index-bar
- v-if="hasIndex"
- v-model="activeIndex"
- :list="indexList"
- :pt="{
- className: parseClass([pt.indexBar?.className])
- }"
- @change="onIndexChange"
- >
- </cl-index-bar>
- <!-- 索引提示 -->
- <view
- class="cl-list-view__index"
- :class="[
- {
- 'is-dark': isDark
- }
- ]"
- :style="{ height: headerHeight + 'px' }"
- v-if="hasIndex"
- >
- <slot name="index" :index="indexList[activeIndex]">
- <cl-text> {{ indexList[activeIndex] }} </cl-text>
- </slot>
- </view>
- <!-- 回到顶部 -->
- <cl-back-top :top="scrollTop" v-if="showBackTop" @back-top="scrollToTop"></cl-back-top>
- </view>
- </template>
- <script setup lang="ts">
- import { computed, getCurrentInstance, nextTick, onMounted, ref, watch, type PropType } from "vue";
- import type {
- ClListViewItem,
- ClListViewGroup,
- ClListViewVirtualItem,
- PassThroughProps,
- ClListViewRefresherStatus
- } from "../../types";
- import { isApp, isDark, isEmpty, parseClass, parsePt } from "@/cool";
- import { t } from "@/locale";
- defineOptions({
- name: "cl-list-view"
- });
- defineSlots<{
- // 顶部插槽
- top(): any;
- // 分组头部插槽
- header(props: { index: string }): any;
- // 列表项插槽
- item(props: {
- data: ClListViewItem;
- item: ClListViewVirtualItem;
- value: any | null;
- index: number;
- }): any;
- // 底部插槽
- bottom(): any;
- // 索引插槽
- index(props: { index: string }): any;
- // 下拉刷新插槽
- refresher(props: { status: ClListViewRefresherStatus; text: string }): any;
- }>();
- const props = defineProps({
- // 透传样式配置
- pt: {
- type: Object,
- default: () => ({})
- },
- // 列表数据源
- data: {
- type: Array as PropType<ClListViewItem[]>,
- default: () => []
- },
- // 列表项高度
- itemHeight: {
- type: Number,
- default: 50
- },
- // 分组头部高度
- headerHeight: {
- type: Number,
- default: 32
- },
- // 列表顶部预留空间高度
- topHeight: {
- type: Number,
- default: 0
- },
- // 列表底部预留空间高度
- bottomHeight: {
- type: Number,
- default: 0
- },
- // 缓冲区大小,即可视区域外预渲染的项目数量
- bufferSize: {
- type: Number,
- default: isApp() ? 5 : 15
- },
- // 是否启用虚拟列表渲染,当数据量大时建议开启以提升性能
- virtual: {
- type: Boolean,
- default: true
- },
- // 滚动到指定位置
- scrollIntoView: {
- type: String,
- default: ""
- },
- // 是否启用滚动动画
- scrollWithAnimation: {
- type: Boolean,
- default: false
- },
- // 是否显示滚动条
- showScrollbar: {
- type: Boolean,
- default: false
- },
- // 是否启用下拉刷新
- refresherEnabled: {
- type: Boolean,
- default: false
- },
- // 下拉刷新触发距离,相当于下拉内容高度
- refresherThreshold: {
- type: Number,
- default: 50
- },
- // 下拉刷新区域背景色
- refresherBackground: {
- type: String,
- default: "transparent"
- },
- // 下拉刷新默认文案
- refresherDefaultText: {
- type: String,
- default: () => t("下拉刷新")
- },
- // 释放刷新文案
- refresherPullingText: {
- type: String,
- default: () => t("释放立即刷新")
- },
- // 正在刷新文案
- refresherRefreshingText: {
- type: String,
- default: () => t("加载中")
- },
- // 是否显示回到顶部按钮
- showBackTop: {
- type: Boolean,
- default: true
- }
- });
- const emit = defineEmits([
- "item-tap",
- "refresher-pulling",
- "refresher-refresh",
- "refresher-restore",
- "refresher-abort",
- "scrolltoupper",
- "scrolltolower",
- "scroll",
- "scrollend",
- "pull",
- "top",
- "bottom"
- ]);
- // 获取当前组件实例,用于后续DOM操作
- const { proxy } = getCurrentInstance()!;
- // 透传样式配置类型
- type PassThrough = {
- className?: string;
- item?: PassThroughProps;
- itemHover?: PassThroughProps;
- list?: PassThroughProps;
- indexBar?: PassThroughProps;
- scroller?: PassThroughProps;
- refresher?: PassThroughProps;
- };
- // 解析透传样式配置
- const pt = computed(() => parsePt<PassThrough>(props.pt));
- // 当前激活的索引位置,用于控制索引栏的高亮状态
- const activeIndex = ref(0);
- // 是否没有数据
- const noData = computed(() => {
- return isEmpty(props.data);
- });
- // 是否包含索引
- const hasIndex = computed(() => {
- return props.data.every((e) => e.index != null) && !noData.value;
- });
- // 计算属性:将原始数据按索引分组
- const data = computed<ClListViewGroup[]>(() => {
- // 初始化分组数组
- const group: ClListViewGroup[] = [];
- // 遍历原始数据,按index字段进行分组
- props.data.forEach((item) => {
- // 查找是否已存在相同index的分组
- const index = group.findIndex((group) => group.index == item.index);
- if (index != -1) {
- // 如果分组已存在,将当前项添加到该分组的列表中
- group[index].children.push(item);
- } else {
- // 如果分组不存在,创建新的分组
- group.push({
- index: item.index ?? "",
- children: [item]
- } as ClListViewGroup);
- }
- });
- return group;
- });
- // 计算属性:提取所有分组的索引列表,用于索引栏显示
- const indexList = computed<string[]>(() => {
- return data.value.map((item) => item.index);
- });
- // 计算属性:将分组数据扁平化为虚拟列表项数组
- const virtualItems = computed<ClListViewVirtualItem[]>(() => {
- // 初始化虚拟列表数组
- const items: ClListViewVirtualItem[] = [];
- // 初始化顶部位置,考虑预留空间
- let top = props.topHeight;
- // 初始化索引计数器
- let index = 0;
- // 遍历每个分组,生成虚拟列表项
- data.value.forEach((group, groupIndex) => {
- if (group.index != "") {
- // 添加分组头部项
- items.push({
- key: `header-${groupIndex}`,
- type: "header",
- index: index++,
- top,
- height: props.headerHeight,
- data: {
- label: group.index!,
- index: group.index
- }
- });
- // 更新top位置
- top += props.headerHeight;
- }
- // 添加分组内的所有列表项
- group.children.forEach((item, itemIndex) => {
- items.push({
- key: `item-${groupIndex}-${itemIndex}`,
- type: "item",
- index: index++,
- top,
- height: props.itemHeight,
- data: item
- });
- // 更新top位置
- top += props.itemHeight;
- });
- });
- return items;
- });
- // 计算属性:计算整个列表的总高度
- const listHeight = computed<number>(() => {
- return (
- // 所有项目高度之和
- virtualItems.value.reduce((total, item) => total + item.height, 0) +
- // 加上顶部预留空间高度
- props.topHeight +
- // 加上底部预留空间高度
- props.bottomHeight
- );
- });
- // 当前滚动位置
- const scrollTop = ref(0);
- // 目标滚动位置,用于控制滚动到指定位置
- const targetScrollTop = ref(0);
- // 滚动容器的高度
- const scrollerHeight = ref(0);
- // 计算属性:获取当前可见区域的列表项
- const visibleItems = computed<ClListViewVirtualItem[]>(() => {
- // 如果虚拟列表为空,返回空数组
- if (isEmpty(virtualItems.value)) {
- return [];
- }
- // 如果未启用虚拟列表,直接返回所有项目
- if (!props.virtual) {
- return virtualItems.value;
- }
- // 计算缓冲区高度
- const bufferHeight = props.bufferSize * props.itemHeight;
- // 计算可视区域的顶部位置(包含缓冲区)
- const viewportTop = scrollTop.value - bufferHeight;
- // 计算可视区域的底部位置(包含缓冲区)
- const viewportBottom = scrollTop.value + scrollerHeight.value + bufferHeight;
- // 初始化可见项目数组
- const visible: ClListViewVirtualItem[] = [];
- // 使用二分查找优化查找起始位置
- let startIndex = 0;
- let endIndex = virtualItems.value.length - 1;
- // 二分查找第一个可见项目的索引
- while (startIndex < endIndex) {
- const mid = Math.floor((startIndex + endIndex) / 2);
- const item = virtualItems.value[mid];
- if (item.top + item.height <= viewportTop) {
- startIndex = mid + 1;
- } else {
- endIndex = mid;
- }
- }
- // 从找到的起始位置开始,收集所有可见项目
- for (let i = startIndex; i < virtualItems.value.length; i++) {
- const item = virtualItems.value[i];
- // 如果项目完全超出视口下方,停止收集
- if (item.top >= viewportBottom) {
- break;
- }
- // 如果项目与视口有交集,添加到可见列表
- if (item.top + item.height > viewportTop) {
- visible.push(item);
- }
- }
- return visible;
- });
- // 计算属性:计算上方占位容器的高度
- const spacerTopHeight = computed<number>(() => {
- // 如果没有可见项目,返回0
- if (isEmpty(visibleItems.value)) {
- return 0;
- }
- // 返回第一个可见项目的顶部位置
- return visibleItems.value[0].top;
- });
- // 计算属性:计算下方占位容器的高度
- const spacerBottomHeight = computed<number>(() => {
- // 如果没有可见项目,返回0
- if (isEmpty(visibleItems.value)) {
- return 0;
- }
- // 获取最后一个可见项目
- const lastItem = visibleItems.value[visibleItems.value.length - 1];
- // 计算下方占位高度
- return listHeight.value - (lastItem.top + lastItem.height);
- });
- // 列表样式
- const listStyle = computed(() => {
- return {
- height: props.virtual ? `${listHeight.value}px` : "auto"
- };
- });
- // 上方占位容器样式
- const spacerTopStyle = computed(() => {
- return {
- height: props.virtual ? `${spacerTopHeight.value}px` : "auto"
- };
- });
- // 下方占位容器样式
- const spacerBottomStyle = computed(() => {
- return {
- height: props.virtual ? `${spacerBottomHeight.value}px` : "auto"
- };
- });
- // 存储每个分组头部距离顶部的位置数组
- const tops = ref<number[]>([]);
- // 计算并更新所有分组头部的位置
- function getTops() {
- // 初始化一个空数组
- const arr = [] as number[];
- // 初始化顶部位置
- let top = 0;
- // 计算每个分组的顶部位置
- data.value.forEach((group) => {
- // 将当前分组头部的位置添加到数组中
- arr.push(top);
- // 累加当前分组的总高度(头部高度+所有项目高度)
- top += props.headerHeight + group.children.length * props.itemHeight;
- });
- tops.value = arr;
- }
- // 下拉刷新触发标志
- const refreshTriggered = ref(false);
- // 下拉刷新相关状态
- const refresherStatus = ref<"default" | "pulling" | "refreshing">("default");
- // 下拉刷新文案
- const refresherText = computed(() => {
- switch (refresherStatus.value) {
- case "pulling":
- return props.refresherPullingText;
- case "refreshing":
- return props.refresherRefreshingText;
- default:
- return props.refresherDefaultText;
- }
- });
- // 停止下拉刷新
- function stopRefresh() {
- setTimeout(() => {
- refresherStatus.value = "default";
- refreshTriggered.value = false;
- }, 300);
- }
- // 滚动到顶部事件处理函数
- function onScrollToUpper(e: UniScrollToUpperEvent) {
- emit("scrolltoupper", e);
- emit("top");
- }
- // 滚动到底部事件处理函数
- function onScrollToLower(e: UniScrollToLowerEvent) {
- emit("scrolltolower", e);
- emit("bottom");
- }
- // 滚动锁定标志,用于防止滚动时触发不必要的计算
- let scrollLock = false;
- // 滚动事件处理函数
- function onScroll(e: UniScrollEvent) {
- // 更新当前滚动位置
- scrollTop.value = Math.floor(e.detail.scrollTop);
- // 如果滚动被锁定,直接返回
- if (scrollLock) return;
- // 根据滚动位置自动更新激活的索引
- tops.value.forEach((top, index) => {
- if (scrollTop.value >= top) {
- activeIndex.value = index;
- }
- });
- emit("scroll", e);
- }
- // 滚动结束事件处理函数
- function onScrollEnd(e: UniScrollEvent) {
- emit("scrollend", e);
- }
- // 行点击事件处理函数
- function onItemTap(item: ClListViewVirtualItem) {
- emit("item-tap", item.data);
- }
- // 索引栏点击事件处理函数
- function onIndexChange(index: number) {
- // 锁定滚动,防止触发不必要的计算
- scrollLock = true;
- // 设置目标滚动位置为对应分组头部的位置
- targetScrollTop.value = tops.value[index];
- // 300ms后解除滚动锁定
- setTimeout(() => {
- scrollLock = false;
- }, 300);
- }
- // 下拉刷新事件处理函数
- function onRefresherPulling(e: UniRefresherEvent) {
- if (e.detail.dy > props.refresherThreshold) {
- refresherStatus.value = "pulling";
- }
- emit("refresher-pulling", e);
- }
- // 下拉刷新事件处理函数
- function onRefresherRefresh(e: UniRefresherEvent) {
- refresherStatus.value = "refreshing";
- refreshTriggered.value = true;
- emit("refresher-refresh", e);
- emit("pull", e);
- }
- // 恢复下拉刷新
- function onRefresherRestore(e: UniRefresherEvent) {
- refresherStatus.value = "default";
- emit("refresher-restore", e);
- }
- // 停止下拉刷新
- function onRefresherAbort(e: UniRefresherEvent) {
- refresherStatus.value = "default";
- emit("refresher-abort", e);
- }
- // 滚动到顶部
- function scrollToTop() {
- targetScrollTop.value = 0.01;
- nextTick(() => {
- targetScrollTop.value = 0;
- });
- }
- // 获取滚动容器的高度
- function getScrollerHeight() {
- setTimeout(() => {
- uni.createSelectorQuery()
- .in(proxy)
- .select(".cl-list-view__scroller")
- .boundingClientRect()
- .exec((res) => {
- if (isEmpty(res)) {
- return;
- }
- // 设置容器高度
- scrollerHeight.value = (res[0] as NodeInfo).height ?? 0;
- });
- }, 100);
- }
- // 组件挂载后的初始化逻辑
- onMounted(() => {
- // 获取容器高度
- getScrollerHeight();
- // 监听数据变化,重新计算位置信息
- watch(
- computed(() => props.data),
- () => {
- getTops();
- },
- {
- // 立即执行一次
- immediate: true
- }
- );
- });
- defineExpose({
- data,
- stopRefresh
- });
- </script>
- <style lang="scss" scoped>
- .cl-list-view {
- @apply h-full w-full relative;
- &__scroller {
- @apply h-full w-full;
- }
- &__virtual-list {
- @apply relative w-full;
- }
- &__spacer-top,
- &__spacer-bottom {
- @apply w-full;
- }
- &__index {
- @apply flex flex-row items-center bg-white;
- @apply absolute top-0 left-0 w-full px-[20rpx] z-20;
- &.is-dark {
- @apply bg-surface-600 border-none;
- }
- }
- &__virtual-item {
- @apply w-full;
- }
- &__header {
- @apply flex flex-row items-center relative px-[20rpx] z-10;
- }
- &__item {
- &-inner {
- @apply flex flex-row items-center px-[20rpx] h-full;
- }
- }
- &__refresher {
- @apply flex flex-row items-center justify-center w-full h-full;
- }
- }
- </style>
|