最近有個需求需要實現(xiàn)自定義首頁布局,需要將屏幕按照 6 列 4 行進行等分成多個格子,然后將組件可拖拽對應(yīng)格子進行渲染展示。
示例
對比一些已有的插件,發(fā)現(xiàn)想要實現(xiàn)產(chǎn)品的交互效果,沒有現(xiàn)成可用的。本身功能并不是太過復(fù)雜,于是決定自己基于 vue 手?jǐn)]一個簡易的 Grid 拖拽布局。
完整源碼在此,在線體驗
需要實現(xiàn) Grid 拖拽布局,主要了解這兩個東西就行
需要實現(xiàn)主要包含:
組件物料欄拖拽到布局容器
布局容器 Grid 布局
放置時是否重疊判斷
拖拽時樣式
放置后樣式
容器內(nèi)二次拖拽
拖放操作實現(xiàn)# 拖拽中主要使用到的事件如下
被拖拽元素事件:
事件 觸發(fā)時刻 dragstart 當(dāng)用戶開始拖拽一個元素或選中的文本時觸發(fā)。 drag 當(dāng)拖拽元素或選中的文本時觸發(fā)。 dragend 當(dāng)拖拽操作結(jié)束時觸發(fā)
放置容器事件:
事件 觸發(fā)時刻 ?dragenter 當(dāng)拖拽元素或選中的文本到一個可釋放目標(biāo)時觸發(fā)。 dragleave 當(dāng)拖拽元素或選中的文本離開一個可釋放目標(biāo)時觸發(fā)。 dragover 當(dāng)元素或選中的文本被拖到一個可釋放目標(biāo)上時觸發(fā)。 drop 當(dāng)元素或選中的文本在可釋放目標(biāo)上被釋放時觸發(fā)。
可拖拽元素# 讓一個元素能夠拖拽只需要給元素設(shè)置 draggable="true"
即可拖拽,拖拽事件 API 提供了 DataTransfer 對象,可以用于設(shè)置拖拽數(shù)據(jù)信息,但是僅僅只能 drop
事件中獲取到,但是我們需要在拖拽中就需要獲取到拖拽信息,用來顯示拖拽時樣式,所以需要我們自己存儲起來,以便讀取。
需要處理主要是,在拖拽時將 將當(dāng)前元素信息設(shè)置到 dragStore
中,結(jié)束時清空當(dāng)前信息
< script setup>
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 draggable= "true" @dragstart= "onDragstart" @dragend= "onDragend" > < / div>
< / template>
封裝一個存儲方法,然后通過配置相同 key ,可以在同時存在多個放置區(qū)域時候,區(qū)分開來。
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) ;
}
}
可放置區(qū)域# 首先時需要告訴瀏覽器當(dāng)前區(qū)域是可以放置的,只需要在元素監(jiān)聽 dragenter
、dragleave
、dragover
事件即可,然后通過 preventDefault
來阻止瀏覽器默認行為。可以在這三個事件中處理判斷當(dāng)前位置是否可以放置等等。
示例:
< script setup>
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>
上面的代碼已經(jīng)可以讓,元素可以拖拽,然后當(dāng)元素拖到可防止區(qū)域時候,可以看到鼠標(biāo)樣式會變?yōu)榭煞胖脴邮搅恕?/p>
Grid 布局# 我們是需要進行 Grid 拖拽布局,所以先對上面放置容器進行改造,首先就是需要將容器進行格子劃分區(qū)域顯示。
計算 Grid 格子大小# 我這里直接使用了 @vueuse/core 的 useElementSize
的 hooks 去獲取容器元素大小變動,也可以自己通過 ResizeObserver
去監(jiān)聽元素變動,然后根據(jù)設(shè)置列數(shù)、行數(shù)、間隔去計算單個格子大小。
import { useElementSize } from "@vueuse/core" ;
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,
} ) ) ;
} ;
設(shè)置 Grid 樣式# 根據(jù)列數(shù)和行數(shù)循環(huán)生成格子數(shù),rowCount
、columnCount
為行數(shù)和列數(shù)。
< 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>
設(shè)置 Grid 樣式,下面變量中 gap
為格子間隔,repeat
是 Grid 用來重復(fù)設(shè)置相同值的,grid-template-columns: repeat(2,100px)
等效于 grid-template-columns: 100px 100px
。因為我們只需在容器里監(jiān)聽拖拽放置事件,所以我們還需要將 所有的 bg-column
事件去掉,設(shè)置 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 位置信息等,這樣才知道元素應(yīng)該放置那哪個地方。
拖拽位置計算# 當(dāng)元素拖拽進容器中時,我們可以通過 offsetX
、offsetY
兩個數(shù)據(jù)獲取當(dāng)前鼠標(biāo)距離容器左上角位置距離,我們可以根據(jù)這兩個值計算出對應(yīng)的在 Grid 中做坐標(biāo)。
計算方式:
const getX = ( num ) => parseInt ( num / ( boxSizeWidth + gap) ) ;
const getY = ( num ) => parseInt ( num / ( boxSizeHeight + gap) ) ;
需要注意的是上面計算坐標(biāo)是 0,0 開始的,而 Grid 是 1,1 開始的。
獲取拖拽信息# 我們在進入容器時,通過上面封裝 dragData
來獲取當(dāng)前拖拽元素信息,獲取它尺寸信息等等。
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 事件中,我們將當(dāng)前拖拽元素存放起來,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 ( ) ,
} ) ;
} ;
計算碰撞# 在上面還需要計算當(dāng)前拖拽的位置是否可以放置,需要處理是否包含在容器內(nèi),是否與其他已放置元素存在重疊等等。
計算是否在容器內(nèi)# 這個是比較好計算的,只需要當(dāng)前拖拽位置左上角坐標(biāo) >= 容器左上角的坐標(biāo),然后右下角的坐標(biāo) <= 容器的右下角的坐標(biāo),就是在容器內(nèi)的。
代碼實現(xiàn):
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 ] ;
} ;
計算是否與現(xiàn)有的相交# 兩個矩形相交情況有很多種,計算比較麻煩,但是我們可以計算他們不相交,然后在取反方式判斷是否相交。
不相交情況只有四種,假設(shè)有 p1、p2 連個矩形,它們不相交的情況只有四種:
p1 在 p2 左邊
p1 在 p2 右邊
p1 在 p2 上邊
p1 在 p2 下邊
代碼實現(xiàn):
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 ] ) ;
} ;
在放置前判斷#
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) )
) ;
} ) ;
拖拽時樣式# 上處理了基本拖放數(shù)據(jù)處理邏輯,為了更好的交互,我們可以在拖拽中顯示元素預(yù)占位信息,更加直觀的顯示元素占位大小,類似這樣:
可放置示例
我們可以根據(jù)上面 current
中信息去計算大小信息,還可以根據(jù) isPutDown
去判斷當(dāng)前位置是否可以放置,用來顯示不同交互效果。
不可放置示例
可以直接通過 Grid 的 grid-area 屬性,快速計算出放置位置信息,應(yīng)為我們上面計算的 x 、y 是從 0 開始的,所以這里需要 +1。
grid- area: ` ${ y + 1 } / ${ x + 1 } / ${ y + row + 1 } / ${ x + column + 1 } `
預(yù)覽容器# 在元素放置后,我們還需要根據(jù) list 中數(shù)據(jù),生成元素占位樣式處理,我們可以拖拽容器上層在放置一個容器,專門用來顯示放置后的樣式,也是可以直接使用 Grid 布局去處理。
預(yù)覽樣式# 樣式基本上和 drop-container
樣式抱持一致即可,需要注意的時需要為預(yù)覽容器設(shè)置 pointer-events: none
,避免遮擋了 drop-container
事件監(jiān)聽。
.drop-content__preview,
.drop-content__drop-container {
// ...
}
每個元素位置信息計算方式,基本和拖拽時樣式計算方式一致,直接通過 grid-area
去布局就可以了。
grid-area: `${y + 1} / ${x + 1} / ${y + row + 1}/ ${ x + column + 1 }`
示例
二次拖拽# 當(dāng)元素拖拽進來后,我們還需要對放置的元素支持繼續(xù)拖拽。因為上面我們將預(yù)覽事件通過 pointer-events
去除了,所以我們需要給每個子元素都加上去。然后給子元素添加 draggable="true"
,然后處理拖拽事件,基本上和上面處理方式一樣,在 dragstart
、dragend
處理拖拽元素信息。
然后我們還需在 onDrop
進行一番修改,如果是二次拖拽時只需要修改坐標(biāo)信息,修改原 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 ( ) ,
} ) ;
}
位置偏移優(yōu)化# 當(dāng)你對元素二次拖拽時,會發(fā)現(xiàn)元素會存在偏移問。比如你放置了一個 1x2 元素后,當(dāng)你從下面拖拽,你會發(fā)現(xiàn)拖拽中的占位樣式和你拖拽元素位置存在偏差。
效果如下圖
示例
出現(xiàn)這情況應(yīng)為上面我們時根據(jù)鼠標(biāo)位置為左上角進行計算的,所以會存在這種偏差問題,我們可在拖拽前計算出偏移量來校正位置。
我們可以在二次拖拽時,獲取到鼠標(biāo)在當(dāng)前元素內(nèi)位置信息、
const onDragstart = ( e ) => {
const data = props. data;
data. offsetX = e. offsetX;
data. offsetY = e. offsetY;
dragStore. set ( props. groupName, data) ;
} ;
在 drop-container
內(nèi)計算 x、y 值時候減去偏移量,對 onDragenter
、onDragover
進行如下調(diào)整修改
current. x = getX ( e. offsetX) - getX ( dragData?. offsetX ?? 0 ) ;
current. y = getY ( e. offsetY) - getY ( dragData?. offsetY ?? 0 ) ;
拖拽元素優(yōu)化# 因為上面我們將預(yù)覽元素添加了 pointer-events: all
,所以在我們拖拽到現(xiàn)有元素上時,會擋住 drop-container
事件的觸發(fā),在二次拖拽時,比如將一個 2x2 元素我們需要往下移動一格時,會發(fā)現(xiàn)也會被自己擋住。
預(yù)覽元素遮擋問題,可以在拖拽時將其他元素都設(shè)置為 none
,二次拖拽時要做自己設(shè)置為 all
否則會無法拖拽
:style="{ pointerEvents: current.show && item.id !== current.id ? 'none' : 'all' }"`
二次拖拽時自己位置遮擋問題 我們可以在拖拽時增加標(biāo)識,將自己通過 transform
移除到多拽容器外去
moveing. value
? {
opacity: 0 ,
transform: ` translate(-999999999px, -9999999999px) ` ,
}
: { } ;
結(jié)語# 到目前為止基本上的 Grid 拖拽布局大致實現(xiàn)了,已經(jīng)滿足基本業(yè)務(wù)需求了,當(dāng)然有需要朋友還可以在上面增加支持拖拉調(diào)整大小、碰撞后自動調(diào)整位置等等。
完整源碼在此,在線體驗
轉(zhuǎn)自https://www.cnblogs.com/nextl/p/17871913.html
該文章在 2024/8/15 16:02:53 編輯過