跟我一塊兒,從0實現並封裝拖拽排列組件

首先演示一下最終效果html

流暢的拖動和交換位置效果,並實時更新數據 vue

效果演示1

支持組件的樣式和內容自定義 git

效果演示2

這是此次系列文章的第二篇,我本身封裝了一個用vue實現的拖拽排列卡片組件,而且發佈到npm,詳細地記錄下來了總體制做過程。總共有三篇文章,介紹組件的製做思路和遇到的問題,以及在發佈到npm上並下載使用的過程當中,發生了什麼問題並如何解決。github


先肯定初步要實現功能的大體需求npm

  • 鼠標點擊卡片可進行移動,鼠標滾動時也根據跟着滾動
  • 移動卡片在另外一張卡片上方附近區域時,要進行位置交換。交換位置時,兩張卡片中間的卡片要自動前移/後移
  • 鬆開鼠標時卡片回到原位置/新位置
  • 將屬性、事件暴露出去給父組件調用,並製做插槽

建議看到一半不知道我在寫什麼的的小夥伴,直接去源碼倉庫看一下個人那個源碼。只想快速瞭解一下的就只看下面問題的總體思路就能夠了!api

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>
複製代碼

卡片還須要對位置和樣式進行調整,須要的其餘參數有:bash

data(){
  return {
    colNum:2,                      //一行有多少列
    cardOutsideWidth:590,          //單個卡片的外範圍寬度
    cardOutsideHeight:380,         //單個卡片的外範圍高度
    cardInsideWidth:default:560,   //單個卡片的內容寬度
    cardInsideHeight:default:320,  //單個卡片的內容高度
    
    mousedownTimer: null           //用於記錄卡片當前是否在過渡狀態中的定時器 
  }
}
複製代碼

卡片的佈局採用absolute定位,便於製做過渡動畫。width和height使用設定的卡片外圍寬高。dom

<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.自從第一篇文章開始進行過哪些優化?

重寫了位置檢測,重寫了拖動,去除了好多無用的代碼。對異步數據沒法加載進行修復。目前以爲已經比一開始好了不少!請放心使用!


😃以上就是我製做這個組件的全過程啦,應該還有不少能夠優化的地方,歡迎你們指正。以爲有點意思的話記得點個贊呀~

相關文章
相關標籤/搜索