拖拽排列卡片組件

預覽圖

前言

以前在掘金上看到了一遍分享拖拽卡片組件的文章,看了大體思路,以爲很清晰,也想動手實現一下;javascript

在過程當中發現了蠻多的細節問題,完成後對比了原做者的代碼,發現許多能夠優化的地方,在這裏記錄一下;css

如下是我的學習實現的demo和源碼地址:html

使用

在倉庫中拿到dragCard.vue文件,引入到項目中,來看下面這個例子vue

// app.js
<template>
  <div id="app">
    <DragCard
      :list="list"
      :col="4"
      :itemWidth="150"
      :itemHeight="150"
      @change="handleChange"
      @mouseUp="handleMouseUp">
    </DragCard>
  </div>
</template>

<script>
  import DragCard from './components/DragCard.vue'

  export default {
    name: 'app',
    components: {
      DragCard
    },
    data() {
      return {
        list: [
          {head: '標題0', content: "演示卡片0"},
          {head: '標題1', content: "演示卡片1"},
          {head: '標題2', content: "演示卡片2"}
        ],
      }
    },
    methods: {
      handleChange(data) {
        console.log(data);
      },
      handleMouseUp(data) {
        console.log(data);
      }
    }
  }
</script>
複製代碼

來看看props和方法

經過組件屬性和方法能夠快速瞭解整個組件的使用;java

屬性

屬性 說明 類型 默認值
list 卡片數據 Array []
col 每一行顯示多少張卡片 Number 3
itemWidth 每一個卡片的寬度(包括外邊距) Number 150
itemHeight 每一個卡片的高度(包括外邊距) Number 150

方法

方法 說明 返回值
@change 當卡片位置變更的時候觸發 返回的是數組中每一項的位置序號數組
@mouseUp 當拖拽完卡片鬆手的時候觸發 同上

::: tip 返回值是數組中每一項的位置序號集合;返回值數組indexlist中的index一致;後續咱們能夠經過操做這兩個數組,合併成[{ id: 'cardid1', seatid: '1' }...]這樣的形式傳遞給後端,修改卡片的位置數據;固然建議是在mouseUp的時候去發送請求更優; :::git

插槽slot

slotName 說明 data
head 卡片的頭部標題部分 listItem
content 卡片內容部分 listItem

::: tip 這兩個做用域插槽都有默認值,若是不填寫的話,標題將顯示list中的head屬性,而內容將顯示content屬性;兩個slot都帶上了當前卡片的list項數據;能夠更加靈活的自定義卡片內容; :::github

具體實現

大概思路

  • 頁面卡片採用absolute佈局,經過設置lefttop,讓卡片按順序排列,所以傳入的list必須是正序的;
  • 初始化樣式,經過props傳入的值,咱們能夠計算出行列數,卡片位置等信息;
  • 給數組中的每一項添加一個位置標識屬性,後續的位置交換均可以經過這個標識標識來展開,也是後面觸發方法給父級傳遞的返回值;
  • 當鼠標按下的時候,記錄下鼠標的當前位置做爲起始位置,,當前卡片做爲參數傳入,並綁定mousemovemouseup事件;這時候鼠標的移動距離就是卡片的移動距離;
  • 在卡片移動的時候,咱們計算出當前是否移動到其餘的卡片位置,是的話,相隔之間的全部卡片向後移或向前移,觸發父組件的change方法;
  • 當鼠標鬆開的時候,卡片回到目標位置,觸發父組件的mouseUp方法;

首先看下頁面結構

<div class="dragCard">
    <div
      class="dragCard_warpper"
      ref="dragCard_warpper"
      :style="dragCardWarpperStyle">
      <div
        v-for="(item, index) in list"
        :key="index"
        class="dragCard_item"
        :style="initItemStyle(index)"
        :ref="item.dragCard_id">
        <div class="dragCard_content">
          <div
            class="dragCard_head"
            @mousedown="touchStart($event, item)">
            <slot name="head" :item="item" >
              <div class="dragCard_head-defaut">
                {{ item.head ? item.head : `卡片標題${index + 1}` }}
              </div>
            </slot>
          </div>
          <div class="dragCard_body">
            <slot name="content" :item="item">
              <div class="dragCard_body-defaut">
                {{ item.content ? item.content : `暫無數據` }}
              </div>
            </slot>
          </div>
        </div>
      </div>
    </div>
  </div>
複製代碼
  • 鼠標點擊標題能夠拖動卡片,因此@mousedown設置在dragCard_head中,爲了實現這一點,把slot分爲了兩個部分,一個是head標題部分,默認顯示item.content;一個是content內容部分,默認顯示item.head,;用戶能夠經過slot自定義卡片;slot知識點
// app.js  使用自定義卡片樣式
<template>
  <div id="app">
    <DragCard
      :list="list"
      :col="4"
      :itemWidth="150"
      :itemHeight="150"
      @change="handleChange"
      @mouseUp="handleMouseUp">
      <template v-slot:head="{ item }">
        <div class="dragHead">{{item.head}}</div>
      </template>
      <template v-slot:content="{ item }">
        <div class="dragContent">{{item.content}}</div>
      </template>
    </DragCard>
  </div>
</template>
複製代碼
  • dragCardWarpperStyle爲容器的樣式,經過props傳入的值計算出容器的寬高;在組件初始化的時候就應該去計算了,用init()包含起來;
// ... 
 created() {
   this.init();
 },
 methods: {
   init () {
     // 根據數組的長度length和每行個數col,能夠計算出須要多少行row,超出不滿一行算一行,用ceil向上取整;
     this.row = Math.ceil(this.list.length / this.col);
     // 計算出容器的寬高
     this.dragCardWarpperStyle = `width: ${this.col * this.itemWidth}px; height:${this.row * this.itemHeight}px`;
     /* * 這裏處理下數組,引入兩個重要的屬性: * dragCard_id: * 給每個卡片建立一個惟一id,做爲ref值,後續經過this.$refs[dragCard_id]獲取卡片的dom * dragCard_index: * 這是每一個卡片的位置序號,用於記錄卡片當前位置 * */
     this.list.forEach((item, index) => {
       this.$set(item, 'dragCard_index', index);
       this.$set(item, 'dragCard_id', 'dragCard_id' + index);
     });
   },
   // 經過index計算出每一個卡片的left和right
   initItemStyle(INDEX) {
     return {
        width: this.itemWidth + 'px',
        height: this.itemHeight + 'px',
        left: (INDEX < this.col ? INDEX : (INDEX % this.col)) * this.itemWidth + 'px',
        top: Math.floor(INDEX / this.col) * this.itemHeight + 'px'
     };
   }
 }
複製代碼
  • 固然咱們的卡片數據是從父級傳入的,因此list確定會有改變的場景,這時候咱們就要從新計算行列數,從新計算容器寬高等,其實也就是從新執行init函數;因此咱們須要監聽list
watch: {
    list: {
      handler: function(newVal, oldVal) {
        this.init();
      },
      immediate: true // 定義的時候就執行一次,因此created的時候就不須要執行init了
    }
  },
複製代碼

handleMousedown()

handleMousedown()的時候直接定義handleMousemove()handleMouseUp()事件,而且在handleMouseUp()中移除;npm

首先是幾個比較重要的變量和方法後端

  • itemListlist的拷貝,並加上後續須要用到的屬性dom(當前卡片的節點信息,經過ref獲取), isMoveing(標記當前卡片是否在移動中), left, top,數組

  • curItem :當前卡片用的比較多,因此這裏單獨拿了出來,而且在移動的時候,單前卡片的過渡效果應該去除,否則移動會卡頓,而且z-index應該在較高的層級

  • targetItem : 即將交換位置的卡片對象,起始爲null

  • mousePosition :鼠標起始位置,移動後的鼠標位置減去起始位置,就是卡片的移動偏移量;

  • handleMousemove() :鼠標移動

  • cardDetect() :卡片移動檢測,是否須要執行位置交換

  • swicthPosition() :交換卡片位置

  • handleMouseUp() :鼠標擡起

handleMousedown(e, optionItem) {
  e.preventDefault();
  let that = this;
  if (this.timer) return false; // timer爲全局的定時器,表示當前有卡片正在移動,直接返回;
  
  // 拷貝一份list,並加上後續要使用的屬性;
  let itemList = that.list.map(item => {
    // 若是ref是動態賦的值,存入$refs中會是一個數組;
    let dom = this.$refs[item.dragCard_id][0];
    let left = parseInt(dom.style.left.slice(0, dom.style.left.length - 2));
    let top = parseInt(dom.style.top.slice(0, dom.style.top.length - 2));
    let isMoveing = false; // 標記正在移動的卡片,正在移動的卡片不參與碰撞檢測
    return {...item, dom, left, top, isMoveing};
  });

  // 當前卡片對象用的比較多,用一個別名curItem把他存起來;
  let curItem = itemList.find(item => item.dragCard_id === optionItem.dragCard_id);
  curItem.dom.style.transition = 'none';
  curItem.dom.style.zIndex = '100';
  curItem.dom.childNodes[0].style.boxShadow = '0 0 5px rgba(0, 0, 0, 0.1)';
  curItem.startLeft = curItem.left; // 起始的left
  curItem.startTop = curItem.top; // 起始的top
  curItem.OffsetLeft = 0; // left的偏移量
  curItem.OffsetTop = 0; // top的偏移量

  // 即將交換位置的對象
  let targetItem = null;

  // 記錄鼠標起始位置
  let mousePosition = {
    startX: e.screenX,
    startY: e.screenY
  };

  document.addEventListener("mousemove", handleMousemove);
  document.addEventListener("mouseup", handleMouseUp);


  // 鼠標移動
  function handleMousemove(e) {}
  // 卡片交換檢測
  function cardDetect() {}
  // 卡片交換 
  function swicthPosition() {}
  // 鼠標擡起
  function handleMouseUp() {}
}
複製代碼

handleMousemove(e)

鼠標當前的座標減去起始的座標,就是當前卡片的偏移量;

移動過程當中就能夠執行卡片交換檢測,爲了提升性能,作了如下節流;200ms執行一次;

// 鼠標移動
  function handleMousemove(e) {
    curItem.OffsetLeft = parseInt(e.screenX - mousePosition.startX);
    curItem.OffsetTop = parseInt(e.screenY - mousePosition.startY);
    // 改變當前卡片對應的style
    curItem.dom.style.left = curItem.startLeft + curItem.OffsetLeft + 'px';
    curItem.dom.style.top = curItem.startTop + curItem.OffsetTop + 'px';
    // 卡片交換檢測,作一下節流
    if (!DectetTimer) {
      DectetTimer = setTimeout(() => {
        cardDetect();
        clearTimeout(DectetTimer);
        DectetTimer = null;
      }, 200)
    }
  }
複製代碼

cardDetect()

一開始想到的是用碰撞檢測去作,循環整個itemList,而後對比當前卡片和每一項的距離;當小於設定的gap的時候,就執行swicthPosition()

後面看了裂泉的原文章後,發現以前的作法性能差太多了;一直在循環數組;

經過當前的位置和偏移量,能夠計算出目標位置targetItemDragCardIndex,判斷一些臨界值以後便執行交換函數;

// 卡片移動檢測
  function cardDetect() {
    // 根據移動的距離計算出移動到哪個位置
    let colNum = Math.round((curItem.OffsetLeft / that.itemWidth));
    let rowNum = Math.round((curItem.OffsetTop / that.itemHeight));
    // 這裏的dragCard_index須要用到最初點擊卡片的位置,由於curItem在後續的卡片交換中dragCard_index已經改變;
    let targetItemDragCardIndex = optionItem.dragCard_index + colNum + (rowNum * that.col);

    // 超出行列,目標位置不變或不存在都直接return;
    if(Math.abs(colNum) >= that.col
      || Math.abs(rowNum) >= that.row
      || Math.abs(colNum) >= that.col
      || Math.abs(rowNum) >= that.row
      || targetItemDragCardIndex === curItem.dragCard_index
      || targetItemDragCardIndex < 0
      || targetItemDragCardIndex > that.list.length - 1) return false;

    let item = itemList.find(item => item.dragCard_index === targetItemDragCardIndex);
    item.isMoveing = true;
    // 將目標卡片拷貝一份,主要是爲了鬆開鼠標的時候賦值給當前卡片;
    targetItem = {...item};
    swicthPosition();
  }
複製代碼

swicthPosition()

卡片交換分爲兩種狀況;

  • 當目標位置比當前移動卡片的原位置大的時候,相隔的卡片和目標卡片都要後移一個位置;
  • 當目標位置比當前移動卡片的原位置小的時候,相隔的卡片和目標卡片都要前移一個位置;

::: tip 注意

  1. 當咱們移動的時候,咱們拿的是前一個或者後一個的值,因此咱們遍歷數組的時候要注意從目標值開始遍歷;
  2. itemList是list的備份,當咱們修改了卡片的dragCard_index以後,須要同步到list中;
  3. 卡片交換動畫爲300ms,這個時間段卡片不該該參與交換檢測,因此設置isMoveing = true,並設置定時器300ms後清除isMoveing
  4. 交換卡片過程當中,當前卡片只須要改變itemList中的屬性,不須要改變list中,等到最後鬆開鼠標的時候才同步到list中 :::
function swicthPosition() {
    const dragCardIndexList = itemList.map(item => item.dragCard_index);
    // 目標卡片位置大於當前卡片位置;
    if (targetItem.dragCard_index > curItem.dragCard_index) {
      for (let i = targetItem.dragCard_index; i >= curItem.dragCard_index + 1; i--) {
        let item = itemList[dragCardIndexList.indexOf(i)];
        let preItem = itemList[dragCardIndexList.indexOf(i - 1)];
        item.isMoveing = true;
        item.left = preItem.left;
        item.top = preItem.top;
        item.dom.style.left = item.left + 'px';
        item.dom.style.top = item.top + 'px';
        item.dragCard_index = that.list[dragCardIndexList.indexOf(i)].dragCard_index -= 1;
        setTimeout(() => {
          item.isMoveing = false;
        }, 300)
      }
    }
    // 目標卡片位置小於當前卡片位置;
    if (targetItem.dragCard_index < curItem.dragCard_index) {
      for (let i = targetItem.dragCard_index; i <= curItem.dragCard_index - 1; i++) {
        let item = itemList[dragCardIndexList.indexOf(i)];
        let nextItem = itemList[dragCardIndexList.indexOf(i + 1)];
        item.isMoveing = true;
        item.left = nextItem.left;
        item.top = nextItem.top;
        item.dom.style.left = item.left + 'px';
        item.dom.style.top = item.top + 'px';
        item.dragCard_index = that.list[dragCardIndexList.indexOf(i)].dragCard_index += 1;
        setTimeout(() => {
          item.isMoveing = false;
        }, 300)
      }
    }
    curItem.left = targetItem.left;
    curItem.top = targetItem.top;
    curItem.dragCard_index =  targetItem.dragCard_index;
    // 派發change事件通知父組件
    that.$emit('change', itemList.map(item => item.dragCard_index));
  }
複製代碼

handleMouseUp()

  • 當鼠標擡起的時候應該判斷是否有目標卡片,若是有的話,就回到目標卡片,沒有的話就回到初始位置;
  • 當前卡片在鼠標點擊的時候去除了過渡效果,當鼠標擡起的時候應該給過渡效果加回去;由於transitioncss中設置了,這裏把style清除便可
function handleMouseUp() {
    //移除全部監聽
    document.removeEventListener("mousemove", handleMousemove);
    document.removeEventListener("mouseup", handleMouseUp);

    // 清除檢測的定時器並作最後一次碰撞檢測
    clearTimeout(DectetTimer);
    DectetTimer = null;
    cardDetect();
    // 把過渡效果加回去
    curItem.dom.style.transition = '';
    // 同步dragCard_index到list中;
    that.list.find(item => item.dragCard_id === optionItem.dragCard_id).dragCard_index = curItem.dragCard_index;
    curItem.dom.style.left = curItem.left + 'px';
    curItem.dom.style.top = curItem.top + 'px';    
    // 派發mouseUp事件通知父組件
    that.$emit('mouseUp', that.list.map(item => item.dragCard_index));
    that.timer = setTimeout(() => {
      curItem.dom.style.zIndex = '';
      curItem.dom.childNodes[0].style.boxShadow = 'none';
      clearTimeout(that.timer);
      that.timer = null;
    }, 300);
  }
複製代碼

寫在後面

到這裏這個組件就完成啦!

最後貼上來自裂泉的原文章連接: 跟我一塊兒,從0實現並封裝拖拽排列組件 ;這仍是一個系列文章,todo中後續還會分享如何把組件上傳到npm;

dranein@163.com

地址:github.com/Dranein/vue…

相關文章
相關標籤/搜索