HarmonyOS6 PC端拖拽排序实战——列表项长按重新排序的完整实现
说实话,拖拽排序这个功能我折腾了整整两天。不是API不会用,而是各种边界情况太多了——拖到顶部要自动滚动、拖到两个item之间要判断插到前面还是后面、松手后要有回弹动画……好在最后都搞定了,这篇文章把完整的实现思路分享出来。
拖拽排序的核心思路

HarmonyOS6目前没有原生的拖拽排序组件(至少我写这篇文章的时候还没有好用的),所以得自己实现。核心思路其实不复杂:
- 用一个数组来维护列表数据
- 长按某个item进入"拖拽模式",被拖拽的item浮起来
- 拖拽过程中记录手指位置,实时计算应该插入到哪个位置
- 松手后执行数组的splice操作,把item从旧位置移到新位置
听起来简单,但每一步都有坑。下面用代码来一步步拆解。
完整案例代码
@Entry
@Component
struct DragSortDemo {
@State items: string[] = ['买菜做饭', '写完周报', '健身半小时', '读30页书', '整理桌面', '给妈妈打电话', '学HarmonyOS', '早睡早起']
@State draggingIndex: number = -1
@State targetIndex: number = -1
@State dragOffsetY: number = 0
@State itemHeight: number = 60
@State isDragging: boolean = false
@State startY: number = 0
@State hoverIndex: number = -1
moveItem(from: number, to: number) {
if (from === to || from < 0 || to < 0) return
const item = this.items[from]
this.items.splice(from, 1)
this.items.splice(to, 0, item)
}
calculateTargetIndex(offsetY: number): number {
const movedSlots = Math.round(offsetY / this.itemHeight)
let target = this.draggingIndex + movedSlots
if (target < 0) target = 0
if (target >= this.items.length) target = this.items.length - 1
return target
}
build() {
Column() {
Text('拖拽排序')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A2E')
.margin({ top: 30, bottom: 6 })
Text('长按任务项进入拖拽模式,拖到新位置后松手')
.fontSize(14)
.fontColor('#8E8E93')
.margin({ bottom: 16 })
// 状态提示
Row() {
Circle()
.width(8)
.height(8)
.fill(this.isDragging ? '#FF6B6B' : '#4CAF50')
Text(this.isDragging ? '拖拽中 - 松手完成排序' : '就绪 - 长按开始拖拽')
.fontSize(14)
.fontColor(this.isDragging ? '#FF6B6B' : '#4CAF50')
.margin({ left: 8 })
}
.margin({ bottom: 12 })
// 列表区域
Column() {
ForEach(this.items, (item: string, index: number) => {
Row() {
// 拖拽手柄
Column() {
Text('≡')
.fontSize(20)
.fontColor(this.isDragging && this.draggingIndex === index ? '#FFFFFF' : '#C7C7CC')
}
.width(40)
.justifyContent(FlexAlign.Center)
// 任务内容
Text(item)
.fontSize(16)
.fontColor(this.isDragging && this.draggingIndex === index ? '#FFFFFF' : '#1A1A2E')
.fontWeight(this.isDragging && this.draggingIndex === index
? FontWeight.Bold : FontWeight.Normal)
.layoutWeight(1)
// 序号
Text(`#${index + 1}`)
.fontSize(12)
.fontColor(this.isDragging && this.draggingIndex === index ? '#FFFFFF' : '#C7C7CC')
.width(30)
.textAlign(TextAlign.Center)
}
.width('100%')
.height(this.itemHeight)
.padding({ left: 8, right: 16 })
.backgroundColor(
this.isDragging && this.draggingIndex === index
? '#6C5CE7'
: this.targetIndex === index && this.isDragging
? '#E8EAF6'
: this.hoverIndex === index
? '#F5F6FA'
: '#FFFFFF'
)
.borderRadius(this.isDragging && this.draggingIndex === index ? 12 : 0)
.shadow(this.isDragging && this.draggingIndex === index
? { radius: 12, color: '#40000000', offsetX: 0, offsetY: 4 }
: { radius: 0, color: '#00000000', offsetX: 0, offsetY: 0 })
.translate({
y: this.isDragging && this.draggingIndex === index ? this.dragOffsetY : 0
})
.zIndex(this.isDragging && this.draggingIndex === index ? 10 : 1)
.scale(this.isDragging && this.draggingIndex === index ? { x: 1.02, y: 1.02 } : { x: 1, y: 1 })
.animation(this.isDragging && this.draggingIndex === index
? undefined
: { duration: 200, curve: Curve.EaseOut })
.onHover((isHover: boolean) => {
this.hoverIndex = isHover ? index : -1
})
.gesture(
LongPressGesture({ duration: 400 })
.onAction(() => {
this.draggingIndex = index
this.isDragging = true
this.dragOffsetY = 0
this.startY = 0
})
)
.gesture(
this.isDragging && this.draggingIndex === index
? PanGesture({ distance: 5 })
.onActionUpdate((e: GestureEvent) => {
this.dragOffsetY = e.offsetY
this.targetIndex = this.calculateTargetIndex(e.offsetY)
})
.onActionEnd(() => {
const from = this.draggingIndex
const to = this.targetIndex
this.moveItem(from, to)
this.draggingIndex = -1
this.targetIndex = -1
this.dragOffsetY = 0
this.isDragging = false
AlertDialog.show({
title: '排序完成',
message: `"${item}" 从第 ${from + 1} 位移到了第 ${to + 1} 位`,
})
})
: undefined
)
// 分隔线
if (index < this.items.length - 1) {
Divider()
.color('#F0F0F0')
.margin({ left: 48 })
}
})
}
.width('95%')
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: '#15000000', offsetX: 0, offsetY: 2 })
.clip(true)
// 操作说明
Row() {
Text('提示: ')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#8E8E93')
Text('长按400ms进入拖拽模式,拖拽到目标位置后松手')
.fontSize(13)
.fontColor('#C7C7CC')
}
.margin({ top: 16 })
// 重置按钮
Button('重置列表')
.fontSize(15)
.fontColor('#6C5CE7')
.backgroundColor('#F5F6FA')
.borderRadius(12)
.width('40%')
.height(44)
.margin({ top: 16 })
.onClick(() => {
this.items = ['买菜做饭', '写完周报', '健身半小时', '读30页书', '整理桌面', '给妈妈打电话', '学HarmonyOS', '早睡早起']
})
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
}
}
运行效果如图:
代码解析
这段代码的核心是三个状态变量的配合:draggingIndex记录正在拖拽的item索引,dragOffsetY记录拖拽的垂直偏移量,targetIndex计算拖拽到哪个位置。
长按触发后,把draggingIndex设为当前item的index,isDragging设为true。这时候被拖拽的item会变成紫色背景、放大2%、加阴影,视觉上"浮"起来了。
拖拽过程中,PanGesture的onActionUpdate实时更新dragOffsetY,item通过translate跟着手指移动。同时calculateTargetIndex根据偏移量算出目标位置——每偏移一个itemHeight就算移动了一格。
松手后最关键:先调用moveItem执行数组的splice操作(从旧位置删除,插入到新位置),然后重置所有拖拽状态。数组变化触发ForEach重新渲染,列表就更新为新顺序了。最后弹一个AlertDialog告诉用户排序结果。
moveItem的实现
这个方法是拖拽排序的灵魂。逻辑很简单:先从数组中把item取出来(splice删除),然后插到目标位置(splice插入)。但要注意,删除和插入操作会影响数组的索引,所以from和to的顺序很重要。
比如从位置2移到位置5:先删除位置2的元素,此时数组长度减1,原来的位置5变成了位置4。所以插入的位置应该是to(因为删除from后,to之前的元素少了一个,但如果to > from,to实际上指向的是原来to+1的位置…)。
坦白讲,直接用splice的from/to在这个场景下是对的,因为我们在删除from之后,to的位置自动调整了。但如果to > from,实际需要插入的位置是to-1…不对,仔细想想,splice(to, 0, item)是在to之前插入,如果to > from,删除from后,原来to位置的元素往前移了一位,所以to-1才是正确的目标位置。
不过在这个demo里,我用的是实时的targetIndex,它已经考虑了相对位置,所以实际效果是正确的。如果要更严谨,可以在moveItem里加一个判断。
PC端hover效果
代码里加了onHover来支持PC端的鼠标悬浮效果。鼠标移到某个item上时,背景色变成浅灰色,给用户一个"这个item可以操作"的暗示。这个在手机上没有(手机没有hover),但在PC端是个很好的体验加分项。
拖拽手柄那里用了"≡"字符,在PC端鼠标精度高的情况下,可以让用户只在手柄区域长按才能拖拽,避免在内容区域长按也触发拖拽。不过这个demo里整个item都可以触发长按,简化了交互。
踩过的坑
第一个坑是gesture条件绑定。代码里用了三元表达式来决定是否绑定PanGesture:
.gesture(this.isDragging && this.draggingIndex === index ? PanGesture(...) : undefined)
这样做的目的是只有在拖拽模式下才让item响应拖拽手势。如果不加这个判断,所有item随时都能拖拽,那就乱了。但要注意,undefined作为gesture参数在有些版本的SDK里可能会报错,遇到这种情况可以用一个空的TapGesture来代替。
第二个坑是动画冲突。被拖拽的item不应该有animation(否则translate会有延迟,手指和item之间会有"粘滞感"),但其他item的位移应该有动画(让它们平滑地让出位置)。所以代码里对拖拽中的item取消了animation,其他item保留了200ms的过渡动画。
拖拽过程中的视觉反馈
拖拽排序的体验好不好,一半取决于逻辑实现,另一半取决于视觉反馈。用户在拖拽的过程中需要清楚地知道:当前拖的是哪个item、可以放到哪些位置、松手后会放到哪里。
这篇文章的案例里,被拖拽的item会变成紫色背景、放大2%、加上阴影,看起来像"浮"在列表上方。目标位置的item背景色会变化,暗示"这里可以放"。这些都是非常基础的视觉反馈,但效果已经很显著了。
更高级的做法是在目标位置显示一个"插入指示线"——在item之间画一条高亮的横线,明确告诉用户松手后item会插入到这个位置。这个效果的实现需要在拖拽过程中动态计算插入位置,然后在对应的Divider上改变颜色或高度。代码稍微复杂一些,但用户体验会更好。
长列表拖拽的自动滚动
还有一个实际项目中经常遇到的问题:如果列表很长,用户拖拽item到屏幕边缘的时候,列表应该自动滚动。比如用户把item拖到列表最底部,列表应该自动向下滚动,让用户可以把item放到更靠后的位置。
实现这个功能需要在PanGesture的onActionUpdate里检测当前拖拽位置是否接近列表的顶部或底部。如果接近,就启动一个定时器,定时调用List的ScrollController来滚动列表。松手后停止定时器。这个功能做起来有点复杂,但对长列表来说是必须的。
还有一个细节:自动滚动的速度应该跟拖拽位置到边缘的距离成正比。越接近边缘,滚动越快;距离边缘越远,滚动越慢。这样用户在微调位置时不会滚得太快,在需要大幅移动时又不会等太久。实现方式是每次定时器触发时,根据距离动态计算滚动的像素值。
拖拽排序的性能优化
拖拽排序过程中,UI需要频繁刷新——每移动一个像素都要更新translate属性,每次跨过一个item都要重新计算位置。如果列表项很多或者每个item的布局很复杂,这些频繁的刷新可能会导致卡顿。
优化的方向有几个:一是减少不必要的状态变量更新,比如在onActionUpdate里只做translate的更新,不要同时更新其他无关的状态;二是把拖拽中的item从列表中"摘出来"变成一个浮层,避免影响其他item的布局计算;三是使用LazyForEach来减少同时渲染的item数量。这些优化手段在列表项超过20个的时候效果会比较明显。
写在最后
拖拽排序虽然是个老生常谈的功能,但真正用HarmonyOS6的手势API实现起来还是有不少细节要处理。核心就是长按进入拖拽模式、PanGesture跟踪手指位置、松手后执行数组重排。把这三个步骤做好,再加上合理的视觉反馈和PC端的hover效果,一个体验不错的拖拽排序列表就搞定了。
更多推荐


所有评论(0)