12

前端拖动排序实践

 3 years ago
source link: https://zhuanlan.zhihu.com/p/128652422
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

前端拖动排序实践

前端的拖动排序也是一直很想实践的对象之一。

排险者露出那毫无特点的微笑说:“这很难理解吗?当生命意识到宇宙奥秘的存在时,距它最终解开这个奥秘只有一步之遥了。”看到人们仍不明白,他接着说,“比如地球生命,用了四十多亿年时间才第一次意识到宇宙奥秘的存在,但那一时刻距你们建成爱因斯坦赤道只有不到四十万年时间,而这一进程最关键的加速期只有不到五百年时间。如果说那个原始人对字宙的几分钟凝视是看到了一颗宝石,其后你们所谓的整个人类文明,不过是弯腰去拾它罢了。

复习D&D

First Thing First,最基础的事情就是复习一下D&D的API。主要是要知道拖动源和拖动目标各个事件

拖动源事件有

  • ondragstart
  • ondrag
  • ondragend

拖动目标事件有

  • ondragenter
  • ondragover
  • ondragleave
  • ondrop

有一点要注意,在可拖动元素上要设置draggable = "true",我一开始按照组件里的快捷方式就仅放置了draggable,结果连拖动的苗头都看不到。

基本DOM

 <ul id="drag" class="drag">
   <li>Item 1</li>
   <li>Item 2</li>
   <li>Item 3</li>
   <li>Item 4</li>
   <li>Item 5</li>
 </ul>

JavaScript给这些<li>元素加上draggable属性。

 const lis = document.querySelectorAll(".drag li");
 for (let i = 0; i < lis.length; i++) {
     lis[i].setAttribute("draggable", true);
 }

现在各<li>元素都是可拖动的了,但没有任何其他的效果。

加上样式美化元素

 ul {
   list-style-type: disc;
   margin-block-start: 0;
   margin-block-end: 0;
   margin-inline-start: 0;
   margin-inline-end: 0;
   padding-inline-start: 0;
 }
 li {
   height: 50px;
   line-height: 50px;
   border: 1px solid #aaaaaa;
   width: 200px;
   padding: 0 10px;
 }

目前的效果如下

第一步普通排序

普通排序的目标就是能在拖动的时候实现最基本的排序功能,交换元素的位置次序。

注意,这里最合理的方式是使用事件代理,在<ul>上监听<li>冒泡来的事件,根据event.target判断事件源是哪个元素,这里我偷懒了,在每一个<li>元素上增加事件监听。

首先要把拖动源元素缓存下来。

 let draggingElement;
 for (let i = 0; i < lis.length; i++) {
   lis[i].setAttribute("draggable", true);
   lis[i].addEventListener("dragstart", (event) => {
     draggingElement = event.target;
   });
 }

接下来就是监听拖动后的变化了,思路是,当拖动某个节点进入另一个节点之后,交换两者的次序。

因为只需要第一次进入另一个节点后交换次序,所以只要监听dragenter事件即可。

 lis[i].addEventListener("dragenter", (event) => {
     //...
 });

此时源节点draggingElement和目标节点event.target都获得了,要做的就仅是交换两者节点的位置。要交换两者的位置,就要用到insertBeforeAPI

但注意insertBefore仅是一个单向的动作,没有insertAfter可供调用,所以在交换之前要判断一下拖动的源节点和目标节点哪个在前哪个在后。于是交换位置的代码如下

 const order = Array.from(node.parentElement.children).indexOf(node);
 //从大的序号移入到小的序号
 if (draggingElementOrder > order) {
   node.parentElement.insertBefore(draggingElement, node);
 }
 //从小的序号移入到大的序号
 else {
   //节点不是最后一个
   if (node.nextElementSibling) {
     node.parentElement.insertBefore(draggingElement, node.nextElementSibling);
   }
   // 节点是最后一个了,不能再用insertBefore
   else {
     node.parentElement.appendChild(draggingElement);
   }
 }

其中draggingElementOrder来自于

 draggingElementOrder = Array.from(draggingElement.parentElement.children).indexOf(draggingElement);

要注意的是这句必须在每次dragenter的时候调用,因为拖动源的位置是时变的,假如这句放在了dragstart里,会发生什么?来做一个实验看看(录屏效果一般,会有些跳帧,将就着看吧)

v2-71cb59315c9f0870e556ab0bf6f97fa0_b.jpg

可以发现当连续拖动时,并不一定能实现排序效果,那是因为在第一次的排序后,元素的位置顺序已经发生了改变,而dragstart事件没有被重新出发,导致draggingElementOrder并不是当前拖动源节点真正在列表里的顺序。

draggingElementOrder的赋值放入dragenter事件中,整个拖动排序的效果就达成了,完整代码如下

 const lis = document.querySelectorAll(".drag li");
 let draggingElementOrder;
 let draggingElement;
 for (let i = 0; i < lis.length; i++) {
   lis[i].setAttribute("draggable", true);
   lis[i].addEventListener("dragstart", (event) => {
     draggingElement = event.target;
   });
 ​
   lis[i].addEventListener("dragenter", (event) => {
     //每次都要新计算,因为有可能已经换位了
     draggingElementOrder = Array.from(draggingElement.parentElement.children).indexOf(draggingElement);
     const node = event.target;
     draggingElementPosition = draggingElement.getBoundingClientRect();
     const order = Array.from(node.parentElement.children).indexOf(node);
     //从大的序号移入到小的序号
     if (draggingElementOrder > order) {
       node.parentElement.insertBefore(draggingElement, node);
     }
     //从小的序号移入到大的序号
     else {
       //节点不是最后一个
       if (node.nextElementSibling) {
         node.parentElement.insertBefore(draggingElement, node.nextElementSibling);
       }
       // 节点是最后一个了,不能再用insertBefore
       else {
         node.parentElement.appendChild(draggingElement);
       }
     }
   });
 }
v2-4303886241c73ba6983b2dd9f468269d_b.jpg

第二步文艺排序

所谓文艺排序就是给排序过程加入动画。这一步真是调试得我老命都要付出去了,可见文艺范不是随便什么人都能装的。

一开始我的思路是正确的,就是计算位移的距离,然后让节点交换移动动画化。但是具体落实到代码上,就一步都不能出错,包括调用次序、异步队列、属性设置等,稍错一步,满盘皆输,各种奇奇怪怪的问题让我一次又一次的怀疑人生,最后实现出效果的代码没那么复杂,但是中间过程实在是太坎坷了。

我在做动画之前特意用sortable.js实现了一个排序效果,然后把动画时间调整的很长,观察动画时的变化,发现节点上被加入了transform以及transition属性,觉得能够印证了我的思路。

首先还是在节点交换前后,记录下节点的位置,因为节点是交换次序,所以只需要计算其中一个即可,另一个取反就行。

 // 交换前
 draggingElementPosition = draggingElement.getBoundingClientRect();
 // 交换后
 const currentPosition = draggingElement.getBoundingClientRect();

先把两个节点做transform移动回原位置

 draggingElement.style.transform = `translateY(${-currentPosition.y + draggingElementPosition.y}px)`;
 node.style.transform = `translateY(${currentPosition.y - draggingElementPosition.y}px)`;

然后加上动画

 draggingElement.style.transition = "transform .1s linear";
 node.style.transition = "transform .1s linear";

要注意的是在动画过程中不能再次触发排序交换,所以加上一个animating的标记

 if (node !== draggingElement && !animating) {
     //...
     //开始动画
     animating = true;
 }

在动画结束后必须要打扫战场——重置标记以及清理节点上的属性,这里我采用了最优雅的方式,我并没有采用延时setTimeout方法,而是使用了transitionend事件。

 if (node !== draggingElement && !animating) {
     //...
     //开始动画
     animating = true;
     node.addEventListener("transitionend", transitionEnd);
 }
 ​
 function transitionEnd() {
   //不能用箭头函数,因为有this
   this.style.transform = null;
   this.style.transition = null;
   draggingElement.style.transform = null;
   draggingElement.style.transition = null;
   this.removeEventListener("transitionend", transitionEnd);
   animating = false;
 }

目前基本动画效果是有了,但实际中还会有一些抖动、跳跃的问题,经过调试我又做了两个优化的地方

  1. dragenter事件改为dragover事件,由node !== draggingElement && !animating条件控制不重复触发
  2. 把添加transition放在了异步队列中,并在transition添加完后加入translateY(0)的属性让节点回到交换后的位置

注意这里万万不能用同步

 node.style.transform = `translateY(${currentPosition.y - draggingElementPosition.y}px)`;
 draggingElement.style.transform = `translateY(${-currentPosition.y + draggingElementPosition.y}px)`;
 draggingElement.style.transition = "transform .1s linear";
 node.style.transition = "transform .1s linear";
 node.style.transform = "translateY(0)";
 draggingElement.style.transform = "translateY(0)";

浏览器并不会帮你做出过渡效果,而是直接计算节点的最终位置,这个用脚指头想想也明白,浏览器只对最终的状态负责,能优化的地方都尽量帮你优化。

最终的代码如下

 const lis = document.querySelectorAll(".drag li");
 let draggingElementOrder;
 let draggingElement;
 let draggingElementPosition;
 let animating;
 for (let i = 0; i < lis.length; i++) {
   lis[i].setAttribute("draggable", true);
   lis[i].addEventListener("dragstart", (event) => {
     draggingElement = event.target;
   });
 ​
   lis[i].addEventListener("dragover", (event) => {
     //不能用dragenter,因为dragenter只会触发一次,有时候鼠标动作是来回进出的
 ​
     //每次都要新计算,因为有可能已经换位了
     draggingElementOrder = Array.from(draggingElement.parentElement.children).indexOf(draggingElement);
     const node = event.target;
     if (node !== draggingElement && !animating) {
       draggingElementPosition = draggingElement.getBoundingClientRect();
       const order = Array.from(node.parentElement.children).indexOf(node);
       //从大的序号移入到小的序号
 ​
       if (draggingElementOrder > order) {
         node.parentElement.insertBefore(draggingElement, node);
       }
       //从小的序号移入到大的序号
       else {
         //节点不是最后一个
         if (node.nextElementSibling) {
           node.parentElement.insertBefore(draggingElement, node.nextElementSibling);
         }
         // 节点是最后一个了,不能再用insertBefore
         else {
           node.parentElement.appendChild(draggingElement);
         }
       }
       const currentPosition = draggingElement.getBoundingClientRect();
 ​
       node.style.transform = `translateY(${currentPosition.y - draggingElementPosition.y}px)`;
       draggingElement.style.transform = `translateY(${-currentPosition.y + draggingElementPosition.y}px)`;
       animating = true;
       requestAnimationFrame(() => {
         draggingElement.style.transition = "transform .1s linear";
         node.style.transition = "transform .1s linear";
         node.style.transform = "translateY(0)";
         draggingElement.style.transform = "translateY(0)";
         node.addEventListener("transitionend", transitionEnd);
       });
     }
   });
   function transitionEnd() {
     //不能用箭头函数,因为有this
     this.style.transform = null;
     this.style.transition = null;
     draggingElement.style.transform = null;
     draggingElement.style.transition = null;
     this.removeEventListener("transitionend", transitionEnd);
     animating = false;
   }
 }

最终的效果如下(没有录出动画的效果来 :( )

v2-a1e413b3956964101344a5ece1c69a75_b.jpg

虽然最后的代码并不复杂,实际调试中实在是不顺,各种不出效果、抖动、连续两次交换位置,无数奇特的情况让我一直想放弃,好在最终还是坚持下来了。

第三步加入手柄

手柄这块我倒是想复杂了,其实只要在手柄点击的时候再给节点加上draggable属性,在鼠标抬起时去掉draggable属性即可。

给手柄加上样式

 .handler {
   width: 30px;
   height: 30px;
   background: #666666;
   display: inline-block;
 }
 .handler:hover {
   cursor: all-scroll;
 }

原谅我偷懒又一次没有使用事件代理

 const handler = lis[i].querySelector(".handler");
 handler.addEventListener("mousedown", () => {
   lis[i].setAttribute("draggable", true);
 });
 handler.addEventListener("mouseup", () => {
   lis[i].removeAttribute("draggable");
 });

唯一一点要小心的是在dragover事件中,event.target是手柄了,而不是<li>

这次用33fps的帧率,终于把动画效果录出来了,就是有点残影。

v2-92b4983b220e0ae7bce50912369544be_b.jpg

再提高一下

让我们把视野从一维扩展到二维。

这次只放一个容器

 <div id="container"></div>

子项也是由代码放入

 const container = document.getElementById("container");
 for (let i = 0; i < 20; i++) {
   const card = document.createElement("DIV");
   card.classList.add("card");
   card.textContent = i + 1;
   card.setAttribute("draggable", true);
   container.appendChild(card);
 }

把样式先加一下

 #container {
   width: 600px;
 }
 .card {
   float: left;
   width: 100px;
   height: 60px;
   border: 1px solid #aaaaaa;
   border-radius: 5px;
   margin: 10px;
 }
 .card:hover {
   cursor: all-scroll;
 }

还是先做不带动画的,这次使用了事件代理,说实话,改成代理后,我原来堵着的心里终于顺畅了

 let draggingCard;
 let draggingCardOrder;
 let draggingCardPosition;
 let animating;
 container.addEventListener("dragstart", (event) => {
   draggingCard = event.target;
 });
 ​
 container.addEventListener("dragover", (event) => {
   draggingCardOrder = Array.from(draggingCard.parentElement.children).indexOf(draggingCard);
   const node = event.target;
   if (node !== draggingCard && !animating) {
     const order = Array.from(node.parentElement.children).indexOf(node);
     //从大的序号移入到小的序号
     if (draggingCardOrder > order) {
       node.parentElement.insertBefore(draggingCard, node);
     }
     //从小的序号移入到大的序号
     else {
       //节点不是最后一个
       if (node.nextElementSibling) {
         node.parentElement.insertBefore(draggingCard, node.nextElementSibling);
       }
       // 节点是最后一个了,不能再用insertBefore
       else {
         node.parentElement.appendChild(draggingCard);
       }
     }
   }
 });

二维版的动画不同的是在拖动源和拖动目标之间的所有子项都要进行动画,我用一个数组来记录排序前的各节点和它们的位置,然后在排序后,做逆向位置的动画,完整代码如下

 ![drag-effect5](drag-effect5.gif)let draggingCard;
 let draggingCardOrder;
 let originPosition;
 let animating;
 container.addEventListener("dragstart", (event) => {
   draggingCard = event.target;
 });
 ​
 container.addEventListener("dragover", (event) => {
   draggingCardOrder = Array.from(draggingCard.parentElement.children).indexOf(draggingCard);
   const node = event.target;
   if (node !== draggingCard && !animating) {
     const order = Array.from(node.parentElement.children).indexOf(node);
     //从大的序号移入到小的序号
 ​
     if (draggingCardOrder > order) {
       originPosition = []; //记录原始位置
       for (let i = order; i <= draggingCardOrder; i++) {
         originPosition.push({
           card: container.children[i],
           position: container.children[i].getBoundingClientRect(),
         });
       }
       node.parentElement.insertBefore(draggingCard, node);
     }
     //从小的序号移入到大的序号
     else {
       originPosition = []; //记录原始位置
       for (let i = draggingCardOrder; i <= order; i++) {
         originPosition.push({
           card: container.children[i],
           position: container.children[i].getBoundingClientRect(),
         });
       }
       //节点不是最后一个
       if (node.nextElementSibling) {
         node.parentElement.insertBefore(draggingCard, node.nextElementSibling);
       }
       // 节点是最后一个了,不能再用insertBefore
       else {
         node.parentElement.appendChild(draggingCard);
       }
     }
     //变更后比较差异
     for (let card of originPosition) {
       //这次变化的不止是Y轴方向
       card.card.style.transform = `translate(${-card.card.getBoundingClientRect().x + card.position.x}px, ${
         -card.card.getBoundingClientRect().y + card.position.y
       }px)`;
     }
 ​
     animating = true;
     requestAnimationFrame(() => {
       for (let card of originPosition) {
         card.card.style.transition = "transform .2s linear";
         card.card.style.transform = "translate(0, 0)";
       }
       draggingCard.addEventListener("transitionend", transitionEnd);
     });
     function transitionEnd() {
       for (let card of originPosition) {
         card.card.style.transition = null;
         card.card.style.transform = null;
       }
       draggingCard.removeEventListener("transitionend", transitionEnd);
       animating = false;
     }
   }
 });

录屏还是有些残影,将就着看吧

v2-dfd282ea984e2d75853dfea171efdbc1_b.jpg



About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK