2 changed files with 156 additions and 141 deletions
@ -1,229 +1,229 @@ |
|||
<!-- 虚拟折叠面板 --> |
|||
<template> |
|||
<DynamicScroller |
|||
ref="scrollbarRef" |
|||
class="scroller" |
|||
:items="list" |
|||
:min-item-size="props.minItemSize" |
|||
:key-field="props.keyField" |
|||
v-slot="{ item, active }" |
|||
style="height: 100%" |
|||
@scroll="onScroll" |
|||
> |
|||
<DynamicScrollerItem :item="item" :active="active" :size-dependencies="[item.__expanded]"> |
|||
<div class="collapse-header" @click="toggleExpand(item)"> |
|||
<div class="collapse-header-left"> |
|||
<slot name="header" :item="item">{{ item[props.nameField] }}</slot> |
|||
</div> |
|||
<DynamicScroller |
|||
ref="scrollbarRef" |
|||
class="scroller" |
|||
:items="list" |
|||
:min-item-size="props.minItemSize" |
|||
:key-field="props.keyField" |
|||
v-slot="{ item, active }" |
|||
style="height: 100%" |
|||
@scroll="onScroll" |
|||
> |
|||
<DynamicScrollerItem :item="item" :active="active" :size-dependencies="[item.__expanded]"> |
|||
<div class="collapse-header" @click="toggleExpand(item)"> |
|||
<div class="collapse-header-left"> |
|||
<slot name="header" :item="item">{{ item[props.nameField] }}</slot> |
|||
</div> |
|||
|
|||
<div class="collapse-header-right" v-if="props.showArrowRight"> |
|||
<el-icon |
|||
class="arrow-right" |
|||
:class="{ 'rotate-icon': item.__expanded }" |
|||
:size="12" |
|||
:color="'#c1c1c1'" |
|||
> |
|||
<ArrowRight /> |
|||
</el-icon> |
|||
</div> |
|||
</div> |
|||
<div class="collapse-header-right" v-if="props.showArrowRight"> |
|||
<el-icon |
|||
class="arrow-right" |
|||
:class="{ 'rotate-icon': item.__expanded }" |
|||
:size="12" |
|||
:color="'#c1c1c1'" |
|||
> |
|||
<ArrowRight /> |
|||
</el-icon> |
|||
</div> |
|||
</div> |
|||
|
|||
<Transition name="fade" mode="out-in"> |
|||
<div class="collapse-content" v-show="item.__expanded"> |
|||
<slot name="content" :item="item"></slot> |
|||
</div> |
|||
</Transition> |
|||
</DynamicScrollerItem> |
|||
</DynamicScroller> |
|||
<Transition name="fade" mode="out-in"> |
|||
<div class="collapse-content" v-show="item.__expanded"> |
|||
<slot name="content" :item="item"></slot> |
|||
</div> |
|||
</Transition> |
|||
</DynamicScrollerItem> |
|||
</DynamicScroller> |
|||
</template> |
|||
<script setup lang="ts"> |
|||
import { ref, onBeforeUnmount, onDeactivated } from 'vue' |
|||
import { ref, computed, onBeforeUnmount, onDeactivated, useTemplateRef } from 'vue' |
|||
import type { PropType } from 'vue' |
|||
import { ArrowRight } from '@element-plus/icons-vue' |
|||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' |
|||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller' |
|||
const emit = defineEmits(['scroll']) |
|||
const props = defineProps({ |
|||
data: { |
|||
type: Array as PropType<Record<string, any>[]>, |
|||
default: () => [] |
|||
}, |
|||
keyField: { |
|||
type: String, |
|||
default: 'id' |
|||
}, |
|||
nameField: { |
|||
type: String, |
|||
default: 'name' |
|||
}, |
|||
/** 每个项的最小高度 */ |
|||
minItemSize: { |
|||
type: Number, |
|||
default: 48 |
|||
}, |
|||
showArrowRight: { |
|||
type: Boolean, |
|||
default: true |
|||
} |
|||
data: { |
|||
type: Array as PropType<Record<string, any>[]>, |
|||
default: () => [] |
|||
}, |
|||
keyField: { |
|||
type: String, |
|||
default: 'id' |
|||
}, |
|||
nameField: { |
|||
type: String, |
|||
default: 'name' |
|||
}, |
|||
/** 每个项的最小高度 */ |
|||
minItemSize: { |
|||
type: Number, |
|||
default: 48 |
|||
}, |
|||
showArrowRight: { |
|||
type: Boolean, |
|||
default: true |
|||
} |
|||
}) |
|||
|
|||
const itemHeight = computed(() => props.minItemSize + 'px') |
|||
|
|||
const activeItem = ref<Record<string, any> | null>(null) |
|||
const list = computed(() => { |
|||
console.time('list') |
|||
const activeItemId = activeItem.value ? activeItem.value[props.keyField] : null |
|||
const newList = props.data.map((listItem: Record<string, any>) => { |
|||
return { |
|||
...listItem, |
|||
__expanded: activeItemId && listItem[props.keyField] === activeItemId ? true : false |
|||
} |
|||
}) |
|||
console.timeEnd('list') |
|||
return newList |
|||
console.time('list') |
|||
const activeItemId = activeItem.value ? activeItem.value[props.keyField] : null |
|||
const newList = props.data.map((listItem: Record<string, any>) => { |
|||
return { |
|||
...listItem, |
|||
__expanded: activeItemId && listItem[props.keyField] === activeItemId ? true : false |
|||
} |
|||
}) |
|||
console.timeEnd('list') |
|||
return newList |
|||
}) |
|||
|
|||
const scrollbarRef = useTemplateRef('scrollbarRef') |
|||
const scrollbarScrollTop = ref(0) |
|||
function onScroll(e) { |
|||
scrollbarScrollTop.value = e.target.scrollTop |
|||
emit('scroll', e) |
|||
scrollbarScrollTop.value = e.target.scrollTop |
|||
emit('scroll', e) |
|||
} |
|||
/** |
|||
* 切换折叠面板展开状态 |
|||
* @param item 要切换展开状态的项 |
|||
*/ |
|||
function toggleExpand(item) { |
|||
activeItem.value = item.__expanded ? null : item |
|||
activeItem.value = item.__expanded ? null : item |
|||
} |
|||
/** |
|||
* 滚动到指定项 |
|||
* @param item 要滚动到的项 |
|||
*/ |
|||
function scrollToItem(item) { |
|||
if (!item) return |
|||
activeItem.value = item |
|||
var findIndex = list.value.findIndex((i) => i[props.keyField] === item[props.keyField]) |
|||
if (findIndex === -1) { |
|||
return |
|||
} |
|||
var top = props.minItemSize * findIndex |
|||
// 取消之前的动画 |
|||
cancelAnimationFrame(AnimationId.value as number) |
|||
scrollTo(scrollbarScrollTop.value, top, scrollbarScrollTop.value) |
|||
if (!item) return |
|||
activeItem.value = item |
|||
const findIndex = list.value.findIndex((i) => i[props.keyField] === item[props.keyField]) |
|||
if (findIndex === -1) { |
|||
return |
|||
} |
|||
const top = props.minItemSize * findIndex |
|||
// 取消之前的动画 |
|||
cancelAnimationFrame(AnimationId.value as number) |
|||
scrollTo(scrollbarScrollTop.value, top, scrollbarScrollTop.value) |
|||
} |
|||
/** |
|||
* 滚动到指定索引项 |
|||
* @param index 要滚动到的索引项 |
|||
*/ |
|||
function scrollToIndex(index) { |
|||
if (index < 0 || index >= list.value.length) return |
|||
if (index < 0 || index >= list.value.length) return |
|||
|
|||
activeItem.value = list.value[index] |
|||
activeItem.value = list.value[index] |
|||
|
|||
var top = props.minItemSize * index |
|||
// 取消之前的动画 |
|||
cancelAnimationFrame(AnimationId.value as number) |
|||
scrollTo(scrollbarScrollTop.value, top, scrollbarScrollTop.value) |
|||
const top = props.minItemSize * index |
|||
// 取消之前的动画 |
|||
cancelAnimationFrame(AnimationId.value as number) |
|||
scrollTo(scrollbarScrollTop.value, top, scrollbarScrollTop.value) |
|||
} |
|||
/** |
|||
* 滚动到指定位置 |
|||
* @param position 要滚动到的位置 |
|||
*/ |
|||
function scrollToPosition(position: number) { |
|||
|
|||
if (position < 0) return |
|||
// 取消之前的动画 |
|||
cancelAnimationFrame(AnimationId.value as number) |
|||
scrollTo(scrollbarScrollTop.value, position, scrollbarScrollTop.value) |
|||
if (position < 0) return |
|||
// 取消之前的动画 |
|||
cancelAnimationFrame(AnimationId.value as number) |
|||
scrollTo(scrollbarScrollTop.value, position, scrollbarScrollTop.value) |
|||
} |
|||
|
|||
// 滚动到指定位置 |
|||
const AnimationId = ref<number>() |
|||
function scrollTo(from: number, to: number, current: number) { |
|||
if (scrollbarScrollTop.value === to) { |
|||
return |
|||
} |
|||
const speed = (to - from) / 30 |
|||
|
|||
if (speed < 0) { |
|||
if (current <= to) { |
|||
return |
|||
if (scrollbarScrollTop.value === to) { |
|||
return |
|||
} |
|||
} else if (speed > 0) { |
|||
if (current >= to) { |
|||
return |
|||
const speed = (to - from) / 30 |
|||
|
|||
if (speed < 0) { |
|||
if (current <= to) { |
|||
return |
|||
} |
|||
} else if (speed > 0) { |
|||
if (current >= to) { |
|||
return |
|||
} |
|||
} |
|||
} |
|||
current = current + speed |
|||
current = current + speed |
|||
|
|||
// DynamicScroller内部用的RecycleScroller组件,所以滚动到指定位置用RecycleScroller方法 |
|||
scrollbarRef.value?.$refs?.scroller?.scrollToPosition(current) |
|||
AnimationId.value = requestAnimationFrame(() => { |
|||
scrollTo(from, to, current) |
|||
}) |
|||
// DynamicScroller内部用的RecycleScroller组件,所以滚动到指定位置用RecycleScroller方法 |
|||
scrollbarRef.value?.$refs?.scroller?.scrollToPosition(current) |
|||
AnimationId.value = requestAnimationFrame(() => { |
|||
scrollTo(from, to, current) |
|||
}) |
|||
} |
|||
onDeactivated(() => { |
|||
// 组件销毁时取消动画 |
|||
cancelAnimationFrame(AnimationId.value as number) |
|||
// 组件销毁时取消动画 |
|||
cancelAnimationFrame(AnimationId.value as number) |
|||
}) |
|||
onBeforeUnmount(() => { |
|||
// 组件销毁时取消动画 |
|||
cancelAnimationFrame(AnimationId.value as number) |
|||
// 组件销毁时取消动画 |
|||
cancelAnimationFrame(AnimationId.value as number) |
|||
}) |
|||
defineExpose({ |
|||
toggleExpand, |
|||
scrollToItem, |
|||
scrollToIndex, |
|||
scrollToPosition |
|||
toggleExpand, |
|||
scrollToItem, |
|||
scrollToIndex, |
|||
scrollToPosition |
|||
}) |
|||
</script> |
|||
<style lang="scss" scoped> |
|||
.collapse-header { |
|||
display: flex; |
|||
flex-direction: row; |
|||
align-items: center; |
|||
width: 100%; |
|||
overflow: hidden; |
|||
display: flex; |
|||
flex-direction: row; |
|||
align-items: center; |
|||
width: 100%; |
|||
overflow: hidden; |
|||
|
|||
height: v-bind(itemHeight); |
|||
padding: 0 4px; |
|||
cursor: pointer; |
|||
border-bottom: 1px solid rgba(0, 0, 0, 0.1); |
|||
box-sizing: border-box; |
|||
.collapse-header-left { |
|||
font-size: 12px; |
|||
color: #303133; |
|||
flex: 1; |
|||
} |
|||
height: v-bind(itemHeight); |
|||
padding: 0 4px; |
|||
cursor: pointer; |
|||
border-bottom: 1px solid rgba(0, 0, 0, 0.1); |
|||
box-sizing: border-box; |
|||
.collapse-header-left { |
|||
font-size: 12px; |
|||
color: #303133; |
|||
flex: 1; |
|||
} |
|||
} |
|||
.collapse-content { |
|||
transform-origin: top; |
|||
border-bottom: 1px solid rgba(0, 0, 0, 0.1); |
|||
transform-origin: top; |
|||
border-bottom: 1px solid rgba(0, 0, 0, 0.1); |
|||
} |
|||
// collapse-content显示隐藏动画 |
|||
.fade-enter-active, |
|||
.fade-leave-active { |
|||
transition: all 0.2s ease; |
|||
transition: all 0.2s ease; |
|||
} |
|||
|
|||
.fade-enter-from, |
|||
.fade-leave-to { |
|||
opacity: 0; |
|||
transform: scaleY(0); |
|||
opacity: 0; |
|||
transform: scaleY(0); |
|||
} |
|||
.arrow-right { |
|||
transition: all 0.2s ease; |
|||
transition: all 0.2s ease; |
|||
} |
|||
.rotate-icon { |
|||
transform: rotate(90deg); |
|||
transform: rotate(90deg); |
|||
} |
|||
::-webkit-scrollbar-thumb { |
|||
background: rgba(0, 0, 0, 0.1) !important; |
|||
border-radius: 4px; |
|||
background: rgba(0, 0, 0, 0.1) !important; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
::-webkit-scrollbar { |
|||
width: 8px; |
|||
background-color: #f5f5f5; |
|||
width: 8px; |
|||
background-color: #f5f5f5; |
|||
} |
|||
</style> |
|||
|
|||
@ -0,0 +1,15 @@ |
|||
# 使用示例 |
|||
|
|||
```vue |
|||
<VirtualCollapsePanel |
|||
ref="scrollbarRef" |
|||
:data="filterMarkers" |
|||
key-field="id" |
|||
name-field="name" |
|||
:min-item-size="40" |
|||
> |
|||
<template #header="{ item }"> |
|||
</template> |
|||
<template #content="{ item }"> </template> |
|||
</VirtualCollapsePanel> |
|||
``` |
|||
Loading…
Reference in new issue