ListView.vue
<template>
<div class="list-view-container" ref="containerRef">
<div class="list-view" ref="listViewRef" @scroll="onScroll">
<div class="list" :style="{ 'padding-top': top + 'px', gap: gap + 'px' }">
<slot></slot>
<div v-if="loading" class="icon-loading">
<div class="balls">
<div></div>
<div></div>
<div></div>
</div>
</div>
<div
v-if="!loading && !hasMore && canScroll"
class="no-more-text"
>
<span>{{ noMoreText }}</span>
</div>
</div>
<div
class="list-scroll-to-top"
v-if="showScrollToTop && showScrollToTopBtn"
@click="scrollToTop"
>
<i class="icon-arrow-up">Top</i>
</div>
</div>
<div v-if="!loading && !hasMore && showEmpty" class="list-empty-view">
<slot name="emptyView">
<img src="@/assets/common/empty.svg" alt="" />
<span>{{ emptyText }}</span>
</slot>
</div>
</div>
</template>
<script setup>
import {
ref,
watchEffect,
defineProps,
defineEmits,
onMounted,
onActivated,
nextTick,
computed,
} from "vue";
const props = defineProps({
emptyText: String,
fetchData: {
type: Function,
required: true,
},
pageSize: {
type: Number,
default: 20,
},
initPage: {
type: Number,
default: 0,
},
showScrollToTopBtn: {
type: Boolean,
default: false,
},
top: {
type: Number,
default: 0,
},
gap: {
type: Number,
default: 0,
},
showEmpty: {
type: Boolean,
default: true,
},
});
const emits = defineEmits(["error"]);
const noMoreText = "No more text"
// ref
const listViewRef = ref();
const containerRef = ref();
const positionScrollTop = ref(0);
const listHeight = ref(0);
const page = ref(props.initPage);
const hasMore = ref(true);
const loading = ref(false);
const showScrollToTop = ref(false);
const canScroll = computed(() => {
if (!listViewRef.value) return false;
return listViewRef.value?.scrollHeight > listViewRef.value.clientHeight;
})
const fetchMoreData = async () => {
if (loading.value || !hasMore.value) {
return;
}
loading.value = true;
try {
const newData = await props.fetchData(page.value, props.pageSize);
if (newData.length < props.pageSize) {
hasMore.value = false;
}
page.value += 1;
} catch (error) {
emits("error");
} finally {
loading.value = false;
checkContentHeight();
}
};
const onScroll = () => {
if (!listViewRef.value) return;
const scrollTop = listViewRef.value.scrollTop;
const scrollHeight = listViewRef.value.scrollHeight;
const clientHeight = listViewRef.value.clientHeight;
// 底部距离
const bottomDistance = 100;
if (scrollTop + clientHeight + bottomDistance >= scrollHeight) {
fetchMoreData();
}
// 滚动到顶部按钮的显示和隐藏
showScrollToTop.value = scrollTop > listHeight.value;
// 记录容器高度
positionScrollTop.value = listViewRef.value?.scrollTop;
};
// 检测内容高度是否小于容器高度,如果小于则继续加载
const checkContentHeight = async () => {
// 执行顺序放到任务队列的末尾,并等待下一次 DOM 更新循环结束之后再执行
await nextTick();
const scrollHeight = containerRef.value?.scrollHeight;
listHeight.value = listViewRef.value?.offsetHeight;
if (listHeight.value > 0 && scrollHeight > listHeight.value) {
fetchMoreData();
}
};
const scrollToTop = () => {
if (!listViewRef.value) {
return;
}
listViewRef.value.scrollTo({ top: 0, behavior: "smooth" });
};
// 监听 items 变化,重新计算容器高度
watchEffect(() => {
listHeight.value = listViewRef.value?.offsetHeight;
});
onMounted(() => {
// 初始化数据
fetchMoreData();
});
onActivated(() => {
if (listViewRef.value)
listViewRef.value.scrollTop = positionScrollTop.value;
});
</script>
<style lang="less" scoped>
.list-view-container {
display: flex;
flex-direction: column;
height: 100%;
.list-empty-view {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 32px;
img {
width: 300px;
opacity: 0.8;
}
span {
font-family: "HarmonyOS_Bold";
font-size: 14px;
color: rgba(242, 242, 242, 0.6);
}
}
}
.list-view {
width: 100%;
color: rgba(242, 242, 242, 0.6);
overflow-x: hidden;
overflow-y: scroll;
/* Firefox IE */
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-thumb {
background: rgba(242, 242, 242, 0.1);
border-radius: 46px;
&:hover {
background: rgba(242, 242, 242, 0.2);
}
}
&::-webkit-scrollbar-track {
margin-top: 10px;
margin-bottom: 10px;
}
}
.list {
display: flex;
flex-direction: column;
padding-right: 6px;
.icon-loading {
margin: 20px 0px;
display: flex;
justify-content: center;
}
}
.no-more-text {
display: flex;
justify-content: center;
padding: 20px 0;
}
.list-scroll-to-top {
width: 50px;
height: 50px;
position: fixed;
bottom: 96px;
right: 54px;
display: flex;
justify-content: center;
align-items: center;
color: #f2f2f2;
background: #232626;
border-radius: 50%;
cursor: pointer;
opacity: 1;
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
&:hover {
background: #3a3d3c;
}
}
.balls {
width: 3.5em;
height: 28px;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
transform: scale(0.8);
}
.balls div {
width: 0.7em;
height: 0.7em;
border-radius: 50%;
background-color: #0bb764ca;
transform: translateY(-100%);
animation: loading 0.6s ease-in-out alternate infinite;
}
.balls div:nth-of-type(1) {
animation-delay: -0.4s;
}
.balls div:nth-of-type(2) {
animation-delay: -0.2s;
}
@keyframes loading {
from {
transform: translateY(-30%);
}
to {
transform: translateY(30%);
}
}
</style>
使用
<list-view
:fetch-data="fetchData"
:pageSize="pageSize"
:initPage="initPage"
:gap="45"
:top="24"
:showEmpty="items.length == 0"
:emptyText="emptyText"
>
...
</list-view>
<script setup>
const fetchData = (pageNum, pageSize) => {
return new Promise((resolve) => {
...
});
};
</script>