玩转 UniApp/小程序拖拽组件:自定义 UI,实现丝滑列表拖拽排序

2026-03-03 31 浏览 0 评论

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

一、组件核心设计思路

该拖拽组件的核心逻辑围绕 触摸事件监听 + 坐标计算 + 数组重排 展开:

  1. 监听长按事件触发拖拽权限,避免误触;
  2. 监听触摸移动事件,实时计算拖拽元素的坐标,并判断是否与其他元素重叠;
  3. 触摸结束时,根据最终坐标重新排列数组,并对外抛出排序后的结果;
  4. 支持自定义列表项高度,适配不同 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):通过插槽暴露 itemindex ,让外部可以完全自定义列表项的 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 ,保证数据隔离;
  • 自定义高度:通过 customHeightgetItemHeight 配合,支持不同列表项设置不同高度。

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>

四、注意事项与优化建议

  1. 性能优化 :拖拽时频繁更新 DOM,可通过 uni.createSelectorQuery 减少不必要的计算,或使用节流函数限制 touchmove 的执行频率;
  2. 兼容处理 :H5 端无震动 API,需添加判断;不同设备的 upx 转换可能有误差,可微调阈值;
  3. 数据处理 :排序后的列表包含额外属性(如 y、yo、height),需过滤后再提交到后端;
  4. 交互体验 :可添加拖拽时的阴影、缩放效果,或拖拽结束后的回弹动画,提升用户体验。

总结

  1. 该拖拽组件基于 UniApp 的触摸事件实现,核心是通过 touchstart/touchmove/touchend 监听坐标变化,结合数组 splice 方法完成排序;
  2. 组件支持自定义 UI(通过插槽)和自定义列表项高度,适配多种业务场景;
  3. 使用时需注意边界限制、数据深拷贝和交互体验优化,确保组件稳定且易用。

通过本文的解析和示例,你可以快速将该拖拽组件集成到自己的 UniApp/小程序项目中,实现丝滑的列表拖拽排序功能。


发布评论

发布评论前请先 登录
取消
0 评论
点赞
收藏

评论列表 0

暂无评论