拖放是一个很好的界面解决方案。从复制和移动(参考文件管理)到排序(放入购物车),拖放是一种简洁明了的方法。

在现代 HTML 标准中,有一个拖动事件的部分

这很有趣,因为它们允许轻松地解决一些简单的任务,而且允许处理“外部”文件拖放到浏览器中的事件。因此我们可以在 OS 文件管理中获取文件,并将其拖动到浏览器窗口。然后 JavaScript 获取对其内容的访问权限。

但是本地的拖动事件总是有局限性。比如,我们可以把拖动范围限制在某个区域内。而且我们也可以把它变成 “horizontal” 或 “vertical”。还有其他的拖放任务无法通过使用 API 实现。

在这里,我们将看到如何使用鼠标事件实现拖放。并不难。

拖放算法

拖放基础算法就像这样:

  1. 在可拖动元素上捕获 mousedown 事件。
  2. 准备要移动的元素(可能创建它的副本或其他任何东西)。
  3. 然后在 mousemove 上,通过改变 left/topposition:absolute 来移动它。
  4. mouseup(释放按钮)中 —— 执行所有完成拖放相关的动作。

这些是基础。我们可以对其进行拓展,例如,当鼠标在可拖动元素上悬停时,高亮这个元素。

这是拖放球的算法:

ball.onmousedown = function(event) { // (1) 启动进程

  // (2) 准备移动:确保 absolute,以及用 z-index 确保在顶部
  ball.style.position = 'absolute';
  ball.style.zIndex = 1000;
  // 将它从当前父亲中直接移到 body 中
  // 确保它的位置是相对于 body 的
  document.body.append(ball);
  // ...将绝对定位的球放在光标下

  moveAt(event.pageX, event.pageY);

  // 球中心在 (pageX, pageY) 坐标上
  function moveAt(pageX, pageY) {
    ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
    ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
  }

  function onMouseMove(event) {
    moveAt(event.pageX, event.pageY);
  }

  // (3) 在 mousemove 事件中移动球
  document.addEventListener('mousemove', onMouseMove);

  // (4) 释放球,移除不需要的处理器
  ball.onmouseup = function() {
    document.removeEventListener('mousemove', onMouseMove);
    ball.onmouseup = null;
  };

};

如果我们运行代码,我们会发现一些奇怪的事情。在拖放的一开始,球会 “forks”:我们开始拖动它的 “clone”。

这是一个动作实例:

尝试拖放鼠标,你会看到奇怪的行为。

这是因为浏览器有自己拖放图像功能和其他一些自动运行可能与我们的产生冲突的元素。

如果禁用:

ball.ondragstart = function() {
  return false;
};

现在一切都会好起来的。

动作:

另一个重要的方面是 —— 我们在 document 上跟踪 mousemove,而不是在 ball 上。第一眼看,鼠标似乎总是在球的上方,我们可以在上面放 mousemove

正如我们记得的那样,mousemove 会经常被触发,但不会针对每个像素都如此。因此在快速移动之后,光标可以从文档中心的某个地方(甚至是窗口外)从球上跳出来。

因此为了捕获它,我们应该监听 document

修正定位

在上述例子中,球总是以指针为中心的:

ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
ball.style.top = pageY - ball.offsetHeight / 2 + 'px';

不错,但这存在副作用。我们可以在球的任何地方使用 mousedown 来开始拖放。如果在边缘那么做,那么球就会突然“跳”到以指针为中心的位置。

如果我们保持元素相对指针的初始位移,情况会更好。

例如,我们从球的边缘处开始拖动,那么光标在拖动时应该保持在边缘。

  1. 当访问者按下按钮(mousedown)时 —— 我们可以使用变量 shiftX/shiftY 来记住光标到球左上角的距离。我们应该在拖动时保持这样的距离。

    我们可以减去坐标来获取位移:

    // onmousedown
    let shiftX = event.clientX - ball.getBoundingClientRect().left;
    let shiftY = event.clientY - ball.getBoundingClientRect().top;

    请注意,在 JavaScript 中没有获取文档坐标的方法,因此我们在这里使用相对于窗口的坐标。

  2. 然后,在拖动球时,我们将球放置在相对于指针移动的位置上,就像这样:

    // onmousemove
    // ball has position:absoute
    ball.style.left = event.pageX - shiftX + 'px';
    ball.style.top = event.pageY - shiftY + 'px';

具有更好定位的最终代码:

ball.onmousedown = function(event) {

  let shiftX = event.clientX - ball.getBoundingClientRect().left;
  let shiftY = event.clientY - ball.getBoundingClientRect().top;

  ball.style.position = 'absolute';
  ball.style.zIndex = 1000;
  document.body.append(ball);

  moveAt(event.pageX, event.pageY);

  // 球中心在 (pageX, pageY) 坐标上
  function moveAt(pageX, pageY) {
    ball.style.left = pageX - shiftX + 'px';
    ball.style.top = pageY - shiftY + 'px';
  }

  function onMouseMove(event) {
    moveAt(event.pageX, event.pageY);
  }

  // (3) 用 mousemove 移动球
  document.addEventListener('mousemove', onMouseMove);

  // (4) 释放球,移除不需要的处理器
  ball.onmouseup = function() {
    document.removeEventListener('mousemove', onMouseMove);
    ball.onmouseup = null;
  };

};

ball.ondragstart = function() {
  return false;
};

In action (inside <iframe>):

如果我们按在球的右下角进行拖动,这种差异就会特别明显。在前面的示例中,球在指针下“跳动”。现在,它从当前位置跟随鼠标会很流畅。

检测是否可释放

在之前的示例中,球可以停放到“任何地方”。在实际中,我们通常把一个元素放在另一个元素上。例如,将文件放入文件夹,或者用户放入回收站之类的操作。

抽象地,我们取一个(可拖放的)“draggable” 元素,并将其放在(可释放的)“droppable” 元素上。

我们需要知道拖放结束时的可释放目标 —— 执行相应的动作,最好是在拖动过程中高亮显示。

这个解决方案很有意思,只是有点麻烦,所以我们在这里提及相关内容。

第一个想法是什么?可能是将 onmouseover/mouseup 处理器放在潜在的可释放的元素上,然后检测鼠标指针出现在它们上面的时机。这样我们就知道了我们正在这个元素上进行拖/放操作

但这并不会运行。

问题是当我们拖动时,可拖动元素总是在其他元素之上。而且鼠标事件总是发生在顶部元素上,而不会在元素之下的其他元素上发生。

比如,下面有两个 <div> 元素,红色在蓝色顶部。没有方法可以在蓝色的事件中捕获到一个事件,因为红色在顶部:

<style>
  div {
    width: 50px;
    height: 50px;
    position: absolute;
    top: 0;
  }
</style>
<div style="background:blue" onmouseover="alert('never works')"></div>
<div style="background:red" onmouseover="alert('over red!')"></div>

与一个可以拖动的元素相同。球总是在其他元素之上,因此事件会在其上发生。无论我们在低元素上如何设置处理器,它们都不会起作用。

这就是为什么起初将处理器放在潜在的可释放的元素中的想法,在实际操作中无效的原因。它们无法运行。

那么,改如何做?

有一个叫做 document.elementFromPoint(clientX, clientY) 的方法。它会根据给定的窗口相对坐标,返回该处嵌套最深的元素(如果坐标在窗口之外,则返回 null)。

因此我们可以检测任何情况下的鼠标事件中,指针下面的潜在可释放元素,就像这样:

// 在鼠标事件处理器中
ball.hidden = true; // (*)
let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
ball.hidden = false;
// elemBelow 是球下面的元素。如果它是可释放的,那么我们就可以对它进行处理。

请注意:我们需要在调用 (*) 之前隐藏球。否则,我们通常会在这些坐标上有个球,因为它的是在指针下的顶部元素:elemBelow=ball

在任何时候,我们都可以使用该代码检测我们“掠过”的东西。当它发生时会进行释放处理。

拓展了 onMouseMove 方法,用来查找“可释放的”元素的代码:

let currentDroppable = null; // 我们正在通过的可释放元素

function onMouseMove(event) {
  moveAt(event.pageX, event.pageY);

  ball.hidden = true;
  let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
  ball.hidden = false;

  // mousemove 事件可能会在窗口外被触发(当球被拖出屏幕时)
  // 如果 clientX/clientY 在窗口外,那么 elementfromPoint 会返回 null
  if (!elemBelow) return;

  // 潜在的可释放的将被标记为 "droppable" 类(可以是其他逻辑)
  let droppableBelow = elemBelow.closest('.droppable');

  if (currentDroppable != droppableBelow) { // 如果有任何改变
    // 鼠标的进入或者离开状态
    // 注意:它们的值都可能是 null
    // 如果鼠标不在一个可释放的物体上(例如,通过任意空白区域),那么 currentDroppable=null
    // droppableBelow=null 如果在这个事件中,我们不是在通过一个可释放的物体上

    if (currentDroppable) {
      // 处理“离开”可释放物体的逻辑
      leaveDroppable(currentDroppable);
    }
    currentDroppable = droppableBelow;
    if (currentDroppable) {
      // 处理“离开”可释放物体的逻辑
      enterDroppable(currentDroppable);
    }
  }
}

在下面的示例中,当球被拖过足球门时,门会被高亮显示。

结果
style.css
index.html
#gate {
  cursor: pointer;
  margin-bottom: 100px;
  width: 83px;
  height: 46px;
}

#ball {
  cursor: pointer;
  width: 40px;
  height: 40px;
}
<!doctype html>
<html>

<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="style.css">
</head>

<body>

  <p>Drag the ball.</p>

  <img src="https://en.js.cx/clipart/soccer-gate.svg" id="gate" class="droppable">

  <img src="https://en.js.cx/clipart/ball.svg" id="ball">

  <script>
    let currentDroppable = null;

    ball.onmousedown = function(event) {

      let shiftX = event.clientX - ball.getBoundingClientRect().left;
      let shiftY = event.clientY - ball.getBoundingClientRect().top;

      ball.style.position = 'absolute';
      ball.style.zIndex = 1000;
      document.body.append(ball);

      moveAt(event.pageX, event.pageY);

      function moveAt(pageX, pageY) {
        ball.style.left = pageX - shiftX + 'px';
        ball.style.top = pageY - shiftY + 'px';
      }

      function onMouseMove(event) {
        moveAt(event.pageX, event.pageY);

        ball.hidden = true;
        let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
        ball.hidden = false;

        if (!elemBelow) return;

        let droppableBelow = elemBelow.closest('.droppable');
        if (currentDroppable != droppableBelow) {
          if (currentDroppable) { // null when we were not over a droppable before this event
            leaveDroppable(currentDroppable);
          }
          currentDroppable = droppableBelow;
          if (currentDroppable) { // null if we're not coming over a droppable now
            // (maybe just left the droppable)
            enterDroppable(currentDroppable);
          }
        }
      }

      document.addEventListener('mousemove', onMouseMove);

      ball.onmouseup = function() {
        document.removeEventListener('mousemove', onMouseMove);
        ball.onmouseup = null;
      };

    };

    function enterDroppable(elem) {
      elem.style.background = 'pink';
    }

    function leaveDroppable(elem) {
      elem.style.background = '';
    }

    ball.ondragstart = function() {
      return false;
    };
  </script>


</body>
</html>

现在在整个过程中,我们在 currentDroppable 变量中存储了当前 “drop target”,并且可以使用它来高亮显示或任何其他内容。

总结

我们考虑了一种基础的拖放算法。

关键部分:

  1. 事件流:ball.mousedowndocument.mousemoveball.mouseup(取消原生 ondragstart)。
  2. 在拖拽启动时 —— 记住指针相对于元素的初始位移:shiftX/shiftY` 并在拖动过程保持状态。
  3. 使用 document.elementFromPoint 检测指针下可放置的元素。

我们可以在这个基础上做很多的工作。

  • mouseup 事件中我们可以完成释放:改变数据,移动元素
  • 我们可以高亮我们涉及的元素。
  • 我们可以把拖动范围限制在某个区域内
  • 我们可以对 mousedown/up 使用事件委托。一个大范围事件处理器可以检查 event.target,它可以管理数百个元素的拖放。
  • 等等。

有一些已经构建好架构的框架:DragZoneDroppableDraggable 和其他类。它们中的大多数都做了类似的事情,所以现在应该很容易理解了。或者我们自己滚动,因为你已经了解了如何处理这个过程,它可能比适应其他东西更灵活。

任务

重要程度: 5

创建一个滚动条

用鼠标拖动蓝色 thumb 并移动它。

重要的细节:

  • 在按住鼠标进行拖动时,鼠标可能会越过滚动条的上下边界,滑块应该还在继续移动(对用户来说,这非常方便)。
  • 如果鼠标在移动到左边或右边的速度非常快,那么 thumb 应该完全停在边缘。

打开一个任务沙箱。

我们这里有一个水平拖放。

我们使用 position:relative 和滚动条的相对坐标的 thumb 来定位元素。这里比 position:absolute 更方便。

使用沙箱打开解决方案。

重要程度: 5

这个任务可以帮助你检查对拖放和 DOM 一些方面的理解程度。

使所有元素都具有类 draggable —— 可拖。就像章节里的球。

要求:

  • 使用事件委托来跟踪拖动的开始:document 用于 mousedown 的单个处理器。
  • 如果将元素拖动到窗口的顶部/底部 —— 页面就会允许进一步的向上/向下滚动。
  • 没有水平滚动。
  • 即使鼠标迅速移动,可拖动的元素也不应离开窗口。

这个示例太大了,不适合放在这里,但这里有相应的链接。

在新窗口中演示

打开一个任务沙箱。

我们应该把属性切换回 position:fixed 来拖动它,它让坐标管理更简单。最后,我们应该使用 position:absolute

然后,当坐标位于窗口顶部/底部时,我们使用 window.scrollTo 滚动它。

注释中有更多关于代码的细节。

使用沙箱打开解决方案。

教程路线图

评论

在评论之前先阅读本内容…
  • 欢迎你在文章下添加补充内容、提出你的问题或回答提出的问题。
  • 使用 <code> 标签插入几行代码,对于多行代码 — 可以使用 <pre>,对于超过十行的代码 — 建议使用沙箱(plnkrJSBincodepen 等)。
  • 如果你无法理解文章中的内容 — 请详细说明。