原文链接:https://javascript.info/mouse-drag-and-drop,translate with ❤️ by zhangbao.
拖放是一个伟大的交互革命。用拖拽的方式实现复制、移动(比如文件管理器)和排序(放入购物车)是一个简单完成很多任务的方式。
在现代 HTML 标准中有一个《拖放事件》一节 。
这类事件很有趣,因为可以借此很轻易地完成一些简单任务,还可以将“外部”文件拖拽到浏览器中,然后用 JavaScript 来获取文件内容。
但原生拖拽事件也有局限性。 例如,我们可以限制某个区域的拖动。此外,我们不能实现仅在“水平”或“垂直”方向上的拖拽。当然,还有其他一些不能使用原生拖拽 API 实现的任务。
因此,下面我们使用鼠标事件来实现拖拽功能,也没那么难。
拖拽算法
基本的拖拽算法是这样的:
为可拖拽元素绑定
mousedown
事件。准备移动元素(创建一个副本或者怎样)。
在
mousemove
事件里通过改变position: absolute
元素的left
/top
属性值来移动元素。释放鼠标按键(
mouseup
)的时候,执行拖拽结束后的行为。
这是基本规则,之后还会拓展。例如,当位于可放置(droppable)元素上时,高亮该元素。
下面展示了拖拽小球的算法:
ball.onmousedown = function (event) { // (1) 要开始了!
// (2) 移动前的准备:绝对定位加设置 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;
};
};
当我们执行代码的时候,会发现有点奇怪。在最开始拖拽球的时候,“复制”了一个球:我们拖拽的其实是副本,这导致了拖拽后释放鼠标,onmouseup
事件失效了。
这是因为浏览器有自己的“拖放”,用于图像和其他一些自动运行、并与我们发生冲突的元素。
禁止此种行为使用:
ball.ondragstart = function () {
return false;
};
好了,现在就正常了。
另外重要的一点是——我们是在 document
上绑定 mousemove
事件的,而不是在 ball
上。我们的第一印象,总会感觉鼠标是在球上的,所以应该给球绑定这个事件才对。
可我们要记住,mousemove
事件触发是有频度的,并不是每移动一像素就会触发。如果我们移动过快的话,鼠标光标可能会从球的某处直接跳入文档中(甚至是窗口外)。
所以我们应该给 document
绑定事件。
正确定位
上例中,光标总是处于球的中央位置。
ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
这样不坏,但有一个副作用。当我们初始化拖拽的时候,不论我们是在球的哪个地方按下(mousedown
),球的中心点都会突然“跳到”光标所在处。
如果我们自始至终都能保证光标和球的相对位置就好了。
例如,如果我们最开始的拖拽点在球的一个边缘位置,我们按下鼠标按键,然后在整个拖拽过程中,都会一直保持在那个位置。
- 当用户按下鼠标的时候(
mousedown
),我们能计算光标相对于球左上角的相对位置shiftX
/shiftY
。在拖动过程中,我们应该保持住这个偏移量。
为了得到这个偏移量,我们需要用到坐标轴减法。
// onmousedown
let shiftX = event.clientX - ball.getBoundingClientRect().left;
let shiftY = event.clientY - ball.getBoundingClientRect().top;
请注意,在 JavaScript 中没有提供获取元素文档坐标的方法,所以我们使用了窗口坐标。
- 然后,在拖拽球进运动的时候,我们始终保持光标和球的相对位置就行了。
// onmousemove
// ball 是 position: absolute 定位的
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);
function moveAt(pageX, pageY) {
ball.style.left = pageX - shiftX + 'px';
ball.style.top = pageY - shiftY + 'px';
}
function onMouseMove(event) {
moveAt(event.pageX, pageY);
}
document.addEvenListener('mousemove', onMouseMove);
ball.onmouseup = function () {
document.removeEventListener('mousemove', onMouseMove);
ball.onmouseup = null;
};
};
ball.ondragstart = function () {
return false;
};
如果我们点击从球的右下方开始拖动,差别就会明显看出来。在前面的例子中,球的中心点会“跳跃”到光标处。现在,球可以流畅地在相对位置上跟随光标移动了。
检测可放置元素
前例中,我们可以把球放在文档里的任何区域,在现实场景里,我们通常是要把一个元素放置到另一个元素中中——例如,把一个文件放到一个文件夹里,把用户放入垃圾箱或者其他的什么操作。
理论上,就是把“可拖拽”元素置于“可放置”元素上。
我们需要知道拖放的目标元素,即那个可放置元素。然后实现拖拽行为,在拖拽过程中,正确地高亮可放置元素。
这个解决方案很有趣,需要点技巧,我们把它写在这里。
我们首先想到的是什么呢?也许要为我们潜在的可放置元素添加 mouseover
/mouseup
事件处理器,然后在鼠标在经过它时高亮,然后我们就知道拖拽到这个元素上了。
但这不行。
问题是,在拖拽的时候,拖拽元素总是位于可放置元素之上。因此鼠标事件只会发生在包括自身在内的祖先元素上,而不会是下面的元素。
例如,有两个 div
元素,红的在蓝的上面。但是我们无法捕捉到蓝的,因为红的在上面。
<style>
div {
width: 50px;
height: 50px;
position: absolute;
top: 0;
}
</style>
<div style="background:blue" onmouseover="alert('不会在这上面的……')"></div>
<div style="background:red" onmouseover="alert('在红的上面了!')"></div>
对可拖动元素而言,球总位于其他元素之上,所以事件发生在它上面。无论我们在底层元素上绑定了什么处理程序,都不会触发的。
这就是为什么在实践中,为潜在的可放置元素绑定处理程序行不通的原因,因为不会执行。
那么,该怎么做呢?
有一个方法 document.elementFromPoint(clientX, clientY)
,它返回距离指定窗口坐标的最内的元素(如果坐标处在窗口之外的话,返回 null
)。
因此,我们可以在鼠标事件处理器中,向下面这样,检查光标下是否有可放置元素:
// 在鼠标事件处理器中
ball.hidden = true; // (*)
let elemBlow = document.elementFromPoint(event.clientX, event.clientY);
ball.hidden = false;
// elemBlow 就是球下面的那个元素了。如果是可放置元素,我们就对球处理。
需要注意的是,调用 elementFromPoint
方法之前,要先隐藏球。否则我们得到的总是球元素本身,因为此时球是光标处最顶层元素:elemBelow=ball
。
我们可以在任何时候使用这些代码来检查我们的“越过”的元素。如果有的话,就处理它。
扩展版的 onMouseMove
方法如下:
let currentDropable = null; // 当前我们越过的可放置元素
function onMouseMove(event) {
moveAt(event.pageX, event.pageY);
ball.hidden = true;
let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
ball.hidden = false;
// mousemove 事件可能会在窗口外触发(当球拖拽离开屏幕时)
// if (clientX/clientY) 离开窗口,那么 elemFromPoint 方法返回 null
if (!elemBlow) { return }
// 约定所有添加 .droppable 类名的元素都是可放置元素
let droppableBelow = elemBlow.closest('.droppable');
if (currentDroppable !== doppableBelow) {
// 飞经或者飞离
// 注意:两个值都有可能是 null
if (currentDroppable) {
// 飞离可放置元素(取消高亮)
leaveDroppable(currentDroppable);
}
currentDroppable = droppableBelow;
if (currentDroppable) {
// 飞入可放置元素(添加高亮)
enterDroppable(currentDroppable);
}
}
}
下面是完整代码:
<!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
中保存当前的“放置目标”,将它高亮或者做其他事情。
总结
我们再回顾一下拖放功能算法。
关键内容:
事件流:
ball.mousedown
→document.mousemove
→ball.mouseup
(取消原生ondragstart
事件)。拖动开始——记住初始时,光标相对元素的偏移值:
shiftX
/shiftY
,在拖动过程中保持住这个偏移量。使用
document.elementFromPoint
方法监测光标下的可放置元素。
我们可以在这个基础上做很多事情。
mouseup
时我们结束拖动:改变数据、移动元素。可以高亮我们经过的元素。
限制拖动的有效区域以及方向。
我们可以对
mousedown
/mouseup
事件使用委托的形式。一个通过检查event.target
的值来管理一个较大区域中发生的此类事件,可以同时管理数百个元素的拖放。……
有许多框架是基于此理论创建的:DragZone
、Droppable
、Draggable
等。它们中的多数做了与上面类似的事情,现在你应该比较容易理解了。或者是自己实现,因为我们已经知道怎样去处理事件,这可能比采用框架更加灵活。
练习题
问题
一、Slider
实现一个 Slider。
当我们用鼠标拖动蓝色滑块的时候,滑块跟随鼠标的移动而移动。
实现细节:
当在滑块上按下鼠标时,拖动过程中,无论鼠标是否位于滑块上,都要保证滑块跟随鼠标移动(为了给用户提供便利)。
当鼠标滑动的范围离开了灰色轨道时,确保滑块保持在轨道边缘,而不是越出轨道之外移动了。
二、在球场里拖动超级英雄
这个任务能够帮助你更加全面的理解拖拽和 DOM。
所有的元素都包含一个类名 draggable
——表示是可拖动的。就像本章中的那个球。
要求是:
使用事件委托来跟踪拖动开始:为
document
绑定一个mousedown
事件处理器。如果元素被拖动到了窗口顶部/底部边缘——往上/往下滚动页面都能允许进一步的拖拽。
没有水平滚动条。
可拖拽元素不应该离开窗口,即使是在非常快速地移动鼠标的情况下。
这里提供一个链接,来让你查看是先前的代码准备。
答案
一、Slider
这里实现是水平拖拽功能。
我们使用 position: relative
来定位 thumb 在滑块中的位置,这比使用 position: absolute
更加方便。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
.slider {
border-radius: 5px;
background: #E0E0E0;
background: linear-gradient(left top, #E0E0E0, #EEEEEE);
width: 310px;
height: 15px;
margin: 5px;
}
.thumb {
width: 10px;
height: 25px;
border-radius: 3px;
position: relative;
left: 10px;
top: -5px;
background: blue;
cursor: pointer;
}
</style>
</head>
<body>
<div id="slider" class="slider">
<div class="thumb"></div>
</div>
<script>
let thumb = slider.querySelector('.thumb');
thumb.onmousedown = function(event) {
event.preventDefault(); // 阻止浏览器的默认选择行为
let shiftX = event.clientX - thumb.getBoundingClientRect().left;
// 无需 shiftY, 因为 thumb 只是水平滚动的
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
function onMouseMove(event) {
let newLeft = event.clientX - shiftX - slider.getBoundingClientRect().left;
// 光标位于滑块之外 => 将 thumb 锁定在边界
if (newLeft < 0) {
newLeft = 0;
}
let rightEdge = slider.offsetWidth - thumb.offsetWidth;
if (newLeft > rightEdge) {
newLeft = rightEdge;
}
thumb.style.left = newLeft + 'px';
}
function onMouseUp() {
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('mousemove', onMouseMove);
}
};
thumb.ondragstart = function() {
return false;
};
</script>
</body>
</html>
二、在球场里拖动超级英雄
为了拖拽元素,我们使用 position: fixed
,能更加容易的管理坐标。最后的的话,需要转换到 position: absolute
。
然后,当坐标位于窗口顶部/底部时,我们可以使用 window.scrollTo
去滚动它。
点击这里查看解决方案。
(完)