經過上一篇 精讀《磁貼布局 - 功能分析》 的分析,這次我們進入實現環節。
精讀
實現磁貼布局前,先要實現最基礎的元件拖拽流程,然後我們才好在拖拽的基礎上增加磁貼效果。
基礎拖拽能力
對佈局抽象來說,它關心的就是 可拖拽的元件 與 容器 的 DOM,至於這些 DOM 是如何建立的都可以不用關心,在這個基礎上,甚至可以再做一套搭建或者佈局框架層,專門實現對 DOM 的管理,但這篇文章還是聚焦在佈局的實現層。
佈局元件首先要收集到有哪些可拖拽元件與容器,假設業務層將這些 DOM 生成好傳給了佈局:
const elementMap: Record<
string,
{
dom: HTMLElement;
x: number;
y: number;
width: number;
height: number;
}
> = {};
const containerMap: Record<
string,
{
dom: HTMLElement;
rectX: number;
rectY: number;
width: number;
height: number;
}
> = {};
elementMap
表示可拖拽的元件資訊,包括其 DOM 例項,以及相對於父容器的x
、y
、width
、height
。containerMap
表示容器元件資訊,之所以儲存rectX
與rectY
這兩個相對瀏覽器絕對定位,是因為容器的直接父元件可能是element
,比如Card
元件可以同時渲染Header
與Footer
,這兩個位置都可以拖入element
,所以這兩個位置都是container
,它們是相對父element
Card
定位的,所以儲存絕對定位方便計算。
接下來給 elementMap
的每一個元件繫結滑鼠按下事件作為 onDragStart
時機:
Object.keys(elementMap).forEach((componentId) => {
elementMap[componentId].dom.onmousedown = () => {
// 記錄拖拽開始
};
});
然後在 document 監聽 onMouseMove
與 onMouseUp
,分別作為 onDrag
與 onDragEnd
時機,這樣我們就抽象了拖拽的前、中、後三個階段:
function onDragStart(context, componentId) {
context.dragComponent = componentId;
}
function onDrag(context, event) {
// 根據 context.dragComponent 響應元件的拖動
// 將 element x、y 改為 event.clientX、event.clientY 即可
}
function onDragEnd(context) {
context.dragComponent = undefined;
}
這樣最基礎的拖拽能力就做好了,在實際程式碼中,可能包含進一步的抽象這裡為了簡化先忽略,比如可能對所有事件的監聽進行 Action 化,以便單測在任何時候模擬使用者輸入。
磁貼布局影響因子
磁貼布局入場後,僅影響 onDrag
階段。在之前的邏輯中,拖拽是完全自由的,那麼磁貼布局就會約束兩點:
- 對當前拖拽元件位置做約束。
- 可能把其他元件擠走。
對拖拽元件位置的約束是由背後的 “鬆手 DOM” 決定的,也就是拖拽時 element 是實時跟手的,但如果拖拽位置無法放置,就會在鬆手時修改落地位置,這個落地位置我們叫做 safePosition
,即當前元件的安全位置。
所以 onDrag
就要計算一個新的 safePosition
,它應該如何計算,由磁貼的碰撞方式決定,我們可以在 onDrag
函式里做如下抽象:
function onDrag(context, event) {
// 根據 context.dragComponent 響應元件的拖動
const { safeX, safeY } = collision(context, event.clientX, event.clientY);
// 實時的把元件位置改為 event.clientX、event.clientY
// 把背後實際落點 DOM 位置改為 safeX、safeY
// onDragEnd 時,再把元件位置改為 safeX、safeY,讓元件落在安全的位置上
}
接下來就到了重點函式 collision
的實現部分,它需要囊括磁貼布局的所有核心邏輯。
collision
函式包括兩大模組,分別是拖入拖出模組與碰撞模組。拖入拖出判斷當前拖拽位置是否進入了一個新容器,或者離開了當前容器;碰撞模組判斷當前拖拽位置是否與其他 element
產生了碰撞,並做出相應的碰撞效果。
除此之外,磁貼布局還允許元件按照重力影響向上吸附,因此我們需要做一個 runGravity
函式,把所有元件按照重力作用排列。
function collision(context, x, y) {
// 先做拖入拖出判斷
if (judgeDragInOrOut(context, event)) {
// 如果判定為拖入或拖出,則不會產生碰撞,提前 return
// 但是拖出時需要對原來的父節點做一次 runGravity
// 拖入時不用對原來父節點做 runGravity
return { safeX: x, safeY: y };
}
// 碰撞模組
return gridCollsion(context, x, y);
}
為什麼拖入時不用對原來父節點做 runGravity: 假設一個 element
從上向下移動入一個 container
,那麼一旦拖入 container
就會在其上方產生 Empty 區域,如果此時 container
立即受重力作用擠了上去,但滑鼠還沒鬆手,可能滑鼠位置又立即落在了 container
之外,導致元件觸發了拖出。因此拖入時,先不要立刻對原先所在的父容器作用重力,這樣可以維持拖入時結構的穩定。
拖入拖出模組
拖入拖出判斷很簡單,即一個 element
如果有 x% 進入了 container
就判定為拖入,有 y% 離開了 container
就判定為離開。
碰撞模組
碰撞模組 gridCollsion
比較複雜,這裡展開來講。首先需要寫一個矩形相交函式判斷兩個元件是否產生了碰撞:
function gridCollsion(context, x, y) {
Object.keys(context.elementMap).forEach((componentId) => {
// 判斷 context.dragComponent 與 context.elementMap[componentId] 是否相交,相交則認為產生了碰撞
});
}
如果沒有產生碰撞,那我們要根據重力影響計算落點 safeY
(橫向不受重力作用且一定跟手,所以不用算 safeX
)。此時直接呼叫 runGravity
函式,傳一個 extraBox
,這個 extraBox
就是當前滑鼠位置產生的 box,這個 box 因為沒有與任何元件產生碰撞,直接判斷一下在重力的作用下,該 extraBox
會落在哪個位置即可,這個位置就是 safeY
:
function gridCollision(context, x, y) {
// 在某個父容器內計算重力,同時塞入一個 extraBox,返回這個 extraBox 生效重力後的 Y:extraBoxY
const { extraBoxY } = runGravity(context, parentId, extraBox);
return { safeY: extraBoxY };
}
沒有產生碰撞的邏輯相對簡單,如果產生了碰撞的邏輯是這樣的:
// 是否為初始化碰撞。初始化碰撞優先順序最低,所以只要發生過非初始碰撞,與其他元件的初始碰撞也視為非初始碰撞
let isInitCollision = true;
Object.keys(context.elementMap).forEach((componentId) => {
// 判斷 context.dragComponent 與 context.elementMap[componentId] 是否相交
const intersect = areRectanglesOverlap();
// 相交
if (intersect.isIntersect) {
// 1. 在 context 儲存一個全域性變數,判斷當前元件之前是否相交過,以此來判斷是否要修改 isInitCollision
// 2. 判斷產生碰撞後,該碰撞會導致滑鼠位置的 box,也就是 extraBox 放到該元件之上還是之下
}
});
首先要確定當前碰撞是否為初始化碰撞,且一旦有一個元件不是初始化碰撞,就認為沒有發生初始化碰撞。原因是初始化碰撞的位置判斷比較簡單,直接根據 source 與 target element
的水平中心點的高低來判斷落地位置。如果 source 水平中心點位置比 target 的高,則放到 target 上方,否則放在 target 下方。
如果是非初始化碰撞邏輯會複雜一些,比如下面的例子:
// [---] [ C ]
// [ B ]
// [---]
// ↑
// [-------]
// [ A ]
// [-------]
當 A 元件向上移動時,因為已經與 B 產生了碰撞,所以就會嘗試判斷合適置於 B 之上,否則永遠會把自己鎖在 B 的下方。實際上,我們希望 A 的上邊緣超過 B 的水平中心點就產生交換,此時 A 的水平中心點還在 B 的水平中心點之下,所以此時按照兩種不同的判斷規則會產生不同的位置判定,區分的手段就是 A 與 B 是否已經處於相交狀態。
現在終於把插入位置算好了(根據是否初始化碰撞,判斷 extraBox 落在哪個 element
的上方或者下方),那麼就進入 runGravity
函式:
function runGravity(context, parentId, extraBox) {}
這個函式針對某個父容器節點生效重力,因此在不考慮 extraBox
的情況下邏輯是這樣的:
先拿到該容器下所有子 element
,對這些 element
按照 y 從小到大排序,然後依次計算落點,已經計算過的元件會計算到碰撞影響範圍內,也就是新的元件 y 要儘可能小,但如果水平方向與已經算過的元件存在重疊,那麼只能頂到這些元件的下方。
如果有 extraBox
的話,問題就複雜了一些,看下面的圖:
// [---] [ C ]
// [ B ]
// [---]
// ↑
// [-------]
// [ A ]
// [-------]
// A 這個 extraBox before B
// 這個例子應該按照 C -> A -> B 的順序計算重力
// 規則:如果有 before ids(ids y,bottom 都一樣),則把排序結果中 y >= ids.y & bottom < ids[0].bottom 的元件抽出來放到 ids 第一個元件之前
// [-------]
// [ A ]
// [-------]
// ↓
// [---] [ C ]
// [ B ]
// [---]
// A 這個 extraBox after B
// 這個例子應該按照 C -> A -> B 的順序計算重力
// 規則:如果有 after ids(ids y,bottom 都一樣),則把排序結果中 y <= ids.y & bottom > ids[0].bottom 的元件抽出來放到 ids 最後一個元件之後
因為 extraBox
是一個插入性質的位置,所以計算方式肯定有所不同。以第一個例子為例:當 A 向上移動並可以與 B 產生交換時,最後希望的結果自上至下是 C -> A -> B,但因為 C 和 B 的 y 都是 0,如果我們把 A 與 B 交換理解為 A 的 y 變成 0 從而把 B 擠下去,那麼 A 也會把 C 擠下去,導致結果不對。
因此重要的是計算重力的優先順序,上面的例子重力計算順序應該是先算 C,再算 A,再算 B,這個邏輯的判斷依據如上面註釋所說。
上面說的都是 isInitCollision=false
的演算法,如果 isInitCollision=true
,則 extraBox
按照 y 順序普通插入即可。原因看下圖:
// [-------] [-]
// [ ] [ ]
// [ ] [D]
// [ A ] → [ ]
// [ ] [-]
// [ ] [-----------------]
// [-------] [ ]
// [-----] [ C ]
// [ B ] [ ]
// [-----] [-----------------]
當將 A 向右移動直到與 C 碰撞時,按照 y 來計算重力優先順序時結果是正確的。如果按照 extraBox 已產生過碰撞的演算法,則會認為 A 放到 C 的上方,但因為 B 相對於 C 滿足 y >= ids.y & bottom < ids[0].bottom
,所以會被提取到 C 的前面計算,導致 B 放在了 A 前面,產生了錯誤結果。因為這種碰撞被誤判為 “A 從 C 的下方向上移動,直到與 C 交換,此時 B 依然要置於 A 的上方”,但實際上並沒有產生這樣的移動,而是 A 與 C 的一次初始化碰撞,因此不能適用這個演算法。
總結
因為篇幅有限,本文僅介紹磁貼布局實現最關鍵的部分,其他比如步長功能,如果後續有機會再單獨整理成一篇文章發出來。
從上面的討論可以發現,在每次移動時都要重新計算 safe 位置的落點,而這個落點又依賴 runGravity
函式,如果每次都要把容器下所有元件排序,並一一計算落點位置的話,時間複雜度達到了 O(n²),如果畫布有 100 個元件,就會至少迴圈一萬次,對效能壓力是比較大的。因此磁貼布局也要做效能最佳化,這個我們放到下篇文章介紹。
討論地址是:精讀《磁貼布局 - 功能實現》· Issue #459 · dt-fe/weekly
如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公眾號
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)