拖拽排列卡片組件

NO IMAGE

拖拽排列卡片組件

前言

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

在過程中發現了蠻多的細節問題,完成後對比了原作者的代碼,發現許多可以優化的地方,在這裡記錄一下;

以下是個人學習實現的demo和源碼地址:

使用

在倉庫中拿到dragCard.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和方法

通過組件屬性和方法可以快速瞭解整個組件的使用;

屬性

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

方法

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

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

插槽slot

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

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

具體實現

大概思路

  • 頁面卡片採用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()中移除;

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

  • 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;

[email protected]

地址:github.com/Dranein/vue…

相關文章

記錄Computed源碼分析

手把手帶你入門AST抽象語法樹

淺析Node進程與線程

Github標星19K+Star,10分鐘自建對象存儲服務!