01
拖拽排序是一种在网页设计和应用程序中常见的交互方式,允许用户通过鼠标或触摸操作来重新排列页面或界面上的元素。这种交互方式对于提升用户体验和操作效率具有重要意义。
在拖拽排序中,用户可以用鼠标或手指按住某个元素,然后将其拖动到新
的位置,从而实现对元素的重新排列。这种操作直观且灵活,使得用户可以根据自己的需求随时调整页面或界面的布局,提升了个性化体验。同时,拖拽排序也增加了用户的参与度和粘性,用户可以通过自由选择和排序感兴趣的内容,提升留存率和活跃度。
从技术实现的角度来看,拖拽排序主要依赖于前端技术的支持。例如,基于JavaScript的实现方法主要是通过监听鼠标或触摸事件来实现。在拖拽开始时,需要记录拖拽元素的位置,然后在拖拽过程中更新元素的位置,最后在拖拽结束时判断元素与其他元素的位置关系并进行排序。
在拖拽排序的应用场景中,列表排序和图片排序是两个典型的例子。在列表排序中,用户可以通过拖动列表项来改变它们的顺序,这在任务管理应用、待办事项列表等场景中非常常见。在图片排序中,用户可以通过拖动图片来改变它们的顺序,这在图片库或相册应用中较为常见。
02
在HTML中,我们给需要拖动的元素加上draggable="true"就可以实现拖拽效果了。在CSS中,我们设置了列表和拖拽项的样式。
1
2
3
4
5
6
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
justify-content: center;
}
.list {
width: 600px;
margin-top: 100px;
}
.list-item {
margin: 6px 0;
padding: 0 20px;
line-height: 40px;
height: 40px;
background: #409eff;
color: #fff;
text-align: center;
cursor: move;
user-select: none;
border-radius: 5px;
}
效果如下:
元素是可以拖拽了,但是拖拽时元素本身的样式要改变,我们只需要给元素加上一个类样式就可以了。那么,什么时候添加这个类呢?当然是开始拖动的时候,我们使用了HTML5的拖放API ondragstart,它是在用户开始拖动元素时触发。
2.1 拖拽开始
我们找到拖拽项的父元素,用事件委托的方式找到父元素,也就是.list并给它注册一个ondragstart事件,当拖拽开始时,可以使用event.target来获取被拖拽的元素,给它的类型样式添加一个moving。
.list-item.moving {
background: transparent;
color: transparent;
border: 1px dashed #ccc;
}
const list = document.querySelector('.list');
list.ondragstart = (e) => {
setTimeout(() => {
e.target.classList.add('moving')
}, 0)
}
为什么要加setTimeout呢?因为跟随鼠标的样式取决于拖拽开始时元素本身的样式,拖转开始时把元素的样式改变了,那就意味着跟随鼠标的样式也改变了,我们可以加一个setTimeout变成异步,在拖拽开始时还是保持原来的样式,然后过一点点时间在变成添加moving的样式。
2.2 拖拽过程
(1)当被拖拽的元素移动到另一个列表项上方时,会不断触发dragover事件。
(2)默认情况下,浏览器不允许放置(drop)操作,因此需要阻止这个事件的默认行为。这可以通过调用event.preventDefault()方法来实现。
ondragover: 当某被拖动的对象在另一对象容器范围内拖动时触发此事件。
list.ondragover = (e) => {
e.preventDefault();
}
(3)当用户释放鼠标按钮,且被拖拽的元素位于一个有效的放置目标上方时,drop事件被触发。
(4)在drop事件处理程序中,首先需要获取拖拽源元素,接着获取放置目标元素,这通常是触发drop事件的元素。
(5)然后,需要更新DOM来反映新的排序。这通常涉及改变元素的位置,可以通过直接操作DOM(如insertBefore或appendChild)来实现。
ondragenter:当被鼠标拖动的对象进入其容器范围内时触发此事件。
const list = document.querySelector('.list');
// 记录被拖拽的元素
let sourceNode;
list.ondragstart = (e) => {
setTimeout(() => {
e.target.classList.add('moving')
}, 0)
// 记录被拖拽的元素
sourceNode = e.target;
}
list.ondragover = (e) => {
e.preventDefault();
}
list.ondragenter = e => {
e.preventDefault();
// 判断拖拽元素进入的元素等于父元素list或等于拖拽元素本身,
// 不做受任何处理,直接结束
if(e.target === list || e.target === sourceNode) {
return;
}
// 判断元素拖拽进入的位置是在目标的上面还是下面,
// 比如拖动3进入到4时,4要移动到上面,
// 当拖动3进入到2时,2要移动到下面,
// 通过元素所处的下表既可判断。
// 首先,拿到元素list所有的子元素
const children = [...list.children];
// 接着,拿到要拖拽元素在整个子元素里面的下标
const sourceIndex = children.indexOf(sourceNode);
// 然后,拿到要进入目标元素在整个子元素里面的下标
const targetIndex = children.indexOf(e.target);
if(sourceIndex < targetIndex) {
// 进入目标元素大于拖拽元素的下标,
// 此时要插入目标元素的下方位置,
// 也就是目标元素下一个元素的前面
list.insertBefore(sourceNode, e.target.nextElementSibling);
} else {
// 进入目标元素小于拖拽元素的下标,
// 此时要插入目标元素的上方位置,
// 也就是目标元素前面的位置
list.insertBefore(sourceNode, e.target);
}
}
2.3 拖拽结束
ondragend:用户完成元素拖动后触发。
list.ondragend = () => {
sourceNode.classList.remove('moving');
}
拖拽结束时,只需要把moving的样式移除即可。
03
为了使元素位置改变时不那么生硬,可能需要提供一些额外的反馈,可以通过动画来平滑地展示元素位置的改变。那么我们来了解一种动画——Flip动画。什么是Flip动画呢?
Flip技术可以让我们的动画更加流畅,同时也能降低复杂动画的开发难度。其实,Flip是几个英文单词的缩写。
F:Fist —— 一个元素的起始位置。
L:Last —— 另一个元素的终止位置,注意另一个这个词,后面会有具体代码的体现。
I:Invert —— 计算"F"与"L"的差异,包括位置,大小等,并将差异用transform属性,添加到终止元素上,让它回到起始位置,也是此项技术的核心。
P:Play —— 添加transtion 过渡效果,清除Invert阶段添加进来transform,播放动画。
直接上带代码:
// Flip.js
const Flip = (function () {
class FlipDom {
constructor(dom, duration = 0.5) {
this.dom = dom;
this.transition =
typeof duration === 'number' ? `${duration}s` : duration;
this.firstPosition = {
x: null,
y: null,
};
this.isPlaying = false;
this.transitionEndHandler = () => {
this.isPlaying = false;
this.recordFirst();
}
}
getDomPosition() {
const rect = this.dom.getBoundingClientRect();
return {
x: rect.left,
y: rect.top,
}
}
recordFirst(firstPosition) {
if (!firstPosition) {
firstPosition = this.getDomPosition()
}
this.firstPosition.x = firstPosition.x;
this.firstPosition.y = firstPosition.y;
}
* play() {
if (!this.isPlaying) {
this.dom.style.transition = 'none';
const lastPosition = this.getDomPosition();
const dis = {
x: lastPosition.x - this.firstPosition.x,
y: lastPosition.y - this.firstPosition.y,
}
if (!dis.x && !dis.y) {
return;
}
this.dom.style.transform = `translate(${-dis.x}px, ${-dis.y}px)`;
yield 'moveToFirst';
this.isPlaying = true;
}
this.dom.style.transition = this.transition;
this.dom.style.transform = 'none';
this.dom.removeEventListener('transitionend', this.transitionEndHandler);
this.dom.addEventListener('transitionend', this.transitionEndHandler);
}
}
class Flip {
constructor(doms, duration = 0.5) {
this.flipDoms = [...doms].map((it) => new FlipDom(it, duration));
this.flipDoms = new Set(this.flipDoms);
this.duration = duration;
this.flipDoms.forEach((it) => it.recordFirst());
}
addDom(dom, firstPosition) {
const flipDom = new FlipDom(dom, this.duration);
this.flipDoms.add(flipDom)
flipDom.recordFirst(firstPosition)
}
play() {
let gs = [...this.flipDoms].map((it) => {
const generator = it.play();
return {
generator,
iteratorResult: generator.next()
}
})
.filter((g) => !g.iteratorResult.done);
while (gs.length > 0) {
document.body.clientWidth;
gs = gs.map((g) => {
g.iteratorResult = g.generator.next();
return g;
})
.filter((g) => !g.iteratorResult.done);
}
}
}
return Flip;
})();
完整代码如下:
1
2
3
4
5
6