UniApp 实现兼容 H5 和小程序的拖拽排序组件

如何使用 UniApp 实现一个兼容 H5 和小程序的 九宫格拖拽排序组件,实现思路和关键步骤。

一、实现目标

  • 支持拖动菜单项改变顺序
  • 拖拽过程实时预览移动位置
  • 拖拽松开后自动吸附回网格
  • 兼容 H5 和小程序平台

二、功能结构拆解以及完整代码

完整代码:

<template>
  <view class="container">
    <view class="menu-title">菜单列表</view>
    <view class="grid-container">
      <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 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>

整个功能可以拆分为以下几个部分:

  1. 网格布局计算:确定每个 item 的初始位置
  2. 拖拽事件绑定:监听 touchstart / touchmove / touchend
  3. 实时移动渲染:跟随手指移动改变 transform 样式
  4. 最近距离判断:判断最近的可交换项并交换
  5. 松开后归位:释放手指后吸附至新的位置

三、组件结构设计

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 拖拽交互的场景需求,欢迎留言讨论!

**

猜你喜欢

转载自blog.csdn.net/qq_34645412/article/details/147115404
今日推荐