前言: 最近实现了一个需求:产品小姐姐要求我们的主页的应用可以拖拽到某个区域进行删除操作。

因为本身自己对移动端拖拽就十分感兴趣,所以也尝试着先不使用其他库,先尝试自己来解决这个需求,在实现过程中也对网页布局有了更深的理解。

今天想分享一下自己在实现这个需求时的一些思路,或许能帮你重新理解拖拽的原理🎁。


一. 效果预览

4.gif

二. 实现元素拖拽的思路

  1. 一说到拖拽,大家可能马上想到的是 H5 原生提供的 draggable 属性及其相关 api。是的,刚开始我也是打算用这个 api 去实现,但是很遗憾,这个 api 在移动端有很大的兼容性问题,但我们的产品是主攻移动端设备的,遂果断放弃。
  2. 后来自己经过查阅其他技术文章,最后选用 touch 事件来作为移动端的实现基础。
  3. 要想实现拖拽排序,我们就得知道如何让一个元素在页面上跟着我们的手指 “动起来” 。不用找别的东西,请拿出你的手机,然后简单对你已安装的应用做一个排序。然后思考🤔:什么是造成元素移动的原因?很简单,因为我们的手拖动了它,那么拖动的结果是什么?显而易见:这个元素相对于起始位置产生了位移
  4. 讲的再直接一些,本质上是因为我们的手指位置发生了位移,因为 app 不可能无缘无故自己排序,所以实现拖拽需求的基础就是如何利用好你手指的偏移量来对元素做特殊处理。
  5. 让我们快速准备一个普通的 div 用来接下来的实验。
    image.png
  6. 然后就是给这个 div 绑定 touchstarttouchmovetouchend 这三个事件,接下来我们要做的就是如何正确处理这三个事件函数。
    image.png
  7. 根据上面提到的信息,我们首先需要在 touchStart 中记录手指最开始位移前的坐标信息,这个坐标信息可以在事件对象的 touches 属性中找到,为什么是一个数组呢?因为你有可能同一时间用多个手指操作,也就是同一时间你屏幕上的手指有可能是多个,所以该属性是一个数组字段。

    image.png
  8. 但是拖拽删除这个需求场景下,我们只需要考虑一个手指的情况即可。我们定义一个变量 initInfo 来存储手指的初始位置信息,也就是 e.touches[0] 的相关数据。
    image.png

    如果你不明白这里相关字段的含义,我之前也有写相关的解读,很建议你读一下: ☕️图解鼠标事件的 ScreenX ,LayerX,clientX,PageX,offsetX,X

  9. 那么我们在 touchmove 的时候取移动中的坐标值,然后减去之前拿到的初始值,不就能拿到最关键的信息手指的偏移量了吗?于是这里我们定一个 diff 变量来保存偏移量。
    image.png
  10. ok,偏移量我们已经拿到了,让元素位移还不简单?我们直接使用 transformtranslate 属性不就可以了吗?

    image.png
  11. 目前的效果:(注意:touch 事件需要在开启手机模拟环境下进行操作)

    1.gif
  12. 相应的,如果移动过程中用户松手,此时我们的元素仍没有产生任何交互行为,那么根据产品需求,元素需要回到原来的位置。很简单,在 touchend 事件中,我们让元素的 translate 属性恢复到 0 的位置即可。

    image.png
    对应的效果如下:

    2.gif

三. 判断两个元素是否相交

  1. 产品小姐姐在项目上的需求是:拖拽的时候,产生一片区域,当应用被 “拖进” 这个区域的时候,就需要提示用户是否要删除该应用。在这里我们就随手创建一个简单的区域来模拟这个场景。

    image.png
  2. 那么我们的现在的主要目的就是如何通过代码判断出下面这种情况。

    image.png
  3. 理清思路很重要,注意,我们准备在干什么?我们在把一个 “元素拖进另外一个元素中。”

    image.png
  4. 让我们把上面的场景给抽象化理解:我们是不是在制造两个矩形相交的场景?

    image.png
  5. 但两个矩形产生相交的场景很多,下面仅仅只是几个简单相交的场景,就包括了这么多判断因素,可见我们要考虑的也需要很多。我们想一想是否能进一步简化一下相交的这个场景呢?

    image.png
  6. 这里其实可以用逆向思维来判断这个场景,我们只需要考虑两个矩形不相交的情况,然后对这个条件取反不就能达到同样的目的吗?如下图所示(B 相对于 A):我不管 B 具体位于 A 的哪个具体方向,到底是左边偏上还是右边偏下这些情况我都不考虑,我关心的是只要 B 同时满足下面这四个条件,那么 BA 就绝对不可能相交。

    image.png

四. 判断两个元素不相交

  1. 首先是第一种场景 BA 的左边。这里我们至少需要知道两个坐标信息,那就是 AB左上角右下角的坐标信息,这里为了简化变量的书写,我们用 ltp 表示 (左-上)的点坐标信息rbp 表示 (右-下)的点坐标信息。(这里假设我们已经拿到了,稍后会解释为什么需要这两个信息)
    image.png
  2. 这里需要发挥一点点你的空间想象能力,Brbp.xB 矩形身上所有坐标点距离 A 最近的那一个点。如下图所示,我们假设 B 矩形产生了无数个点坐标,那么它最右侧边上的点就是此时距离 A 最近的。

    image.png
  3. 那么相对的 Altp.x 是距离 B 最近的那个点。如果 B.rbp.x < A.ltp.x 那么 B 在这种情况下一定与 A 的左侧不可能产生相交。因为 AB 距离彼此最近的两个点都没有产生空间关系,(如果相交,则 B.rbpx 一定大于等于 A.ltp.x )那么别点就更不可能了。
  4. 继续用这种方法思考 BA 下边的情况。对于 B 来讲,距离 A 最近的点就是 B.ltp.y,相对应的 A 距离 B 最近的点是 A.rbp.y。假如说 AB 连这两个相距最近的点的关系是 B.ltp.y>A.rbp.y,那么 B 一定不和 A 位于下方相交。

    image.png
  5. 相同的空间思维, BA 的上边的情况,如果 B.rbp.y < A.ltp.yB 一定不可能和 A 在上方产生相交。

    image.png
  6. 同理,如果 BA 的右侧,如果此时相距彼此最近的 B.ltp.x > A.rbp.x, 那么 B 就不能和 A 在右侧相交。

    image.png
  7. 希望你能明白这其中的空间位置关系,如果第一遍没看明白,你可能需要再读一遍。

五. 实现相交判断

  1. 读懂了上面不相交的情况判断,那么我们接下来的需求就是如何获得 ltprbp 对应的坐标信息了。
  2. 这里需要用到 getBoundingClientRect 这个 api,这个 api 会返回元素相对于视口的相对位置。

image.png

  1. 聪明的你可能已经在 MDN 看到了下面这个图,你也或许注意到了,这个函数的返回值 xy 恰好对应了我们需要的 ltp 的信息,bottomright 是我们需要的 rbp 的信息。

    image.png
  2. 为了方便的进行下一步,我们分别给拖拽元素删除区域打上 ref 方便后面获取 dom 元素进行操作。

    image.png
  3. 此时我们就需要另外几个变量来存放我们马上要用到的信息。

    image.png
  4. 我们随手写一个获取坐标信息的函数,很简单的逻辑,不过多解释。

    image.png
  5. 然后在 touchstart 里分别获取 dragInfodeleteInfo

    image.png
  6. touchmove 中添加新的逻辑,我们新增一个 moveDragInfo 变量来当作移动过程中 dragEl 的临时坐标信息,因为我们已经有了 diff 值,那么我们只需要每次移动的时候,将原始值 dragInfo 的坐标信息和 diff 相加即可,然后将结果赋值给 moveDragInfo

    image.png
  7. 有了实时的坐标信息,那么根据上文第四章节的内容,我们可以写出以下不相交的逻辑。

    image.png
  8. 我们测试一下之前的猜想是否成立:

    3.gif
  9. 由上图可以很清楚的看到,我们已经可以正确判断出拖动元素什么时候和删除区域相交了。那么我们也就可以在相交的时候正确处理之后的逻辑了。至此这个需求的核心步骤就已经完成,剩下的只是完善处理业务上的需求交互逻辑,这里不再过多赘述。

六. 源码

<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 库。不过我认为结果不是最重要的,重要的是自己尝试完成这个需求过程中学到的其他知识,丰富了我脑中的知识库,将来在完成别的需求时,我相信我会有不一样的思路。

5.gif


FFF方
451 声望12 粉丝