2 changed files with 156 additions and 141 deletions
@ -1,229 +1,229 @@ |
|||||
<!-- 虚拟折叠面板 --> |
<!-- 虚拟折叠面板 --> |
||||
<template> |
<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> |
</template> |
||||
<script setup lang="ts"> |
<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 { ArrowRight } from '@element-plus/icons-vue' |
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' |
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' |
||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller' |
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller' |
||||
const emit = defineEmits(['scroll']) |
const emit = defineEmits(['scroll']) |
||||
const props = defineProps({ |
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 itemHeight = computed(() => props.minItemSize + 'px') |
||||
|
|
||||
const activeItem = ref<Record<string, any> | null>(null) |
const activeItem = ref<Record<string, any> | null>(null) |
||||
const list = computed(() => { |
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 scrollbarRef = useTemplateRef('scrollbarRef') |
||||
const scrollbarScrollTop = ref(0) |
const scrollbarScrollTop = ref(0) |
||||
function onScroll(e) { |
function onScroll(e) { |
||||
scrollbarScrollTop.value = e.target.scrollTop |
|
||||
emit('scroll', e) |
|
||||
|
scrollbarScrollTop.value = e.target.scrollTop |
||||
|
emit('scroll', e) |
||||
} |
} |
||||
/** |
/** |
||||
* 切换折叠面板展开状态 |
* 切换折叠面板展开状态 |
||||
* @param item 要切换展开状态的项 |
* @param item 要切换展开状态的项 |
||||
*/ |
*/ |
||||
function toggleExpand(item) { |
function toggleExpand(item) { |
||||
activeItem.value = item.__expanded ? null : item |
|
||||
|
activeItem.value = item.__expanded ? null : item |
||||
} |
} |
||||
/** |
/** |
||||
* 滚动到指定项 |
* 滚动到指定项 |
||||
* @param item 要滚动到的项 |
* @param item 要滚动到的项 |
||||
*/ |
*/ |
||||
function scrollToItem(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 要滚动到的索引项 |
* @param index 要滚动到的索引项 |
||||
*/ |
*/ |
||||
function scrollToIndex(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 要滚动到的位置 |
* @param position 要滚动到的位置 |
||||
*/ |
*/ |
||||
function scrollToPosition(position: number) { |
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>() |
const AnimationId = ref<number>() |
||||
function scrollTo(from: number, to: number, current: 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(() => { |
onDeactivated(() => { |
||||
// 组件销毁时取消动画 |
|
||||
cancelAnimationFrame(AnimationId.value as number) |
|
||||
|
// 组件销毁时取消动画 |
||||
|
cancelAnimationFrame(AnimationId.value as number) |
||||
}) |
}) |
||||
onBeforeUnmount(() => { |
onBeforeUnmount(() => { |
||||
// 组件销毁时取消动画 |
|
||||
cancelAnimationFrame(AnimationId.value as number) |
|
||||
|
// 组件销毁时取消动画 |
||||
|
cancelAnimationFrame(AnimationId.value as number) |
||||
}) |
}) |
||||
defineExpose({ |
defineExpose({ |
||||
toggleExpand, |
|
||||
scrollToItem, |
|
||||
scrollToIndex, |
|
||||
scrollToPosition |
|
||||
|
toggleExpand, |
||||
|
scrollToItem, |
||||
|
scrollToIndex, |
||||
|
scrollToPosition |
||||
}) |
}) |
||||
</script> |
</script> |
||||
<style lang="scss" scoped> |
<style lang="scss" scoped> |
||||
.collapse-header { |
.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 { |
.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显示隐藏动画 |
// collapse-content显示隐藏动画 |
||||
.fade-enter-active, |
.fade-enter-active, |
||||
.fade-leave-active { |
.fade-leave-active { |
||||
transition: all 0.2s ease; |
|
||||
|
transition: all 0.2s ease; |
||||
} |
} |
||||
|
|
||||
.fade-enter-from, |
.fade-enter-from, |
||||
.fade-leave-to { |
.fade-leave-to { |
||||
opacity: 0; |
|
||||
transform: scaleY(0); |
|
||||
|
opacity: 0; |
||||
|
transform: scaleY(0); |
||||
} |
} |
||||
.arrow-right { |
.arrow-right { |
||||
transition: all 0.2s ease; |
|
||||
|
transition: all 0.2s ease; |
||||
} |
} |
||||
.rotate-icon { |
.rotate-icon { |
||||
transform: rotate(90deg); |
|
||||
|
transform: rotate(90deg); |
||||
} |
} |
||||
::-webkit-scrollbar-thumb { |
::-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 { |
::-webkit-scrollbar { |
||||
width: 8px; |
|
||||
background-color: #f5f5f5; |
|
||||
|
width: 8px; |
||||
|
background-color: #f5f5f5; |
||||
} |
} |
||||
</style> |
</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