最近有個需求需要實現自定義首頁佈局,需要將螢幕按照 6 列 4 行進行等分成多個格子,然後將元件可拖拽對應格子進行渲染展示。
示例
對比一些已有的外掛,發現想要實現產品的互動效果,沒有現成可用的。本身功能並不是太過複雜,於是決定自己基於 vue 手擼一個簡易的 Grid 拖拽佈局。
完整原始碼在此,線上體驗
概況
需要實現 Grid 拖拽佈局,主要了解這兩個東西就行
- 拖放 API,關於拖放 API 介紹文章有很多 ,可以直接看 MDN 裡拖放 API介紹,可以說很詳細了。
- Grid 佈局, Grid 佈局與 Flex 佈局很相似,但是 Grid 像是二維佈局,Flex 則為一維佈局,Grid 佈局遠比 Flex 佈局強大。MDN 關於網格佈局介紹
需要實現主要包含:
- 元件物料欄拖拽到佈局容器
- 佈局容器 Grid 佈局
- 放置時是否重疊判斷
- 拖拽時樣式
- 放置後樣式
- 容器內二次拖拽
拖放操作實現
拖拽中主要使用到的事件如下
- 被拖拽元素事件:
事件 | 觸發時刻 |
---|---|
dragstart | 當使用者開始拖拽一個元素或選中的文字時觸發。 |
drag | 當拖拽元素或選中的文字時觸發。 |
dragend | 當拖拽操作結束時觸發 |
- 放置容器事件:
事件 | 觸發時刻 |
---|---|
dragenter | 當拖拽元素或選中的文字到一個可釋放目標時觸發。 |
dragleave | 當拖拽元素或選中的文字離開一個可釋放目標時觸發。 |
dragover | 當元素或選中的文字被拖到一個可釋放目標上時觸發。 |
drop | 當元素或選中的文字在可釋放目標上被釋放時觸發。 |
可拖拽元素
讓一個元素能夠拖拽只需要給元素設定 draggable="true"
即可拖拽,拖拽事件 API 提供了 DataTransfer 物件,可以用於設定拖拽資料資訊,但是僅僅只能 drop
事件中獲取到,但是我們需要在拖拽中就需要獲取到拖拽資訊,用來顯示拖拽時樣式,所以需要我們自己儲存起來,以便讀取。
需要處理主要是,在拖拽時將 將當前元素資訊設定到 dragStore
中,結束時清空當前資訊
<script setup lang="ts">
import { dragStore } from "./drag";
const props = defineProps<{
data: DragItem;
groupName?: string;
}>();
const onDragstart = (e) => dragStore.set(props.groupName, { ...props.data });
const onDragend = () => dragStore.remove(props.groupName);
</script>
<template>
<div class="drag-item__el" draggable="true" @dragstart="onDragstart" @dragend="onDragend"></div>
</template>
封裝一個儲存方法,然後透過配置相同 key ,可以在同時存在多個放置區域時候,區分開來。
class DragStore<T extends DragItemData> {
moveItem = new Map<string, DragItemData>();
set(key: string, data: T) {
this.moveItem.set(key, data);
}
remove(key: string) {
this.moveItem.delete(key);
}
get(key: string): undefined | DragItemData {
return this.moveItem.get(key);
}
}
可放置區域
首先時需要告訴瀏覽器當前區域是可以放置的,只需要在元素監聽 dragenter
、dragleave
、dragover
事件即可,然後透過 preventDefault
來阻止瀏覽器預設行為。可以在這三個事件中處理判斷當前位置是否可以放置等等。
示例:
<script setup lang="ts">
// 進入放置目標
const onDragenter = (e) => {
e.preventDefault();
};
// 在目標中移動
const onDragover = (e) => {
e.preventDefault();
};
// 離開目標
const onDragleave = (e) => {
e.preventDefault();
};
</script>
<template>
<div @dragenter="onDragenter($event)" @dragover="onDragover($event)" @dragleave="onDragleave($event)" @drop="onDrop($event)"></div>
</template>
上面的程式碼已經可以讓,元素可以拖拽,然後當元素拖到可防止區域時候,可以看到滑鼠樣式會變為可放置樣式了。
Grid 佈局
我們是需要進行 Grid 拖拽佈局,所以先對上面放置容器進行改造,首先就是需要將容器進行格子劃分割槽域顯示。
計算 Grid 格子大小
我這裡直接使用了 @vueuse/core 的 useElementSize
的 hooks 去獲取容器元素大小變動,也可以自己透過 ResizeObserver
去監聽元素變動,然後根據設定列數、行數、間隔去計算單個格子大小。
import { useElementSize } from "@vueuse/core";
/**
* 容器等分尺寸
* @param {*} target 容器 HTML
* @param {*} column 列數
* @param {*} row 行數
* @param {*} gap 間隔
* @returns
*/
export const useBoxSize = (target: Ref<HTMLElement | undefined>, column: number, row: number, gap: number) => {
const { width, height } = useElementSize(target);
return computed(() => ({
width: (width.value - (column - 1) * gap) / column,
height: (height.value - (row - 1) * gap) / row,
}));
};
設定 Grid 樣式
根據列數和行數迴圈生成格子數,rowCount
、columnCount
為行數和列數。
<div class="drop-content__drop-container" @dragenter="onDragenter($event)" @dragover="onDragover($event)" @dragleave="onDragleave($event)" @drop="onDrop($event)">
<template v-for="x in rowCount">
<div class="bg-column" v-for="y in columnCount" :key="`${x}-${y}`"></div>
</template>
</div>
設定 Grid 樣式,下面變數中 gap
為格子間隔,repeat
是 Grid 用來重複設定相同值的,grid-template-columns: repeat(2,100px)
等效於 grid-template-columns: 100px 100px
。因為我們只需在容器裡監聽拖拽放置事件,所以我們還需要將
所有的 bg-column
事件去掉,設定 pointer-events: none
即可。
.drop-content__drop-container {
display: grid;
row-gap: v-bind("gap+'px'");
column-gap: v-bind("gap+'px'");
grid-template-columns: repeat(v-bind("columnCount"), v-bind("boxSize.width+'px'"));
grid-template-rows: repeat(v-bind("rowCount"), v-bind("boxSize.height+'px'"));
.bg-column {
background-color: #fff;
border-radius: 6px;
pointer-events: none;
}
}
效果如下:
Grid 容器樣式
放置元素
放置元素時我們需要先計算出元素在 Grid 位置資訊等,這樣才知道元素應該放置那哪個地方。
拖拽位置計算
當元素拖拽進容器中時,我們可以透過 offsetX
、offsetY
兩個資料獲取當前滑鼠距離容器左上角位置距離,我們可以根據這兩個值計算出對應的在 Grid 中做座標。
計算方式:
// 計算 x 座標
const getX = (num) => parseInt(num / (boxSizeWidth + gap));
// 計算 y 座標
const getY = (num) => parseInt(num / (boxSizeHeight + gap));
需要注意的是上面計算座標是 0,0 開始的,而 Grid 是 1,1 開始的。
獲取拖拽資訊
我們在進入容器時,透過上面封裝 dragData
來獲取當前拖拽元素資訊,獲取它尺寸資訊等等。
// 拖拽中的元素
const current = reactive({
show: <boolean>false,
id: <undefined | number>undefined,
column: <number>0, // 寬
row: <number>0, // 高
x: <number>0, // 列
y: <number>0, // 行
});
// 進入放置目標
const onDragenter = (e) => {
e.preventDefault();
const dragData = dragStore.get(props.groupName);
if (dragData) {
current.column = dragData.column;
current.row = dragData.row;
current.x = getX(e.offsetX);
current.y = getY(e.offsetY);
current.show = true;
}
};
// 在目標中移動
const onDragover = (e) => {
e.preventDefault();
const dragData = dragStore.get(props.groupName);
if (dragData) {
current.x = getX(e.offsetX);
current.y = getY(e.offsetY);
}
};
const onDragleave = (e) => {
e.preventDefault();
current.show = false;
current.id = undefined;
};
在 drop 事件中,我們將當前拖拽元素存放起來,list 會存放每一次拖拽進來元素資訊。
const list = ref([]);
// 放置在目標上
const onDrop = async (e) => {
e.preventDefault();
current.show = false;
const item = dragStore.get(props.groupName);
list.value.push({
...item,
x: current.x,
y: current.y,
id: new Date().getTime(),
});
};
計算碰撞
在上面還需要計算當前拖拽的位置是否可以放置,需要處理是否包含在容器內,是否與其他已放置元素存在重疊等等。
計算是否在容器內
這個是比較好計算的,只需要當前拖拽位置左上角座標 >= 容器左上角的座標,然後右下角的座標 <= 容器的右下角的座標,就是在容器內的。
程式碼實現:
/**
* 判斷是否在當前四邊形內
* @param {*} p1 父容器
* @param {*} p2
* 對應是 左上角座標 和 右下角座標
* [0,0,1,1] => 左上角座標 0,0 右下角 1,1
*/
export const booleanWithin = (p1: [number, number, number, number], p2: [number, number, number, number]) => {
return p1[0] <= p2[0] && p1[1] <= p2[1] && p1[2] >= p2[2] && p1[3] >= p2[3];
};
計算是否與現有的相交
兩個矩形相交情況有很多種,計算比較麻煩,但是我們可以計算他們不相交,然後在取反方式判斷是否相交。
不相交情況只有四種,假設有 p1、p2 連個矩形,它們不相交的情況只有四種:
- p1 在 p2 左邊
- p1 在 p2 右邊
- p1 在 p2 上邊
- p1 在 p2 下邊
程式碼實現:
/**
* 判斷是兩四邊形是否相交
* @param {*} p1 父容器
* @param {*} p2
* 對應是 左上角座標 和 右下角座標
* [0,0,1,1] => 左上角座標 0,0 右下角 1,1
*/
export const booleanIntersects = (p1: [number, number, number, number], p2: [number, number, number, number]) => {
return !(p1[2] <= p2[0] || p2[2] <= p1[0] || p1[3] <= p2[1] || p2[3] <= p1[1]);
};
在放置前判斷
可以透過計算屬性去計算,在後面拖拽中處理樣式也可以用到。修改 drop
中方法,然後在 drop
中根據 isPutDown
是否有效。
// 是否可以放置
const isPutDown = computed(() => {
const currentXy = [current.x, current.y, current.x + current.column, current.y + current.row];
return (
booleanWithin([0, 0, columnCount.value, rowCount.value], currentXy) && //
list.value.every((item) => item.id === current.id || !booleanIntersects([item.x, item.y, item.x + item.column, item.y + item.row], currentXy))
);
});
拖拽時樣式
上處理了基本拖放資料處理邏輯,為了更好的互動,我們可以在拖拽中顯示元素預佔位資訊,更加直觀的顯示元素佔位大小,類似這樣:
可放置示例
我們可以根據上面 current
中資訊去計算大小資訊,還可以根據 isPutDown
去判斷當前位置是否可以放置,用來顯示不同互動效果。
不可放置示例
可以直接透過 Grid 的 grid-area 屬性,快速計算出放置位置資訊,應為我們上面計算的 x 、y 是從 0 開始的,所以這裡需要 +1。
grid-area: `${y + 1} / ${x + 1} / ${y + row + 1}/ ${ x + column + 1 }`
預覽容器
在元素放置後,我們還需要根據 list 中資料,生成元素佔位樣式處理,我們可以拖拽容器上層在放置一個容器,專門用來顯示放置後的樣式,也是可以直接使用 Grid 佈局去處理。
預覽樣式
樣式基本上和 drop-container
樣式抱持一致即可,需要注意的時需要為預覽容器設定 pointer-events: none
,避免遮擋了 drop-container
事件監聽。
.drop-content__preview,
.drop-content__drop-container {
// ...
}
每個元素位置資訊計算方式,基本和拖拽時樣式計算方式一致,直接透過 grid-area
去佈局就可以了。
grid-area: `${y + 1} / ${x + 1} / ${y + row + 1}/ ${ x + column + 1 }`
示例
二次拖拽
當元素拖拽進來後,我們還需要對放置的元素支援繼續拖拽。因為上面我們將預覽事件透過 pointer-events
去除了,所以我們需要給每個子元素都加上去。然後給子元素新增 draggable="true"
,然後處理拖拽事件,基本上和上面處理方式一樣,在 dragstart
、dragend
處理拖拽元素資訊。
然後我們還需在 onDrop
進行一番修改,如果是二次拖拽時只需要修改座標資訊,修改原 onDrop
處理方式:
if (item.id) {
item.x = current.x;
item.y = current.y;
} else {
list.value.push({
...item,
x: current.x,
y: current.y,
id: new Date().getTime(),
});
}
位置偏移最佳化
當你對元素二次拖拽時,會發現元素會存在偏移問。比如你放置了一個 1x2 元素後,當你從下面拖拽,你會發現拖拽中的佔位樣式和你拖拽元素位置存在偏差。
效果如下圖
示例
出現這情況應為上面我們時根據滑鼠位置為左上角進行計算的,所以會存在這種偏差問題,我們可在拖拽前計算出偏移量來校正位置。
我們可以在二次拖拽時,獲取到滑鼠在當前元素內位置資訊
const onDragstart = (e) => {
const data = props.data;
data.offsetX = e.offsetX;
data.offsetY = e.offsetY;
dragStore.set(props.groupName, data);
};
在 drop-container
內計算 x、y 值時候減去偏移量,對 onDragenter
、onDragover
進行如下調整修改
current.x = getX(e.offsetX) - getX(dragData?.offsetX ?? 0);
current.y = getY(e.offsetY) - getY(dragData?.offsetY ?? 0);
拖拽元素最佳化
因為上面我們將預覽元素新增了 pointer-events: all
,所以在我們拖拽到現有元素上時,會擋住 drop-container
事件的觸發,在二次拖拽時,比如將一個 2x2 元素我們需要往下移動一格時,會發現也會被自己擋住。
- 預覽元素遮擋問題,可以在拖拽時將其他元素都設定為
none
,二次拖拽時要做自己設定為all
否則會無法拖拽
:style="{ pointerEvents: current.show && item.id !== current.id ? 'none' : 'all' }"`
- 二次拖拽時自己位置遮擋問題
我們可以在拖拽時增加標識,將自己透過transform
移除到多拽容器外去
moveing.value
? {
opacity: 0,
transform: `translate(-999999999px, -9999999999px)`,
}
: {};
結語
到目前為止基本上的 Grid 拖拽佈局大致實現了,已經滿足基本業務需求了,當然有需要朋友還可以在上面增加支援拖拉調整大小、碰撞後自動調整位置等等。
完整原始碼在此,線上體驗