可拖拽单元格组件
本 Component 是 uni ext component,需下载插件:uni-drag-cell
uni-drag-cell 是一个基于 uni-app x + uvue + UTS 实现的可拖拽排序组件。
它适合这些场景:
组件通过 v-model 绑定了一个数组,通过插槽渲染绑定数组的内容,通过拖拽调整数组项的顺序。
组件还提供了show-close属性,显示底部删除条,拖到删除条即可删除。
组件还提供了show-append属性,显示尾部的添加插槽。该插槽不参与拖拽排序,始终在最后一个。(添加数组项的逻辑需自行实现)
通过这些能力,本组件具备对数组数据排序、添加、删除的能力。
但本组件不适合超长虚拟列表。不具备拖动到顶部或底部后自动滚动列表的功能。
这个组件的设计不是“拖一下就立刻改数组”,而是分成两个阶段:
拖拽预览阶段
transform 动态调整视觉顺序v-model 还不会被正式改写拖拽提交阶段
update:modelValue 和 change 把最终结果抛给外部这样做的好处是:
组件目前支持两种进入拖拽态的方式:长按拖拽和手柄拖拽。
默认模式。
350ms补充:
适合:
当 handleMode 为 true,且同时提供 handle 插槽时,会启用手柄模式。
适合:
| 插槽名 | 插槽参数 | 说明 |
|---|---|---|
default | { item, index } | 默认内容插槽,用于渲染每一个可排序项。组件不限制 item 的视觉形式,可以自由渲染标签、卡片、宫格、图片等内容。 |
append | - | 列表末尾的固定入口。不参与拖拽排序,不会写入 v-model,不会被删除,适合放“新增按钮”“上传入口”“更多入口”等内容。 |
handle | { item, index } | 拖拽手柄插槽,仅在 handleMode 开启时生效。手柄由组件放在每个 item 的右上角,建议给内容区预留右侧空间,避免手柄遮挡正文。 |
| 事件名 | 回调参数 | 触发时机 | 说明 |
|---|---|---|---|
change | 最新数组 | 排序完成后、删除完成后 | 最终结果事件。拖动中的视觉预览不会频繁触发 change。 |
<uni-drag-cell v-model="tagList">
<template #default="{ item, index }">
<view class="tag-item" @click="onClickTag(item, index)">
<text>{{ item }}</text>
</view>
</template>
</uni-drag-cell>
适合:
说明:
350ms<uni-drag-cell v-model="gridList" :column="3">
<template #default="{ item }">
<view class="grid-item">
<text>{{ item.name }}</text>
</view>
</template>
</uni-drag-cell>
适合:
<uni-drag-cell v-model="list" :show-close="true">
<template #default="{ item }">
<view class="cell-item">
<text>{{ item }}</text>
</view>
</template>
</uni-drag-cell>
交互:
<uni-drag-cell v-model="tagList" :show-append="true">
<template #default="{ item }">
<view class="tag-item">
<text>{{ item }}</text>
</view>
</template>
<template #append>
<view class="tag-add" @click="addTag">
<text>+</text>
</view>
</template>
</uni-drag-cell>
说明:
append 是通用设计,不局限于图片场景append 不参与位置计算<uni-drag-cell
v-model="imageList"
:column="3"
:show-close="true"
:show-append="imageList.length < 9">
<template #default="{ item, index }">
<view class="image-item" @click="previewImage(index)">
<image :src="item" mode="aspectFill"></image>
</view>
</template>
<template #append>
<view class="image-add" @click="chooseImages">
<text>+</text>
</view>
</template>
</uni-drag-cell>
设计说明:
v-modelappend 用来放“添加图片”入口showAppend 设为 false<uni-drag-cell v-model="list" :column="1" :handle-mode="true">
<template #default="{ item }">
<view class="cell-item">
<text>{{ item }}</text>
</view>
</template>
<template #handle>
<view class="cell-handle">
<text>≡</text>
</view>
</template>
</uni-drag-cell>
说明:
拖动过程中,组件内部维护的是“预览顺序”。
实现方式:
transform 让其他项移动到目标位置这样可以避免:
删除命中不是简单按手指坐标判断,而是优先按“拖动内容的真实边界”和“删除条边界”是否接触来判断。
这样做是为了避免:
append 项的设计目标是“固定入口”,因此:
也就是说:
这样可以减少两套逻辑分叉导致的维护成本。
拖拽时耗费性能的点主要有两个:
每次 move 都会更新:
这是拖拽组件的必要开销,当前实现已经把重测布局的频率控制在较低水平,只在必要时重新测量。
当启用 showClose 时,拖动中会优先读取当前拖动内容的实时矩形,以保证删除命中准确。
这部分是“精度优先”的设计,开销可接受,但不建议把它用于超大数量、超复杂 item 的高频拖拽场景。
尤其是宫格模式下,建议每个 item 的宽高尽量稳定。
否则可能带来:
因为手柄是覆盖在 item 右上角的。
建议:
append 只是入口,不是数据项这点非常重要。
不要指望:
append 参与排序append 进入 v-modelappend 被删除如果你需要“真正的数据项”,请直接写进 modelValue。
建议:
不建议:
这个组件当前更适合:
如果是超长列表、虚拟滚动列表,应该使用专门为大数据量设计的方案。
| Web | 微信小程序 | Android | Android(Vapor) | iOS | iOS(Vapor) | HarmonyOS | HarmonyOS(Vapor) |
|---|---|---|---|---|---|---|---|
| 5.07 | 5.07 | 5.08 | x | 5.07 | x | 5.07 | 5.07 |
| 名称 | 类型 | 默认值 | 兼容性 | 描述 |
|---|---|---|---|---|
| modelValue | array | - | - | 可拖拽排序的数据源,支持 v-model 双向绑定,内部会在拖拽结束后更新顺序 |
| column | number | 0 | - | 网格列数。大于 0 时按网格排布,等于 0 时按一行流式排布 |
| showClose | boolean | false | - | 是否显示拖拽到底部删除栏,开启后可将 item 拖入底部删除 |
| showAppend | boolean | true | - | 是否显示末尾的 append 插槽(非拖拽状态下) |
| handleMode | boolean | false | - | 是否启用拖拽手柄模式,仅点击 handle 插槽才能触发拖拽 |
| @update:modelValue | Event | - | - | v-model 绑定值变化事件,参数为最新数组,类型为数组 |
| @change | Event | - | - | 排序完成后、删除完成后触发,参数为最新数组,类型为数组 |
示例为hello uni-app x alpha分支,与最新HBuilderX Alpha版同步。与最新正式版同步的master分支示例另见
示例
<template>
<!-- #ifdef APP -->
<scroll-view style="flex: 1; padding: 15px;">
<!-- #endif -->
<!-- #ifndef APP -->
<view style="padding: 15px;">
<!-- #endif -->
<text class="page-title">drag-cell 拖拽排序组件</text>
<view class="section">
<text class="section-title">示例 1:标签拖拽排序</text>
<text class="section-desc">长按标签后可拖拽排序,点击标签可拿到 item 和 index</text>
<uni-drag-cell v-model="data.tagList" @change="onTagChange">
<template #default="{ item, index }">
<view class="tag-slot">
<view class="tag-item" @click="clickItem(item as string, index as number)">
<text class="tag-text">{{ item as string }}</text>
</view>
</view>
</template>
</uni-drag-cell>
<text class="result-text">当前顺序:{{ data.tagList.join(', ') }}</text>
</view>
<view class="section">
<text class="section-title">示例 2:宫格拖拽排序(3 列)</text>
<text class="section-desc">长按宫格项后可拖拽排序</text>
<uni-drag-cell v-model="data.gridList3" :column="3" @change="onGridChange">
<template #default="{ item }">
<view class="grid-slot">
<view class="grid-item">
<text class="grid-text">{{ (item as UTSJSONObject)['name'] }}</text>
</view>
</view>
</template>
</uni-drag-cell>
<text class="result-text">当前顺序:{{ getGridNames(data.gridList3) }}</text>
</view>
<view class="section">
<text class="section-title">示例 3:列表拖拽排序(1 列)</text>
<text class="section-desc">长按列表项后可拖拽排序</text>
<uni-drag-cell v-model="data.gridList4" :column="1" @change="onGridChange">
<template #default="{ item }">
<view class="grid-slot-small">
<view class="grid-item-small">
<text class="grid-text-small">{{ (item as UTSJSONObject)['name'] }}</text>
</view>
</view>
</template>
</uni-drag-cell>
<text class="result-text">当前顺序:{{ getGridNames(data.gridList4) }}</text>
</view>
<view class="section">
<text class="section-title">示例 4:可新增并删除的标签</text>
<text class="section-desc">长按标签可排序,拖到底部可删除,点击 + 可输入并新增标签</text>
<uni-drag-cell v-model="data.deleteList" :show-close="true" :show-append="true" @change="onDeleteChange">
<template #default="{ item }">
<view class="tag-slot">
<view class="tag-item-delete">
<text class="tag-text">{{ item as string }}</text>
</view>
</view>
</template>
<template #append>
<view class="tag-slot">
<view class="tag-item-add" @click="promptAddDeleteTag">
<text class="tag-plus-text">+</text>
</view>
</view>
</template>
</uni-drag-cell>
<text class="result-text">当前顺序:{{ data.deleteList.join(', ') }}</text>
<text class="result-text">剩余:{{ data.deleteList.length }} 项</text>
</view>
<view class="section">
<text class="section-title">示例 5:带删除功能的图标宫格</text>
<text class="section-desc">长按后可拖动图标宫格项,并支持拖到底部删除</text>
<uni-drag-cell v-model="data.iconGridList" :column="3" :show-close="true" @change="onIconGridChange">
<template #default="{ item }">
<view class="icon-grid-slot">
<view class="icon-grid-item">
<view class="icon-box">
<text class="icon-emoji">{{ (item as UTSJSONObject)['icon'] }}</text>
</view>
<text class="icon-label">{{ (item as UTSJSONObject)['label'] }}</text>
</view>
</view>
</template>
</uni-drag-cell>
<text class="result-text">当前顺序:{{ getIconNames(data.iconGridList) }}</text>
</view>
<view class="section">
<text class="section-title">示例 6:九宫格图片选择</text>
<text class="section-desc">点击 + 选择图片,支持拖拽排序,也支持拖到底部删除</text>
<uni-drag-cell v-model="data.imageList" :column="3" :show-close="true"
:show-append="data.imageList.length < maxImageCount" @change="onImageChange">
<template #default="{ item, index }">
<view class="image-grid-slot">
<view class="image-grid-item" @click="previewImages(index as number)">
<image class="image-grid-img" :src="item" mode="aspectFill"></image>
</view>
</view>
</template>
<template #append>
<view class="image-grid-slot">
<view class="image-grid-plus" @click="chooseImages">
<text class="image-grid-plus-icon">+</text>
</view>
</view>
</template>
</uni-drag-cell>
<text class="result-text">已选择:{{ data.imageList.length }}/{{ maxImageCount }}</text>
</view>
<view class="section">
<text class="section-title">示例 7:手柄拖拽排序</text>
<text class="section-desc">开启手柄模式后,不需要长按,只能拖动右侧手柄进行排序</text>
<uni-drag-cell v-model="data.handleList" :column="1" :handle-mode="true" @change="onHandleListChange">
<template #default="{ item, index }">
<view class="handle-demo-slot">
<view class="handle-demo-item">
<text class="handle-demo-index">{{ (index as number) + 1 }}</text>
<text class="handle-demo-text">{{ item as string }}</text>
</view>
</view>
</template>
<template #handle="{ item }">
<view class="handle-demo-grip">
<text class="handle-demo-grip-text">≡</text>
</view>
</template>
</uni-drag-cell>
<text class="result-text">当前顺序:{{ data.handleList.join(', ') }}</text>
</view>
<view style="height: 50px;"></view>
<!-- #ifdef APP -->
</scroll-view>
<!-- #endif -->
<!-- #ifndef APP -->
</view>
<!-- #endif -->
</template>
<script setup lang="uts">
const maxImageCount = 9
type Data = {
tagList: string[],
gridList3: UTSJSONObject[],
gridList4: UTSJSONObject[],
deleteList: string[],
iconGridList: UTSJSONObject[],
imageList: string[],
handleList: string[]
}
const data = reactive<Data>({
tagList: ['标签1', '标签2', '标签3', '标签4'],
gridList3: [
{ name: '宫格1' },
{ name: '宫格2' },
{ name: '宫格3' },
{ name: '宫格4' },
{ name: '宫格5' },
{ name: '宫格6' },
{ name: '宫格7' },
{ name: '宫格8' },
{ name: '宫格9' }
],
gridList4: [
{ name: '1' },
{ name: '2' },
{ name: '3' },
{ name: '4' },
{ name: '5' }
],
deleteList: ['标签A', '标签B', '标签C', '标签D', '标签E'],
iconGridList: [
{ icon: 'H', label: '首页' },
{ icon: 'M', label: '消息' },
{ icon: 'P', label: '我的' },
{ icon: 'S', label: '设置' },
{ icon: 'A', label: '相册' },
{ icon: 'Y', label: '音乐' }
],
imageList: [],
handleList: ['微信支付', '支付宝', 'Apple Pay']
})
const clickItem = (item : string, index : number) => {
uni.showToast({
title: '点击了第 ' + (index + 1) + ' 项:' + item,
icon: 'none'
})
}
const onTagChange = (list : string[]) => {
console.log('tag list changed:', list)
}
const onGridChange = (list : UTSJSONObject[]) => {
console.log('grid list changed:', list)
}
const onDeleteChange = (list : string[]) => {
console.log('delete list changed:', list)
}
const onIconGridChange = (list : UTSJSONObject[]) => {
console.log('icon grid list changed:', list)
}
const onImageChange = (list : string[]) => {
console.log('image list changed:', list)
}
const onHandleListChange = (list : string[]) => {
console.log('handle list changed:', list)
}
const promptAddDeleteTag = () => {
uni.showModal({
title: '新增标签',
editable: true,
placeholderText: '请输入标签内容',
confirmText: '确定',
cancelText: '取消',
success: (res) => {
if (res.confirm != true) {
return
}
const value = res.content != null ? res.content.trim() : ''
if (value.length == 0) {
uni.showToast({
title: '标签内容不能为空',
icon: 'none'
})
return
}
const nextList = data.deleteList.slice()
nextList.push(value)
data.deleteList = nextList
onDeleteChange(data.deleteList)
}
})
}
const extractChooseImagePaths = (res : ChooseImageSuccess) : string[] => {
const paths : string[] = []
for (let i = 0; i < res.tempFilePaths.length; i++) {
const path = res.tempFilePaths[i]
if (path.length > 0) {
paths.push(path)
}
}
if (paths.length > 0) {
return paths
}
for (let i = 0; i < res.tempFiles.length; i++) {
const path = res.tempFiles[i].path
if (path.length > 0) {
paths.push(path)
}
}
return paths
}
const chooseImages = () => {
const remainCount = maxImageCount - data.imageList.length
if (remainCount <= 0) {
return
}
uni.chooseImage({
count: remainCount,
success: (res : ChooseImageSuccess) => {
const selectedPaths = extractChooseImagePaths(res)
if (selectedPaths.length == 0) {
return
}
const nextList = data.imageList.slice()
for (let i = 0; i < selectedPaths.length; i++) {
if (nextList.length >= maxImageCount) {
break
}
nextList.push(selectedPaths[i])
}
data.imageList = nextList
onImageChange(data.imageList)
}
})
}
const previewImages = (index : number) => {
if (index < 0 || index >= data.imageList.length) {
return
}
uni.previewImage({
urls: data.imageList,
current: data.imageList[index]
})
}
const getGridNames = (list : UTSJSONObject[]) : string => {
const names : string[] = []
for (let i = 0; i < list.length; i++) {
const value = list[i]['name']
if (value != null) {
names.push(value as string)
}
}
return names.join(', ')
}
const getIconNames = (list : UTSJSONObject[]) : string => {
const names : string[] = []
for (let i = 0; i < list.length; i++) {
const value = list[i]['label']
if (value != null) {
names.push(value as string)
}
}
return names.join(', ')
}
defineExpose({
data,
clickItem,
promptAddDeleteTag,
chooseImages,
previewImages
})
</script>
<style>
.page-title {
font-size: 24px;
font-weight: bold;
color: var(--text-color, #333333);
margin-bottom: 20px;
text-align: center;
}
.section {
margin-bottom: 30px;
background-color: var(--list-background-color, #ffffff);
border-radius: 8px;
padding: 15px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #007aff;
margin-bottom: 5px;
}
.section-desc {
font-size: 12px;
color: var(--text-color, #333333);
opacity: 0.7;
margin-bottom: 15px;
}
.tag-slot {
padding-right: 10px;
padding-bottom: 10px;
}
.tag-item {
display: flex;
flex-direction: column;
background-color: #007aff;
border-radius: 4px;
padding: 8px 16px;
}
.tag-text {
color: #ffffff;
font-size: 14px;
}
.tag-item-delete {
display: flex;
flex-direction: column;
background-color: #ff6b6b;
border-radius: 4px;
padding: 4px 16px;
height: 32px;
}
.tag-item-add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #fff5f5;
border-width: 1px;
border-style: dashed;
border-color: #ff6b6b;
border-radius: 4px;
padding: 4px 16px;
height: 32px;
}
.tag-plus-text {
color: #ff6b6b;
font-size: 16px;
font-weight: bold;
}
.grid-slot {
width: 100%;
padding: 5px;
}
.grid-item {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #28a745;
border-radius: 8px;
height: 80px;
}
.grid-text {
color: #ffffff;
font-size: 14px;
font-weight: bold;
}
.grid-slot-small {
width: 90%;
/* 避免scale放大1.12倍时被裁剪*/
padding: 3px;
align-self: center;
}
.grid-item-small {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #ffc107;
border-radius: 8px;
height: 60px;
}
.grid-text-small {
color: #333333;
font-size: 16px;
font-weight: bold;
}
.icon-grid-slot {
width: 100%;
padding: 5px;
}
.icon-grid-item {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #f8f9fa;
border-radius: 8px;
padding: 10px;
}
.icon-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
background-color: #e9ecef;
border-radius: 25px;
margin-bottom: 5px;
}
.icon-emoji {
font-size: 24px;
font-weight: bold;
color: #333333;
}
.icon-label {
font-size: 12px;
color: #333333;
}
.image-grid-slot {
width: 100%;
padding: 5px;
}
.image-grid-item {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 104px;
background-color: #f5f5f5;
border-radius: 12px;
overflow: hidden;
}
.image-grid-img {
width: 100%;
height: 100%;
border-radius: 12px;
}
.image-grid-plus {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 104px;
background-color: #fafafa;
border-width: 1px;
border-style: dashed;
border-color: #d9d9d9;
border-radius: 12px;
}
.image-grid-plus-icon {
font-size: 36px;
font-weight: bold;
color: #999999;
}
.handle-demo-slot {
width: 100%;
padding: 4px 0;
}
.handle-demo-item {
display: flex;
flex-direction: row;
align-items: center;
background-color: #f7f8fa;
border-width: 1px;
border-style: solid;
border-color: #e5e6eb;
border-radius: 10px;
padding: 16px 52px 16px 14px;
}
.handle-demo-index {
width: 24px;
margin-right: 12px;
color: #007aff;
font-size: 15px;
font-weight: bold;
text-align: center;
}
.handle-demo-text {
color: #333333;
font-size: 14px;
}
.handle-demo-grip {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 40px;
height: 56px;
}
.handle-demo-grip-text {
color: #999999;
font-size: 24px;
font-weight: bold;
}
.result-text {
font-size: 12px;
color: var(--text-color, #333333);
opacity: 0.7;
margin-top: 10px;
}
</style>