HTML5 拖放 (DnD) API 可以实现拖动功能,下面具体看如何实现。
原理
对元素设置设置 draggable=true可以使元素支持拖动,并且通过dragstart、dragover、drop来实现简单的拖动功能。
<div class="grid">
<div class="item" draggable="true">item 1</div>
<div class="item" draggable="true">item 2</div>
<div class="item" draggable="true">item 3</div>
<div class="item" draggable="true">item 4</div>
<div class="item" draggable="true">item 5</div>
<div class="item" draggable="true">item 6</div>
</div>
.grid {
display: grid;
gap: 20px 80px;
grid-template-columns: auto auto auto auto;
}
.item {
border: 1px solid #2196f3;
background-color: #d3eafd;
border-radius: 5px;
padding: 10px;
cursor: move;
user-select: none;
}
var items = document.querySelectorAll(".item");
var dragEl;
function handleDragStart() {
dragEl = this;
}
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
return false;
}
function handleDrop() {
document.querySelector(".grid").insertBefore(dragEl, this);
}
items.forEach(function (item, index) {
item.addEventListener("dragstart", handleDragStart);
item.addEventListener("dragover", handleDragOver);
item.addEventListener("drop", handleDrop);
});
效果:
这里使用insertBefore将拖动的元素放到目标元素前面。
优点
实现简单,性能好。
缺点
- 不能自定义跟随鼠标透明元素的样式
- 不能自定义设置拖拽过程中的鼠标指针
- 不支持触摸拖拽
优化
在实际项目中这么简单实现拖动肯定不行,需要对其细节效果进行优化。
拖动元素添加响应效果
拖动元素添加透明度来和其他元素进行区分。
var items = document.querySelectorAll(".item");
var dragEl;
function handleDragStart() {
dragEl = this;
this.style.opacity = "0.5";
}
function handleDragEnd() {
this.style.opacity = "1";
}
function handleDragOver() {
if (e.preventDefault) {
e.preventDefault();
}
return false;
}
function handleDrop() {
document.querySelector(".grid").insertBefore(dragEl, this);
}
items.forEach(function (item, index) {
item.addEventListener("dragstart", handleDragStart);
item.addEventListener("dragend", handleDragEnd);
item.addEventListener("dragover", handleDragOver);
item.addEventListener("drop", handleDrop);
});
效果:
拖动到其他元素添加响应效果
拖动到其他元素添加边框给以区分。
.grid {
display: grid;
gap: 20px 80px;
grid-template-columns: auto auto auto auto;
}
.item {
border: 1px solid #2196f3;
background-color: #d3eafd;
border-radius: 5px;
padding: 10px;
cursor: move;
user-select: none;
margin: 1px;
}
.enter {
border: 2px solid #2196f3;
margin: 0;
}
var items = document.querySelectorAll(".item");
var dragEl;
function handleDragStart() {
dragEl = this;
this.style.opacity = "0.5";
}
function handleDragEnd() {
this.style.opacity = "1";
}
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
return false;
}
function handleDrop() {
document.querySelector(".grid").insertBefore(dragEl, this);
this.classList.remove("enter");
}
function handleDragEnter() {
if (this !== dragEl) {
this.classList.add("enter");
}
}
function handleDragLeave() {
this.classList.remove("enter");
}
items.forEach(function (item, index) {
item.addEventListener("dragstart", handleDragStart);
item.addEventListener("dragend", handleDragEnd);
item.addEventListener("dragover", handleDragOver);
item.addEventListener("drop", handleDrop);
item.addEventListener("dragenter", handleDragEnter);
item.addEventListener("dragleave", handleDragLeave);
});
效果:
通过dragend和dragleave事件对元素添加样式。
自定义跟随鼠标透明元素样式
拖动时跟随鼠标指针的那个元素样式过分简陋,虽然HTML5 拖放 API并没有提供方法自定义跟随鼠标的元素样式,但可以用另一种方式去实现。可以利用setDragImage方法。
发生拖动时,从拖动目标 (dragstart事件触发的元素) 生成半透明图像,并在拖动过程中跟随鼠标指针。这个图片是自动创建的,你不需要自己去创建它。然而,如果想要设置为自定义图像,那么 DataTransfer.setDragImage() 方法就能派上用场。
--MDN
可以利用setDragImage设置透明图片,将拖动原生的跟随鼠标指针的那个元素去掉,然后自定一个元素来跟随鼠标移动。
在dragstart事件中setDragImage方法设置透明图片
var img = new Image();
img.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' %3E%3Cpath /%3E%3C/svg%3E";
e.dataTransfer.setDragImage(img, 0, 0);
然后用cloneNode将元素复制一个新节点对象。
cloneDragEl = dragEl.cloneNode(true);
然后给新节点添加定位和设置位置最后添加到boy中。
cloneDragEl.classList.add("drag-over");
let targetOffset = dragEl.getClientRects();
dragElOffset = {
left: e.clientX - targetOffset[0].left,
top: e.clientY - targetOffset[0].top,
width: targetOffset[0].width,
};
cloneDragEl.style.width = `${dragElOffset.width}px`;
cloneDragEl.style.transform = `translate3d(${targetOffset[0].left}px,${targetOffset[0].top}px,0)`;
document.body.appendChild(cloneDragEl);
在grid元素的dragover事件中根据鼠标的位置来设置复制元素的位置。
function handleGridDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
if (cloneDragEl) {
cloneDragEl.style.transform = `translate3d(${
e.clientX - dragElOffset.left
}px,${e.clientY - dragElOffset.top}px,0)`;
}
return false;
}
在dragend事件中复制元素移除。
function handleDragEnd() {
this.style.opacity = "1";
document.body.removeChild(cloneDragEl);
}
效果:
完整代码:
.grid {
display: grid;
gap: 20px 80px;
grid-template-columns: auto auto auto auto;
}
.item {
border: 1px solid #2196f3;
background-color: #d3eafd;
border-radius: 5px;
padding: 10px;
cursor: move;
user-select: none;
margin: 1px;
}
.enter {
border: 2px solid #2196f3;
margin: 0;
}
.drag-over {
position: fixed;
top: 0;
left: 0;
pointer-events: none;
box-sizing: border-box;
}
var items = document.querySelectorAll(".item");
var dragEl = null;
var cloneDragEl = null;
function handleDragStart(e) {
dragEl = this;
this.style.opacity = "0.5";
var img = new Image();
img.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' %3E%3Cpath /%3E%3C/svg%3E";
e.dataTransfer.setDragImage(img, 0, 0);
cloneDragEl = dragEl.cloneNode(true);
cloneDragEl.classList.add("drag-over");
let targetOffset = dragEl.getClientRects();
dragElOffset = {
left: e.clientX - targetOffset[0].left,
top: e.clientY - targetOffset[0].top,
width: targetOffset[0].width,
};
cloneDragEl.style.width = `${dragElOffset.width}px`;
cloneDragEl.style.transform = `translate3d(${targetOffset[0].left}px,${targetOffset[0].top}px,0)`;
document.body.appendChild(cloneDragEl);
}
function handleDragEnd() {
this.style.opacity = "1";
document.body.removeChild(cloneDragEl);
}
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
return false;
}
function handleDrop() {
document.querySelector(".grid").insertBefore(dragEl, this);
this.classList.remove("enter");
}
function handleDragEnter() {
this.classList.add("enter");
}
function handleDragLeave() {
this.classList.remove("enter");
}
function handleGridDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
if (cloneDragEl) {
cloneDragEl.style.transform = `translate3d(${
e.clientX - dragElOffset.left
}px,${e.clientY - dragElOffset.top}px,0)`;
}
return false;
}
items.forEach(function (item, index) {
item.addEventListener("dragstart", handleDragStart);
item.addEventListener("dragend", handleDragEnd);
item.addEventListener("dragover", handleDragOver);
item.addEventListener("drop", handleDrop);
item.addEventListener("dragenter", handleDragEnter);
item.addEventListener("dragleave", handleDragLeave);
});
document
.querySelector(".grid")
.addEventListener("dragover", handleGridDragOver);
这里鼠标跟随的元素需要设置样式 pointer-events: none;让元素不会触发鼠标事件。
设置拖拽过程中的鼠标指针
可以通过effectAllowed来设置拖拽过程中的鼠标指针。
e.dataTransfer.effectAllowed = 'move';
但只有预设的几个指针样式,自定义还没办法,知道的同学可以留言告知。
insertBefore方式有点生硬,加上动画效果
首先添加过渡属性。
.item {
border: 1px solid #2196f3;
background-color: #d3eafd;
border-radius: 5px;
padding: 10px;
cursor: move;
user-select: none;
margin: 1px;
transition: transform 0.3s;
}
在dragstart事件中把元素隐藏,并且记录元素的位置。
function handleDragStart(e) {
var offset = dragEl.getClientRects();
this.style.opacity = "0";
targetOffset = {
left: offset[0].left,
top: offset[0].top,
};
}
在dragenter事件当鼠标进入元素时触发,改变元素的transform移动到拖拽元素的位置上,也就是targetOffset记录的位置。
function handleDragEnter() {
if (dragEl !== this) {
this.classList.remove("drag");
let offset = this.getClientRects();
let translate = getTranslate(this);
this.style.transform = `translate3d(${
targetOffset.left - offset[0].left + translate.x.value
}px,${targetOffset.top - offset[0].top + translate.y.value}px,0)`;
targetOffset = {
left: offset[0].left,
top: offset[0].top,
};
}
}
这里封装了一个getTranslate函数,getTranslate函数是用来获取元素的translate值,getClientRects获取的是元素在页面视图中的位置,translate是相对初始位置的偏移,获取元素的初始位置(getClientRects获取位置-translate的偏移)就要获取元素的translate偏移值。
function getTranslate(dom) {
const attrs = dom.attributeStyleMap.get("transform");
if (!attrs) return { x: { value: 0 }, y: { value: 0 } };
const translation = Array.from(attrs.values()).find(
(attr) => attr instanceof CSSTranslate
);
return translation;
}
这里通过attributeStyleMap来取元素的样式Map。
HTMLElement.attributeStyleMap 只读
一个StylePropertyMap,代表元素的样式属性的声明 --MDN
兼容性:
Firefox不支持该属性。
dragend事件中将拖动元素定位到最后位置上。
function handleDragEnd() {
let offset = this.getClientRects();
let translate = getTranslate(this);
this.style.transform = `translate3d(${
targetOffset.left - offset[0].left + translate.x.value
}px,${targetOffset.top - offset[0].top + translate.y.value}px,0)`;
this.style.opacity = "1";
document.body.removeChild(cloneDragEl);
dragEl = null;
cloneDragEl = null;
}
完整代码:
.grid {
display: grid;
gap: 20px 80px;
grid-template-columns: auto auto auto auto;
}
.item {
border: 1px solid #2196f3;
background-color: #d3eafd;
border-radius: 5px;
padding: 10px;
cursor: move;
user-select: none;
margin: 1px;
transition: transform 0.3s;
}
.enter {
border: 2px solid #2196f3;
margin: 0;
}
.drag-over {
position: fixed;
top: 0;
left: 0;
pointer-events: none;
box-sizing: border-box;
transition: none;
}
.drag {
transition: none;
}
var items = document.querySelectorAll(".item");
var dragEl = null;
var cloneDragEl = null;
var targetOffset = null;
function getTranslate(dom) {
const attrs = dom.attributeStyleMap.get("transform");
if (!attrs) return { x: { value: 0 }, y: { value: 0 } };
const translation = Array.from(attrs.values()).find(
(attr) => attr instanceof CSSTranslate
);
return translation;
}
function handleDragStart(e) {
dragEl = this;
this.classList.add("drag");
cloneDragEl = dragEl.cloneNode(true);
this.style.opacity = "0";
var img = new Image();
img.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' %3E%3Cpath /%3E%3C/svg%3E";
e.dataTransfer.setDragImage(img, 0, 0);
e.dataTransfer.effectAllowed = "move";
cloneDragEl.classList.add("drag-over");
var offset = dragEl.getClientRects();
targetOffset = {
left: offset[0].left,
top: offset[0].top,
};
dragElOffset = {
left: e.clientX - offset[0].left,
top: e.clientY - offset[0].top,
width: offset[0].width,
};
cloneDragEl.style.width = `${dragElOffset.width}px`;
cloneDragEl.style.transform = `translate3d(${targetOffset.left}px,${targetOffset.top}px,0)`;
document.body.appendChild(cloneDragEl);
}
function handleDragEnd() {
let offset = this.getClientRects();
let translate = getTranslate(this);
this.style.transform = `translate3d(${
targetOffset.left - offset[0].left + translate.x.value
}px,${targetOffset.top - offset[0].top + translate.y.value}px,0)`;
this.style.opacity = "1";
document.body.removeChild(cloneDragEl);
dragEl = null;
cloneDragEl = null;
}
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
return false;
}
function handleGridDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
if (cloneDragEl) {
cloneDragEl.style.transform = `translate3d(${
e.clientX - dragElOffset.left
}px,${e.clientY - dragElOffset.top}px,0)`;
}
return false;
}
function handleDrop(e) {
e.stopPropagation();
document.body.removeChild(cloneDragEl);
dragEl = null;
cloneDragEl = null;
}
function handleDragEnter() {
if (dragEl !== this) {
this.classList.remove("drag");
// this.classList.add("enter");
let offset = this.getClientRects();
let translate = getTranslate(this);
this.style.transform = `translate3d(${
targetOffset.left - offset[0].left + translate.x.value
}px,${targetOffset.top - offset[0].top + translate.y.value}px,0)`;
targetOffset = {
left: offset[0].left,
top: offset[0].top,
};
}
}
items.forEach(function (item, index) {
item.addEventListener("dragstart", handleDragStart);
item.addEventListener("dragend", handleDragEnd);
item.addEventListener("dragover", handleDragOver);
item.addEventListener("drop", handleDrop);
item.addEventListener("dragenter", handleDragEnter);
});
document
.querySelector(".grid")
.addEventListener("dragover", handleGridDragOver);
思考
除了上面的优化,还有很多优化的点,比如:
- 如何获取最终的排序列表。
- 性能上如何优化提高性能。
- 封装成一个工具插件的话代码结构如何组织,功能配置项如何抽离等。
总结
本文主要是帮助理解拖拽的实现原理,后面会通过解读拖拽工具库源码来总结更多的优化角度。