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

news2025/4/16 3:24:33

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

一、完整效果图示例

H5端
在这里插入图片描述

小程序端
在这里插入图片描述

git地址

二、实现目标

  • 支持拖动菜单项改变顺序
  • 拖拽过程实时预览移动位置
  • 拖拽松开后自动吸附回网格
  • 兼容 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

七、总结

本组件通过计算每个 item 的位置并绑定触摸事件,实现了拖拽排序的能力,支持吸附、交换和动态位置调整,兼容多个平台。适用于菜单管理、组件排序等场景,封装后复用性强。

如果你有更多关于 UniApp 拖拽交互的场景需求,欢迎留言讨论!

**

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2334868.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【网络协议】WebSocket讲解

目录 webSocket简介 连接原理解析: 客户端API 服务端API&#xff08;java&#xff09; 实战案例 &#xff08;1&#xff09;引入依赖 &#xff08;2&#xff09;编写服务端逻辑 &#xff08;3&#xff09;注册配置类 &#xff08;4&#xff09;前端连接 WebSocket 示例…

啥是Spring,有什么用,既然收费,如何免费创建SpringBoot项目,依赖下载不下来的解决方法,解决99%问题!

一、啥是Spring&#xff0c;为啥选择它 我们平常说的Spring指的是Spring全家桶&#xff0c;我们为什么要选择Spring&#xff0c;看看官方的话&#xff1a; 意思就是&#xff1a;用这个东西&#xff0c;又快又好又安全&#xff0c;反正就是好处全占了&#xff0c;所以我们选择它…

一天时间,我用AI(deepseek)做了一个配色网站

前言 最近在开发颜色搭配主题的相关H5和小程序&#xff0c;想到需要补充一个web网站&#xff0c;因此有了这篇文章。 一、确定需求 向AI要答案之前&#xff0c;一定要清楚自己想要做什么。如果你没有100%了解自己的需求&#xff0c;可以先让AI帮你理清逻辑和思路&#xff0c;…

Spring - 13 ( 11000 字 Spring 入门级教程 )

一&#xff1a; Spring AOP 备注&#xff1a;之前学习 Spring 学到 AOP 就去梳理之前学习的知识点了&#xff0c;后面因为各种原因导致 Spring AOP 的博客一直搁置。。。。。。下面开始正式的讲解。 学习完 Spring 的统一功能后&#xff0c;我们就进入了 Spring AOP 的学习。…

Spring Cloud Alibaba微服务治理实战:Nacos+Sentinel深度解析

一、引言 在微服务架构中&#xff0c;服务发现、配置管理、流量控制是保障系统稳定性的核心问题。Spring Cloud Netflix 生态曾主导微服务解决方案&#xff0c;但其部分组件&#xff08;如 Eureka、Hystrix&#xff09;已进入维护模式。 Spring Cloud Alibaba 凭借 高性能、轻…

红宝书第三十六讲:持续集成(CI)配置入门指南

红宝书第三十六讲&#xff1a;持续集成&#xff08;CI&#xff09;配置入门指南 资料取自《JavaScript高级程序设计&#xff08;第5版&#xff09;》。 查看总目录&#xff1a;红宝书学习大纲 一、什么是持续集成&#xff1f; 持续集成&#xff08;CI&#xff09;就像咖啡厅的…

Java—HTML:3D形变

今天我要介绍的是在Java HTML中CSS的相关知识点内容之一&#xff1a;3D形变&#xff08;3D变换&#xff09;。该内容包含透视&#xff08;属性&#xff1a;perspective&#xff09;&#xff0c;3D变换&#xff0c;3D变换函数以及案例演示&#xff0c; 接下来我将逐一介绍&…

什么是音频预加重与去加重,预加重与去加重的原理是什么,在什么条件下会使用预加重与去加重?

音频预加重与去加重是音频处理中的两个重要概念&#xff0c;以下是对其原理及应用条件的详细介绍&#xff1a; 1、音频预加重与去加重的定义 预加重&#xff1a;在音频信号的发送端&#xff0c;对音频信号的高频部分进行提升&#xff0c;增加高频信号的幅度&#xff0c;使其在…

免费下载 | 2025清华五道口:“十五五”金融规划研究白皮书

《2025清华五道口&#xff1a;“十五五”金融规划研究白皮书》的核心内容主要包括以下几个方面&#xff1a; 一、五年金融规划的重要功能与作用 凝聚共识&#xff1a;五年金融规划是国家金融发展的前瞻性谋划和战略性安排&#xff0c;通过广泛听取社会各界意见&#xff0c;凝…

微信小程序实战案例 - 餐馆点餐系统 阶段 4 - 订单列表 状态

✅ 阶段 4 – 订单列表 & 状态 目标 展示用户「我的订单」列表支持状态筛选&#xff08;全部 / 待处理 / 已完成&#xff09;支持分页加载和实时刷新使用原生组件编写 ✅ 1. 页面结构&#xff1a;文件结构 pages/orders/├─ index.json├─ index.wxml├─ index.js└─…

如何为C++实习做准备?

博主介绍&#xff1a;程序喵大人 35- 资深C/C/Rust/Android/iOS客户端开发10年大厂工作经验嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手《C20高级编程》《C23高级编程》等多本书籍著译者更多原创精品文章&#xff0c;首发gzh&#xff0c;见文末&#x1f447;&#x1…

Cesium.js(6):Cesium相机系统

Camera表示观察场景的视角。通过操作摄像机&#xff0c;可以控制视图的位置、方向和角度。 帮助文档&#xff1a;Camera - Cesium Documentation 1 setView setView 方法允许你指定相机的目标位置和姿态。你可以通过 Cartesian3 对象来指定目标位置&#xff0c;并通过 orien…

AI 代码生成工具如何突破 Java 单元测试效能天花板?

一、传统单元测试的四大痛点 时间黑洞&#xff1a;根据 JetBrains 调研&#xff0c;Java 开发者平均花费 35% 时间编写测试代码覆盖盲区&#xff1a;手工测试覆盖率普遍低于 60%&#xff08;Jacoco 全球统计数据&#xff09;维护困境&#xff1a;业务代码变更导致 38% 的测试用…

客户端负载均衡与服务器端负载均衡详解

客户端负载均衡与服务器端负载均衡详解 1. 客户端负载均衡&#xff08;Client-Side Load Balancing&#xff09; 核心概念 定义&#xff1a;负载均衡逻辑在客户端实现&#xff0c;客户端主动选择目标服务实例。典型场景&#xff1a;微服务内部调用&#xff08;如Spring Cloud…

基于springboot的“流浪动物管理系统”的设计与实现(源码+数据库+文档+PPT)

基于springboot的“流浪动物管理系统”的设计与实现&#xff08;源码数据库文档PPT) 开发语言&#xff1a;Java 数据库&#xff1a;MySQL 技术&#xff1a;springboot 工具&#xff1a;IDEA/Ecilpse、Navicat、Maven 系统展示 系统功能结构图 局部E-R图 系统首页界面 系统…

爬虫解决debbugger之替换文件

鼠鼠上次做一个网站的时候&#xff0c;遇到的debbugger问题&#xff0c;是通过打断点然后编辑断点解决的&#xff0c;现在鼠鼠又学会了一个新的技能 首先需要大家下载一个reres的插件&#xff0c;这里最好用谷歌浏览器 先请大家看看案例国家水质自动综合监管平台 这里我们只…

奇怪的电梯——DFS算法

题目 题解 每到一层楼都面临了两种选择&#xff1a;上还是下&#xff1f;因此我们可以定义一个布尔数组用来记录选择。 终止条件其实也明显&#xff0c;要么到了B层&#xff0c;要么没有找到楼层。 如果找到了&#xff0c;选择一个步骤少的方式。又怎么表示没有找到楼层&…

Open GL ES-> 工厂设计模式包装 SurfaceView + 自定义EGL的OpenGL ES 渲染框架

XML文件 <?xml version"1.0" encoding"utf-8"?> <com.example.myapplication.EGLSurfaceView xmlns:android"http://schemas.android.com/apk/res/android"android:layout_width"match_parent"android:layout_height"…

深入解析多表联查(MySQL)

前言 在面试中以及实际开发中&#xff0c;多表联查是每个程序员必备技能&#xff0c;下文通过最简单的学生表和课程表的实例帮大家最快入门多表联查技能。 建立数据表 1. 学生表&#xff08;students&#xff09; 创建学生表 CREATE TABLE students (student_id INT AUTO_…

宇视设备视频平台EasyCVR打造智慧酒店安防体系,筑牢安全防线

一、需求背景 酒店作为人员流动频繁的场所&#xff0c;对安全保障与隐私保护有着极高的要求。为切实维护酒店内部公共区域的安全秩序&#xff0c;24小时不间断视频监控成为必要举措。通常情况下&#xff0c;酒店需在本地部署视频监控系统以供查看&#xff0c;部分连锁酒店还希…