我的个人项目中待办模块的一个需求是拖拽调整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"></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高度来调整
这里要实现的效果是拖拽后实现如下图所示的灰色区域(图片来自我我个人项目)
(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, 可以看出第二个数据远远大于第一个数据。
我们不需要每几百秒毫米执行一次绑定的事件回调函数,所以使用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 "></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>