目录
前言
最近做uniapp 项目的时候需要实现拖拽排序功能,本来准备直接用 touchstart 、touchmove 、touchend 三剑客去实现的,但无意中发现uniapp 内置了 movable-area 和 movable-view组件,可快速实现该功能,索性就直接使用该组件,后续对拖拽排序做了一些扩展,希望对你有帮助。
准备
在开发之前需要先对movable-area 和 movable-view组件有一定的了解。
movable-area
可拖动的范围,可以理解为画布大小,元素在该区域内进行拖动。宽高必给,不然默认为10px
movable-view
- movable-view 必须设置width和height属性,不设置默认为10px
- movable-view 默认为绝对定位,top和left属性为0px
- 当movable-view小于movable-area时,movable-view的移动范围是在movable-area内;
- 当movable-view大于movable-area时,movable-view的移动范围必须包含movable-area(x轴方向和y轴方向分开考虑
该组件就是每一个拖拽的容器,必须在movable-area 组件内部,否则无法拖拽。它有一些常用参数和方法,这里我们主要了解下:x、y、direction、damping、animation以及change方法。
-
x: 即在画布中所处的left位置;
-
y: 即在画布中所处的top位置;
-
direction: 容器可移动的位置,可选:
all(可任意移动)、vertical(垂直移动)、horizontal(水平移动)、none(不可移动)。 -
damping: 设置移动的速度;
-
animation: 动画;
-
change方法:移动中的回调。
<template> <view> <movable-area> <movable-view :x="x" :y="y" direction="all">text</movable-view> </movable-area> <movable-area> <movable-view class="max" direction="all">text</movable-view> </movable-area> </view> </template> <script> export default { data() { return { x: 0, y: 0, }; }, }; </script> <style> movable-view { display: flex; align-items: center; justify-content: center; height: 100rpx; width: 100rpx; background: #1aad19; color: #fff; }movable-area {
height: 400rpx;
width: 400rpx;
margin: 50rpx;
background-color: #ccc;
overflow: hidden;
}.max {
width: 600rpx;
height: 600rpx;
}
</style>
简单实现
<template>
<view>
<movable-area style="width: 100%; height: 300px">
<movable-view
v-for="(item, index) in list"
direction="all"
:key="item.key"
:x="0"
:y="item.y"
@touchstart="handleDragStart(index)"
@change="handleMoving"
@touchend="handleDragEnd"
>
{{ item.title }}
</movable-view>
</movable-area>
</view>
</template>
<script>
export default {
data() {
return {
activeIndex: -1,
moveToIndex: -1,
list: [],
oldIndex: -1,
cloneList: [],
itemHeight: 50,
};
},
methods: {
initList(list = []) {
const newList = this.deepCopy(list);
this.list = newList.map((item, index) => {
return {
...item,
y: index * this.itemHeight,
key: Math.random() + index,
};
});
//拷贝一份初始list值
this.cloneList = this.deepCopy(this.list);
console.log(this.list);
},
//拖拽开始
handleDragStart(index) {
this.activeIndex = index;
this.oldIndex = index;
},
//拖拽中
handleMoving(e) {},
//拖拽结束
handleDragEnd(e) {},
//简单实现深拷贝。
deepCopy(val) {
return JSON.parse(JSON.stringify(val));
},
},
created() {
let list = [
{
title: "111",
key: 1,
},
{
title: "222",
key: 2,
},
{
title: "333",
key: 3,
},
];
this.initList(list);
},
};
</script>
<style scoped>
/* 可拖拽区域 */
movable-area {
height: 300px;
background-color: #f5f5f5;
}
/* 单个拖拽项 */
movable-view {
width: 100%;
height: 50px; /* 必须与 itemHeight 一致 */
line-height: 50px;
box-sizing: border-box;
background-color: #ffffff;
border-bottom: 1px solid #e5e5e5;
display: flex;
align-items: center;
justify-content: center;
}
</style>
上面基本实现了拖拽的基本结构,现在来实现核心逻辑 move 和 end 方法。
move方法
拖拽时持续计算中心点 → 得到目标索引 → 索引变化才重排 → 用虚拟新顺序计算其他项的 y → 当前项自由拖动,其它项自动让位
索引计算原理
- (y + 25) / 50
- = (50 + 25) / 50
- = 75 / 50
- = 1.5
- Math.floor(1.5) = 1
// ========================
// 拖拽过程中触发(手指移动)
// ========================
handleMoving(e) {
// 只处理手指触摸产生的拖拽事件
// 防止程序性 setData / 动画触发 change 导致逻辑错乱
if (e.detail.source !== "touch") return;
// 当前拖拽元素的实时坐标
const { x, y } = e.detail;
/**
* 计算当前拖拽元素"应该落在哪一行"
*
* y -> 当前元素顶部位置
* this.itemHeight / 2 -> 加半个高度,保证是以"中心点"来判断
* / this.itemHeight -> 换算成索引
* Math.floor -> 向下取整,得到目标索引
* Math.min -> 限制最大值,防止拖到列表外导致数组越界
*/
const currentY = Math.floor((y + this.itemHeight / 2) / this.itemHeight);
/**
* 限制目标索引的最大值
* 防止拖到列表外导致数组越界
*/
this.moveToIndex = Math.min(currentY, this.list.length - 1);
/**
* 只有在以下情况下才需要重新计算位置:
* 1. 目标位置发生变化
* 2. 拖拽前的索引合法
* 3. 当前目标索引合法
*/
if (
this.oldIndex !== this.moveToIndex &&
this.oldIndex !== -1 &&
this.moveToIndex !== -1
) {
// 使用 cloneList 的深拷贝,避免直接修改原始顺序
const newList = this.deepCopy(this.cloneList);
/**
* 将正在拖拽的元素插入到新的位置
* splice 说明:
* - 先从 activeIndex 删除 1 个元素
* - 再插入到 moveToIndex
*/
newList.splice(
this.moveToIndex,
0,
...newList.splice(this.activeIndex, 1)
);
/**
* 根据 newList 重新计算所有非激活项的 y 坐标
* 激活项(正在拖拽的项)由 movable-view 自己控制位置
*/
this.list.forEach((item, index) => {
// 跳过当前正在拖拽的元素
if (index !== this.activeIndex) {
// 找到该元素在新顺序中的索引
const itemIndex = newList.findIndex(
(val) => val[this.itemKey] === item[this.itemKey]
);
// 根据索引重新设置 y 坐标,实现"自动让位"效果
item.y = itemIndex * this.itemHeight;
}
});
// 记录本次移动的位置,避免重复计算
this.oldIndex = this.moveToIndex;
}
},
end方法
松手时,如果位置变了就真正修改数据顺序,然后重置坐标、抛出结果、清空拖拽状态。
// ========================
// 拖拽结束(手指松开)
// ========================
handleDragEnd(e) {
/**
* 判断是否需要真正调整数据顺序
* 必须满足:
* 1. 目标索引合法
* 2. 起始索引合法
* 3. 起始索引 !== 目标索引
*/
if (
this.moveToIndex !== -1 &&
this.activeIndex !== -1 &&
this.moveToIndex !== this.activeIndex
) {
/**
* 将 cloneList 中的数据顺序调整为最终顺序
* 这里才是真正"落地"的数据变更
*/
this.cloneList.splice(
this.moveToIndex,
0,
...this.cloneList.splice(this.activeIndex, 1)
);
}
/**
* 根据最终顺序重新初始化 list
* 统一重置每一项的 y 坐标,避免残留偏移
*/
this.initList(this.cloneList);
// 生成最终排序结果(浅拷贝一份,避免外部误改内部状态)
const endList = this.list.map((item) => item);
/**
* 向外通知排序结果
* input :用于 v-model
* end :用于拖拽结束回调
*/
this.$emit("input", endList);
this.$emit("end", endList);
// 重置拖拽状态
this.activeIndex = -1;
this.oldIndex = -1;
this.moveToIndex = -1;
},
实现效果

多列拖拽
上述是单列拖拽的方法,仅仅只需要控制y的值即可,但是如果是多列拖拽,还需考虑x的值,上述方法稍微小改造一下。 同时还需定义一个每行显示数量的字段column。
row = Math.floor(index / this.column)
| index | index / 4 | floor | 行号(row) |
|---|---|---|---|
| 0 | 0 | 0 | 第 0 行 |
| 1 | 0.25 | 0 | 第 0 行 |
| 2 | 0.5 | 0 | 第 0 行 |
| 3 | 0.75 | 0 | 第 0 行 |
| 4 | 1 | 1 | 第 1 行 |
| 5 | 1.25 | 1 | 第 1 行 |
y = 行号 × 每一行的高度,所以完整公式就是:
y = Math.floor(index / this.column) * this.getItemHeight
用「当前在第几列」×「每一列的宽度」,算出 item 在 X 轴的位置
index → 列号
| index | index % 4 | 列号(col) |
|---|---|---|
| 0 | 0 | 第 0 列 |
| 1 | 1 | 第 1 列 |
| 2 | 2 | 第 2 列 |
| 3 | 3 | 第 3 列 |
| 4 | 0 | 第 0 列 |
| 5 | 1 | 第 1 列 |
| 6 | 2 | 第 2 列 |
| 7 | 3 | 第 3 列 |
👉 一到新行,列号重新从 0 开始,
x = 列号 × 每一列的宽度,所以完整公式就是:
x: (index % this.column) * this.getItemWidth,
<template>
<view>
<movable-area style="width: 100%; height: 300px">
<!-- x:0 ==> item.x -->
<movable-view
v-for="(item, index) in list"
direction="all"
:key="item.key"
:x="item.x"
:y="item.y"
@touchstart="handleDragStart(index)"
@change="handleMoving"
@touchend="handleDragEnd"
>
{{ item.title }}
</movable-view>
</movable-area>
</view>
</template>
<script>
export default {
data() {
return {
activeIndex: -1,
moveToIndex: -1,
list: [],
oldIndex: -1,
cloneList: [],
itemHeight: 50,
itemKey: "key",
column: 4, //列数
getItemWidth: 0, //item宽度
getItemHeight: 0, //item高度
};
},
methods: {
initList(list = []) {
const newList = this.deepCopy(list);
this.list = newList.map((item, index) => {
// 获取屏幕宽度
const width = uni.getSystemInfoSync().windowWidth;
// item宽度
this.getItemWidth = width / this.column;
// item高度
this.getItemHeight = this.itemHeight;
return {
...item,
// y: index * this.itemHeight,
x: (index % this.column) * this.getItemWidth,
y: Math.floor(index / this.column) * this.getItemHeight,
key: Math.random() + index,
};
});
//拷贝一份初始list值
this.cloneList = this.deepCopy(this.list);
},
//拖拽开始
handleDragStart(index) {
this.activeIndex = index;
this.oldIndex = index;
},
handleMoving(e) {
if (e.detail.source !== "touch") return;
const { x, y } = e.detail;
const currentY = Math.floor((y + this.itemHeight / 2) / this.itemHeight);
const currentX = Math.floor(
(x + this.getItemWidth / 2) / this.getItemWidth
);
// currentY = 50+25=75 75/50=1.5 向下取整1
//currentX 0+46/96=0.48 向下取整0
// 如果 column=4
// 移动第5个
// currentY=1 currentX=0
// 1*4+0=4 索引是4
this.moveToIndex = Math.min(
currentY * this.column + currentX,
this.list.length - 1
);
if (
this.oldIndex !== this.moveToIndex &&
this.oldIndex !== -1 &&
this.moveToIndex !== -1
) {
const newList = this.deepCopy(this.cloneList);
newList.splice(
this.moveToIndex,
0,
...newList.splice(this.activeIndex, 1)
);
this.list.forEach((item, index) => {
if (index !== this.activeIndex) {
const itemIndex = newList.findIndex(
(val) => val[this.itemKey] === item[this.itemKey]
);
// item.y = itemIndex * this.itemHeight;
// 计算行和列
/**
* column = 4
* 第5个的位置 row = 1 「floor:向下取整(5/4)」 col = 1「5%4」
* 第6个的位置 row = 1 「floor:向下取整(6/4)」 col = 2「6%4」
*/
const row = Math.floor(itemIndex / this.column);
const col = itemIndex % this.column;
// 设置x和y
item.x = col * this.getItemWidth;
item.y = row * this.getItemHeight;
}
});
this.oldIndex = this.moveToIndex;
}
},
handleDragEnd(e) {
if (
this.moveToIndex !== -1 &&
this.activeIndex !== -1 &&
this.moveToIndex !== this.activeIndex
) {
this.cloneList.splice(
this.moveToIndex,
0,
...this.cloneList.splice(this.activeIndex, 1)
);
}
this.initList(this.cloneList);
const endList = this.list.map((item) => item);
this.$emit("input", endList);
this.$emit("end", endList);
this.activeIndex = -1;
this.oldIndex = -1;
this.moveToIndex = -1;
},
deepCopy(val) {
return JSON.parse(JSON.stringify(val));
},
},
created() {
let list = [
{
title: "111",
key: 1,
},
{
title: "222",
key: 2,
},
{
title: "333",
key: 3,
},
// 添加数据到六个
{
title: "444",
key: 4,
},
{
title: "555",
key: 5,
},
{
title: "666",
key: 6,
},
];
this.initList(list);
},
};
</script>
<style scoped>
movable-area {
height: 300px;
background-color: #f5f5f5;
}
movable-view {
width: 25%; /* 每个item的宽度 */
height: 50px;
line-height: 50px;
box-sizing: border-box;
background-color: #ffffff;
border-bottom: 1px solid #e5e5e5;
display: flex;
align-items: center;
justify-content: center;
}
</style>
列表滚动
如果列表是长列表,需要滚动的话,可以外面包裹一层scrollView标签,然后通过移动的时候修改scrollTop的值,去实现长列表拖动。
<scroll-view scroll-y :scroll-top="scrollTop" @scroll="handleScroll">
...其他代码
</scroll-view>
//js
handleScroll(e) {
this.scrollTop = e.detail.scrollTop;
},
// move的时候添加一个 scrollIntoView 方法
scrollIntoView() {
if (this.height === 'auto') return;
const { height, moveToIndex, getItemHeight, scrollTop } = this;
if ((moveToIndex + 1) * this.getItemHeight >= scrollTop + parseFloat(height)) {
this.scrollTop = Math.min(parseFloat(this.getAreaStyle.height), scrollTop + Math.ceil(moveToIndex / 2) * this.getItemHeight);
} else if ((moveToIndex - 1) * this.getItemHeight <= scrollTop) {
this.scrollTop = Math.max(0, scrollTop - Math.ceil(moveToIndex / 2) * this.getItemHeight);
}
},
使用说明
对于上面功能,已经封装成了一个组件,有兴趣可以看看。
参数说明
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|---|---|---|---|---|
| value / v-model | 绑定值 | array | - | [] |
| column | 每行展示数量 | number | - | 3 |
| width | 拖拽容器的宽度 | string | - | 100% |
| height | 拖拽容器的高度,若不传则默认根据column和每个盒子的高度自动生成 | string | auto | |
| itemKey | 唯一key,必传 | string | - | - |
| itemHeight | 每个拖拽盒子的高度 | string | - | 100px |
| direction | 可拖拽方向,具体看movable-view | string | all/vertical/horizontal/none | all |
| damping | 阻尼系数,用于控制x或y改变时的动画和过界回弹的动画,值越大移动越快 | number | - | 20 |
使用方法
由于微信小程序的特性,所以导致 movable-view for中嵌套插槽,会导致显示更新问题,所以微信小程序不能使用插槽,其他平台是没问题的。
<basic-drag v-model="list" :column="1" itemHeight="50px" itemKey="title">
<template #item="{element}">
<view class="drag-item">{{ element.title }}</view>
</template>
</basic-drag>
最后
拖拽组件源代码放在uniapp插件社区了,有兴趣可以下载下来看看,希望能对你有帮助,如果对该组件有问题,可以评论区留言。