虚拟列表基本实现
虚拟列表的实现,重点是实现“假装”滚动条用以计算滚动到的位置对应列表匹配的高度,装载对应高度的单页元素列表。简单来说,滑动滚动条切换元素列表。
列表元素总高度(topHeight) = (单元素高度 * 列表数量),滚动条本身高度 = (div高度)^2/topHeight。监听两类事件,一类事件是鼠标滚动事件(适合局部短滚动),一类事件是鼠标按下和释放事件(适合大段长滚动)。滚动后 start 元素的高度 = 滚动后的top * topHeight / div 高度,start元素索引位为 (start元素高度/单元素高度 -1) 向下取整。滚动后 end 元素的高度 = ((start 元素索引位 + 1) * 单元素高度 + div 高度),end 元素索引位为(end元素高度/单元素高度 - 1) 向下取整。当然,如果 topHeight < divHeight 那就没有必要出现滚动条。还有就是调整窗口大小其实也需要重绘滚动条本身高度及单页元素列表。
初级实现步骤
滚动条部分
vue 模板代码
pageHeight,当前 div 高度。 topHeight,需要将滚动条块进行移动的高度距离。barHeight,滚动条块自身的高度。
<template>
<div id="scroll" ref="scroll" class="d-flex scroller border" :style="{'height': pageHeight + 'px'}">
<div id="bar" ref="bar" class="scroller bar"
:style="{'height': barHeight +'px', 'transform': 'translateY(' + topHeight + 'px)'}">
</div>
</div>
</template>
css 代码
.scroller {
width: 12px;
}
.bar {
width: 8px;
margin: 0 1px;
position: absolute;
background-color: rgba(141, 152, 164, 0.75);
border-radius: 5px;
}
.bar:hover {
background-color: rgba(141, 152, 164, 1.25);
}
javascript 代码
export default {
props: {
totalHeight: {
type: Number,
default: 1000
},
pageHeight: {
type: Number,
default: 1000
}
},
data() {
return {
topHeight: 0,
barHeight: 0,
isDragging: false,
startY: 0,
startTop: 0
};
},
created() {
this.calcBarHeight();
this.$nextTick(() => {
this.$refs.scroll.addEventListener('wheel', (event) => {
this.wheelScroll(event);
});
this.$refs.scroll.addEventListener('mousedown',(event) => {
const clientY = event.clientY;
const top = this.$refs.bar.getBoundingClientRect().top + this.barHeight;
let topHeight = 0;
if (clientY > top) {
topHeight = this.topHeight + this.barHeight/10;
} else if (clientY < top) {
topHeight = this.topHeight - this.barHeight/10;
}
if (topHeight < 0) {
this.topHeight = 0;
} else if (topHeight > this.pageHeight - this.barHeight) {
this.topHeight = this.pageHeight - this.barHeight;
} else {
this.topHeight = topHeight;
}
this.noticeScroll();
});
let index = 0;
this.$refs.bar.addEventListener('mousedown', (event) => {
this.isDragging = true;
console.info(`startY:${this.startY}down:${event.clientY}`)
this.startY = event.clientY;
this.startTop = this.topHeight;
event.preventDefault();
});
this.$refs.bar.addEventListener('mousemove', (event) => {
if (!this.isDragging) {
return;
}
const distinctY = this.startTop - (this.startY - event.clientY);
console.log(`(${index++})offset:${this.$refs.bar.offsetTop}move:${distinctY}`)
const topHeight = distinctY;
if (topHeight < 0) {
this.topHeight = 0;
} else if(topHeight > this.pageHeight - this.barHeight) {
this.topHeight = this.pageHeight - this.barHeight;
} else {
this.topHeight = topHeight;
}
this.noticeScroll();
});
this.$refs.bar.addEventListener('mouseup', (event) => {
this.isDragging = false;
});
this.$refs.bar.addEventListener('mouseleave', () => {
this.isDragging = false;
});
});
},
methods: {
calcBarHeight: function () {
this.barHeight = this.pageHeight * this.pageHeight / this.totalHeight;
},
noticeScroll: function() {
const itemStartHeight = this.topHeight / this.pageHeight * this.totalHeight;
const itemEndHeight = (this.topHeight + this.barHeight) / this.pageHeight * this.totalHeight;
this.$emit('scroll', itemStartHeight, itemEndHeight);
},
refreshScroll: function () {
if (!this.totalHeight) {
return;
}
this.calcBarHeight();
if (this.topHeight + this.barHeight > this.pageHeight) {
this.topHeight = this.pageHeight - this.barHeight;
}
this.noticeScroll();
},
wheelScroll: function (event) {
event.preventDefault();
const topHeight = this.topHeight;
console.log(event.deltaY/this.pageHeight * this.barHeight);
const latestTopHeight = topHeight - event.deltaY/this.pageHeight * this.barHeight;
if (latestTopHeight < 0) {
this.topHeight = 0;
} else if (latestTopHeight > this.pageHeight - this.barHeight) {
this.topHeight = this.pageHeight - this.barHeight;
} else {
this.topHeight = latestTopHeight;
}
this.noticeScroll();
},
nextOrAbovePage: function(startHeight, endHeight) {
if (startHeight > this.totalHeight) {
this.topHeight = this.pageHeight - this.barHeight;
this.noticeScroll();
return;
}
if (endHeight < 0) {
this.topHeight = 0;
this.noticeScroll();
return;
}
}
}
}
列表部分
vue 模板代码
items,是当前页需要展示的元素集合。activeItemId,是当前被选择的元素。totalHeight,是所有元素完全展开高度。pageHeight,是div#content 高度。scrollItem(),是依据滚动的 startIndex 和 endIndex 变化 items 集合。
<template>
<div class="d-flex overflow-y-hidden w-100 h-100">
<div class="list-group h-100 w-280" id="content" ref="content">
<a href="#" :class="{'list-group-item': true, 'list-group-item-action': true, 'active': item.id == activeItemId}"
v-for="item in items" @key="item.id">
<div class="d-flex w-auto justify-content-between">
<span class="mb-1 truncate">{{item.filename}}</span>
</div>
</a>
</div>
<scroller ref="cScroller" :total-height="totalHeight" :page-height="pageHeight" @scroll="scrollItem"></scroller>
</div>
</template>
css 代码
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.w-280{
width: 280px !important;
}
.list-group {
border-radius: 0;
--bs-list-group-active-bg: var(--ev-c-purple-soft);
--bs-list-group-active-border-color: var(--ev-c-purple-soft);
}
javascript 代码
如果其中 fileList 会变化,那就在 methods 里面 handleResize 方法变相完成刷新。
import scroller from '../scroller/index.vue';
export default {
components: {
scroller
},
props: {
itemHeight: {
type: Number,
default: 50
}
},
data() {
return {
activeItemId: '',
totalHeight: null,
pageHeight: 0,
items: []
}
},
created() {
this.$nextTick(() => {
this.handleResize();
this.$refs.content.addEventListener('wheel', (event) => {
this.$refs.cScroller.wheelScroll(event);
});
});
window.addEventListener('resize', () => this.handleResize);
},
methods: {
refreshItems: function (items = []) {
this.totalHeight = items.length * this.itemHeight;
this.pageHeight = this.$refs.content.offsetHeight;
this.$store.commit('updateFiles', items);
this.$nextTick(() => this.$refs.cScroller.refreshScroll());
},
handleResize: function() {
this.pageHeight = this.$refs.content.offsetHeight;
this.$nextTick(() => this.$refs.cScroller.refreshScroll());
},
fileClick: function(item) {
this.activeItemId = item.id;
this.$emit('fileClick', item);
},
partFiles: function(startIndex, endIndex) {
const fileList = this.$store.getters.getFiles;
if (startIndex > fileList.length) {
return [];
}
if (endIndex < 0) {
return [];
}
startIndex = startIndex < 0 ? 0 : startIndex;
endIndex = endIndex > fileList.length ? fileList.length : endIndex;
return fileList.slice(startIndex, endIndex);
},
scrollItem: function(startHeight, endHeight) {
if (startHeight == endHeight) {
if (this.items.length) {
this.items.splice(0, this.items.length);
}
return;
}
const startIndex = Math.ceil(startHeight / this.itemHeight);
const endIndex = Math.ceil(endHeight/ this.itemHeight);
const items = this.partFiles(startIndex, endIndex);
if (!items.length) {
this.$refs.cScroller.nextOrAbovePage(startHeight, endHeight);
return;
}
if (this.items.length) {
this.items.splice(0, this.items.length);
}
this.items.push(...items);
}
}
}
使用 vue-state 进行数据共享
// store/index.js
export default {
state: () => {
return {
fileList: []
}
},
mutations: {
updateFiles: function(state,fileList) {
const files = state.fileList;
if (files.length > 0) {
files.splice(0, files.length);
}
files.push(...fileList);
}
},
getters: {
getFiles: function(state) {
return state.fileList;
}
}
}
// store/index.js
import {createStore} from 'vuex'
import fileStore from './fileStore'
export default createStore({
modules: {
fileStore
}
})