精讀《磁貼布局 - 功能實現》

黃子毅發表於2022-12-14

經過上一篇 精讀《磁貼布局 - 功能分析》 的分析,這次我們進入實現環節。

精讀

實現磁貼布局前,先要實現最基礎的元件拖拽流程,然後我們才好在拖拽的基礎上增加磁貼效果。

基礎拖拽能力

對佈局抽象來說,它關心的就是 可拖拽的元件容器 的 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 例項,以及相對於父容器的 xywidthheight
  • containerMap 表示容器元件資訊,之所以儲存 rectXrectY 這兩個相對瀏覽器絕對定位,是因為容器的直接父元件可能是 element,比如 Card 元件可以同時渲染 HeaderFooter,這兩個位置都可以拖入 element,所以這兩個位置都是 container,它們是相對父 element Card 定位的,所以儲存絕對定位方便計算。

接下來給 elementMap 的每一個元件繫結滑鼠按下事件作為 onDragStart 時機:

Object.keys(elementMap).forEach((componentId) => {
  elementMap[componentId].dom.onmousedown = () => {
    // 記錄拖拽開始
  };
});

然後在 document 監聽 onMouseMoveonMouseUp,分別作為 onDragonDragEnd 時機,這樣我們就抽象了拖拽的前、中、後三個階段:

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 階段。在之前的邏輯中,拖拽是完全自由的,那麼磁貼布局就會約束兩點:

  1. 對當前拖拽元件位置做約束。
  2. 可能把其他元件擠走。

對拖拽元件位置的約束是由背後的 “鬆手 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 許可證

相關文章