Files
2026-09-24 16:25:22 +08:00

799 lines
22 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- #ifdef APP-HARMONY -->
<view class="xVirtualList" ref="xVirtualList">
<view >
<!--
@slot sticky插槽
-->
<slot name="sticky"></slot>
</view>
<scroll-view
:show-scrollbar="false"
type="nested"
style="flex:1"
>
<nested-scroll-header>
<view>
<!--
@slot 顶部插槽,仅APP有效,它是nested-scroll-header协商节点,会自动与内容衔接在头上然后自动上往上推。
-->
<slot name="header"></slot>
</view>
</nested-scroll-header>
<nested-scroll-body>
<view style="flex:1">
<scroll-view
:show-scrollbar="false"
@scroll="onScroll"
:refresher-enabled="_refresherEnabled"
refresher-default-style="none"
:refresher-triggered="refresherTriggered"
:scroll-top="scrollTopTop"
@refresherpulling="refresherpulling"
@refresherrefresh="refresherrefresh"
@refresherrestore="refresherrestore"
@refresherabort="refresherabort"
@scrolltoupper="scrolltoupper"
@scrolltolower="scrolltolower"
direction="vertical"
associative-container="nested-scroll-view"
>
<!-- 自定义下拉刷新元素 -->
<!-- #ifndef APP-HARMONY -->
<view v-if="_refresherEnabled" @click="refreshOnload" slot="refresher" class="refresh-box" :style="{height:refreshHeight+'px'}">
<x-icon :color="refresState==2?'primary':''" v-if="refresState==0||refresState==1||refresState==2||refresState==3" :duration="800" :spin="refresState==2" name="loader-line" font-size="26"></x-icon>
<x-icon v-if="refresState==5" color="success" name="checkbox-circle-line" font-size="26"></x-icon>
<x-icon v-if="refresState==4" color="error" name="error-warning-fill" font-size="26"></x-icon>
<view class="tip-text" >
<x-text font-size="16">{{refresStateMap.get(refresState)}}</x-text>
</view>
</view>
<!-- #endif -->
<!-- #ifdef APP-HARMONY -->
<view v-if="(refresState==5||refresState==4) && _refresherEnabled" @click="refreshOnload" class="refresh-box" :style="{height:refreshHeight+'px'}">
<x-icon v-if="refresState==5" color="success" name="checkbox-circle-line" font-size="26"></x-icon>
<x-icon v-if="refresState==4" color="error" name="error-warning-fill" font-size="26"></x-icon>
<view class="tip-text" >
<x-text font-size="16">{{refresStateMap.get(refresState)}}</x-text>
</view>
</view>
<!-- #endif -->
<!-- 虚拟占位容器,用于撑开滚动高度 -->
<view class="xVirtualListContainer" :style="{ height: totalHeight + 'px', paddingTop: offsetY + 'px' }">
<!--
@slot 内容插槽你在这里渲染内容
@prop {any[]} data - list数据
@prop {number} startIndex - 数据切割的起始位置
@prop {number} endIndex - 数据切割的结束位置
-->
<slot name="default" :data="renderList" :startIndex="startIndex" :endIndex="endIndex"></slot>
</view>
<!-- 自定义下拉刷新元素 -->
<view @click="refreshBottomOnload" v-if="_refresherBottomEnabled&&refresBottomState!=0" class="refresh-box" :style="{height:refreshHeight+'px'}">
<x-icon color="primary" v-if="refresBottomState==1" :spin="true" name="loader-line" font-size="26"></x-icon>
<x-icon v-if="refresBottomState==2" color="error" name="error-warning-fill" font-size="26"></x-icon>
<view class="tip-text" >
<x-text font-size="16">{{bottomRefresStateMap.get(refresBottomState)}}</x-text>
</view>
</view>
<!--
@slot 底部插槽
-->
<slot name="bottom"></slot>
</scroll-view>
</view>
</nested-scroll-body>
</scroll-view>
</view>
<!-- #endif -->
<!-- #ifdef MP||WEB||APP-IOS||APP-ANDROID -->
<view class="xVirtualList" ref="xVirtualList" >
<view >
<!--
@slot sticky插槽
-->
<slot name="sticky"></slot>
</view>
<scroll-view
:show-scrollbar="false"
@scroll="onScroll"
class="xVirtualListScrollBody"
:refresher-enabled="_refresherEnabled"
refresher-default-style="none"
:scroll-y="true"
:refresher-triggered="refresherTriggered"
:scroll-top="scrollTopTop"
:style="{ height: containerHeight + 'px' }"
@refresherpulling="refresherpulling"
@refresherrefresh="refresherrefresh"
@refresherrestore="refresherrestore"
@refresherabort="refresherabort"
@scrolltoupper="scrolltoupper"
@scrolltolower="scrolltolower"
>
<!-- 自定义下拉刷新元素 -->
<view @click="refreshOnload" slot="refresher" class="refresh-box" :style="{height:refreshHeight+'px'}">
<x-icon :color="refresState==2?'primary':''" v-if="refresState==0||refresState==1||refresState==2||refresState==3" :duration="800" :spin="refresState==2" name="loader-line" font-size="26"></x-icon>
<x-icon v-if="refresState==5" color="success" name="checkbox-circle-line" font-size="26"></x-icon>
<x-icon v-if="refresState==4" color="error" name="error-warning-fill" font-size="26"></x-icon>
<view class="tip-text" >
<x-text font-size="16">{{refresStateMap.get(refresState)}}</x-text>
</view>
</view>
<!-- 虚拟占位容器,用于撑开滚动高度 -->
<view class="xVirtualListContainer" :style="{ height: totalHeight + 'px', paddingTop: offsetY + 'px' }">
<!--
@slot 内容插槽你在这里渲染内容
@prop {any[]} data - list数据
@prop {number} startIndex - 数据切割的起始位置
@prop {number} endIndex - 数据切割的结束位置
-->
<slot name="default" :data="renderList" :startIndex="startIndex" :endIndex="endIndex"></slot>
</view>
<!-- 自定义下拉刷新元素 -->
<view @click="refreshBottomOnload" v-if="_refresherBottomEnabled&&refresBottomState!=0" class="refresh-box" :style="{height:refreshHeight+'px'}">
<x-icon color="primary" v-if="refresBottomState==1" :spin="true" name="loader-line" font-size="26"></x-icon>
<x-icon v-if="refresBottomState==2" color="error" name="error-warning-fill" font-size="26"></x-icon>
<view class="tip-text" >
<x-text font-size="16">{{bottomRefresStateMap.get(refresBottomState)}}</x-text>
</view>
</view>
<!--
@slot 底部插槽
-->
<slot name="bottom"></slot>
</scroll-view>
</view>
<!-- #endif -->
</template>
<script setup lang="ts">
import { getCurrentInstance, ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue"
import { getUid } from "../../core/util/xCoreUtil.uts"
import { getDefaultColor } from "../../core/util/xCoreColorUtil.uts"
import { checkIsCssUnit, getUnit } from "../../core/util/xCoreUtil.uts"
import { xConfig } from "../../config/xConfig.uts"
const i18n = xConfig.i18n;
/**
* 虚拟列表组件属性类型定义
*/
type xVirtualListType = {
/** 项目高度,不可用auto,只能是数字(默认为px),带单位的rpx,px */
itemHeight: string,
/** 渲染显示多少个,它不是最终的渲染数量,是最少数量,因为内部还有个缓存数量 */
viewCount: number,
/** 数据列表 */
list: any[],
/** 缓冲区大小,在可视区域前后额外渲染的项目数量 */
bufferSize: number,
/** 是否启用虚拟滚动 */
enableVirtual: boolean,
/** 启用下拉刷新 */
refresherEnabled: boolean,
/** 需要自行管理下拉状态,默认为false,通过事件pull触发设置为true,结束设置为False */
pullState:boolean,
/** 启用触底刷新 */
refresherBottomEnabled: boolean,
/** 需要自行管理下拉状态,默认为false,通过事件bottom触发设置为true,结束设置为False */
bottomState:boolean,
}
/**
* 容器尺寸类型定义
*/
type Bounds = {
width: number,
height: number,
top: number,
}
/**
* 滚动容器尺寸类型定义
*/
type ScrollContainerBounds = {
width: number,
height: number,
scrollHeight: number,
scrollTop: number
}
/**
* 虚拟列表渲染范围类型定义
*/
type RenderRange = {
startIndex: number,
endIndex: number,
offsetY: number
}
/**
* 刷新状态
* 0,'继续下拉刷新'
* 1,'松开刷新'
* 2,'数据刷新中'
* 3,'取消刷新'
* 4,'刷新超时,点击重试'
*/
type refresStateType = 0|1|2|3|4|5
/**
* 0初始值
* 1刷新中
* 2超时出错了
*/
type bottomStateType = 0|1|2
/**
* @name 虚拟列表 xVirtualList
* @description 这是一个超高虚拟列表,无论几万数据都能轻松应对是你列表处理数据的不二之选,集成下拉刷新,触底更新,指定索引位置等。
* 未来将应于表格,picker等系列组件,作为其它组件的底层存在,极大提高性能。使用时请注意demo示例使用方法。
* @page /pages/zhanshi/virtual-list
* @category 展示组件
* @constant 平台兼容
* | Harmony | H5 | andriod | IOS | 小程序 | UTS | UNIAPP-X SDK | version |
| --- | --- | --- | --- | --- | --- | --- | --- |
| ☑ | ☑ | ☑️ | ☑️ | ☑️ | ☑️ | 4.76+ | 1.1.18 |
*/
defineOptions({name:"xVirtualList"})
const proxy = getCurrentInstance()?.proxy;
const xVirtualList = ref<UniElement|null>(null)
const nestHeader = ref<UniElement|null>(null)
/**
* 组件事件定义
*/
const emits = defineEmits<{
/** 滚动事件 */
scroll: [detail: UniScrollEventDetail],
/** 滚动到顶时触发 */
pull: [],
/** 触底时触发 */
bottom: [],
/** 渲染范围变化时触发 */
rangeChange: [range: RenderRange]
}>()
/**
* 组件属性定义
*/
const props = withDefaults(defineProps<xVirtualListType>(), {
itemHeight: "50",
viewCount: 5,
bufferSize: 2,
enableVirtual: true,
list: () => [] as any[],
refresherEnabled: false,
refresherBottomEnabled: false,
pullState:false,
bottomState:false
})
/** 当前滚动位置 */
const scrollTop = ref(0)
const scrollTopTop = ref(0)
/** 滚动容器总高度 */
const scrollHeight = ref(0)
/** 容器的尺寸 */
const containerBounds = ref<Bounds>({ width: 0, height: 0,top:0 })
/** 是否已初始化 */
const isInitialized = ref(false)
/** 防抖定时器 */
let scrollTimer: number | null = null
const disabledScroll = ref(false)
const _refresherEnabled = computed((): boolean => props.refresherEnabled)
const _refresherBottomEnabled = computed((): boolean => props.refresherBottomEnabled)
const _isDark= computed((): boolean => xConfig.dark == 'dark')
const refreshHeight = ref(45)
const refresStateMap = ref<Map<number,string>>(new Map([
[0,i18n.t("tmui4x.virtualList.status_0")],
[1,i18n.t("tmui4x.virtualList.status_1")],
[2,i18n.t("tmui4x.virtualList.status_2")],
[3,i18n.t("tmui4x.virtualList.status_3")],
[4,i18n.t("tmui4x.virtualList.status_4")],
[5,i18n.t("tmui4x.virtualList.status_5")],
]))
const bottomRefresStateMap = ref<Map<number,string>>(new Map([
[0,i18n.t("tmui4x.virtualList.status_0")],
[1,i18n.t("tmui4x.virtualList.status_1")],
[2,i18n.t("tmui4x.virtualList.status_2")]
]))
const refresState = ref<refresStateType>(0)
const refresBottomState = ref<refresStateType>(0)
const refresherTriggered = ref(props.pullState)
const bottomTriggered = ref(props.bottomState)
let timeoutid = null as null|number
let timeoutid2 = null as null|number
/**
* 用户定义的项目高度(转换为px)
*/
const itemHeight = computed((): number => {
const heightval = checkIsCssUnit(props.itemHeight, xConfig.unit);
const unit = getUnit(heightval);
let realHeight = parseInt(heightval);
if (unit === 'rpx') {
realHeight = uni.rpx2px(realHeight)
}
return realHeight;
})
/**
* 容器高度(转换为px
*/
const containerHeight = computed((): number => {
return containerBounds.value.height;
})
/**
* 数据列表
*/
const _list = computed((): any[] => props.list)
/**
* 列表总高度
*/
const totalHeight = computed((): number => {
return _list.value.length * itemHeight.value;
})
/**
* 可视区域能显示的项目数量
*/
const visibleCount = computed((): number => {
if (containerHeight.value <= 0) return props.viewCount;
return Math.ceil(containerHeight.value / itemHeight.value);
})
/**
* 渲染范围计算
*/
const renderRange = computed((): RenderRange => {
if (!props.enableVirtual || _list.value.length == 0) {
return {
startIndex: 0,
endIndex: _list.value.length - 1,
offsetY: 0
};
}
// 计算开始索引
const startIndex = Math.floor(scrollTop.value / itemHeight.value);
// 添加缓冲区
const bufferedStartIndex = Math.max(0, startIndex - props.bufferSize);
// 计算结束索引
const endIndex = Math.min(
_list.value.length - 1,
startIndex + visibleCount.value + props.bufferSize * 2
);
// 计算偏移量
const offsetY = bufferedStartIndex * itemHeight.value;
return {
startIndex: bufferedStartIndex,
endIndex,
offsetY
};
})
/**
* 开始索引
*/
const startIndex = computed((): number => renderRange.value.startIndex)
/**
* 结束索引
*/
const endIndex = computed((): number => renderRange.value.endIndex)
/**
* Y轴偏移量
*/
const offsetY = computed((): number => renderRange.value.offsetY)
/**
* 当前渲染的数据列表(通过可视计算切割得到的数据列表)
*/
const renderList = computed((): any[] => {
if (!props.enableVirtual) {
return _list.value;
}
return _list.value.slice(startIndex.value, endIndex.value + 1);
})
/**
* 读取容器宽和高
*/
function getContainerBounds(): Promise<void> {
return new Promise((resolve, reject) => {
const ele = xVirtualList.value
if (ele == null) {
reject('容器元素不存在');
console.error('容器元素不存在')
return;
}
ele.getBoundingClientRectAsync()?.then((rect: DOMRect) => {
containerBounds.value.width = rect.width;
containerBounds.value.height = rect.height;
containerBounds.value.top = rect.top;
isInitialized.value = true;
resolve();
}).catch((error) => {
console.error("容器读取宽高失败:", error);
reject(error);
});
});
}
/**
* 读取非APP子协商头的宽和高。
*/
function getNestHeaderBounds():Promise<DOMRect>{
return new Promise((resolve, reject) => {
const ele = nestHeader.value
if (ele == null) {
reject('子协商头不存在');
console.error('子协商头不存在')
return;
}
ele.getBoundingClientRectAsync()?.then((rect: DOMRect) => {
resolve(rect);
}).catch((error) => {
console.error("容器读取宽高失败:", error);
reject(error);
});
});
}
/**
* 检查滚动边界
*/
function checkScrollBoundary(detail: UniScrollEventDetail): void {
// 检查是否滚动到顶部
// if (detail.scrollTop <= 0) {
// emits('pull');
// }
// // 检查是否滚动到底部(允许5px的误差)
// const isAtBottom = detail.scrollTop + containerHeight.value >= detail.scrollHeight - 5;
// if (isAtBottom) {
// emits('bottom');
// }
}
/**
* 滚动事件处理(带防抖优化)
*/
function onScroll(evt: UniScrollEvent): void {
const detail = evt.detail
// 触发滚动事件
emits('scroll', detail);
// 优化scrollTop赋值,避免边界情况下的重复计算
const newScrollTop = evt.detail.scrollTop;
const maxScrollTop = totalHeight.value - containerHeight.value;
// 只有在有效范围内且值发生变化时才更新scrollTop
if (newScrollTop >= 0 && newScrollTop <= maxScrollTop && newScrollTop != scrollTop.value) {
scrollTop.value = newScrollTop;
} else if (newScrollTop < 0 && scrollTop.value != 0) {
// 到达顶部时,确保scrollTop为0
scrollTop.value = 0;
} else if (newScrollTop > maxScrollTop && scrollTop.value != maxScrollTop) {
// 到达底部时,确保scrollTop为最大值
scrollTop.value = Math.max(0, maxScrollTop);
}
scrollHeight.value = evt.detail.scrollHeight;
// // 防抖处理边界检测
// if (scrollTimer !=null) {
// clearTimeout(scrollTimer!);
// }
// scrollTimer = setTimeout(() => {
// checkScrollBoundary(detail);
// }, 30);
}
/**
* 滚动到指定位置
*/
function scrollToIndex(index: number): void {
if (index < 0 || index >= _list.value.length) {
console.warn('滚动索引超出范围');
return;
}
const targetScrollTop = index * itemHeight.value;
scrollTop.value = targetScrollTop;
scrollTopTop.value = targetScrollTop-1
nextTick(()=>{
scrollTopTop.value = targetScrollTop
})
}
/**
* 刷新容器尺寸
*/
function refreshSize(): void {
nextTick(() => {
getContainerBounds()
});
}
/**
* 重置滚动位置
*/
function resetScroll(): void {
scrollTop.value = 0;
scrollTopTop.value = 0
}
/**
* 刷新加载数据
*/
function onpull(){
emits('pull');
if(timeoutid!=null){
clearTimeout(timeoutid! as number)
timeoutid = null;
}
/**
* 是否超时
*/
timeoutid = setTimeout(function() {
refresState.value = 4
}, 6000);
}
/**
* 刷新加载数据
*/
function onbottom(){
emits('bottom');
if(timeoutid2!=null){
clearTimeout(timeoutid2! as number)
timeoutid2 = null;
}
/**
* 是否超时
*/
timeoutid2 = setTimeout(function() {
refresBottomState.value = 2
}, 6000);
}
function refreshOnload(){
refresState.value = 2
refresherTriggered.value = true;
onpull()
}
/**
* 被下拉。
*/
function refresherpulling(evt:UniRefresherEvent): void {
let pullHeight = evt.detail.dy;
// #ifdef APP-HARMONY
if(refresState.value==2) return;
// #endif
if(pullHeight>=refreshHeight.value){
refresState.value = 1
}else{
refresState.value = 0
}
}
/**
* 触发刷新
*/
function refresherrefresh(evt:UniRefresherEvent): void {
refresState.value = 2
refresherTriggered.value = true;
onpull()
}
/**
* 被复位
*/
function refresherrestore(evt:UniRefresherEvent): void {
refresherTriggered.value = false;
refresState.value = 3
}
/**
* 刷新被中断
*/
function refresherabort(evt:UniRefresherEvent): void {
refresState.value = 3
refresherTriggered.value = false;
}
function scrolltoupper(evt:UniScrollToUpperEvent){
if(refresState.value==3||refresState.value ==4){
refresState.value =0
refresherTriggered.value = false;
}
}
function scrolltolower(evt:UniScrollToLowerEvent){
if(refresBottomState.value==2||refresBottomState.value==1||!_refresherBottomEnabled.value) return;
refresBottomState.value = 1;
bottomTriggered.value = true
onbottom()
}
function refreshBottomOnload(){
refresBottomState.value = 1
bottomTriggered.value = true;
onbottom()
}
/**
* 监听刷新外部设置的状态。
*/
watch(():boolean => props.pullState, (newPullState:boolean) => {
if(!newPullState&&refresherTriggered.value&&refresState.value!=4){
refresState.value = 5
// nextTick(()=>{
// refresherTriggered.value = newPullState;
// })
setTimeout(()=>{
refresherTriggered.value = newPullState;
},1000)
}else if(refresState.value!=4){
refresherTriggered.value = newPullState;
}
}, { deep: true });
/**
* 监听刷新外部设置的状态。
*/
watch(():boolean => props.bottomState, (newPullState:boolean) => {
if(!newPullState&&bottomTriggered.value&&refresBottomState.value!=2){
refresBottomState.value = 0
bottomTriggered.value = false
if(timeoutid2!=null){
clearTimeout(timeoutid2! as number)
timeoutid2 = null;
}
}else if(refresBottomState.value==2){
bottomTriggered.value = false
refresBottomState.value = 0
}
}, { deep: true });
/**
* 监听渲染范围变化
*/
watch(renderRange, (newRange:RenderRange) => {
emits('rangeChange', newRange);
}, { deep: true });
/**
* 监听列表数据变化
*/
watch(():any => props.list, (newList: any[], oldList: any[]) => {
// 如果新列表为空,重置滚动位置
// 如果是首次加载数据(从空到有数据)
if ((oldList.length == 0 && newList.length > 0 )|| newList.length == 0) {
resetScroll();
refreshSize();
return;
}
// 数据长度变化的处理
if (oldList.length != newList.length) {
const currentIndex = Math.floor(scrollTop.value / itemHeight.value);
// 如果数据减少
if (newList.length < oldList.length) {
// 如果当前位置超出了新数据范围,调整到最后一项
if (currentIndex >= newList.length) {
const maxIndex = Math.max(0, newList.length - 1);
scrollToIndex(maxIndex);
}
// 否则保持当前位置
} else {
// 数据增加时,保持当前滚动位置不变
// 只需要刷新容器尺寸以适应新的总高度
}
}
// 刷新容器尺寸以适应数据变化
refreshSize();
}, { deep: true });
/**
* 组件挂载时初始化
*/
onMounted(() => {
nextTick(() => {
// #ifdef APP-IOS
resetScroll();
refreshSize();
// #endif
// #ifndef APP-IOS
getContainerBounds()
// #endif
});
});
/**
* 组件卸载时清理
*/
onBeforeUnmount(() => {
if (scrollTimer!=null) {
clearTimeout(scrollTimer! as number);
scrollTimer = null;
}
if(timeoutid!=null){
clearTimeout(timeoutid! as number)
timeoutid = null;
}
if(timeoutid2!=null){
clearTimeout(timeoutid2! as number)
timeoutid2 = null;
}
});
defineExpose({
/** 滚动到指定索引 */
scrollToIndex,
/** 刷新容器尺寸 */
refreshSize,
/** 重置滚动位置 */
resetScroll,
/** 获取当前渲染范围 */
getRenderRange: ():RenderRange => renderRange.value,
/** 获取容器尺寸 */
getContainerBounds: ():Bounds => containerBounds.value
});
</script>
<style lang="scss" >
.refresh-box{
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
height: 100%;
// #ifdef MP
width:100%;
// #endif
}
.tip-text{
margin-left: 5px;
}
.xVirtualListWrap{
display: flex;
flex-direction: column;
}
.xVirtualList {
position: relative;
flex: 1;
}
.xVirtualListContainer {
position: relative;
width: 100%;
}
</style>