forked from ascoders/weekly
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
2 changed files
with
245 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,243 @@ | ||
经过上一篇 [精读《磁贴布局 - 功能分析》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/265.%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%A3%81%E8%B4%B4%E5%B8%83%E5%B1%80%20-%20%E5%8A%9F%E8%83%BD%E5%88%86%E6%9E%90%E3%80%8B.md) 的分析,这次我们进入实现环节。 | ||
|
||
## 精读 | ||
|
||
实现磁贴布局前,先要实现最基础的组件拖拽流程,然后我们才好在拖拽的基础上增加磁贴效果。 | ||
|
||
### 基础拖拽能力 | ||
|
||
对布局抽象来说,它关心的就是 **可拖拽的组件** 与 **容器** 的 DOM,至于这些 DOM 是如何创建的都可以不用关心,在这个基础上,甚至可以再做一套搭建或者布局框架层,专门实现对 DOM 的管理,但这篇文章还是聚焦在布局的实现层。 | ||
|
||
布局组件首先要收集到有哪些可拖拽组件与容器,假设业务层将这些 DOM 生成好传给了布局: | ||
|
||
```ts | ||
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` 时机: | ||
|
||
```js | ||
Object.keys(elementMap).forEach((componentId) => { | ||
elementMap[componentId].dom.onmousedown = () => { | ||
// 记录拖拽开始 | ||
}; | ||
}); | ||
``` | ||
|
||
然后在 document 监听 `onMouseMove` 与 `onMouseUp`,分别作为 `onDrag` 与 `onDragEnd` 时机,这样我们就抽象了拖拽的前、中、后三个阶段: | ||
|
||
```ts | ||
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` 函数里做如下抽象: | ||
|
||
```ts | ||
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` 函数,把所有组件按照重力作用排列。 | ||
|
||
```ts | ||
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` 比较复杂,这里展开来讲。首先需要写一个矩形相交函数判断两个组件是否产生了碰撞: | ||
|
||
```js | ||
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`: | ||
|
||
```js | ||
function gridCollision(context, x, y) { | ||
// 在某个父容器内计算重力,同时塞入一个 extraBox,返回这个 extraBox 生效重力后的 Y:extraBoxY | ||
const { extraBoxY } = runGravity(context, parentId, extraBox); | ||
|
||
return { safeY: extraBoxY }; | ||
} | ||
``` | ||
|
||
没有产生碰撞的逻辑相对简单,如果产生了碰撞的逻辑是这样的: | ||
|
||
```js | ||
// 是否为初始化碰撞。初始化碰撞优先级最低,所以只要发生过非初始碰撞,与其他组件的初始碰撞也视为非初始碰撞 | ||
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 下方。 | ||
|
||
如果是非初始化碰撞逻辑会复杂一些,比如下面的例子: | ||
|
||
```js | ||
// [---] [ C ] | ||
// [ B ] | ||
// [---] | ||
// ↑ | ||
// [-------] | ||
// [ A ] | ||
// [-------] | ||
``` | ||
|
||
当 A 组件向上移动时,因为已经与 B 产生了碰撞,所以就会尝试判断合适置于 B 之上,否则永远会把自己锁在 B 的下方。实际上,我们希望 A 的上边缘超过 B 的水平中心点就产生交换,此时 A 的水平中心点还在 B 的水平中心点之下,所以此时按照两种不同的判断规则会产生不同的位置判定,区分的手段就是 A 与 B 是否已经处于相交状态。 | ||
|
||
现在终于把插入位置算好了(根据是否初始化碰撞,判断 extraBox 落在哪个 `element` 的上方或者下方),那么就进入 `runGravity` 函数: | ||
|
||
```js | ||
function runGravity(context, parentId, extraBox) {} | ||
``` | ||
|
||
这个函数针对某个父容器节点生效重力,因此在不考虑 `extraBox` 的情况下逻辑是这样的: | ||
|
||
先拿到该容器下所有子 `element`,对这些 `element` 按照 y 从小到大排序,然后依次计算落点,已经计算过的组件会计算到碰撞影响范围内,也就是新的组件 y 要尽可能小,但如果水平方向与已经算过的组件存在重叠,那么只能顶到这些组件的下方。 | ||
|
||
如果有 `extraBox` 的话,问题就复杂了一些,看下面的图: | ||
|
||
```ts | ||
// [---] [ 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 顺序普通插入即可。原因看下图: | ||
|
||
```js | ||
// [-------] [-] | ||
// [ ] [ ] | ||
// [ ] [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](https://github.com/dt-fe/weekly/issues/459) | ||
**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** | ||
|
||
> 关注 **前端精读微信公众号** | ||
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg"> | ||
|
||
> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) |