draggable拖拽列表与虚拟列表结合实例

在最近的项目中有拖拽元素的需求,且数据量较大,考虑使用虚拟列表,而网上并没有找到能很好将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内容不过多讨论,可以自己试试,本文重点讨论movechange事件

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触发的回调函数,动态设置draggabletop值,即可实现简单的虚拟列表

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;
    }
  }
}
相关推荐
夕水8 分钟前
这个提升效率宝藏级工具一定要收藏使用
前端·javascript·trae
会飞的鱼先生22 分钟前
vue3 内置组件KeepAlive的使用
前端·javascript·vue.js
斯~内克36 分钟前
前端浏览器窗口交互完全指南:从基础操作到高级控制
前端
Mike_jia1 小时前
Memos:知识工作者的理想开源笔记系统
前端
前端大白话1 小时前
前端崩溃瞬间救星!10 个 JavaScript 实战技巧大揭秘
前端·javascript
loveoobaby1 小时前
Shadertoy着色器移植到Three.js经验总结
前端
蓝易云1 小时前
在Linux、CentOS7中设置shell脚本开机自启动服务
前端·后端·centos
浩龙不eMo1 小时前
前端获取环境变量方式区分(Vite)
前端·vite
一千柯橘1 小时前
Nestjs 解决 request entity too large
javascript·后端
土豆骑士1 小时前
monorepo 实战练习
前端