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 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60619 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了9 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅9 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅9 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅10 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment10 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端
爱敲代码的小鱼10 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax