跟我一起,从0实现并封装拖拽排列组件

10,253 阅读11分钟

首先演示一下最终效果

流畅的拖动和交换位置效果,并实时更新数据

效果演示1

支持组件的样式和内容自定义

效果演示2

这是这次系列文章的第二篇,我自己封装了一个用vue实现的拖拽排列卡片组件,并且发布到npm,详细地记录下来了整体制作过程。总共有三篇文章,介绍组件的制作思路和遇到的问题,以及在发布到npm上并下载使用的过程中,发生了什么问题并如何解决。


先确定初步要实现功能的大致需求

  • 鼠标点击卡片可进行移动,鼠标滚动时也根据跟着滚动
  • 移动卡片在另一张卡片上方附近区域时,要进行位置交换。交换位置时,两张卡片中间的卡片要自动前移/后移
  • 松开鼠标时卡片回到原位置/新位置
  • 将属性、事件暴露出去给父组件调用,并制作插槽

建议看到一半不知道我在写什么的的小伙伴,直接去源码仓库看一下我的那个源码。只想快速了解一下的就只看下面问题的整体思路就可以了!

Q1:如何实现卡片移动?

整体思路:

  • 所有卡片统一采用absolute布局,根据位置号码和列数等参数计算出top和left进行显示。
  • 点击卡片的时候,先判断卡片是否在拖动状态。若不是则进入下一步,获取数据和去除卡片默认的过渡。
  • 点击后,全局监听鼠标的移动事件,鼠标移动多少距离,卡片就移动多少距离。同时需要监听窗口的滚动事件进行相同的操作。
  • 当鼠标松开时,清除所有监听,把卡片恢复至根据位置号码计算出的位置。将点击状态改为false

具体实现:

首先我们先要将卡片结构制作出来,读取数据循环生成卡片。

<!-- 外层的div是用于制定卡片的范围包括外面的margin -->
<div
  class="cardBorderBox"
  v-for="item of listData"
  :key="item.id"
  :id="item.id"
>
<!-- 里面的div是用于显示卡片本身的内容 -->
  <div class="cardInsideBox" >
    <div class="topWrapBox">
        <!-- 这里是标题栏,用于添加点击事件 -->
    </div>
    <div class="emptyContent">
        <!-- 这里是内容部分 -->
    </div>
  </div>
</div>

<script>
export default {
  //name记得一定要定义
  name: "cardDragger",
  data(){
    return {
      listData: [
        {
          positionNum: 1,        // 位置号码,卡片的位置根据这个计算生成
          name: "演示卡片1",      // 卡片标题
          id: "card1",           // 用于辨识的卡片ID
        },
      ]
    }
  },
}
</script>

卡片还需要对位置和样式进行调整,需要的其他参数有:

data(){
  return {
    colNum:2,                      //一行有多少列
    cardOutsideWidth:590,          //单个卡片的外范围宽度
    cardOutsideHeight:380,         //单个卡片的外范围高度
    cardInsideWidth:default:560,   //单个卡片的内容宽度
    cardInsideHeight:default:320,  //单个卡片的内容高度
    
    mousedownTimer: null           //用于记录卡片当前是否在过渡状态中的定时器 
  }
}

卡片的布局采用absolute定位,便于制作过渡动画。width和height使用设定的卡片外围宽高。

<template>
  <div
    class="cardBorderBox"
    v-for="item of listData"
    :key="item.id"
    :id="item.id"
    :style="{ 
      width:cardOutsideWidth+'px', 
      height:cardOutsideHeight+'px'
    }"
  >
    <!-- 省略部分代码 -->
  <div>
</template>
<script>
//整体就是按列数的限定,从左往右一行一行地排列数据
computeLeft(num) {
  //left为(位置号码-1)%列数*卡片外围宽度
  return (num-1) % this.colNum * this.cardOutsideWidth;
},
computeTop(num) {
  //top为(位置号码/列数)向上取整,减去1,再乘以卡片外围高度
  return (Math.ceil(num / this.colNum) - 1) * this.cardOutsideHeight;
}
</script>
<!-- 省略部分样式代码 -->

在首次加载和监听到卡片数量产生变动时,需要重新根据自身的位置号码计算生成卡片的top和left。保证异步数据的加载也能读取到。

//判断卡片的selectState是否存在,不存在则添加false
  methods:{
    addCardStyle(){
      this.$nextTick(()=>{
        this.listData.forEach(item=>{
          document.querySelector('#'+item.id).style.top = this.computeTop(item.positionNum)+'px'
          document.querySelector('#'+item.id).style.left = this.computeLeft(item.positionNum)+'px'
        })
      })
    }
  },
  watch:{
    listData:{
      handler:function(){
        this.addCardStyle()     
      },
      immediate: true
    }
  }

接下来我们需要在所有内容的最外面再包裹一层div,再添加上position:relative,根据listData的数量设定div的宽高。

<!-- 
    首先,absolute是根据第一个父元素不为static 定位的元素进行定位
    其次,确定宽高是因为将卡片移动的的时候,宽高会根据内容自适应,这里不需要宽高自适应。
    宽度为:列数*卡片外围宽度
    高度为:最后一个卡片的top+卡片外围的高度
-->
<div
  :style="{
    position:'relative',
    height:computeTop(listData.length)+cardOutsideHeight+'px',
    width:cardOutsideWidth*colNum+'px'}"
>
<!-- computeTop()方法是上面计算卡片top的方法 -->

   <!-- 卡片代码 -->
</div>

然后我们就给普通卡片的标题栏添加点击事件,当鼠标点击标题栏的时候,先判断进行过渡动画的定时器是否为空,为空的话直接返回。不为空则执行点击事件.

<div
  class="cardBorderBox"
  v-for="item of listData"
  :key="item.id"
  :id="item.id"
>
  <div class="cardInsideBox" >
    <div @mousedown="touchStart($event,item.id)" class="topWrapBox">
        <!-- 标题栏添加点击事件 -->
    </div>
    <div class="emptyContent">
        <!-- 这里是内容部分 -->
    </div>
  </div>
</div>

methods: {
  //event为鼠标的点击事件,selectId是当前数据的id
  touchStart(event, selectId) {
      
      //其他卡片正在动画中的时候不可以再次点击,否则动画和数据会出错。
      if (this.mousedownTimer) {
        return false;
      }
      
      const that = this;
      //选中的卡片的dom和数据
      let selectDom = document.getElementById(selectId);
      let selectMenuData = this.data.find(item => {
        return item.id === selectId;
      });
      //获取屏幕滚动条位置
      let originTop = document.body.scrollTop === 0 ?
                      document.documentElement.scrollTop : document.body.scrollTop;
      let scrolTop = originTop;
      //记录卡片的top和left
      let moveTop 
      let moveLeft 
      //记录起始选中位置
      let OriginObjPosition = {
        left: 0,
        top: 0,
        originNum: -1
      };
      //起始鼠标信息
      let OriginMousePosition = {
        x: 0,
        y: 0
      };
      //记录交换位置的号码
      let OldPositon = null;
      let NewPositon = null;
    
      
      //1.保存点击的起始鼠标位置
      OriginMousePosition.x = event.screenX;
      OriginMousePosition.y = event.screenY;
      
      //2.给选中卡片一个transition:none的class,去除默认过渡
      selectDom.classList.add('d_moveBox')
      
      //3.保存现在卡片的top和left
      moveLeft = OriginObjPosition.left = parseInt(
        //这里获取到的left是带单位的字符串,要转换成纯数字
        selectDom.style.left.slice(0, selectDom.style.left.length - 2)
      );
      moveTop = OriginObjPosition.top = parseInt(
        selectDom.style.top.slice(0, selectDom.style.top.length - 2)
      );
      
      //4.添加其他鼠标事件
      document.addEventListener("mousemove", mouseMoveListener);
      document.addEventListener("mouseup", mouseUpListener);
      document.addEventListener("scroll", mouseScroll);

      
      //省略部分代码 
  }
}

鼠标移动、松开、滚轮事件也添加了。剩下的就是完善每个事件的内容了。首先是鼠标移动事件,我们需要监听鼠标的当前位置和原先位置进行对比,再调整当前卡片的top和left,就可完成点击卡片并移动卡片的效果。

methods: {
  //所有其他函数都添加在touchStart方法里,共同使用点击事件的数据
  touchStart(event, selectId) {
    //省略部分代码
    
    function mouseMoveListener(event) {
      //在原来的top和left基础上,加上鼠标的偏移量
      moveTop = OriginObjPosition.top + ( event.screenY - OriginMousePosition.y );
      moveLeft = OriginObjPosition.left + ( event.screenX - OriginMousePosition.x );
        
      document.querySelector(".d_moveBox").style.left = moveLeft + "px";
      document.querySelector(".d_moveBox").style.top = moveTop + (scrolTop - originTop) + "px";  //这里要加上滚动的高度
    }
  }
}

鼠标滚轮事件也差不多,监听滚动的具体,对卡片的位置进行改变。

function mouseScroll(event) {
    scrolTop = document.body.scrollTop === 0
               ? document.documentElement.scrollTop
               : document.body.scrollTop;

    document.querySelector(".d_moveBox").style.top = moveTop + scrolTop - originTop + "px";
  }

Q2:如何检测并交换卡片?

整体思路:

  • 移动卡片时,调用计算当前卡片位置属于哪个位置号码的函数,若与现有号码重复且不是自身号码的话则交换位置。
  • 交换时对比位置号码是由小换到大,还是由大换到小,分别对两种情况的中间的号码分别前移一位/后移一位。

具体实现:

在上面的鼠标移动事件中,我们调用检测函数,检测当前移动位置是否有卡片在下方,但需要对检测函数进行节流,否则检测频率太高影响性能。卡片移动至另一张卡片的某一方向距离超过百分之50的距离时,则进行位置交换。(这里检测的是以卡片外围宽高进行计算的)

methods: {
  touchStart(event, selectId) {
    //用于保存检测位置的定时器
    let DectetTimer = null;
    //省略部分代码...

    function mouseMoveListener(event) {
      //省略部分代码...
      
      //在鼠标移动的监听中添加如下代码
      if (!DectetTimer) {
        DectetTimer = setTimeout(()=>{
          //节流调用检测函数,传入当前位置信息
          cardDetect(moveTop + (scrolTop - originTop),moveLeft) 
          //调用结束清空定时器
          DectetTimer = null;
        }, 200);
      }     
    }
    
    function cardDetect(moveItemTop, moveItemLeft){
      //计算当前移动卡片位于卡片的哪一行哪一列
      let newWidthNum = Math.round((moveItemLeft/ that.cardOutsideWidth))+1
      let newHeightNum = Math.round((moveItemTop/ that.cardOutsideHeight))
      
      //如果移动卡片至范围外则不会有任何操作,直接返回
      if(newHeightNum>(Math.ceil(that.listData.length / that.colNum) - 1)||
        newHeightNum<0||
        newWidthNum<=0||
        newWidthNum>that.colNum){
        return false
      }
      
      //将计算的行列转换为位置号码
      const newPositionNum = (newWidthNum) + newHeightNum * that.colNum
      if(newPositionNum!==selectMenuData.positionNum){
        //寻找当前位置号码有没有卡片数据
        let newItem = that.listData.find(item=>{
          return item.positionNum === newPositionNum
        })
        //有卡片数据的话就进行交换
        if( newItem ){
          swicthPosition(newItem, selectMenuData);
        }
      }      
    }
  }
}

当检测得到的位置号码,与现有的其他普通卡片位置号码重复时,则判定为需要交换位置。交换的情况分为位置号码从小移动到大,和从大移动到小两种情况。

//省略部分代码
 function swicthPosition(newItem, originItem) {
    OldPositon = originItem.positionNum;
    NewPositon = newItem.positionNum;

    that.$emit('swicthPosition',OldPositon,NewPositon,originItem)

    //位置号码从小移动到大
    if (NewPositon > OldPositon) {
      let changeArray = [];
      //从小移动到大,那小的号码就会空出来,其余卡片应往前移动一位 
      //找出两个号码中间对应的卡片数据
      for (let i = OldPositon + 1; i <= NewPositon; i++) {
        let pushData = that.data.find(item => {
          return item.positionNum === i;
        });
        changeArray.push(pushData);
      }
      
      for (let item of changeArray) {
        //vue的$set实时更改数据
        that.$set(item, "positionNum", item.positionNum - 1);
        //原生js调整卡片动画
        document.querySelector('#'+item.id).style.top = that.computeTop(item.positionNum)+'px'
        document.querySelector('#'+item.id).style.left = that.computeLeft(item.positionNum)+'px'
      }
      //正在拖动的卡片就不需要动画了
      that.$set(originItem, "positionNum", NewPositon);
    }

    //位置号码从大移动到小
    if (NewPositon < OldPositon) {
      let changeArray = [];
      //从大移动到小,那大的号码就会空出来,其余卡片应往后移动一位 
      //找出两个号码中间对应的卡片数据
      for (let i = OldPositon - 1; i >= NewPositon; i--) {
        let pushData = that.data.find(item => {
          return item.positionNum === i;
        });
        changeArray.push(pushData);
      }

      for (let item of changeArray) {
        that.$set(item, "positionNum", item.positionNum + 1);
        document.querySelector('#'+item.id).style.top = that.computeTop(item.positionNum)+'px'
        document.querySelector('#'+item.id).style.left = that.computeLeft(item.positionNum)+'px'
      }
      that.$set(originItem, "positionNum", NewPositon);

    }
  }  

Q3:鼠标松开之后回到原位?

整体思路:

  • 鼠标松开时,先清空位置检测中的定时器,再进行最后一次位置检测。
  • 将卡片恢复至位置号码对应的位置,并同时添加与卡片过渡的同等时长的定时器,在定时器中清除定时器并去除卡片的其他class。定时器为空才可以进行下一次点击。

具体实现:

function mouseUpListener() {
    /*首先清除位置检测的定时器,
      因为位置检测的定时器,会在鼠标松开事件结束后执行,
      会导致拖拽卡片都已经回到原位置并隐藏了,还会发生位置交换导致报错。
      应该调整为,先清楚定时器,直接检测,再添加卡片返回原处的动画*/
    clearTimeout(DectetTimer)
    DectetTimer = null
    
    //对鼠标松开位置直接进行最后一次位置检测
    cardDetect(moveTop + (scrolTop - originTop),moveLeft)

    //设置卡片当前位置号码计算生成的宽高,并添加transition进行过渡
    document.querySelector(".d_moveBox").classList.add('d_transition');
    document.querySelector(".d_moveBox").style.top = that.computeTop(selectMenuData.positionNum) + "px";
    document.querySelector(".d_moveBox").style.left = that.computeLeft(selectMenuData.positionNum) + "px";
    that.$emit('finishDrag',OldPositon,NewPositon,selectMenuData)

    that.mousedownTimer = setTimeout(() => {
      /*mousedownTimer是一个全局定时器,默认为空。详情可看仓库源码。
        若鼠标松开,卡片过渡动画开始时后则激活定时器,
        时间到了的话就清空定时器内容。
        保证在过渡动画执行期间,不能点击其他卡片。
        mousedownTimer在点击事件开始时进行判断,若不为空则直接返回跳出点击事件
      */
      document.querySelector(".d_moveBox").classList.remove('d_transition')
      document.querySelector(".d_moveBox").classList.remove('d_moveBox')
      clearTimeout(that.mousedownTimer);
      that.mousedownTimer = null;
    }, 300);
    
    //移除所有监听
    document.removeEventListener("mousemove", mouseMoveListener);
    document.removeEventListener("mouseup", mouseUpListener);
    document.removeEventListener("scroll", mouseScroll);
}

Q4:如何制作组件插槽和属性、事件的自定义?

整体思路:

  • 属性:将data的数据都放在props进行定义并设定默认值
  • 事件:只要在组件中的某些函数中调用$emit,在使用时进行监听
  • 插槽:使用vue在2.6.0更新的具名插槽进行制作

具体实现:

原来在data中的需要让用户自定义使用的属性,都改为放在props中,并赋予默认值

//组件中:
  props:{
    data:{
      type:Array,
      //设定默认值,返回空数组
      default: function () {
        return []
      }
    },
    colNum:{
      type:Number,
      default:2
    },
    cardOutsideWidth:{
      type:Number,
      default:590      
    },
    cardOutsideHeight:{
      type:Number,
      default:380      
    },
    cardInsideWidth:{
      type:Number,
      default:560      
    },
    cardInsideHeight:{
      type:Number,
      default:320      
    }
  },
 
//使用时: 
<cardDragger 
  :data="componentData"
  :colNum="3"
  :cardOutsideWidth="360"
  :cardInsideWidth="320"
  :cardOutsideHeight="250"
  :cardInsideHeight="210"
>  

事件封装也很简单,只需在需要的地方调用自定义事件,例如,我在鼠标松开的事件中调用了:

//组件中$emit事件名+要传递的数据
function mouseUpListener() {
  that.$emit('finishDrag',OldPositon,NewPositon,that.selectMenuData)
}
//使用时
<cardDragger 
  :data="componentData"
  @finishDrag="finishDrag"
>
export default {
  methods: {
    finishDrag(OldPositon,NewPositon,originItem){
      console.log(OldPositon,NewPositon,originItem)
    }
  }
}

插槽制作的话,先要确定你有什么内容是需要制作至插槽的。我这里的话是要将标题栏的内容和卡片内容添加插槽,使用的是vue的具名插槽。把你原有的需要用插槽替换的内容放入slot里面,当做默认内容就可以了。

<div
  class="d_cardBorderBox"
  v-for="item of listData"
  :key="item.id"
  :id="item.id"
>
  <div 
    class="d_cardInsideBox" 
    v-if="item.selectState===false"
  >
   <!--保留标题栏添加事件内容的div里添加slot,保留点击事件-->
    <div @mousedown="touchStart($event,item.id)" class="d_topWrapBox">
      <!--原来这里应该是标题栏的内容,将slot添加至slot的默认值即可-->
      <slot name="header" v-bind:item="item">
        <div class="d_topMenuBox" >
          <div class="d_menuTitle" >{{item.name}}</div>
        </div>
      </slot>
    </div>

    <slot name="content" v-bind:item="item" >
      <div class="d_emptyContent">
        卡片暂无内容
      </div>
    </slot>
  </div>
</div>

还使用了作用域插槽,让插槽内容能够访问子组件中才有的数据。并且我还做了一些判断,若data数据里的componentData是存在的话就使用vue的component优先显示。这里就不再赘述啦。

Q5:制作遇到哪些问题?

1.为什么不用drag和drop?

不采用h5的drag和drop是因为鼠标样式会变成禁止符号和拖拽时会变成透明。不符合我对拖拽样式的需求。

2.拖拽卡片何时添加transition?

在显示卡片和移动卡片的时候,是不能添加transition的,否则拖起来会有延迟。只有在鼠标松开后,使卡片返回原处的时候再添加transition进行过渡。又因为拖拽卡片是用v-if显示的,在下次显示拖拽卡片的时候transition已经被销毁了。

3.动画还没结束时快速点击另一张卡片报错了怎么办?

添加了一个全局定时器,若鼠标松开,卡片过渡动画开始时后则激活定时器,结束后清空定时器内容。点击卡片的事件先判断定时器内容是否为空再往下执行。

4.自从第一篇文章开始进行过哪些优化?

重写了位置检测,重写了拖动,去除了好多无用的代码。对异步数据无法加载进行修复。目前觉得已经比一开始好了很多!请放心使用!


😃以上就是我制作这个组件的全过程啦,应该还有很多可以优化的地方,欢迎大家指正。觉得有点意思的话记得点个赞呀~