如何使用 UniApp 实现一个兼容 H5 和小程序的 九宫格拖拽排序组件,实现思路和关键步骤。
一、完整效果图示例
H5端
小程序端
git地址
二、实现目标
- 支持拖动菜单项改变顺序
- 拖拽过程实时预览移动位置
- 拖拽松开后自动吸附回网格
- 兼容 H5 和小程序平台
三、功能结构拆解以及完整代码
完整代码:
<template><view class="container"><view class="menu-title">菜单列表</view><view class="grid-container"><viewclass="grid-item"v-for="(item, index) in menuList":key="index":class="{ 'active': currentIndex === index }":style="getPositionStyle(index)"@touchstart="handleTouchStart($event, index)"@touchmove.stop.prevent="handleTouchMove($event)"@touchend="handleTouchEnd"><view class="item-content"><view class="item-icon"><uni-icons :type="item.icon || 'star'" size="24"></uni-icons></view><view class="item-name">{{ item.name }}</view></view></view></view></view>
</template><script>
export default {name: 'MenuGrid',data() {return {// 菜单项列表menuList: [{ name: '首页', icon: 'home' },{ name: '消息', icon: 'chat' },{ name: '联系人', icon: 'contact' },{ name: '日历', icon: 'calendar' },{ name: '设置', icon: 'gear' },{ name: '相册', icon: 'image' },{ name: '文件', icon: 'folder' },{ name: '位置', icon: 'location' },{ name: '收藏', icon: 'star-filled' },{ name: '视频', icon: 'videocam' },{ name: '音乐', icon: 'sound' },{ name: '订单', icon: 'paperplane' }],// 网格配置columns: 4, // 每行显示的列数itemSize: 80, // 每个项目的大小 (单位px)itemGap: 15, // 项目之间的间隔// 拖拽状态currentIndex: -1, // 当前拖拽的项目索引startX: 0, // 触摸开始X坐标startY: 0, // 触摸开始Y坐标moveOffsetX: 0, // X轴移动的距离moveOffsetY: 0, // Y轴移动的距离positions: [], // 所有项目的位置isDragging: false // 是否正在拖拽}},mounted() {this.initPositions();},methods: {// 初始化所有项目的位置initPositions() {this.positions = [];const { itemSize, itemGap, columns } = this;this.menuList.forEach((_, index) => {const row = Math.floor(index / columns);const col = index % columns;// 计算项目位置this.positions.push({x: col * (itemSize + itemGap),y: row * (itemSize + itemGap),zIndex: 1});});},// 获取项目定位样式getPositionStyle(index) {if (!this.positions[index]) return '';const position = this.positions[index];const { itemSize } = this;return {transform: `translate3d(${position.x}px, ${position.y}px, 0)`,width: `${itemSize}px`,height: `${itemSize}px`,zIndex: position.zIndex || 1};},// 处理触摸开始handleTouchStart(event, index) {if (this.isDragging) return;const touch = event.touches[0];this.currentIndex = index;this.startX = touch.clientX;this.startY = touch.clientY;this.moveOffsetX = 0;this.moveOffsetY = 0;this.isDragging = true;// 提升当前项的层级this.positions[index].zIndex = 10;// 震动反馈uni.vibrateShort();},// 处理触摸移动handleTouchMove(event) {if (this.currentIndex === -1 || !this.isDragging) return;const touch = event.touches[0];// 计算移动距离const deltaX = touch.clientX - this.startX;const deltaY = touch.clientY - this.startY;this.moveOffsetX += deltaX;this.moveOffsetY += deltaY;// 更新拖拽项的位置this.positions[this.currentIndex].x += deltaX;this.positions[this.currentIndex].y += deltaY;// 更新开始位置,用于下一次移动计算this.startX = touch.clientX;this.startY = touch.clientY;// 检查是否需要交换位置this.checkForSwap();},// 处理触摸结束handleTouchEnd() {if (this.currentIndex === -1) return;// 重置拖拽项的层级if (this.positions[this.currentIndex]) {this.positions[this.currentIndex].zIndex = 1;}// 将所有项吸附到网格this.snapAllItemsToGrid();// 重置拖拽状态this.isDragging = false;this.currentIndex = -1;this.moveOffsetX = 0;this.moveOffsetY = 0;// 触发排序完成事件this.$emit('sort-complete', [...this.menuList]);},// 将所有项吸附到网格snapAllItemsToGrid() {const { itemSize, itemGap, columns } = this;this.menuList.forEach((_, index) => {const row = Math.floor(index / columns);const col = index % columns;this.positions[index] = {x: col * (itemSize + itemGap),y: row * (itemSize + itemGap),zIndex: 1};});},// 检查是否需要交换位置checkForSwap() {if (this.currentIndex === -1) return;const currentPos = this.positions[this.currentIndex];const { itemSize, itemGap } = this;let closestIndex = -1;let minDistance = Number.MAX_VALUE;// 找出与当前拖拽项距离最近的项this.positions.forEach((pos, index) => {if (index !== this.currentIndex) {// 计算中心点之间的距离const centerX1 = currentPos.x + itemSize / 2;const centerY1 = currentPos.y + itemSize / 2;const centerX2 = pos.x + itemSize / 2;const centerY2 = pos.y + itemSize / 2;const distance = Math.sqrt(Math.pow(centerX1 - centerX2, 2) +Math.pow(centerY1 - centerY2, 2));// 只考虑距离小于阈值的项const threshold = (itemSize + itemGap) * 0.6;if (distance < threshold && distance < minDistance) {minDistance = distance;closestIndex = index;}}});// 如果找到了足够近的项,交换位置if (closestIndex !== -1) {this.swapItems(this.currentIndex, closestIndex);}},// 交换两个项目swapItems(fromIndex, toIndex) {// 交换菜单列表中的项const temp = { ...this.menuList[fromIndex] };this.$set(this.menuList, fromIndex, { ...this.menuList[toIndex] });this.$set(this.menuList, toIndex, temp);// 交换位置信息[this.positions[fromIndex], this.positions[toIndex]] =[this.positions[toIndex], this.positions[fromIndex]];// 更新当前拖拽的索引this.currentIndex = toIndex;}}
}
</script><style scoped>
.container {padding: 20rpx;background-color: #f7f7f7;
}.menu-title {font-size: 32rpx;font-weight: bold;margin-bottom: 30rpx;text-align: center;
}.grid-container {position: relative;width: 100%;min-height: 500rpx;overflow: hidden;
}.grid-item {position: absolute;left: 0;top: 0;transition: transform 0.3s ease;will-change: transform;
}.grid-item.active {transition: none;transform: scale(1.05);z-index: 10;
}.item-content {width: 100%;height: 100%;display: flex;flex-direction: column;align-items: center;justify-content: center;background-color: #ffffff;border-radius: 12rpx;box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
}.item-icon {display: flex;justify-content: center;align-items: center;margin-bottom: 10rpx;
}.item-name {font-size: 24rpx;color: #333;text-align: center;
}
</style>
整个功能可以拆分为以下几个部分:
- 网格布局计算:确定每个 item 的初始位置
- 拖拽事件绑定:监听
touchstart
/touchmove
/touchend
- 实时移动渲染:跟随手指移动改变 transform 样式
- 最近距离判断:判断最近的可交换项并交换
- 松开后归位:释放手指后吸附至新的位置
四、组件结构设计
1. 模板部分
使用 v-for
渲染菜单项,并绑定触摸事件。
<view class="grid-item"v-for="(item, index) in menuList":key="index":class="{ 'active': currentIndex === index }":style="getPositionStyle(index)"@touchstart="handleTouchStart($event, index)"@touchmove.stop.prevent="handleTouchMove($event)"@touchend="handleTouchEnd"><!-- 图标和文字 -->
</view>
2. 数据结构
menuList
: 菜单数据positions
: 所有 item 的坐标信息currentIndex
: 当前拖拽的索引startX/Y
: 拖拽起始点坐标moveOffsetX/Y
: 移动的累计距离isDragging
: 是否正在拖拽中
3. 初始化位置
通过 itemSize
+ itemGap
+ columns
计算每一项的坐标。
const row = Math.floor(index / columns);
const col = index % columns;
positions.push({x: col * (itemSize + itemGap),y: row * (itemSize + itemGap),zIndex: 1
});
4. 拖拽处理流程
- 触摸开始
- 记录初始触摸位置
- 提升 z-index
- 设置当前拖拽 index
- 拖动中
- 计算当前位置偏移量
- 实时更新拖拽项的 transform 位置
- 检查距离最近的其他项是否可交换
- 拖动结束
- 重置拖拽状态
- 吸附所有项回网格对齐
- 发出排序完成事件
5. 交换逻辑
通过拖拽项与其它项之间的中心点距离,找到最近项,判断是否在交换阈值范围内(比如 0.6 倍 itemSize + gap),再触发 swapItems
。
const distance = Math.sqrt((dx)^2 + (dy)^2);
if (distance < threshold) swapItems(fromIndex, toIndex);
五、平台兼容性说明
- 小程序端: 使用
touchstart
,touchmove
,touchend
原生事件即可 - H5端: 同样支持原生事件,需使用
stop.prevent
修饰符阻止页面滚动 - 注意事项: 不建议使用
@mousedown
等 PC 事件,移动端表现不一致
六、性能优化建议
- 使用
transform: translate3d
提升动画性能 - 拖拽时关闭 transition,松开后再开启
- 将 drag 状态变化为响应式变量,避免频繁操作 DOM
七、总结
本组件通过计算每个 item 的位置并绑定触摸事件,实现了拖拽排序的能力,支持吸附、交换和动态位置调整,兼容多个平台。适用于菜单管理、组件排序等场景,封装后复用性强。
如果你有更多关于 UniApp 拖拽交互的场景需求,欢迎留言讨论!
**