经过上一篇 精读《磁贴布局 - 功能分析》 的分析,这次我们进入实现环节。
实现磁贴布局前,先要实现最基础的组件拖拽流程,然后我们才好在拖拽的基础上增加磁贴效果。
对布局抽象来说,它关心的就是 可拖拽的组件 与 容器 的 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 个组件,就会至少循环一万次,对性能压力是比较大的。因此磁贴布局也要做性能优化,这个我们放到下篇文章介绍。
本文由哈喽比特于1年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/5Igv6buEVPZ_sUMVzf7FdQ
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。