如何使用 UniApp 實現一個兼容 H5 和小程序的 九宮格拖拽排序組件,實現思路和關鍵步驟。
一、實現目標
- 支持拖動菜單項改變順序
- 拖拽過程實時預覽移動位置
- 拖拽松開后自動吸附回網格
- 兼容 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
六、完整效果圖示例
H5端
小程序端
七、總結
本組件通過計算每個 item 的位置并綁定觸摸事件,實現了拖拽排序的能力,支持吸附、交換和動態位置調整,兼容多個平臺。適用于菜單管理、組件排序等場景,封裝后復用性強。
如果你有更多關于 UniApp 拖拽交互的場景需求,歡迎留言討論!
**