[vuejs] 写一个可拖拽调整顺序的list

450 阅读6分钟

我的个人项目中待办模块的一个需求是拖拽调整list中的元素可实现顺序调整。写这个当时也费了不少时间,最近在写后端代码,回顾了一下前端这部分代码,把思路整理下来。

基本思路

自己最开始的思路难点是:当拖拽调整顺序后,怎么能把这个新顺序传递给后端?如果给数据库中每个item都添加一个排序索引值(orderIndex)字段(第一个item索引是1,第二个是2...),那么调整顺序后,需要全部重新排序,效率比较低。

后来查了查,重新排序的思路我参考了 这篇文章。 基本思路是:

  • 给每个item设置的orderIndex间距很大,例如:第一个是65536,第二个是65535 * 2, 第三个是65536 * 3, 当我们把第三个元素拖到第一个之前时,第三个元素orderIndex变成( 65536+ 0 ) / 2, 如果把第三个元素拖到第一个和第二个之间,那么它的orderIndex是(65536 + 65536 * 2)/2。这样一来,每次我们只需要改变被拖拽的元素的orderIndex即可,也不影响其他元素。

  • 这种方法的缺点是,假设操作的次数足够多,orderIndex变得非常小,那么可能会出现调整顺序失效的问题。我参考的文章中提出的一种方法是设置一个安全值,当orderIndex小于这个安全值时,所有数据的orderIndex进行重新生成。

  • 假设我们不停地往list最前面拖拽元素,那么第一次拖拽时新index是65536/2, 第二次拖拽时是65536/2/2 ....依次类推,到第16次,index便成为了1(2的16次方是65536),此时除非将index设置为浮点数,否则排序会失效。

根据此思路,这个小demo里的数据基本样子为:

{
  id: 2,
  orderIndex: 131072,
  content: '赤井秀一'
 }
  • 此外,在前端思路方面,主要是用drag事件来实现。主要分为以下几个步骤来实现:

1. 实现hover到移动图标时拖拽

(1)实现可拖拽

实现可拖拽很简单,只要加上draggable = “true”即可,目前的html结构如下。

         <div class="container">
            <ul class="draggableList">
                <li v-for="item in items" :key="item.id">
                    <div draggable="true" class="draggableList__content" class="draggableList__item">{{item.content}}</div>
                </li>
            </ul>
        </div>

(2) 实现hover后出现移动图标,并且鼠标变成移动图标的样子。

html中加一个button,设置成iconfont移动图标

<div draggable="true" class="item__content" class="draggableList__item">
    {{item.content}}
    <button class="item__movebtn"><span class="iconfont">&#xe67b;</span></button>
</div>

css设置hover和鼠标效果

.item__movebtn {
      float: right;
      opacity: 0;
  }
  
  .item__content:hover .item__movebtn {
      opacity: 1;
  }
  
  .item__movebtn:hover {
      cursor: move
  }

2. 实现item拖拽后出现相应的placeholder,placeholder高度根据item高度来调整

这里要实现的效果是拖拽后实现如下图所示的灰色区域(图片来自我我个人项目)

image.png

(1)html中加入placehoder并设置好样式

只需要在每个item之前都渲染一个placeholder的div即可,最后一个item还需要在它的后面也渲染出一个placeholder,默认情况下它是隐藏的,这里方便起见写一个class名为hide(display: none;)。

(2)实现拖拽到某一区域时出现上方的placeholder

  • 先实现当拖拽经过一个item时,这个item的上方出现placeholder,也就是说,我们需要对每个item绑定一个dragenter事件。

与dragenter类似的有一个dragover事件,根据mdn的文档:

The dragover event is fired when an element or text selection is being dragged over a valid drop target (every few hundred milliseconds).

The dragenter event is fired when a dragged element or text selection enters a valid drop target.

从上可以看出,当经过一个区域时,每几百毫米dragover就会被激活,而dragenter仅在经过区域时激活。

我在vue中写了两个相应式数据,第一个在dragenter时加1, 第二个在dragover时加1, 可以看出第二个数据远远大于第一个数据。 image.png 我们不需要每几百秒毫米执行一次绑定的事件回调函数,所以使用dragenter。

            // 当拖拽进入到某个item区域时的处理
            handleDragEnterItem() {
                // 清除之前出现过的占位符
                this.removePlaceholder()

                // 获取经过的item的占位符元素
                const $placeholder = event.currentTarget.firstElementChild
                if ($placeholder) {
                    $placeholder.classList.remove('hide')
                    $placeholder.classList.add('show')
                }
            },

            //清除之前出现过的占位符
            removePlaceholder() {
                const $placeholder = document.querySelector('.show');
                if ($placeholder) {
                    $placeholder.classList.add('hide')
                    $placeholder.classList.remove('show')
                }
            },

通过上面的代码,当我们进入某一item时,其上方会出现placeholder区域,其他placehoder会消失。

(3)实现最下方placehoder的出现

要实现的效果是:当进入到最后一个item时的下方区域时,出现最后一个placeholder。 因为最后一个item下方区域已经不是<li>了,所以我们要给整个容器绑定一个dragenter时间。

除了下面的代码外,前面的子元素要注意阻止事件冒泡

            handleDragEnterList() {
                event.stopPropagation()
                event.preventDefault()
                const $placeholder = document.querySelector('.item__placeholder--last');
                this.removePlaceholder()

                $placeholder.classList.add('show')
                $placeholder.classList.remove('hide')
            },

(4)实现placehoder根据被拖拽元素的高度改变

这里需要实现在enter后获取元素高度并将placeholder高度值设置为元素高度 可以把item中的内容元素绑定一个dragstart事件,获取到height,然后传递给其他事件。

拖拽中有一个dataTransfer对象可以用来传输数据。基本的用法是event.dataTransfer.setData(key, value)event.dataTransfer.getData(key, value) 但是getData只能在drop时用(数据传输安全性的考虑),而我们需要在enter阶段就获取高度数值,为了达到此目的,直接把高度放在key的位置上,然后用event.dataTransfer.types[index]来获取。

这部分的关键代码如下:

            // 开始拖拽时的处理
            handleDragStart() {
                // 传输被拖拽元素的高度
                // 只有在drop时才能用dataTransfer.getData获取值,所以这里为了在enter阶段能获取高度,将高度直接设置到类似key(而不是value)的位置
                event.dataTransfer.setData(event.currentTarget.offsetHeight, '')
            }
            
            
            // 进入item区域时的处理
             handleDragEnterItem() {
               .... 

                // 获取经过的item的上方占位符元素并设置高度、令其显示
                const $placeholder = event.currentTarget.firstElementChild
                if ($placeholder) {
                    $placeholder.style.height = event.dataTransfer.types[0] + 'px'
                 ....
                }

            },

(5) 实现拖拽时元素原位置上不再显示该元素

如果在drag开始后给被拖拽元素加上hide类名,会导致拖拽过程中该元素也不可见。这是因为添加类名会发生drag真正开始前,为了让添加类名可以在开始drag后再出现,我们可以加上一个timeout,把事件设置为0。或者也可以用Window.requestAnimationFrame

3.实现拖拽停止后更新数据

思路: 直接利用drop事件即可。

  • 当drop后,我们需要拿到的数据有: 被拖拽的item的id,index(为了判定是否被拖拽到了自己的原来位置)、被拖入到的placerholder对应的item的index。

  • 被拖拽item的id和index可以用dataTransfer来传输,但是placerholder对应的item的index因为是动态变化的,可以直接给每个placeholder添加一个属性,记录其对应的index。

  • 关于这里的目标index:第一个item之前的index0, 最后一个item的index是items.length,例如,当传入后端时,如果传输的index是0,代表我们要把数据调整到最前面,如果传入的index是2, 则代表这个数据要放在第3个数据之前。(当然这里实际的调换顺序操作是发生在后端,但这里我们就只在前端操作)

(1)实现拖拽回原位;

需要做的有:

  • 判定是否拖拽回原位
  • 令占位符消失
  • 去除被拖拽元素的‘hide’class名。

其中要做到第二个,我们要获取被拖拽的元素,但要注意到的是,在drop事件的处理回调中,event对应的是被拖拽到的位置的元素,而不是被拖拽的元素。所以我们需要用dragend事件来获取被拖拽的元素。

    handleDropItem(event) {
          //获取id和index数值
          const id = event.dataTransfer.getData('id')
          const index = event.dataTransfer.getData('index')
          const destIndex = document.querySelector('.show').getAttribute('data-index')

          // 不进行位置改变的情况:
          // 移动到下一个元素之前的位置,也就是自己原来的位置,此时(index+1 = dest)
          // getData 中拿到的是字符串,这里需要进行转化
          if (Number(index) + 1 == destIndex) {
              return
          }
      },

      // dragend处理 
      handleDragEnd() {
          this.removePlaceholder()
          // 需要在该事件处理中获取被拖拽的元素
          const $dragged = event.currentTarget
          $dragged.classList.remove('hide')
      },

(2)实现位置调整

当拖拽到的地方不是原位置时,则修改其orderIndex即可:

           reorderList(id, desIndex) {
                // 找到需要调整顺序的元素
                const target = this.items.find(item => item.id == id)
                if (desIndex == 0) {
                    // 需要调整到最前面
                    target.orderIndex = this.items[0].orderIndex / 2
                } else if (desIndex == this.items.length) {
                    // 需要调整到最后面
                    target.orderIndex = this.items[this.items.length - 1].orderIndex + 65536;
                } else {
                    // 插在两者中间
                    const prevOrderIndex = this.items[desIndex - 1].orderIndex
                    const nextOrderIndex = this.items[desIndex].orderIndex
                    target.orderIndex = (prevOrderIndex + nextOrderIndex) / 2
                }
                // 按照orderIndex排序
                this.items.sort((a, b) => {
                    return a.orderIndex - b.orderIndex
                })
            }

完整代码

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="./vue.global.js"></script>
    <link rel="stylesheet" href="https://at.alicdn.com/t/c/font_3779933_k9lchpkrira.css?spm=a313x.7781069.1998910419.53&file=font_3779933_k9lchpkrira.css">
</head>

<body>
    <div id="app">
        <div class="container">
            <ul class="draggableList" @dragenter="handleDragEnterList" @dragover="handleDragOver" @drop="handleDropItem">
                <li v-for="(item, index) in items" :key="item.id" class="item">
                    <div class="item__container" @dragenter="handleDragEnterItem" @dragover="handleDragOver">
                        <div class="item__placeholder hide" :data-index="index"></div>
                        <div draggable="true" class="item__content" :id="'item'+ item.id" class="draggableList__item" @dragstart="handleDragStart(item.id, index)" @dragend="handleDragEnd">{{item.content}}
                            <button class=" item__movebtn "><span class="iconfont ">&#xe67b;</span></button>
                        </div>
                        <div class="item__placeholder item__placeholder--last hide " v-if="index==items.length-1 " :data-index="index+1"></div>
                    </div>
                </li>
            </ul>
        </div>
    </div>
</body>
<script>
    const {
        createApp
    } = Vue

    createApp({
        data() {
            return {
                items: [{
                    id: 1,
                    orderIndex: 65536,
                    content: '柯南'
                }, {
                    id: 2,
                    orderIndex: 131072,
                    content: '赤井秀一'
                }, {
                    id: 3,
                    orderIndex: 196608,
                    content: '灰原哀'
                }, {
                    id: 4,
                    orderIndex: 262144,
                    content: '毛利兰'
                }]
            }
        },

        methods: {
            // 开始拖拽时的处理
            handleDragStart(id, index) {
                const $dragged = event.currentTarget
                    // 传输被拖拽元素的高度
                    //  //只有在drop时才能用dataTransfer.getData获取值,所以这里为了在enter阶段能获取高度,将高度直接设置到类似key(而不是value)的位置
                event.dataTransfer.setData($dragged.offsetHeight, '')
                event.dataTransfer.setData('id', id)
                event.dataTransfer.setData('index', index)

                // 使得拖拽开始后原来位置上的元素不再显示,但是拖拽过程中可以看到被拖拽的该元素
                setTimeout(() => {
                    $dragged.classList.add('hide')
                }, 0)
            },

            // 当拖拽进入到某个item区域时的处理
            handleDragEnterItem() {
                event.stopPropagation()
                event.preventDefault()

                // 清除之前出现过的占位符
                this.removePlaceholder()

                // 获取经过的item的上方占位符元素并设置高度、令其显示
                const $placeholder = event.currentTarget.firstElementChild
                if ($placeholder) {
                    $placeholder.style.height = event.dataTransfer.types[0] + 'px'
                    $placeholder.classList.remove('hide')
                    $placeholder.classList.add('show')
                }
            },

            //当拖拽进入到list里item之外时
            handleDragEnterList() {
                event.stopPropagation()
                event.preventDefault()
                const $placeholder = document.querySelector('.item__placeholder--last');
                this.removePlaceholder()
                $placeholder.style.height = event.dataTransfer.types[0] + 'px'
                $placeholder.classList.add('show')
                $placeholder.classList.remove('hide')
            },

            // drop结束
            handleDropItem(event) {
                //获取id和index数值
                const id = event.dataTransfer.getData('id')
                const index = event.dataTransfer.getData('index')
                const destIndex = document.querySelector('.show').getAttribute('data-index')

                // 不进行位置改变的情况:
                // 移动到下一个元素之前的位置,也就是自己原来的位置,此时(index+1 = dest)
                // getData 中拿到的是字符串,这里需要进行转化
                if (Number(index) + 1 === destIndex) {
                    return
                }
                this.reorderList(id, destIndex)
            },

            // dragend处理 
            handleDragEnd() {
                this.removePlaceholder()
                    // 需要在该事件处理中获取被拖拽的元素
                const $dragged = event.currentTarget
                $dragged.classList.remove('hide')
            },

            handleDragOver() {
                event.stopPropagation()
                event.preventDefault()
            },

            reorderList(id, desIndex) {
                // 找到需要调整顺序的元素
                const target = this.items.find(item => item.id == id)
                if (desIndex == 0) {
                    // 需要调整到最前面
                    target.orderIndex = this.items[0].orderIndex / 2
                } else if (desIndex == this.items.length) {
                    // 需要调整到最后面
                    target.orderIndex = this.items[this.items.length - 1].orderIndex + 65536;
                } else {
                    // 插在两者中间
                    const prevOrderIndex = this.items[desIndex - 1].orderIndex
                    const nextOrderIndex = this.items[desIndex].orderIndex
                    target.orderIndex = (prevOrderIndex + nextOrderIndex) / 2
                }
                // 按照orderIndex排序
                this.items.sort((a, b) => {
                    return a.orderIndex - b.orderIndex
                })
            },

            //清除之前出现过的占位符
            removePlaceholder() {
                const $placeholder = document.querySelector('.show');
                if ($placeholder) {
                    $placeholder.classList.add('hide')
                    $placeholder.classList.remove('show')
                }
            },
        }
    }).mount('#app')
</script>



</html>
<style>
    .draggableList {
        height: 400px;
        border: 1px solid rgb(25, 24, 113);
    }
    
    .item {
        list-style: none;
        width: 300px;
        margin-bottom: 4px;
    }
    
    #item1 {
        height: 30px;
    }
    
    #item2 {
        height: 40px;
    }
    
    #item3 {
        height: 60px;
    }
    
    .item__content {
        border: 1px solid skyblue;
        width: 100%;
        background-color: pink;
    }
    
    .item__placeholder {
        margin: 10px 0;
        width: 100%;
        background-color: rgb(204, 204, 204);
        border-radius: 30px;
        height: 30px;
    }
    
    .item__movebtn {
        float: right;
        opacity: 0;
    }
    
    .item__content:hover .item__movebtn {
        opacity: 1;
    }
    
    .item__movebtn:hover {
        cursor: move
    }
    
    .hide {
        display: none;
    }
    
    .show {
        display: block;
    }
</style>