01
拖拽排序是一种在网页设计和应用程序中常见的交互方式,允许用户通过鼠标或触摸操作来重新排列页面或界面上的元素。这种交互方式对于提升用户体验和操作效率具有重要意义。
在拖拽排序中,用户可以用鼠标或手指按住某个元素,然后将其拖动到新
的位置,从而实现对元素的重新排列。这种操作直观且灵活,使得用户可以根据自己的需求随时调整页面或界面的布局,提升了个性化体验。同时,拖拽排序也增加了用户的参与度和粘性,用户可以通过自由选择和排序感兴趣的内容,提升留存率和活跃度。
从技术实现的角度来看,拖拽排序主要依赖于前端技术的支持。例如,基于JavaScript的实现方法主要是通过监听鼠标或触摸事件来实现。在拖拽开始时,需要记录拖拽元素的位置,然后在拖拽过程中更新元素的位置,最后在拖拽结束时判断元素与其他元素的位置关系并进行排序。
在拖拽排序的应用场景中,列表排序和图片排序是两个典型的例子。在列表排序中,用户可以通过拖动列表项来改变它们的顺序,这在任务管理应用、待办事项列表等场景中非常常见。在图片排序中,用户可以通过拖动图片来改变它们的顺序,这在图片库或相册应用中较为常见。
02
在HTML中,我们给需要拖动的元素加上draggable="true"就可以实现拖拽效果了。在CSS中,我们设置了列表和拖拽项的样式。
<div class="list"> <div draggable="true" class="list-item">1</div> <div draggable="true" class="list-item">2</div> <div draggable="true" class="list-item">3</div> <div draggable="true" class="list-item">4</div> <div draggable="true" class="list-item">5</div> <div draggable="true" class="list-item">6</div></div>
* { 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.jsconst 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;})();
完整代码如下:
<!DOCTYPE html><html> <head> <meta charset="utf-8"> <title></title> <style> * { 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; } .list-item.moving { background: transparent; color: transparent; border: 1px dashed #ccc; } </style> </head> <body> <div class="list"> <div draggable="true" class="list-item">1</div> <div draggable="true" class="list-item">2</div> <div draggable="true" class="list-item">3</div> <div draggable="true" class="list-item">4</div> <div draggable="true" class="list-item">5</div> <div draggable="true" class="list-item">6</div> </div> </body> <script src="./flip.js"></script> <script> const list = document.querySelector('.list'); // 记录被拖拽的元素 let sourceNode; let flip; list.ondragstart = (e) => { setTimeout(() => { e.target.classList.add('moving') }, 0) sourceNode = e.target; flip = new Flip(list.children, 0.5); } 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); } // 调用flip动画play方法 flip.play(); } list.ondragend = () => { sourceNode.classList.remove('moving'); } </script></html>