uniapp movable-area、movable-view组件对图片列表进行长按拖拽排序

uniapp movable-area、movable-view组件对图片列表进行长按拖拽排序

只适用于我自己开发,需要用到的可能需要调整
涉及的是uniapp组件,样式就不提供了
附带图片/视频上传,视频上传增加了进度展示
前端代码

<template>
  <view class="dragsort-box">
    <movable-area id="drag" class="dragsort-area" :style="{'height':listHeight+'px', 'width': listWidth+'px'}">
      <movable-view v-for="(item, index) in list" :key="item.uid" :x="item.x" :y="item.y" :data-uid="item.uid"
                    :data-sortid="item.sortID" :data-isadd="item.isAdd" :direction="item.direction" damping="40"
                    :animation="item.animation" :disabled="item.disabled"
                    @touchstart="touchstart" @touchmove.stop="touchmove" @touchend="touchend"
                    class="flex-items dragsort-view" :class="{
                      'dragsort-view-active': isDrag&&activeModel&&activeModel.uid === item.uid
                    }"
                    :style="{ 'width':item.width+'px', 'height':item.height+'px' }">
        <view v-if="!item.isAdd" class="dragsort-view-item">
          <video class="preview-video" v-if="item.fileType === 'video'" :src="item.url"/>
          <image v-else :src="fixResourcesUrl(item.url)" @click="previewImages(item.url)"/>
          <cover-view class="close" @click="deletePic(item)">
            <uni-icons class="close" type="closeempty" size="14" color="#999"/>
          </cover-view>
        </view>
        <view v-if="item.isAdd" class="dragsort-view-item">
          <u-upload
              :accept="fileType === 1 ? 'video' : 'image'"
              multiple
              :max-count="imagesLimit"
              :previewImage="false"
              showUploadList="false"
              @afterRead="afterRead"
              @oversize="oversize"
          >
            <view>
              <image class="btn-image" :src="$Img + 'image/market/upload-icon.png'"/>
              <u-line-progress v-if="showProgress" :percentage="percentage" activeColor="rgb(25, 190, 107)"/>
            </view>
          </u-upload>
        </view>
      </movable-view>
    </movable-area>
  </view>
</template>

js脚本

let timeOut = 0
const debounceWait = 200
let touchTimeOut = 0
const touchDebounceWait = 200
let touchMoveTimeOut = 0
const touchMoveDebounceWait = 10
let lastTouchE = null

export default {
  name: 'drag-sort',
  mixins: [],
  components: {},
  data() {
    return {
      list: [], // 外部传入集合总数
      row: 0, // 总行数,根据集合总数和列数进行计算
      width: 0, // Item元素宽度,根据列数进行计算
      height: 0, // Item元素高度,同宽度
      listWidth: 300,
      listHeight: 0, // 拖拽元素列表的高度【主要是用于撑起拖拽区域dragsort-list】
      topY: 0, // 容器距离设备顶部的距离
      topX: 0, // 容器距离设备左侧偏移位置
      activeModel: null, // 当前激活的元素
      activeX: 0, // 激活元素旧的x偏移
      activeY: 0, // 激活元素旧的y偏移
      targetModel: null, // 当前目标元素
      targetX: 0, // 记录的旧的目标元素x偏移
      targetY: 0, // 记录的旧的目标元素y偏移
      isDrag: false, // 标记是否为拖动触发
      isUpdate: false, // 数据是否需要更新,如果没有找到目标元素,那么此值始终是false,用于判断是否需要触发update更新事件

      action: API.UploadUrl,
      formData: {
        'folderId': -1
      },
      headerToken: {
        Authorization: ''
      },
      showProgress: false, // 是否显示进度条
      percentage: 0, // 进度条值
    }
  },
  computed: {
    maxSortID() {
      this.list = this.list || []
      let arr = this.list.filter(i => !i.isAdd)
      if (!arr.length) {
        return 0
      }
      return arr[arr.length - 1].sortID
    },
    addObject() { // 新增按钮实体元素
      return this.list.find(i => i.isAdd)
    },
    imagesLimit() {
      // 图片/视频上传数量限制
      return this.fileType === 1 ? 1 : 9
    },
    fileTypeName() {
      return this.fileType === 1 ? '视频' : '图片'
    },
  },
  props: {
    modelValue: {
      type: Array,
      default: () => [],
    },
    // 默认3列
    column: {
      type: Number,
      default: () => 3,
    },
    // 容器左右内间隔【同padding】,单位px
    areaXGap: {
      type: Number,
      default: () => 0,
    },
    // 容器上下内间隔【同padding】,单位px
    areaYGap: {
      type: Number,
      default: () => 12,
    },
    // 元素的间距【元素和元素之间的间隔】,单位px
    viewGap: {
      type: Number,
      default: () => 12,
    },
    fileType: {
      type: Number,
      default: () => 2,
    },
  },
  mounted() {
    this.$nextTick(() => {
      this.init()
      this.initGrid()
    })
  },
  emits: ['update:modelValue'],
  methods: {
    // 图片预览
    previewImages(current) {
      uni.previewImage({
        current: current,
        urls: this.list.filter(i => !i.isAdd).map(item => item.url)
      })
    },

    // 对传入的集合进行初始化处理,根据排序号进行排序操作,后续内部处理不会改变集合的元素索引排序
    init() {
      this.list = this.modelValue || []
      this.list.forEach((item, index) => {
        item.sortID = index
      })

      // 先根据排序编号进行排序,序号越小越靠前
      this.list.sort((a, b) => a.sortID - b.sortID)

      this.processAddObject()
    },

    // 初始化处理,根据每列显示数和总行数,计算每个元素偏移
    initGrid(list = this.list) {
      const arr = []
      let x = 0
      let tmpIndex = 0
      if (list && list.length > 0) {
        // 先计算出总行数
        this.row = Math.ceil(list.length / this.column)
        // 计算每个元素的宽高,宽高相同,这里屏幕宽度-容器内间距*2(包含左右的内间距)-元素间距(元素的数量-1即为这个间距值的数量,比如3个元素,那么只会有1和2的间距,2和3的间距)
        this.height = this.width = (this.listWidth - this.areaXGap * 2 - (this.column - 1) * this.viewGap) / this.column
        // 元素列表所占据的总高度(包含各种内边距,元素和元素的间距)
        this.listHeight = this.height * this.row + this.areaYGap * 2 + (this.row - 1) * this.viewGap
        // 每行循环,设置x和y偏移
        for (let row = 1; row <= this.row; row++) {
          // 取出第一行数据,根据分页算法
          const min = (row - 1) * this.column
          let max = row * this.column
          max = max > list.length ? list.length : max
          const rowList = list.slice(min, max)
          // 计算偏移
          const lastx = this.areaXGap
          const lasty = (row - 1) * (this.height + this.viewGap) + this.areaYGap
          rowList.map((item, index) => {
            if (index === 0) {
              x = lastx
            } else {
              x = lastx + index * (this.width + this.viewGap)
            }
            arr.push({
              ...item,
              y: lasty,
              x,
              // 默认上传图片按钮不可移动
              direction: 'all',
              disabled: item.isAdd || false,
              animation: true,
              rowNumber: row,
              columnNumber: index + 1,
              width: this.width,
              height: this.height,
            })
            tmpIndex++
          })
        }
      }
      this.list = arr
    },

    fixResourcesUrl(url) {
      // 此方法是用于修改url【某些平台下图片必须使用全路径】
      return url
    },

    // 查找目标元素【当前鼠标X坐标和Y坐标】
    findTarget(touchX, touchY) {
      // 如果当前拖拽元素不存在,直接返回
      if (this.activeModel == null) return
      // 设置标志,用于判断是否需要更新元素操作
      let isAddContain = true
      // 循环找出,当前鼠标坐标点在哪个元素的范围内,即找出目标元素
      this.list.map((res, index) => {
        // 如果当前鼠标坐标在目标元素内则重排更新排序号
        // 需要排除掉自己和上传图片按钮
        if (res.x < touchX && touchX <= (res.x + this.width) && touchY > res.y && touchY <= (res.y +
            this.height) && this.activeModel.uid !== res.uid && !res.isAdd) {
          // 此处目标元素肯定不是上传图片按钮,所以设置一下标志
          isAddContain = false
          // 记录目标索引ID
          this.targetModel = res
          this.isUpdate = true
        }
      })
      // 如果目标元素非新增按钮,则更新操作
      if (!isAddContain && this.isUpdate) {
        this.updateList()
      }
    },
    // 更新元素偏移
    updateList() {
      // console.info(this.list);
      // 把目标元素旧的偏移记录下来
      this.targetX = this.targetModel.x
      this.targetY = this.targetModel.y
      const targetSortID = this.targetModel.sortID
      const rowNumber = this.targetModel.rowNumber
      const columnNumber = this.targetModel.columnNumber
      // 其实需要改变x.y偏移和排序号的始终是拖拽元素和目标元素之间的元素
      if (this.activeModel.sortID > targetSortID) {
        // 从大到小,需要判断是同行移动,还是跨行移动
        if (this.activeModel.rowNumber === rowNumber) {
          // 单行只需要改变x偏移,从后往前以此修改之间元素偏移
          let sortid = this.activeModel.sortID - 1
          for (; sortid >= targetSortID; sortid--) {
            const model = this.getModelBySortID(sortid)
            model.x += (this.width + this.viewGap)
            model.sortID++
            model.columnNumber++
          }
        } else {
          // 如果激活元素和目标元素不在一行
          let sortid = this.activeModel.sortID - 1
          for (; sortid >= targetSortID; sortid--) {
            const model = this.getModelBySortID(sortid)
            model.sortID++
            // 此时由于不在同一行,需要改变的元素可能存在需要换行的情况,需要分别处理
            if (model.columnNumber === this.column) {
              // 如果当前元素处于最后一列,那么需要换行
              model.columnNumber = 1
              model.rowNumber++
              model.y = (this.areaYGap + (model.rowNumber - 1) * (this.height + this.viewGap))
              model.x = this.areaXGap + (model.columnNumber - 1) * (this.width + this.viewGap)
            } else {
              // 如果当前元素不处于临界状态,不需要考虑换行问题
              model.x += (this.width + this.viewGap)
              model.columnNumber++
            }
          }
        }
      } else if (this.activeModel.sortID < targetSortID) {
        // 从小到大,需要判断是同行移动,还是跨行移动
        if (this.activeModel.rowNumber === rowNumber) {
          // 单行只需要改变x偏移,从前往后以此修改之间元素偏移
          let sortid = this.activeModel.sortID + 1
          for (; sortid <= targetSortID; sortid++) {
            const model = this.getModelBySortID(sortid)
            model.x -= (this.width + this.viewGap)
            model.sortID--
            model.columnNumber--
          }
        } else {
          // 如果激活元素和目标元素不在一行
          let sortid = this.activeModel.sortID + 1
          for (; sortid <= targetSortID; sortid++) {
            const model = this.getModelBySortID(sortid)
            model.sortID--
            // 此时由于不在同一行,需要改变的元素可能存在需要换行的情况,需要分别处理
            if (model.columnNumber === 1) {
              // 如果当前元素处于第一列,那么需要减一行
              model.columnNumber = this.column
              model.rowNumber--
              model.y = (this.areaYGap + (model.rowNumber - 1) * (this.height + this.viewGap))
              model.x = this.areaXGap + (model.columnNumber - 1) * (this.width + this.viewGap)
            } else {
              // 如果当前元素不处于临界状态,不需要考虑换行问题
              model.x -= (this.width + this.viewGap)
              model.columnNumber--
            }
          }
        }
      }
      this.activeModel.sortID = targetSortID
      this.activeModel.columnNumber = columnNumber
      this.activeModel.rowNumber = rowNumber
    },

    touchstart(e) {
      // 如果是新增按钮被触及不用管
      if (e.currentTarget.dataset.isadd) {
        return
      }

      const me = this
      // 这里是因为click事件也会触发touchstart、touchmove、touchend事件
      // 所以防抖延迟处理,如果是click,那么会依次立即触发touchstart->touchmove->touchend,而在touchend中会将定时器清除
      // 如果是按住不动保持touchDebounceWait时间,此时不会触发touchend事件
      touchTimeOut = setTimeout(function () {
        me.isDrag = true
        // 计算 x y 轴点击位置
        const query = uni.createSelectorQuery().in(me)
        query.select('#drag').boundingClientRect()
        query.exec((res) => {
          // console.info(res);
          // 获取容器drag位置信息,TOP为距离顶部的距离,LEFT为距离左边的距离
          me.topY = res[0].top
          me.topX = res[0].left

          // 记录当前拖拽元素
          me.activeModel = me.getModel(e.currentTarget.dataset.uid)
          me.activeModel.width += 10
          me.activeModel.height += 10
          me.activeX = me.activeModel.x
          me.activeY = me.activeModel.y

          // 获取鼠标实时的坐标
          const temY = e.touches[0].clientY - me.topY
          const temX = e.touches[0].clientX - me.topX
          // 这里需要处理一下,如果直接把鼠标实时坐标复制给拖拽元素的xy偏移,那么此时元素的左上角会对准当前鼠标位置
          // 而正常情况下,我们点击的可能是元素的正中间,或者别的位置,所以需要修正一下
          // 这里x和y分别减去宽度和高度的一半,相当于把元素向左和向上偏移一半,把元素中心位置对应到鼠标点击的问题
          const itemY = temY - (me.height + 10) / 2
          const itemX = temX - (me.width + 10) / 2
          // 把当前坐标设置到当前拖拽元素的xy偏移值
          me.activeModel.y = itemY
          me.activeModel.x = itemX
          me.activeModel.animation = false
        })
      }, touchDebounceWait)
    },

    touchmove(e) {
      // 如果是新增按钮被触及不用管
      if (e.currentTarget.dataset.isadd) {
        return
      }

      const me = this
      if (touchMoveTimeOut) clearTimeout(touchMoveTimeOut)
      touchMoveTimeOut = setTimeout(function () {
        // 如果当前拖拽元素不存在,直接返回
        if (me.activeModel == null) return

        // 这里主要是为了解决touchmove触发比较频繁,而手指在操作时候,会产生小范围抖动,会造成拖动元素快速来回抖动
        // 所以这里修复如果和上次相比,移动距离小于20px则直接会返回
        if (lastTouchE) {
          if (Math.abs(e.touches[0].clientY - lastTouchE.touches[0].clientY) < 20 &&
              Math.abs(e.touches[0].clientX - lastTouchE.touches[0].clientX) < 20) {
            return
          }
        }
        lastTouchE = e

        // 获取鼠标实时的坐标
        const temY = e.touches[0].clientY - me.topY
        const temX = e.touches[0].clientX - me.topX
        // 这里需要处理一下,如果直接把鼠标实时坐标复制给拖拽元素的xy偏移,那么此时元素的左上角会对准当前鼠标位置
        // 而正常情况下,我们点击的可能是元素的正中间,或者别的位置,所以需要修正一下
        // 这里x和y分别减去宽度和高度的一半,相当于把元素向左和向上偏移一半,把元素中心位置对应到鼠标点击的问题
        // console.info(me.activeModel);
        const itemY = temY - (me.height + 10) / 2
        const itemX = temX - (me.width + 10) / 2
        // 把当前坐标设置到当前拖拽元素的xy偏移值
        me.activeModel.y = itemY
        me.activeModel.x = itemX
        me.activeModel.animation = false

        // touchmove触发太频繁,防抖处理,N秒后判断是否需要更改排序和偏移
        // 这里防抖函数使用util内的,延迟没有作用,还是会执行多次,暂时无法解决 不知道什么问题
        if (timeOut) clearTimeout(timeOut)
        timeOut = setTimeout(function () {
          me.findTarget(temX, temY)
        }, debounceWait)
      }, touchMoveDebounceWait)
    },

    touchend(e) {
      // 如果是新增按钮被触及不用管
      if (e.currentTarget.dataset.isadd) {
        return
      }

      if (touchTimeOut) clearTimeout(touchTimeOut)

      // 如果当前拖拽元素不存在,直接返回
      if (this.activeModel == null) return

      // 将拖拽元素移动到排序号所在的位置
      this.activeModel.width -= 10
      this.activeModel.height -= 10
      if (this.targetModel) {
        // 如果目标元素存在情况下,激活元素的偏移即为目标元素旧的偏移
        this.activeModel.x = this.targetX
        this.activeModel.y = this.targetY
      } else {
        // 如果目标元素不存在,还原激活元素的旧的位置
        this.activeModel.x = this.activeX
        this.activeModel.y = this.activeY
      }
      this.activeModel.animation = true
      // 如果确实更新了数据,那么触发事件
      if (this.isUpdate) {
        this.isUpdate = false
      }

      // 重置数据
      this.activeModel = null
      this.targetModel = null
      this.isDrag = false
      lastTouchE = null

      // 先根据排序编号进行排序,序号越小越靠前
      this.list.sort((a, b) => a.sortID - b.sortID)
      this.$emit('update:modelValue', this.list)
    },

    getModel(pk_id) {
      return this.list.find(i => i.uid === pk_id)
    },

    getModelBySortID(sortID) {
      return this.list.find(i => i.sortID === sortID)
    },

    afterRead(event) {
      try {
        event.file.forEach(file => {
          const isLt5M = file.size / 1024 / 1024 < 5;
          const isVLt100M = file.size / 1024 / 1024 < 100;
          if (this.fileType === 1) {
            if (!isVLt100M) {
              throw new Error('上传视频大小不能超过 100MB!')
            }
          } else if (this.fileType === 2) {
            if (!isLt5M) {
              throw new Error('上传图片大小不能超过 5MB!')
            }
          }
        })
      } catch (e) {
        uni.showToast({
          title: e.message || '',
          icon: 'none'
        })
        return false
      }

      const arr = []
      const fileArr = []
      event.file.forEach(file => {
        fileArr.push(file)
        arr.push(this.uploadImage(file))
      })
      uni.showToast({
        title: `${this.fileTypeName}上传中~`,
        icon: 'none'
      })
      Promise.all(arr).then(res => {
        for (const index in res) {
          let r = JSON.parse(res[index])
          if (r.code === '200') {
            // 延迟一下,避免uid相同
            let obj = {
              sortID: this.maxSortID + 1,
              url: r.data.url,
              fileName: fileArr[index].name,
              name: this.extractFileNameFromUrl(r.data.url),
              uid: `${new Date().getTime()}${index}`,
              type: fileArr[index].type,
              status: 'success',
            }
            this.list.splice(this.list.length - 1, this.list.length === this.imagesLimit ? 1 : 0, obj)
          }
          this.$nextTick(() => {
            this.initGrid()
            this.$emit('update:modelValue', this.list)
          })
        }
        uni.showToast({
          title: '上传成功!',
          icon: 'none'
        })
      }).catch(e => {
        uni.showToast({
          title: e.message || '',
          icon: 'none'
        })
      })
    },

    oversize(event) {
      console.log(event)
    },

    uploadImage(file) {
      let _this = this
      return new Promise((resolve, reject) => {
        const uploadTask = uni.uploadFile({
          url: API.UploadUrl,
          filePath: file.url,
          name: 'file',
          formData: this.formData,
          header: this.headerToken,
          success: (res) => {
            resolve(res.data)
          },
          fail: (err) => {
            reject(err)
          }
        })
        if (this.fileType === 1) {
          uploadTask.onProgressUpdate((res) => {
            _this.showProgress = true
            _this.percentage = res.progress
            if (_this.percentage === 100) {
              setTimeout(() => {
                _this.showProgress = false
              }, 1500)
            }
          })
        }
      })
    },

    extractFileNameFromUrl(url) {
      // 使用反斜杠对点号进行转义,并匹配URL末尾的文件名
      const fileNameRegex = /.*\/([^?&]+)$/;
      const match = url.match(fileNameRegex);
      return match ? match[1] : null;
    },

    deletePic(file) {
      this.list = this.list.filter(item => item.url !== file.url)
      this.$emit('update:modelValue', this.list)
      this.processAddObject()
      this.initGrid()
    },

    // 如果元素数量少于this.imagesLimit个,并且没有上传按钮则新增一个上传图片按钮
    processAddObject() {
      if (this.list.length < this.imagesLimit && !this.addObject) {
        this.list.push({
          uid: 0,
          sortID: 10000, // 按钮始终放在末尾位置,默认排序号10000
          url: '',
          isAdd: true,
          direction: 'all',
          disabled: true,
          animation: true,
        })
      }
    },
  },
}
在微信小程序的原生开发中,`movable-area`和`movable-view`组件主要用于实现列表项的拖动排序功能。这两个组件配合使用可以让你创建一个用户能够直接通过拖拽操作调整列表元素顺序的应用场景。以下是基本的工作原理: 1. `movable-area`: 这是一个容器组件,它提供了一个区域供子组件(如`movable-view`) 可以自由移动。你需要设置`binddragstart`, `binddragmove`, `binddragend`等事件处理器来跟踪用户的拖拽动作。 2. `movable-view`: 这是实际可以被拖动的视图组件,通常包含列表项的内容。它会响应`draggable`属性,当设置为true时,允许用户对其进行拖拽操作。同时,你也可以自定义样式和处理拖动过程中的状态变化。 要使用它们,首先在WXML文件中添加`movable-area`,然后将`movable-view`作为其子元素。在WXSS中设置样式,并在JS或WXS文件中编写事件处理器,处理开始拖动、移动和结束拖动的逻辑,包括更新数据源以反映新的排列顺序。 ```html <!-- WXML --> <movable-area binddragstart="handleDragStart" binddragmove="handleDragMove" binddragend="handleDragEnd"> <view wx:for="{{ listItems }}" draggable="true" index="{{index}}" class="list-item">{{item}}</view> </movable-area> <!-- JS 或者 WXS --> Page({ data: { listItems: [...], // 初始列表项 }, handleDragStart(e) { // 开始拖动处理 }, handleDragMove(e) { // 移动过程中处理 }, handleDragEnd(e) { // 结束拖动并同步数据 } }) ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值