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,
})
}
},
},
}