Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[组件-下载视频] feat: 引入了虚拟列表来改善列表太长时操作卡顿的问题 #4455

Open
wants to merge 2 commits into
base: preview-features
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
67 changes: 46 additions & 21 deletions registry/lib/components/video/download/inputs/EpisodesPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,36 +32,48 @@
</VButton>
</div>
</div>
<div class="episodes-picker-items">
<div v-for="(item, index) of episodeItems" :key="item.key" class="episodes-picker-item">
<CheckBox
v-model="item.isChecked"
icon-position="left"
:data-aid="item.inputItem.aid"
:data-cid="item.inputItem.cid"
:data-bvid="item.inputItem.bvid"
@click.native="shiftSelect($event, item, index)"
>
<span class="episode-title">
{{ item.title }}
</span>
<span v-if="item.durationText" class="episode-duration">
{{ item.durationText }}
</span>
</CheckBox>
</div>
</div>
<VirtualList
class="episodes-picker-items"
:items="episodeItems"
:item-height="24"
:stage-item-count="100"
:buffer-item-count="40"
:update-required-item-count="20"
:focuse-item-index="currentEpisodeIndex"
>
<template #item="{ item, index }">
<div class="episodes-picker-item">
<CheckBox
v-model="item.isChecked"
icon-position="left"
:data-aid="item.inputItem.aid"
:data-cid="item.inputItem.cid"
:data-bvid="item.inputItem.bvid"
@click.native="shiftSelect($event, item, index)"
>
<span class="episode-title">
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

限制为 1 行的话, 建议加一个 title, 鼠标停留时可以看到完整标题

{{ item.title }}
</span>
<span v-if="item.durationText" class="episode-duration">
{{ item.durationText }}
</span>
</CheckBox>
</div>
</template>
</VirtualList>
</div>
</template>
<script lang="ts">
import { VButton, VIcon, CheckBox } from '@/ui'
import { EpisodeItem } from './episode-item'
import VirtualList from './VirtualList.vue'

export default Vue.extend({
components: {
VButton,
VIcon,
CheckBox,
VirtualList,
},
props: {
api: {
Expand All @@ -74,6 +86,7 @@ export default Vue.extend({
episodeItems: [],
maxCheckedItems: 32,
lastCheckedEpisodeIndex: -1,
currentEpisodeIndex: 0, // 当前页面的剧集 index
}
},
computed: {
Expand All @@ -89,8 +102,14 @@ export default Vue.extend({
return items.filter(it => it.isChecked).map(it => it.inputItem)
},
},
created() {
this.getEpisodeItems()
async created() {
await this.getEpisodeItems()
const { aid } = unsafeWindow
if (aid) {
this.currentEpisodeIndex = this.episodeItems.findIndex(ep => ep.inputItem.aid === +aid) ?? 0
this.episodeItems[this.currentEpisodeIndex].isChecked = true
}
// console.log('dididi', this.episodeItems)
},
methods: {
shiftSelect(e: MouseEvent, item: EpisodeItem, index: number) {
Expand Down Expand Up @@ -164,6 +183,12 @@ export default Vue.extend({
.be-check-box {
padding: 2px 6px;
}
.episode-title {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.episode-duration {
margin-right: 4px;
text-align: right;
Expand Down
156 changes: 156 additions & 0 deletions registry/lib/components/video/download/inputs/VirtualList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'

type PropsType = {
items: any[]
itemHeight: number
stageItemCount?: number // 容器容纳的列表项数量
bufferItemCount?: number // 边缘预留缓冲的列表项数量
updateRequiredItemCount?: number // 缓冲列表项低于此数时触发更新
focuseItemIndex?: number // 初始聚焦的列表项
}

const props = withDefaults(defineProps<PropsType>(), {
stageItemCount: 50,
bufferItemCount: 5,
updateRequiredItemCount: 5,
focuseItemIndex: 0,
})

const container = ref<HTMLDivElement>()
const stageHeight = computed(() => props.stageItemCount * props.itemHeight)
const clientHeight = computed(() => container.value?.clientHeight ?? 0)
// const scrollHeight = computed(() => container.value?.scrollHeight ?? 0)

const ptr = ref(0)
const appendPaddingTop = computed(() => ptr.value * props.itemHeight)
const appendPaddingBottom = computed(() => {
return (props.items.length - ptr.value - props.stageItemCount) * props.itemHeight
})
const activatedItems = computed(() => {
return props.items.slice(ptr.value, ptr.value + props.stageItemCount)
})

const forceScrolling = ref(false)
const lastScrollTop = ref(0)

/**
* 在 stage 之内移动 viewport,不引起数据变化
* @param top float
*/
const moveViewport = (top: number) => {
if (top < 0) {
top = 0
} else if (top > stageHeight.value - clientHeight.value) {
top = stageHeight.value - clientHeight.value
}
// 滚动列表
forceScrolling.value = true
container.value?.scrollTo({
top: appendPaddingTop.value + top,
behavior: 'instant',
})

return top
}

/**
* 在虚拟列表之内移动 stage,引起数据变化
* @param index int
*/
const moveStage = (index: number) => {
if (index < 0) {
index = 0
} else if (index > props.items.length - props.stageItemCount) {
index = props.items.length - props.stageItemCount
}
// 触发内容变化并推动列表
return (ptr.value = index)
}

/**
* 将 viewport 对齐到指定元素
* @param index
* @param alignment 决定用顶部还是底部对齐
*/
const focus = (index: number, alignment: 'top' | 'bottom' = 'top') => {
if (alignment === 'top') {
const offset = index - moveStage(index - props.bufferItemCount)
moveViewport(offset * props.itemHeight)
} else if (alignment === 'bottom') {
const offset = index - moveStage(index + props.bufferItemCount - props.stageItemCount)
moveViewport(offset * props.itemHeight)
}
}

onMounted(() => {
setTimeout(() => focus(props.focuseItemIndex))
})

watch(
() => props.focuseItemIndex,
newv => {
focus(newv)
},
)

const onScroll = lodash.throttle((event: Event) => {
if (forceScrolling.value) {
// 手动触发滚动时不做处理
forceScrolling.value = false
return
}

const tar = event.currentTarget as HTMLDivElement
const step = tar.scrollTop - lastScrollTop.value
// console.log(tar.scrollHeight, tar.scrollTop, step)
lastScrollTop.value = tar.scrollTop
if (step > 0) {
// 向下滚动
const actualBottomHeight = tar.scrollTop + tar.clientHeight
const contentBottomHeight = appendPaddingTop.value + stageHeight.value
const buffer = contentBottomHeight - actualBottomHeight
const min = props.itemHeight * props.updateRequiredItemCount
if (buffer < min) {
const offset = props.bufferItemCount - Math.round(buffer / props.itemHeight)
moveStage(ptr.value + offset)
}
} else if (step < 0) {
// 向上滚动
const actualTopHeight = tar.scrollHeight - tar.scrollTop
const contentTopHeight = tar.scrollHeight - appendPaddingTop.value
const buffer = contentTopHeight - actualTopHeight
const min = props.itemHeight * props.updateRequiredItemCount
if (buffer < min) {
const offset = props.bufferItemCount - Math.round(buffer / props.itemHeight)
moveStage(ptr.value - offset)
}
}
}, 33)
</script>

<template>
<div class="virtual-list" ref="container" @scroll.self="onScroll">
<ul class="scroll-box">
<li class="list-item" v-for="(item, index) in activatedItems" :key="index">
<slot name="item" :item="item" :index="index"></slot>
</li>
</ul>
</div>
</template>

<style scoped>
.virtual-list {
overflow-x: hidden;
overflow-y: auto;

> .scroll-box {
list-style: none;
padding-top: v-bind('`${appendPaddingTop}px`');
padding-bottom: v-bind('`${appendPaddingBottom}px`');

> .list-item {
}
}
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const bangumiBatchInput: DownloadVideoInput = {
return {
key: it.cid,
title: `${nText} - ${title}`,
isChecked: index < instance.maxCheckedItems,
isChecked: index < instance.maxCheckedItems && totalLength <= instance.maxCheckedItems,
the1812 marked this conversation as resolved.
Show resolved Hide resolved
inputItem: {
aid: it.aid,
cid: it.cid,
Expand Down