在最近的项目中有拖拽元素的需求,且数据量较大,考虑使用虚拟列表,而网上并没有找到能很好将draggable与虚拟列表相结合的库,于是我就采用了vue.draggable.next+手写虚拟列表的方案。
本篇文章总结了我vue.draggable.next库的基础使用以及简单虚拟列表的实现,并将两者结合的经验
环境
- vue3
- vue.draggable.next
css
npm i -S vuedraggable@next
vue.draggable.next
这是一款vue3的拖拽插件,是vue.draggable升级版本,同样是基于Sortable.js实现的,你可以用它来拖拽列表、菜单、工作台、选项卡等常见的工作场景。
如果你已经掌握了该插件的基本使用,可以直接跳到change------融合虚拟列表关键点
一个简单例子
html
<template>
<draggable v-model="list" item-key="id"
:animation='150'
tag="div"
>
<template #item="{element,index}">
<div>编号:{{index}} 元素:{{element}}</div>
</template>
<template #footer>
<div style="padding:20px 0;color: #a38f8f;border: #a38f8f dashed 2px;">
拖至此处加到末尾
</div>
</template>
</draggable>
</template>
<script>
import draggable from "vuedraggable";
import {ref} from "vue";
const list=ref(['A','B','C','D','E','F','G','H','I','J','K','L'])
</script>
通过这个例子,我们可以得到一个拖拽列表,且编号会随拖拽变化,这就是利用了内容插槽的index
,而elemnet
对应v-model
绑定的数组元素
多列表
那么要如何进行多列表之间的拖拽呢,只需要配置group
属性
name
表示分组名,同一组名的区域可以交换内容
pull
表示拉取的方法,true
表示允许拉取,且完成拖拽操作后会删除原元素,若设置为clone
,则会保留原元素,clone一个新元素
put
表示是否允许放入
ini
:group="{name: 'user',pull:true,put:true}"
API
属性名称 | 说明 |
---|---|
group | 如果一个页面有多个拖拽区域,通过设置group名称可以实现多个区域之间相互拖拽 或者 { name: "...", pull: [true, false, 'clone', array , function], put: [true, false, array , function] } |
sort | 是否开启排序,如果设置为false,它所在组无法排序 |
delay | 鼠标按下多少秒之后可以拖拽元素 |
touchStartThreshold | 鼠标按下移动多少px才能拖动元素 |
disabled | :disabled= "true",是否启用拖拽组件 |
animation | 拖动时的动画效果,如设置animation=1000表示1秒过渡动画效果 |
handle | :handle=".mover" 只有当鼠标在class为mover类的元素上才能触发拖到事件 |
filter | :filter=".unmover" 设置了unmover样式的元素不允许拖动 |
draggable | :draggable=".item" 样式类为item的元素才能被拖动 |
ghost-class | :ghost-class="ghostClass" 设置拖动元素的占位符类名,你的自定义样式可能需要加!important才能生效,并把forceFallback属性设置成true |
chosen-class | :ghost-class="hostClass" 被选中目标的样式,你的自定义样式可能需要加!important才能生效,并把forceFallback属性设置成true |
drag-class | :drag-class="dragClass"拖动元素的样式,你的自定义样式可能需要加!important才能生效,并把forceFallback属性设置成true |
force-fallback | 默认false,忽略HTML5的拖拽行为,因为h5里有个属性也是可以拖动,你要自定义ghostClass chosenClass dragClass样式时,建议forceFallback设置为true |
fallback-class | 默认false,克隆选中元素的样式到跟随鼠标的样式 |
fallback-on-body | 默认false,克隆的元素添加到文档的body中 |
fallback-tolerance | 按下鼠标移动多少个像素才能拖动元素,:fallback-tolerance="8" |
scroll | 默认true,有滚动区域是否允许拖拽 |
scroll-fn | 滚动回调函数 |
scroll-fensitivity | 距离滚动区域多远时,滚动滚动条 |
scroll-speed | 滚动速度 |
事件
该插件具有与Sortable.js
相同的事件,触发时会返回一个事件对象evt
,常用的有
事件 | 描述 |
---|---|
@start | 开始拖拽时触发 |
@add | 将列表A元素添加到列表B的时候触发 |
@remove | 元素被移除时触发 |
@end | 拖拽结束时触发 |
@change | 拖拽结束时触发 |
@choose | 选中元素时触发 |
@move | 元素移动时触发 |
关于evt
内容不过多讨论,可以自己试试,本文重点讨论move
与change
事件
move------可用于实现拖拽约束
vue.draggable.next提供了升级版的move
事件的回调函数
lua
function onMoveCallback(evt, originalEvent){
...
// return false; 返回Boolean表示是否允许释放
}
该move使用时为:move而非@move
evt 对象具有与 Sortable onMove 事件相同的属性,以及 3 个附加属性:
-
draggedContext
:链接到拖动元素的上下文index
:拖动的元素索引element
:拖动的元素底层视图模型元素futureIndex
:如果接受 drop作,则拖动元素的潜在索引
-
relatedContext
:链接到当前拖动作的上下文index
:目标元素索引element
:目标元素视图模型元素list
: 目标列表component
: 目标 VueComponent
利用这一点,我们可以实现拖拽约束,比如分区有名额限制,通过编写回调函数检测拖入区域是否还有名额
ini
<draggable ...
:move="(evt)=>checkMove(evt,areaName)"
:data-area-id="areaName"
>
</draggable>
...
checkMove(evt,sourceAreaName){
//获取目标区域名
const targetAreaName = evt.relatedContext.component?.$attrs['data-area-id']
if(targetAreaName===sourceAreaName){
//相同区域不需要判断,放行
return true;
}
if(targetAreaName==='notAssigned'){
//为分配区,放行
return true;
}
//目标区域剩余名额,areas为一个字典(Map<String>,List<ELEMENT>>)
const targetAreaQuota=areas[targetAreaName].quota-areas[targetAreaName].list.length;
return targetAreaQuota>0;
},
change------融合虚拟列表关键点
change
会在完成拖拽操作后触发,返回一个字典,该字典会列出本次操作触发的事件
javascript
{
'moved':{
element: Object,
newIndex: Number,
oldIndex: Number,
},
'added':{
element: Object,
newIndex: Number,
},
'removed':{
element: Object,
oldIndex: Number,
},
}
每个区域会返回且只返回本次操作触发的事件
如从A区拖拽到B区,那么A区返回的字典只包含removed
,B区则只包含added
若区域内部移动,则只返回moved
因为虚拟列表本质上是只展示一个视图数组,所以要结合拖拽,就要多一层视图数组到源数组的操作映射,通过记录视图数组相对源数组的偏移值,通过change
事件实现这个操作映射
简单的虚拟列表
前文也提到,虚拟列表本质上就是只渲染视图窗口的元素,也就是说我们只需计算出scrollTop
相对于源数组移动了多少个元素,从已经滑过的最后一个元素开始,渲染视图窗口长度的元素数
因为拖拽的只是视图数组,意味着draggable
的高度固定,所以在其最外层需要套个div
,高度为源数组元素总高度
,然后通过scroll
触发的回调函数,动态设置draggable
的top
值,即可实现简单的虚拟列表
html
<el-scrollbar noresize view-class="draggableScroll"
v-if="'wrapHeight' in viewList[areaName]"
@scroll="(dis)=>handleDraggableScroll(dis,areaName)">
<!-- wrapHeight为源数组总高度,hasScroll为已经滑动的距离 -->
<div :style="{height: viewList[areaName].wrapHeight+'px',position: 'relative'}">
<draggable ...
:style="{top:viewList[areaName].hasScroll+'px'}"
@change="(evt)=>handleDraggableViewDataMapping(evt,areaName)"
:group="{name:'teamArea',pull:true,put:true}"
class="draggable">
<template #item="{element,index}">
...
</template>
</draggable>
</div>
</el-scrollbar>
js
//处理Scroll事件
handleDraggableScroll(dis,areaName){
const scrollTop=dis.scrollTop;
//已经移动的距离
viewList[areaName]['hasScroll']=scrollTop
//防抖节约资源
const debounced= debounce((scrollTop)=>{
const itemHeight=24;//元素的高度,建议设置为全局变量
//滑过的元素数
const missItem=Math.floor(scrollTop/itemHeight);
//已经展示过的元素,即滑过的元素+视图窗口的元素
const hasShow=Math.floor((scrollTop+288)/itemHeight);
viewList[areaName]['teams']=originList[areaName].slice(missItem,hasShow);
//记录相对源数组的偏移值
viewList[areaName]['offset']=missItem;
console.log({'滑过元素数':missItem, '展示过的元素':hasShow});
},100)
return debounced(scrollTop);
}
两者结合
前文提到关键点在于change
事件,也就是说我们只需要区分出操作类型,从evt
给出的索引加上偏移值对源数组进行操作即可
js
handleDraggableViewDataMapping(evt,areaName){
const offset=viewList[areaName].offset;
//获取操作key
const op=Object.keys(evt)[0];
//本次操作的元素
const el=evt[op].element;
if(op==='moved'){ //自身区域移动
const oldIndex=evt[op].oldIndex+offset;
const newIndex=evt[op].newIndex+offset;
//需要区别新旧位置插入和删除顺序
if(oldIndex<newIndex){
originList[areaName].splice(newIndex+1,0,el);
originList[areaName].splice(oldIndex,1);
}else{
originList[areaName].splice(oldIndex,1);
originList[areaName].splice(newIndex,0,el);
}
}else{
if(op==='added'){ //加入区域
const newIndex=evt[op].newIndex+offset;
originList[areaName].splice(newIndex,0,el);
//重新计算wrapHeight
viewList[areaName].wrapHeight+=itemHeight;
}else if(op==='removed'){ //移出区域
const oldIndex=evt[op].oldIndex+offset;
originList[areaName].splice(oldIndex,1);
viewList[areaName].wrapHeight-=itemHeight;
}
}
}