玩转 UniApp/小程序拖拽组件:自定义 UI,实现丝滑列表拖拽排序
在 UniApp 或小程序开发中,拖拽排序是一个非常常见的交互需求,比如自定义功能菜单、待办事项排序等。本文将手把手教你实现一个可自定义 UI 的通用拖拽组件,支持长按触发拖拽、边界限制、自动吸附排序,且能适配不同高度的列表项,满足多样化的业务场景。

一、组件核心设计思路
该拖拽组件的核心逻辑围绕 触摸事件监听 + 坐标计算 + 数组重排 展开:
- 监听长按事件触发拖拽权限,避免误触;
- 监听触摸移动事件,实时计算拖拽元素的坐标,并判断是否与其他元素重叠;
- 触摸结束时,根据最终坐标重新排列数组,并对外抛出排序后的结果;
- 支持自定义列表项高度,适配不同 UI 场景。
二、组件代码逐段解析
2.1 模板结构(template):搭建拖拽容器与列表项
模板部分是组件的 UI 骨架,主要包含拖拽容器和可拖拽的列表项,核心逻辑如下:
<template>
<!-- 拖拽容器:动态设置高度,宽度固定为 690upx(可根据需求调整) -->
<view class="drag" :style="'height: ' + vaheight + 'px; width: 690upx'">
<!-- 拖拽列表项:循环渲染数据,绑定触摸事件 -->
<view
class="dragItem"
style="width: 650upx"
v-for="(item, index) in list"
:key="item.label" <!-- 唯一标识,建议使用 id,此处示例用 label -->
:style="{ top: item.y + 'px' }" <!-- 动态设置 top 值,实现位置移动 -->
@touchstart="touchstart($event, item, index)" <!-- 触摸开始:记录初始信息 -->
@longpress="longpress" <!-- 长按:触发拖拽权限 -->
@touchend="touchend" <!-- 触摸结束:处理排序逻辑 -->
@touchmove="touchmove" <!-- 触摸移动:实时更新位置 -->
:class="{ touching: item.touch }" <!-- 拖拽时提升 z-index,避免被遮挡 -->
>
<!-- 自定义插槽:支持外部自定义列表项 UI -->
<slot name="default" :item="item" :index="index"></slot>
</view>
</view>
</template>关键说明 :
vaheight:动态计算的容器总高度,确保容器能包裹所有列表项;touching类:拖拽中的元素 z-index 设为 1000,保证在最上层显示;- 插槽(slot):通过插槽暴露
item和index,让外部可以完全自定义列表项的 UI 样式。
2.2 脚本部分(script):核心逻辑实现
脚本是拖拽组件的灵魂,我们拆分为 属性定义、数据初始化、监听逻辑、核心方法 四部分解析。
2.2.1 属性与数据初始化
export default {
// 外部传入的属性
props: {
listData: { type: Array, default: () => [] }, // 原始列表数据
itemHeight: { type: Number, default: 80 }, // 列表项默认高度(upx)
customHeight: { type: Boolean, default: false }, // 是否开启自定义高度
getItemHeight: { type: Function, default: () => {} }, // 自定义高度的回调函数
},
// 监听 listData 变化,实时更新组件数据
watch: {
listData: {
handler() {
if (!this.upx) this.upx = this.wInfo.windowWidth / 750; // 计算 upx 转 px 的比例
let { list, vaheight } = this.calcAttr(this.listData); // 初始化列表项坐标和高度
this.vaheight = vaheight;
this.list = list;
},
immediate: true, // 立即执行
deep: true, // 深度监听数组变化
},
},
// 组件内部数据
data() {
return {
list: [], // 处理后的列表(包含坐标、高度等信息)
wInfo: uni.getSystemInfoSync(), // 获取设备信息(屏幕宽度等)
vaheight: 0, // 容器总高度(px)
upx: 0, // upx 转 px 的比例(不同设备适配)
moving: false, // 是否正在拖拽
runtimeIndex: 0, // 当前拖拽项的索引
runtimeItem: null, // 当前拖拽项的信息
startY: 0, // 拖拽开始的 Y 坐标
canDrag: false, // 是否允许拖拽(长按触发)
};
},
// 组件挂载时初始化 upx 比例
mounted() {
this.upx = this.wInfo.windowWidth / 750;
},
}关键说明 :
upx计算:UniApp 中 upx 是自适应单位,通过屏幕宽度/750将 upx 转为 px,保证不同设备显示一致;watch监听listData:外部数据变化时,重新计算列表项的坐标和高度,确保组件数据实时同步;canDrag开关:通过长按触发,避免点击时误触拖拽。
2.2.2 核心方法:触摸事件处理
(1)长按事件:触发拖拽权限
longpress() {
uni.vibrateShort(); // 短震动反馈,提升交互体验
this.canDrag = true; // 开启拖拽权限
this.$emit('dragstart'); // 向外抛出拖拽开始事件
},关键说明 : uni.vibrateShort() 是 UniApp 的震动 API,小程序/APP 端可用,H5 端需适配。
(2)触摸开始:记录初始信息
touchstart(e, item, index) {
item.touch = true; // 标记当前项为触摸状态(提升 z-index)
this.runtimeItem = item; // 保存当前拖拽项
this.runtimeIndex = index; // 保存当前拖拽项索引
this.startY = e.touches[0].pageY; // 记录触摸开始的 Y 坐标
},关键说明 : e.touches[0].pageY 获取触摸点的绝对 Y 坐标,是后续计算移动距离的基础。
(3)触摸移动:实时更新位置与重叠判断
touchmove(e) {
if (this.canDrag) { // 仅当允许拖拽时执行
this.moving = true; // 标记正在拖拽
// 计算移动距离:当前触摸 Y 坐标 - 初始 Y 坐标
let move = e.touches[0].pageY - this.startY;
// 计算当前拖拽项的新 Y 坐标(yo 是初始 top 值)
let y = this.runtimeItem.yo + move;
// 边界限制:不允许超出容器顶部
if (y < 0) y = 0;
// 边界限制:不允许超出容器底部
if (y > this.vaheight - this.runtimeItem.height) y = this.vaheight - this.runtimeItem.height;
this.runtimeItem.y = y; // 更新拖拽项的 top 值
// 向下移动:判断是否与下方元素重叠,调整下方元素位置
if (y > this.runtimeItem.yo) {
for (let i = this.runtimeIndex + 1; i < this.list.length; i++) {
if (y > this.list[i].yo - this.list[i].height / 2) {
this.list[i].y = this.list[i].yo - this.list[i - 1].height;
} else {
this.list[i].y = this.list[i].yo;
}
}
} else {
// 向上移动:判断是否与上方元素重叠,调整上方元素位置
for (let i = 0; i < this.runtimeIndex; i++) {
if (y < this.list[i].yo + this.list[i].height / 2) {
this.list[i].y = this.list[i].yo + this.list[i + 1].height;
} else {
this.list[i].y = this.list[i].yo;
}
}
}
}
},关键说明 :
- 边界限制:通过判断
y的取值范围,确保拖拽项不会超出容器; - 重叠判断:以元素高度的一半为阈值,当拖拽项移动到其他元素的半高位置时,调整其他元素的位置,实现“吸附”效果。
(4)触摸结束:完成排序并抛出结果
touchend() {
if (this.moving) { // 仅当发生拖拽时执行
// 移除当前拖拽项
let ritem = this.list.splice(this.runtimeIndex, 1);
let insertIndex = -1; // 新的插入位置
// 判断插入位置:顶部
if (this.runtimeItem.y < this.list[0].height / 2) {
insertIndex = 0;
}
// 判断插入位置:底部
else if (this.runtimeItem.y > this.list[this.list.length - 1].y + this.list[this.list.length - 1].height / 2) {
insertIndex = this.list.length;
}
// 判断插入位置:中间
else {
for (let i = 1; i < this.list.length; i++) {
if (this.runtimeItem.y < this.list[i].y && this.runtimeItem.y > this.list[i - 1].y) {
insertIndex = i;
break;
}
}
}
// 插入拖拽项,完成排序
if (insertIndex > -1) {
this.list.splice(insertIndex, 0, ...ritem);
}
// 向外抛出排序后的列表
this.$emit('change', this.list);
// 重置状态
this.moving = false;
this.canDrag = false;
// 向外抛出拖拽结束事件
this.$emit('dragend');
}
},关键说明 :
splice方法:通过删除+插入实现数组重排,是拖拽排序的核心;change事件:向外抛出排序后的列表,外部可监听该事件更新业务数据。
2.2.3 辅助方法:计算列表项属性
calcAttr(list) {
let vaheight = 0; // 容器总高度
list = this.deepCopy(list); // 深拷贝,避免修改原始数据
for (let i = 0; i < list.length; i++) {
let item = list[i];
let itemHeightCurr = this.itemHeight;
// 如果开启自定义高度,执行回调获取高度
if (this.customHeight) itemHeightCurr = this.getItemHeight(item, i);
// 初始 top 值(yo):累计高度
item.yo = vaheight;
// 当前 top 值(y):初始与 yo 一致
item.y = vaheight;
// 转换为 px 高度
item.height = this.upx * itemHeightCurr;
// 触摸状态:默认 false
item.touch = false;
// 累计容器高度
vaheight += item.height;
}
return { list, vaheight };
},
// 补充:深拷贝方法(组件中未显示,需补充)
deepCopy(obj) {
return JSON.parse(JSON.stringify(obj));
},关键说明 :
- 深拷贝:避免修改外部传入的
listData,保证数据隔离; - 自定义高度:通过
customHeight和getItemHeight配合,支持不同列表项设置不同高度。
2.3 样式部分(style):基础样式保障
<style>
/* 拖拽容器:相对定位,作为列表项绝对定位的参考 */
.drag {
position: relative;
}
/* 拖拽列表项:绝对定位,通过 top 值控制位置 */
.dragItem {
position: absolute;
}
/* 拖拽中:提升 z-index,确保在最上层 */
.touching {
z-index: 1000;
}
</style>关键说明 : position 定位是拖拽的基础,容器用 relative ,列表项用 absolute ,才能通过 top 值控制位置。
三、组件使用教程
3.1 注册组件
在 UniApp 中,将上述组件保存为 components/basic-drag/basic-drag.vue ,然后在需要使用的页面/组件中注册:
<script>
import basicDrag from '@/components/basic-drag/basic-drag.vue';
export default {
components: {
basicDrag
},
// ...其他代码
}
</script>3.2 页面中使用组件
<template>
<view class="container">
<basic-drag
:listData="calcViewList()" <!-- 传入列表数据 -->
@dragstart="draging = true" <!-- 拖拽开始:标记状态 -->
@dragend="draging = false" <!-- 拖拽结束:重置状态 -->
@change="handleChange" <!-- 排序变化:处理结果 -->
:itemHeight="102" <!-- 列表项高度(upx) -->
>
<!-- 自定义列表项 UI:通过插槽接收 item 和 index -->
<template #default="{ item, index }">
<view class="eilItem" :key="index" @click="jumpPage(item)">
<img src="/static/idog_06.png" style="width: 40upx; height: 40upx" />
<img :src="item.icon" style="width: 68upx; height: 68upx; margin: 0 10upx" />
{{ item.name }}
<img src="/static/idog_09.png" style="width: 32upx; height: 32upx; margin-left: auto" />
</view>
</template>
</basic-drag>
</view>
</template>
<script>
import basicDrag from '@/components/basic-drag/basic-drag.vue';
export default {
components: { basicDrag },
data() {
return {
draging: false, // 是否正在拖拽
listBase: [
{ id: 1, name: '基本信息', label: '基本信息', icon: '/static/sdg_03.png', path: 'baseInfo' },
{ id: 2, name: '个人简介', label: '个人简介', icon: '/static/sdg_06.png', path: 'introduction' },
// 可补充更多数据
]
};
},
methods: {
// 处理排序变化
handleChange(sortedList) {
console.log('排序后的列表:', sortedList);
// 此处可将排序后的列表同步到后端,或更新本地数据
this.listBase = sortedList.map(item => ({
id: item.id,
name: item.name,
label: item.label,
icon: item.icon,
path: item.path
}));
},
// 跳转页面
jumpPage(item) {
if (!this.draging) { // 避免拖拽时触发点击
uni.navigateTo({
url: `/pages/${item.path}/${item.path}`
});
}
},
// 格式化列表数据(可根据业务需求处理)
calcViewList() {
return this.listBase;
}
}
};
</script>
<style scoped>
/* 自定义列表项样式 */
.eilItem {
display: flex;
align-items: center;
padding: 20upx;
background: #fff;
border-radius: 10upx;
margin-bottom: 10upx;
box-shadow: 0 2upx 10upx rgba(0,0,0,0.1);
}
</style>3.3 自定义高度使用示例
如果列表项高度不一致,可开启 customHeight 并传入 getItemHeight 回调:
<basic-drag
:listData="calcViewList()"
@change="handleChange"
:customHeight="true"
:getItemHeight="getItemHeight"
>
<!-- 自定义 UI -->
</basic-drag>
<script>
export default {
methods: {
// 自定义每个列表项的高度
getItemHeight(item, index) {
// 比如:基本信息项高度 102upx,个人简介项高度 120upx
return item.id === 1 ? 102 : 120;
}
}
};
</script>四、注意事项与优化建议
- 性能优化 :拖拽时频繁更新 DOM,可通过
uni.createSelectorQuery减少不必要的计算,或使用节流函数限制touchmove的执行频率; - 兼容处理 :H5 端无震动 API,需添加判断;不同设备的 upx 转换可能有误差,可微调阈值;
- 数据处理 :排序后的列表包含额外属性(如 y、yo、height),需过滤后再提交到后端;
- 交互体验 :可添加拖拽时的阴影、缩放效果,或拖拽结束后的回弹动画,提升用户体验。
总结
- 该拖拽组件基于 UniApp 的触摸事件实现,核心是通过
touchstart/touchmove/touchend监听坐标变化,结合数组splice方法完成排序; - 组件支持自定义 UI(通过插槽)和自定义列表项高度,适配多种业务场景;
- 使用时需注意边界限制、数据深拷贝和交互体验优化,确保组件稳定且易用。
通过本文的解析和示例,你可以快速将该拖拽组件集成到自己的 UniApp/小程序项目中,实现丝滑的列表拖拽排序功能。




