799 lines
22 KiB
Plaintext
799 lines
22 KiB
Plaintext
<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> |