拖拽排序
背景
最近自己鼓捣一个思维导图,涉及一个拖动排序的过程。在掘金看到了神光大佬的写的文章[1],有很大的启发。在原文的基础上,根据自己的思路进行修改,并写了个demo。
效果
思路
- 直接修改dom无法触发transiton,因此通过translate对元素位置进行修改,达到一个平滑的transition,最后再进行一个dom操作。
- 增加一个views数组,用于表示页面上元素的被拖动到哪里,在拖拽过程中不断修改这个views,使得和视觉顺序一致,然后通过views进一步确定哪些元素需要translate。(虽然增加了一个views数组会增加空间复杂度,但是实现起来就简单很多了)
- 拖拽结束后,修改dom,使视觉顺序和dom保持顺序一致。
views数组定义和更新过程
views[i]记录的是原始的位置,下标是最终的位置。 比如把0号元素拖动到5号位置,view数组的变化
i befoer views[i] after views[i]
0 0 1
1 1 2
2 2 3
3 3 4
4 4 5
5 5 0
updateViews更新views数组, 类似一个reorder过程。参考react-beautiful-dnd的示例中的reorder
initViews() {
this.views = Array(this.containerElement.children.length)
.fill(0)
.map((_, index) => index);
}
updateViews(from, to) {
const [removed] = this.views.splice(from, 1);
this.views.splice(to, 0, removed);
this.translate();
}
根据views, 很容易计算translate
translate() {
const children = this.containerElement.children;
this.views.forEach((last, now) => {
const child = children[last];
const fromRect = this.rects[last];
const toRect = this.rects[now];
if (last !== now) {
child.style.transform = `translate3d(${toRect.x - fromRect.x}px, ${
toRect.y - fromRect.y
}px,0)`;
} else {
child.style.transform = `translate3d(0,0,0)`;
}
});
}
实现
graph TD
onDragStart -->初始化--> onDdrag --> 更新views数组并进行translate & 更新cloneElement位置
-->onDragEnd --> 清理
完整代码
class Draggable {
// 容器
containerElement = null;
// 几何信息
rects = null;
// 视图
views = null;
// 拖动元素
dragElement = null;
// 是否按下鼠标
isDragging = false;
// 上一次释放的位置
lastDropIndex = -1;
// 记录鼠标移动位置
lastPoint = {
x: 0,
y: 0,
};
// cloneElement 位置
position = {
left: 0,
top: 0,
};
// cloneElement
cloneElement = null;
constructor(selector) {
this.containerElement = document.querySelector(selector);
this.initRects();
this.initViews();
this.on();
}
initRects() {
this.rects = [...this.containerElement.children].map((child) => {
return child.getBoundingClientRect();
});
}
initViews() {
this.views = Array(this.containerElement.children.length)
.fill(0)
.map((_, index) => index);
}
updateViews(from, to) {
const [removed] = this.views.splice(from, 1);
this.views.splice(to, 0, removed);
this.translate();
}
translate() {
const children = this.containerElement.children;
this.views.forEach((last, now) => {
const child = children[last];
const fromRect = this.rects[last];
const toRect = this.rects[now];
if (last !== now) {
child.style.transform = `translate3d(${toRect.x - fromRect.x}px, ${
toRect.y - fromRect.y
}px,0)`;
} else {
child.style.transform = `translate3d(0,0,0)`;
}
});
}
commitDOM(from, to) {
const children = this.containerElement.children;
if (from === to) return;
let anchor = null;
if (from < to) {
anchor = children[to].nextSibling;
} else {
anchor = children[to];
}
this.containerElement.insertBefore(this.dragElement, anchor);
}
getPointerIndex(e) {
/* 根据鼠标位置计算出当前item位置 */
const { x, y } = e;
for (const [i, rect] of this.rects.entries()) {
if (
rect.left <= x &&
x <= rect.right &&
rect.top <= y &&
y <= rect.bottom
) {
return i;
}
}
/* 不在items里面,如items间隔 */
return -1;
}
onDragStart(e) {
const target = e.target;
// 判断是不是draggable
if (target.dataset.drag !== "true") {
return;
}
this.isDragging = true;
this.dragElement = e.target;
this.lastDropIndex = [...this.containerElement.children].indexOf(
this.dragElement
);
this.lastPoint.x = e.x;
this.lastPoint.y = e.y;
for (const item of this.containerElement.children) {
item.style.transition = "transform 500ms";
}
// cloneElement
const rect = this.rects[this.lastDropIndex];
this.position.left = rect.x;
this.position.top = rect.y;
this.cloneElement = this.dragElement.cloneNode(true);
this.cloneElement.style.transition = "none";
this.cloneElement.style.position = "fixed";
this.cloneElement.style.top = 0;
this.cloneElement.style.left = 0;
this.cloneElement.style.width = `${rect.width}px`;
this.cloneElement.style.height = `${rect.height}px`;
this.cloneElement.style.transform = `translate3d(${this.position.left}px, ${this.position.top}px,0)`;
this.cloneElement.style.pointerEvents = "none";
this.cloneElement.style.backgroundColor = "pink";
document.body.appendChild(this.cloneElement);
//
this.dragElement.classList.add("moving");
}
onDrag(e) {
if (!this.isDragging) {
return;
}
// cloneElement
this.position.left += e.x - this.lastPoint.x;
this.position.top += e.y - this.lastPoint.y;
this.cloneElement.style.transform = `translate3d(${this.position.left}px, ${this.position.top}px,0)`;
this.lastPoint.x = e.x;
this.lastPoint.y = e.y;
// translate
const dragIndex = this.lastDropIndex;
const dropIndex = this.getPointerIndex(e);
if (dropIndex < 0) return;
this.lastDropIndex = dropIndex;
this.updateViews(dragIndex, dropIndex);
}
onDragEnd(e) {
if (!this.isDragging) {
return;
}
this.initViews();
const from = [...this.containerElement.children].indexOf(this.dragElement);
this.commitDOM(from, this.lastDropIndex);
for (const item of this.containerElement.children) {
// commitDOM之后,dom和视图是一致的,因此不需要translate&transition
item.style.transition = "none"; // 否则设置translate会有过度效果
item.style.transform = "translate3d(0px, 0px, 0px)";
}
this.isDragging = false;
this.dragElement.classList.remove("moving");
this.cloneElement.remove();
}
on() {
this.containerElement.addEventListener(
"pointerdown",
this.onDragStart.bind(this)
);
this.containerElement.addEventListener(
"pointermove",
this.onDrag.bind(this)
);
this.containerElement.addEventListener(
"pointerup",
this.onDragEnd.bind(this)
);
}
}
const d = new Draggable(".container");
总结
- 学习的大佬的内容并根据自己的思路进行修改
- 使用事件委托方式,不确定是否能提高性能,因为拖拽过程中是会一直触发
pointemove, 改成在item里面绑定enter、leave事件,或许能性能更好?
参考
[1] 手写一个性能较好的拖拽排序