uniapp之实现拖拽排序

目录

前言

准备

movable-area

movable-view

简单实现

move方法

end方法

实现效果

多列拖拽

列表滚动

使用说明

参数说明

使用方法

最后


前言

最近做uniapp 项目的时候需要实现拖拽排序功能,本来准备直接用 touchstarttouchmovetouchend 三剑客去实现的,但无意中发现uniapp 内置了 movable-areamovable-view组件,可快速实现该功能,索性就直接使用该组件,后续对拖拽排序做了一些扩展,希望对你有帮助。

准备

在开发之前需要先对movable-areamovable-view组件有一定的了解。

movable-area

可拖动的范围,可以理解为画布大小,元素在该区域内进行拖动。宽高必给,不然默认为10px

movable-view

  • movable-view 必须设置width和height属性,不设置默认为10px
  • movable-view 默认为绝对定位,top和left属性为0px
  • 当movable-view小于movable-area时,movable-view的移动范围是在movable-area内;
  • 当movable-view大于movable-area时,movable-view的移动范围必须包含movable-area(x轴方向和y轴方向分开考虑

该组件就是每一个拖拽的容器,必须在movable-area 组件内部,否则无法拖拽。它有一些常用参数和方法,这里我们主要了解下:xydirectiondampinganimation以及change方法。

  • x: 即在画布中所处的left位置;

  • y: 即在画布中所处的top位置;

  • direction: 容器可移动的位置,可选:all(可任意移动)、vertical(垂直移动)、horizontal(水平移动)、none(不可移动)。

  • damping: 设置移动的速度;

  • animation: 动画;

  • change方法:移动中的回调。

    <template> <view> <movable-area> <movable-view :x="x" :y="y" direction="all">text</movable-view> </movable-area> <movable-area> <movable-view class="max" direction="all">text</movable-view> </movable-area> </view> </template> <script> export default { data() { return { x: 0, y: 0, }; }, }; </script> <style> movable-view { display: flex; align-items: center; justify-content: center; height: 100rpx; width: 100rpx; background: #1aad19; color: #fff; }

    movable-area {
    height: 400rpx;
    width: 400rpx;
    margin: 50rpx;
    background-color: #ccc;
    overflow: hidden;
    }

    .max {
    width: 600rpx;
    height: 600rpx;
    }
    </style>

简单实现

复制代码
<template>
  <view>
    <movable-area style="width: 100%; height: 300px">
      <movable-view
        v-for="(item, index) in list"
        direction="all"
        :key="item.key"
        :x="0"
        :y="item.y"
        @touchstart="handleDragStart(index)"
        @change="handleMoving"
        @touchend="handleDragEnd"
      >
        {{ item.title }}
      </movable-view>
    </movable-area>
  </view>
</template>
<script>
export default {
  data() {
    return {
      activeIndex: -1,
      moveToIndex: -1,
      list: [],
      oldIndex: -1,
      cloneList: [],
      itemHeight: 50,
    };
  },
  methods: {
    initList(list = []) {
      const newList = this.deepCopy(list);
      this.list = newList.map((item, index) => {
        return {
          ...item,
          y: index * this.itemHeight,
          key: Math.random() + index,
        };
      });
      //拷贝一份初始list值
      this.cloneList = this.deepCopy(this.list);
      console.log(this.list);
    },

    //拖拽开始
    handleDragStart(index) {
      this.activeIndex = index;
      this.oldIndex = index;
    },

    //拖拽中
    handleMoving(e) {},

    //拖拽结束
    handleDragEnd(e) {},

    //简单实现深拷贝。
    deepCopy(val) {
      return JSON.parse(JSON.stringify(val));
    },
  },
  created() {
    let list = [
      {
        title: "111",
        key: 1,
      },
      {
        title: "222",
        key: 2,
      },
      {
        title: "333",
        key: 3,
      },
    ];
    this.initList(list);
  },
};
</script>
<style scoped>
/* 可拖拽区域 */
movable-area {
  height: 300px;
  background-color: #f5f5f5;
}

/* 单个拖拽项 */
movable-view {
  width: 100%;
  height: 50px; /* 必须与 itemHeight 一致 */
  line-height: 50px;
  box-sizing: border-box;
  background-color: #ffffff;
  border-bottom: 1px solid #e5e5e5;
  display: flex;
  align-items: center;
  justify-content: center;
}
</style>

上面基本实现了拖拽的基本结构,现在来实现核心逻辑 moveend 方法。

move方法

拖拽时持续计算中心点 → 得到目标索引 → 索引变化才重排 → 用虚拟新顺序计算其他项的 y → 当前项自由拖动,其它项自动让位


索引计算原理

  • (y + 25) / 50
  • = (50 + 25) / 50
  • = 75 / 50
  • = 1.5
  • Math.floor(1.5) = 1
复制代码
  // ========================
    // 拖拽过程中触发(手指移动)
    // ========================
    handleMoving(e) {
      // 只处理手指触摸产生的拖拽事件
      // 防止程序性 setData / 动画触发 change 导致逻辑错乱
      if (e.detail.source !== "touch") return;

      // 当前拖拽元素的实时坐标
      const { x, y } = e.detail;

      /**
       * 计算当前拖拽元素"应该落在哪一行"
       *
       * y                     -> 当前元素顶部位置
       * this.itemHeight / 2    -> 加半个高度,保证是以"中心点"来判断
       * / this.itemHeight      -> 换算成索引
       * Math.floor             -> 向下取整,得到目标索引
       * Math.min               -> 限制最大值,防止拖到列表外导致数组越界
       */
      const currentY = Math.floor((y + this.itemHeight / 2) / this.itemHeight);

      /**
       * 限制目标索引的最大值
       * 防止拖到列表外导致数组越界
       */
      this.moveToIndex = Math.min(currentY, this.list.length - 1);

      /**
       * 只有在以下情况下才需要重新计算位置:
       * 1. 目标位置发生变化
       * 2. 拖拽前的索引合法
       * 3. 当前目标索引合法
       */
      if (
        this.oldIndex !== this.moveToIndex &&
        this.oldIndex !== -1 &&
        this.moveToIndex !== -1
      ) {
        // 使用 cloneList 的深拷贝,避免直接修改原始顺序
        const newList = this.deepCopy(this.cloneList);

        /**
         * 将正在拖拽的元素插入到新的位置
         * splice 说明:
         * - 先从 activeIndex 删除 1 个元素
         * - 再插入到 moveToIndex
         */
        newList.splice(
          this.moveToIndex,
          0,
          ...newList.splice(this.activeIndex, 1)
        );

        /**
         * 根据 newList 重新计算所有非激活项的 y 坐标
         * 激活项(正在拖拽的项)由 movable-view 自己控制位置
         */
        this.list.forEach((item, index) => {
          // 跳过当前正在拖拽的元素
          if (index !== this.activeIndex) {
            // 找到该元素在新顺序中的索引
            const itemIndex = newList.findIndex(
              (val) => val[this.itemKey] === item[this.itemKey]
            );

            // 根据索引重新设置 y 坐标,实现"自动让位"效果
            item.y = itemIndex * this.itemHeight;
          }
        });

        // 记录本次移动的位置,避免重复计算
        this.oldIndex = this.moveToIndex;
      }
    },

end方法

松手时,如果位置变了就真正修改数据顺序,然后重置坐标、抛出结果、清空拖拽状态。

复制代码
    // ========================
    // 拖拽结束(手指松开)
    // ========================
    handleDragEnd(e) {
      /**
       * 判断是否需要真正调整数据顺序
       * 必须满足:
       * 1. 目标索引合法
       * 2. 起始索引合法
       * 3. 起始索引 !== 目标索引
       */
      if (
        this.moveToIndex !== -1 &&
        this.activeIndex !== -1 &&
        this.moveToIndex !== this.activeIndex
      ) {
        /**
         * 将 cloneList 中的数据顺序调整为最终顺序
         * 这里才是真正"落地"的数据变更
         */
        this.cloneList.splice(
          this.moveToIndex,
          0,
          ...this.cloneList.splice(this.activeIndex, 1)
        );
      }

      /**
       * 根据最终顺序重新初始化 list
       * 统一重置每一项的 y 坐标,避免残留偏移
       */
      this.initList(this.cloneList);

      // 生成最终排序结果(浅拷贝一份,避免外部误改内部状态)
      const endList = this.list.map((item) => item);

      /**
       * 向外通知排序结果
       * input  :用于 v-model
       * end    :用于拖拽结束回调
       */
      this.$emit("input", endList);
      this.$emit("end", endList);

      // 重置拖拽状态
      this.activeIndex = -1;
      this.oldIndex = -1;
      this.moveToIndex = -1;
    },

实现效果

多列拖拽

上述是单列拖拽的方法,仅仅只需要控制y的值即可,但是如果是多列拖拽,还需考虑x的值,上述方法稍微小改造一下。 同时还需定义一个每行显示数量的字段column

row = Math.floor(index / this.column)

index index / 4 floor 行号(row)
0 0 0 第 0 行
1 0.25 0 第 0 行
2 0.5 0 第 0 行
3 0.75 0 第 0 行
4 1 1 第 1 行
5 1.25 1 第 1 行

y = 行号 × 每一行的高度,所以完整公式就是:

复制代码
y = Math.floor(index / this.column) * this.getItemHeight

用「当前在第几列」×「每一列的宽度」,算出 item 在 X 轴的位置

index → 列号

index index % 4 列号(col)
0 0 第 0 列
1 1 第 1 列
2 2 第 2 列
3 3 第 3 列
4 0 第 0 列
5 1 第 1 列
6 2 第 2 列
7 3 第 3 列

👉 一到新行,列号重新从 0 开始,

x = 列号 × 每一列的宽度,所以完整公式就是:

复制代码
      x: (index % this.column) * this.getItemWidth,

<template>
  <view>
    <movable-area style="width: 100%; height: 300px">
      <!-- x:0 ==> item.x -->
      <movable-view
        v-for="(item, index) in list"
        direction="all"
        :key="item.key"
        :x="item.x"
        :y="item.y"
        @touchstart="handleDragStart(index)"
        @change="handleMoving"
        @touchend="handleDragEnd"
      >
        {{ item.title }}
      </movable-view>
    </movable-area>
  </view>
</template>
<script>
export default {
  data() {
    return {
      activeIndex: -1,
      moveToIndex: -1,
      list: [],
      oldIndex: -1,
      cloneList: [],
      itemHeight: 50,
      itemKey: "key",
      column: 4, //列数
      getItemWidth: 0, //item宽度
      getItemHeight: 0, //item高度
    };
  },
  methods: {
    initList(list = []) {
      const newList = this.deepCopy(list);
      this.list = newList.map((item, index) => {
        // 获取屏幕宽度
        const width = uni.getSystemInfoSync().windowWidth;
        // item宽度
        this.getItemWidth = width / this.column;
        // item高度
        this.getItemHeight = this.itemHeight;
        return {
          ...item,
          // y: index * this.itemHeight,
          x: (index % this.column) * this.getItemWidth,
          y: Math.floor(index / this.column) * this.getItemHeight,
          key: Math.random() + index,
        };
      });
      //拷贝一份初始list值
      this.cloneList = this.deepCopy(this.list);
    },

    //拖拽开始
    handleDragStart(index) {
      this.activeIndex = index;
      this.oldIndex = index;
    },

    handleMoving(e) {
      if (e.detail.source !== "touch") return;

      const { x, y } = e.detail;

      const currentY = Math.floor((y + this.itemHeight / 2) / this.itemHeight);
      const currentX = Math.floor(
        (x + this.getItemWidth / 2) / this.getItemWidth
      );
      // currentY = 50+25=75 75/50=1.5 向下取整1
      //currentX 0+46/96=0.48 向下取整0

      //  如果 column=4
      //  移动第5个
      //  currentY=1 currentX=0
      //  1*4+0=4 索引是4

      this.moveToIndex = Math.min(
        currentY * this.column + currentX,
        this.list.length - 1
      );

      if (
        this.oldIndex !== this.moveToIndex &&
        this.oldIndex !== -1 &&
        this.moveToIndex !== -1
      ) {
        const newList = this.deepCopy(this.cloneList);

        newList.splice(
          this.moveToIndex,
          0,
          ...newList.splice(this.activeIndex, 1)
        );

        this.list.forEach((item, index) => {
          if (index !== this.activeIndex) {
            const itemIndex = newList.findIndex(
              (val) => val[this.itemKey] === item[this.itemKey]
            );

            // item.y = itemIndex * this.itemHeight;

            // 计算行和列
            /**
             * column = 4
             * 第5个的位置 row = 1 「floor:向下取整(5/4)」 col = 1「5%4」
             * 第6个的位置 row = 1 「floor:向下取整(6/4)」 col = 2「6%4」
             */
            const row = Math.floor(itemIndex / this.column);
            const col = itemIndex % this.column;
            // 设置x和y
            item.x = col * this.getItemWidth;
            item.y = row * this.getItemHeight;
          }
        });

        this.oldIndex = this.moveToIndex;
      }
    },

    handleDragEnd(e) {
      if (
        this.moveToIndex !== -1 &&
        this.activeIndex !== -1 &&
        this.moveToIndex !== this.activeIndex
      ) {
        this.cloneList.splice(
          this.moveToIndex,
          0,
          ...this.cloneList.splice(this.activeIndex, 1)
        );
      }

      this.initList(this.cloneList);

      const endList = this.list.map((item) => item);

      this.$emit("input", endList);
      this.$emit("end", endList);

      this.activeIndex = -1;
      this.oldIndex = -1;
      this.moveToIndex = -1;
    },

    deepCopy(val) {
      return JSON.parse(JSON.stringify(val));
    },
  },
  created() {
    let list = [
      {
        title: "111",
        key: 1,
      },
      {
        title: "222",
        key: 2,
      },
      {
        title: "333",
        key: 3,
      },
      // 添加数据到六个
      {
        title: "444",
        key: 4,
      },
      {
        title: "555",
        key: 5,
      },
      {
        title: "666",
        key: 6,
      },
    ];
    this.initList(list);
  },
};
</script>
<style scoped>
movable-area {
  height: 300px;
  background-color: #f5f5f5;
}

movable-view {
  width: 25%; /* 每个item的宽度 */
  height: 50px;
  line-height: 50px;
  box-sizing: border-box;
  background-color: #ffffff;
  border-bottom: 1px solid #e5e5e5;
  display: flex;
  align-items: center;
  justify-content: center;
}
</style>

列表滚动

如果列表是长列表,需要滚动的话,可以外面包裹一层scrollView标签,然后通过移动的时候修改scrollTop的值,去实现长列表拖动。

复制代码
<scroll-view scroll-y  :scroll-top="scrollTop" @scroll="handleScroll">
   ...其他代码
</scroll-view>

//js
handleScroll(e) {
   this.scrollTop = e.detail.scrollTop;
},

// move的时候添加一个 scrollIntoView 方法
scrollIntoView() {
        if (this.height === 'auto') return;
        const { height, moveToIndex, getItemHeight, scrollTop } = this;
        if ((moveToIndex + 1) * this.getItemHeight >= scrollTop + parseFloat(height)) {
                this.scrollTop = Math.min(parseFloat(this.getAreaStyle.height), scrollTop + Math.ceil(moveToIndex / 2) * this.getItemHeight);
        } else if ((moveToIndex - 1) * this.getItemHeight <= scrollTop) {
                this.scrollTop = Math.max(0, scrollTop - Math.ceil(moveToIndex / 2) * this.getItemHeight);
        }
},

使用说明

对于上面功能,已经封装成了一个组件,有兴趣可以看看。

参数说明

参数 说明 类型 可选值 默认值
value / v-model 绑定值 array - []
column 每行展示数量 number - 3
width 拖拽容器的宽度 string - 100%
height 拖拽容器的高度,若不传则默认根据column和每个盒子的高度自动生成 string auto
itemKey 唯一key,必传 string - -
itemHeight 每个拖拽盒子的高度 string - 100px
direction 可拖拽方向,具体看movable-view string all/vertical/horizontal/none all
damping 阻尼系数,用于控制x或y改变时的动画和过界回弹的动画,值越大移动越快 number - 20

使用方法

由于微信小程序的特性,所以导致 movable-view for中嵌套插槽,会导致显示更新问题,所以微信小程序不能使用插槽,其他平台是没问题的。

复制代码
    <basic-drag v-model="list" :column="1" itemHeight="50px" itemKey="title">
            <template #item="{element}">
                    <view class="drag-item">{{ element.title }}</view>
            </template>
    </basic-drag>

最后

拖拽组件源代码放在uniapp插件社区了,有兴趣可以下载下来看看,希望能对你有帮助,如果对该组件有问题,可以评论区留言。

相关推荐
一室易安2 小时前
uniapp vue3 小程序中 手写模仿京东淘宝加入购物车红点曲线飞入样式效果 简化版代码
小程序·uni-app
00后程序员张3 小时前
混合 App 怎么加密?分析混合架构下常见的安全风险
android·安全·小程序·https·uni-app·iphone·webview
2501_915921434 小时前
Flutter App 到底该怎么测试?如何在 iOS 上进行测试
android·flutter·ios·小程序·uni-app·cocoa·iphone
2501_915909065 小时前
如何在 Windows 上上架 iOS App,分析上架流程哪些是不用mac的
android·macos·ios·小程序·uni-app·iphone·webview
2501_915921435 小时前
分析 iOS 描述文件创建与管理中常见的问题
android·ios·小程序·https·uni-app·iphone·webview
Aftery的博客5 小时前
Uniapp-vue实现语言功能切换(多语言)
javascript·vue.js·uni-app
我这一生如履薄冰~1 天前
uni-app 项目配置代理踩坑
uni-app
毕设源码-朱学姐1 天前
【开题答辩全过程】以 基于uniapp的疫苗预约系统为例,包含答辩的问题和答案
uni-app
CHB2 天前
uni-app,你的最佳vibe coding搭子
uni-app·vibecoding