前言: 最近实现了一个需求:产品小姐姐要求我们的主页的应用可以拖拽到某个区域进行删除操作。
因为本身自己对移动端拖拽就十分感兴趣,所以也尝试着先不使用其他库,先尝试自己来解决这个需求,在实现过程中也对网页布局有了更深的理解。
今天想分享一下自己在实现这个需求时的一些思路,或许能帮你重新理解拖拽的原理🎁。
一. 效果预览
二. 实现元素拖拽的思路
- 一说到拖拽,大家可能马上想到的是
H5
原生提供的draggable
属性及其相关api
。是的,刚开始我也是打算用这个api
去实现,但是很遗憾,这个api
在移动端有很大的兼容性问题,但我们的产品是主攻移动端设备的,遂果断放弃。 - 后来自己经过查阅其他技术文章,最后选用
touch
事件来作为移动端的实现基础。 - 要想实现拖拽排序,我们就得知道如何让一个元素在页面上跟着我们的手指 “动起来” 。不用找别的东西,请拿出你的手机,然后简单对你已安装的应用做一个排序。然后思考🤔:什么是造成元素移动的原因?很简单,因为我们的手拖动了它,那么拖动的结果是什么?显而易见:这个元素相对于起始位置产生了位移。
- 讲的再直接一些,本质上是因为我们的手指位置发生了位移,因为
app
不可能无缘无故自己排序,所以实现拖拽需求的基础就是如何利用好你手指的偏移量来对元素做特殊处理。 - 让我们快速准备一个普通的
div
用来接下来的实验。 - 然后就是给这个
div
绑定touchstart
、touchmove
和touchend
这三个事件,接下来我们要做的就是如何正确处理这三个事件函数。 - 根据上面提到的信息,我们首先需要在
touchStart
中记录手指最开始位移前的坐标信息,这个坐标信息可以在事件对象的touches
属性中找到,为什么是一个数组呢?因为你有可能同一时间用多个手指操作,也就是同一时间你屏幕上的手指有可能是多个,所以该属性是一个数组字段。 但是拖拽删除这个需求场景下,我们只需要考虑一个手指的情况即可。我们定义一个变量
initInfo
来存储手指的初始位置信息,也就是e.touches[0]
的相关数据。如果你不明白这里相关字段的含义,我之前也有写相关的解读,很建议你读一下: ☕️图解鼠标事件的 ScreenX ,LayerX,clientX,PageX,offsetX,X
- 那么我们在
touchmove
的时候取移动中的坐标值,然后减去之前拿到的初始值,不就能拿到最关键的信息手指的偏移量了吗?于是这里我们定一个diff
变量来保存偏移量。 - ok,偏移量我们已经拿到了,让元素位移还不简单?我们直接使用
transform
的translate
属性不就可以了吗? - 目前的效果:(注意:
touch
事件需要在开启手机模拟环境下进行操作) - 相应的,如果移动过程中用户松手,此时我们的元素仍没有产生任何交互行为,那么根据产品需求,元素需要回到原来的位置。很简单,在
touchend
事件中,我们让元素的translate
属性恢复到 0 的位置即可。
对应的效果如下:
三. 判断两个元素是否相交
- 产品小姐姐在项目上的需求是:拖拽的时候,产生一片区域,当应用被 “拖进” 这个区域的时候,就需要提示用户是否要删除该应用。在这里我们就随手创建一个简单的区域来模拟这个场景。
- 那么我们的现在的主要目的就是如何通过代码判断出下面这种情况。
- 理清思路很重要,注意,我们准备在干什么?我们在把一个 “元素拖进另外一个元素中。”
- 让我们把上面的场景给抽象化理解:我们是不是在制造两个矩形相交的场景?
- 但两个矩形产生相交的场景很多,下面仅仅只是几个简单相交的场景,就包括了这么多判断因素,可见我们要考虑的也需要很多。我们想一想是否能进一步简化一下相交的这个场景呢?
- 这里其实可以用逆向思维来判断这个场景,我们只需要考虑两个矩形不相交的情况,然后对这个条件取反不就能达到同样的目的吗?如下图所示(B 相对于 A):我不管 B 具体位于 A 的哪个具体方向,到底是左边偏上还是右边偏下这些情况我都不考虑,我关心的是只要 B 同时满足下面这四个条件,那么 B 和 A 就绝对不可能相交。
四. 判断两个元素不相交
- 首先是第一种场景 B 在 A 的左边。这里我们至少需要知道两个坐标信息,那就是 A 和 B 的左上角和右下角的坐标信息,这里为了简化变量的书写,我们用
ltp
表示 (左-上)的点坐标信息,rbp
表示 (右-下)的点坐标信息。(这里假设我们已经拿到了,稍后会解释为什么需要这两个信息) - 这里需要发挥一点点你的空间想象能力,B 的
rbp.x
是 B 矩形身上所有坐标点距离 A 最近的那一个点。如下图所示,我们假设 B 矩形产生了无数个点坐标,那么它最右侧边上的点就是此时距离 A 最近的。 - 那么相对的 A 的
ltp.x
是距离 B 最近的那个点。如果B.rbp.x < A.ltp.x
那么 B 在这种情况下一定与 A 的左侧不可能产生相交。因为 A 和 B 距离彼此最近的两个点都没有产生空间关系,(如果相交,则B.rbpx
一定大于等于A.ltp.x
)那么别点就更不可能了。 - 继续用这种方法思考 B 在 A 下边的情况。对于 B 来讲,距离 A 最近的点就是
B.ltp.y
,相对应的 A 距离 B 最近的点是A.rbp.y
。假如说 A 和 B 连这两个相距最近的点的关系是B.ltp.y>A.rbp.y
,那么 B 一定不和 A 位于下方相交。 - 相同的空间思维, B 在 A 的上边的情况,如果
B.rbp.y < A.ltp.y
则 B 一定不可能和 A 在上方产生相交。 - 同理,如果 B 在 A 的右侧,如果此时相距彼此最近的
B.ltp.x > A.rbp.x
, 那么 B 就不能和 A 在右侧相交。 - 希望你能明白这其中的空间位置关系,如果第一遍没看明白,你可能需要再读一遍。
五. 实现相交判断
- 读懂了上面不相交的情况判断,那么我们接下来的需求就是如何获得
ltp
和rbp
对应的坐标信息了。 - 这里需要用到
getBoundingClientRect
这个api
,这个api
会返回元素相对于视口的相对位置。
- 聪明的你可能已经在 MDN 看到了下面这个图,你也或许注意到了,这个函数的返回值
x
和y
恰好对应了我们需要的ltp
的信息,bottom
和right
是我们需要的rbp
的信息。 - 为了方便的进行下一步,我们分别给拖拽元素和删除区域打上
ref
方便后面获取 dom 元素进行操作。 - 此时我们就需要另外几个变量来存放我们马上要用到的信息。
- 我们随手写一个获取坐标信息的函数,很简单的逻辑,不过多解释。
- 然后在
touchstart
里分别获取dragInfo
和deleteInfo
。 - 在
touchmove
中添加新的逻辑,我们新增一个 moveDragInfo 变量来当作移动过程中dragEl
的临时坐标信息,因为我们已经有了diff
值,那么我们只需要每次移动的时候,将原始值dragInfo
的坐标信息和diff
相加即可,然后将结果赋值给moveDragInfo
。 - 有了实时的坐标信息,那么根据上文第四章节的内容,我们可以写出以下不相交的逻辑。
- 我们测试一下之前的猜想是否成立:
- 由上图可以很清楚的看到,我们已经可以正确判断出拖动元素什么时候和删除区域相交了。那么我们也就可以在相交的时候正确处理之后的逻辑了。至此这个需求的核心步骤就已经完成,剩下的只是完善处理业务上的需求交互逻辑,这里不再过多赘述。
六. 源码
<script lang="ts" setup>
import { ref, onMounted } from "vue";
const initInfo = {
x: 0,
y: 0,
};
const diff = {
x: 0,
y: 0,
};
const dragEl = ref<HTMLDivElement>();
const deleteArea = ref<HTMLDivElement>();
let deleteInfo = {
//删除元素的坐标信息
ltp: {
x: 0,
y: 0,
},
rbp: {
x: 0,
y: 0,
},
};
let dragInfo = {
//拖拽元素的坐标信息
ltp: {
x: 0,
y: 0,
},
rbp: {
x: 0,
y: 0,
},
};
let test = 0;
function touchStart(e: TouchEvent) {
if (!dragEl.value || !deleteArea.value) return;
const { clientX, clientY } = e.touches[0];
initInfo.x = clientX;
initInfo.y = clientY;
}
function touchMove(e: TouchEvent) {
const { clientX, clientY } = e.touches[0];
diff.x = clientX - initInfo.x;
diff.y = clientY - initInfo.y;
let moveDragInfo = { ltp: { x: 0, y: 0 }, rbp: { x: 0, y: 0 } };
moveDragInfo.ltp.x = dragInfo.ltp.x + diff.x;
moveDragInfo.ltp.y = dragInfo.ltp.y + diff.y;
moveDragInfo.rbp.x = dragInfo.rbp.x + diff.x;
moveDragInfo.rbp.y = dragInfo.rbp.y + diff.y;
const notIntersected =
moveDragInfo.rbp.x < deleteInfo.ltp.x || //左侧
moveDragInfo.ltp.x > deleteInfo.rbp.x || //右侧
moveDragInfo.ltp.y > deleteInfo.rbp.y || //下侧
moveDragInfo.rbp.y < deleteInfo.ltp.y; //上侧
if (!notIntersected) {
console.log("相交了,提示删除");
} else {
console.log("无事发生");
}
const target = e.target as HTMLDivElement;
target.style.transform = `translate(${diff.x}px,${diff.y}px)`;
}
function touchEnd(e: TouchEvent) {
const target = e.target as HTMLDivElement;
target.style.transform = `translate(0)`;
}
//获取元素的左上角和右下角坐标信息
function getElementInfo(element: HTMLDivElement) {
const rectInfo = element.getBoundingClientRect();
const { left, top, right, bottom } = rectInfo;
return {
element: element,
ltp: {
x: left,
y: top,
},
rbp: {
x: right,
y: bottom,
},
};
}
onMounted(() => {
dragInfo = getElementInfo(dragEl.value!);
deleteInfo = getElementInfo(deleteArea.value!);
});
</script>
<template>
<div class="w-full h-full bg-blue flex flex-col gap-230px">
<div
@touchstart="touchStart"
@touchmove="touchMove"
@touchend="touchEnd"
class="w-100px h-100px mx-auto bg-black"
ref="dragEl"
></div>
<div ref="deleteArea" class="w-full leading-100px bg-red text-center">
<span class="text-black text-20px">删除区域</span>
</div>
</div>
</template>
## 七. 结语
实现自由拖拽其实是这个功能最简单的部分,关键点在于如何判断两个元素相交,这里我们通过逆向思维,通过判断什么时候 “不相交” 来较为简单的得出相交的场景,从而实现了相交时的逻辑判断。
下图是我自己实现的一版拖拽排序,也用到了相同的判断逻辑。其实大部分的功能都已经实现了,可惜我没办法正确处理页面滚动时元素位置的重新获取,由于时间限制,最后无奈放弃而选用了 vue-drag-plus
库。不过我认为结果不是最重要的,重要的是自己尝试完成这个需求过程中学到的其他知识,丰富了我脑中的知识库,将来在完成别的需求时,我相信我会有不一样的思路。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。