前言
在业务需求下,容器内的子项通过拖拽进行位置调换(排序)的场景不在少数,多数情况下会借助第三方库 Sortable.js 来去实现功能。
如果让我们自己去实现一套拖拽排序,你该如何去做呢?其实也是借助于 HTML5 拖拽 API 来实现。
对于 HTML5 拖拽事件的用法可以翻阅之前这篇文件:「React 中使用拖拽」。
下面,我们通过原生 JS 来分析并实现容器项的拖拽排序,并且伴随动画效果。
容器项排序
这里,我们先初始化环境,创建一个容器并添加几个子项,实现如下:
<!DOCTYPE html>
<html lang="en">
<head>
<title>drag-sort</title>
<style>
*{
margin: 0;
padding: 0;
}
.list{
width: 300px;
list-style: none;
margin: 100px auto;
height: 500px;
padding: 12px;
border: 2px solid pink;
overflow: auto;
}
.list-item{
cursor: move;
background: darkgray;
border-radius: 4px;
color: #FFF;
margin-bottom: 6px;
height: 50px;
line-height: 50px;
text-align: center;
}
</style>
</head>
<body>
<ul class="list" id="container">
<li class="list-item" draggable="true">列表1</li>
<li class="list-item" draggable="true">列表2</li>
<li class="list-item" draggable="true">列表3</li>
<li class="list-item" draggable="true">列表4</li>
<li class="list-item" draggable="true">列表5</li>
<li class="list-item" draggable="true">列表6</li>
</ul>
</body>
</html>
我们为每个 li 设置 draggable 属性,现在你可以按住 li 进行拖动并看到拖动效果。
有了拖动效果还不够,我们希望拖动一个子项,到另一个子项上时,两者能够发生位置交换。下面我们基于拖拽事件来实现此功能。
<script>
function _index(el) {
let index = 0;
if (!el || !el.parentNode) {
return -1;
}
while (el && (el = el.previousElementSibling)) { // 逆向思维,从当前元素向前查找,确定元素所在位置
index++;
}
return index;
}
const container = document.querySelector("#container");
let dragEl = null;
container.ondragstart = function(event) {
dragEl = event.target;
}
container.ondragover = function(event) {
event.preventDefault();
}
container.ondragenter = function(event) {
const target = event.target;
if (target.nodeName.toLowerCase() === "li" && target !== dragEl) {
// 非列表内元素排序
if (!container.contains(dragEl)) return;
// 进行位置交换
const after = _index(dragEl) < _index(target);
target.parentNode.insertBefore(dragEl, after ? target.nextSibling : target);
}
}
</script>
实现排序的关键就在于 parentNode.insertBefore 来操作 DOM 交换两个子项的位置,比较重要的一点是 _index 方法的实现。
排序动画
既然容器内的元素可以拖动排序了,但使用体验稍加欠缺。若是能有个过渡动画,拖拽排序起来会感觉很舒服。下面我们加入 animation 动画逻辑。
动画的思路大致如下:
- 记录拖拽元素和目标元素交换之前的位置;
- 交换后记录拖拽元素和目标元素现在的位置;
- 将元素回到交换之前,应用动画并通过 translate 交换后的位置。
关键在于记录位置。
// DOM 操作动画的实现
function _animate(prevRect, el) {
const animation = 150;
const currentRect = el.getBoundingClientRect();
// 1、回到初始位置
el.style['transition'] = 'none';
el.style['transform'] = `translate3d(${prevRect.left - currentRect.left}px, ${prevRect.top - currentRect.top}px, 0)`;
el.offsetWidth; // 触发重绘
// 2、回到移动后的位置
el.style['transition'] = `all ${animation}ms`;
el.style['transform'] = `translate3d(0, 0, 0)`;
clearTimeout(el.animated);
el.animated = setTimeout(() => {
el.style['transition'] = 'none';
el.style['transform'] = 'none';
el.animated = undefined;
}, animation);
}
// 拖拽加入动画逻辑
container.ondragenter = function(event) {
const target = event.target;
if (target.nodeName.toLowerCase() === "li" && target !== dragEl) {
// 非列表内元素排序
if (!container.contains(dragEl)) return;
// 防止重复触发动画
if (target.animated) return;
// 1、记录移动前的位置
const preTargetRect = target.getBoundingClientRect();
const preDragRect = dragEl.getBoundingClientRect();
// 2、进行位置交换
const after = _index(dragEl) < _index(target);
target.parentNode.insertBefore(dragEl, after ? target.nextSibling : target);
// 3、应用动画
_animate(preDragRect, dragEl);
_animate(preTargetRect, target);
}
}
封装实例
我们可以将上述代码封装为一个构造函数,这有些类似于 Sortable.js,封装如下:
function _index(el) {
let index = 0;
if (!el || !el.parentNode) {
return -1;
}
while (el && (el = el.previousElementSibling)) { // 逆向思维,从当前元素向前查找,确定元素所在位置
index++;
}
return index;
}
function _animate(prevRect, el, animation) {
const currentRect = el.getBoundingClientRect();
// 1、回到初始位置
el.style['transition'] = 'none';
el.style['transform'] = `translate3d(${prevRect.left - currentRect.left}px, ${prevRect.top - currentRect.top}px, 0)`;
el.offsetWidth; // 触发重绘
// 2、回到移动后的位置
el.style['transition'] = `all ${animation}ms`;
el.style['transform'] = `translate3d(0, 0, 0)`;
clearTimeout(el.animated);
el.animated = setTimeout(() => {
el.style['transition'] = 'none';
el.style['transform'] = 'none';
el.animated = undefined;
}, animation);
}
let dragEl = null;
let dragIndex = -1;
function SortDrag(config) {
this.container = config.container;
this.animation = config.animation || 150;
this.sort = config.sort === false ? false : true;
this.onSort = config.onSort;
this.init();
}
SortDrag.prototype.init = function() {
this.container.ondragstart = event => {
dragEl = event.target;
dragIndex = _index(dragEl);
}
this.container.ondragover = event => {
event.preventDefault();
}
this.container.ondragend = event => {
const oldIndex = dragIndex, newIndex = _index(event.target);
event.oldIndex = oldIndex;
event.newIndex = newIndex;
if (oldIndex !== newIndex) {
this.onSort && this.onSort(event); // 通知排序完成
}
}
this.container.ondragenter = event => {
const target = event.target;
if (target.nodeName.toLowerCase() === "li" && target !== dragEl) {
if (!this.sort) return;
// 非列表内元素排序
if (!container.contains(dragEl)) return;
// 防止重复触发动画
if (target.animated) return;
// 1、记录移动前的位置
const preTargetRect = target.getBoundingClientRect();
const preDragRect = dragEl.getBoundingClientRect();
// 2、进行位置交换
const after = _index(dragEl) < _index(target);
target.parentNode.insertBefore(dragEl, after ? target.nextSibling : target);
// 3、应用动画
_animate(preDragRect, dragEl, this.animation);
_animate(preTargetRect, target, this.animation);
}
}
}
现在,我们只需这样使用即可:
const instance = new SortDrag({
container: document.querySelector("#container"),
animation: 150,
referenceLine: true,
onSort: event => console.log('event: ', event.oldIndex, event.newIndex)
})
最后
感谢阅读。