功能概述
本文介绍了一个基于鸿蒙ArkUI框架开发的可拖拽排序网格组件。该组件实现了以下核心功能:
- 多列网格布局展示数据
- 支持元素拖拽重新排序
- 支持多方向拖拽(上、下、左、右及对角线方向)
- 自动滚动功能(当拖拽到边缘区域时)
- 平滑的动画过渡效果
这个组件可以应用于多种场景,如应用图标排序、照片墙管理、任务卡片排序等需要用户自定义排序的界面。
效果预览
技术实现
核心数据结构
@State numbers: number[] = [] // 网格中显示的数据
@State row: number = 2 // 网格列数
@State lastIndex: number = 0 // 数组最后一个元素的索引
@State dragItem: number = -1 // 当前被拖拽的元素
@State offsetX/offsetY: number = 0 // 拖拽元素的位移
private isUpdating: boolean = false // 是否正在更新
private lastEvent: GestureEvent | null = null // 最新的手势事件
布局实现
组件使用ArkUI的Grid和GridItem组件构建网格布局,通过columnsTemplate属性设置多列布局:
Grid(this.scroller) {
ForEach(this.numbers, (item: number) => {
GridItem() {
Text(item + '')
.fontSize(16)
.width('100%')
.textAlign(TextAlign.Center)
.height(136)
.borderRadius(10)
.backgroundColor(0xFFFFFF)
}
// 其他属性和手势处理...
}
}
.columnsTemplate(this.str) // 设置多列布局
拖拽实现
拖拽功能通过PanGesture手势实现,主要包含三个事件处理:
- onActionStart:开始拖拽时记录初始状态并启动更新循环
- onActionUpdate:仅记录最新事件,不直接更新UI
- onActionEnd:结束拖拽时停止更新循环并重置状态
.gesture(
GestureGroup(GestureMode.Sequence,
PanGesture({
fingers: 1,
direction: null,
distance: 0
})
.onActionStart(() => {
this.dragItem = item
this.dragRefOffsetx = 0
this.dragRefOffsety = 0
this.isUpdating = true
this.startUpdateLoop()
})
.onActionUpdate((event: GestureEvent) => {
// 只保存最新的事件,不直接更新UI
this.lastEvent = event
})
.onActionEnd(() => {
this.isUpdating = false
this.stopSmoothScroll()
animateTo({
curve: curves.interpolatingSpring(0, 1, 400, 38)
}, () => {
this.dragItem = -1
})
// 其他重置逻辑...
})
)
)
统一更新循环实现
为解决拖拽抖动问题,使用统一的更新循环处理所有UI更新:
private startUpdateLoop() {
if (!this.isUpdating) return
// 处理最新的事件
if (this.lastEvent) {
// 更新位置
this.offsetX = this.lastEvent.offsetX - this.dragRefOffsetx
this.offsetY = this.lastEvent.offsetY - this.dragRefOffsety
this.dragIndex = this.numbers.indexOf(this.dragItem)
// 处理滚动逻辑
let fingerInfo = this.lastEvent.fingerList[0]
let clickPercentY =
(fingerInfo.globalY - Number(this.listArea.globalPosition.y)) / Number(this.listArea.height)
// 获取当前滚动位置
const currentOffset = this.scroller.currentOffset()
const isAtTop = currentOffset.yOffset <= 0
const isAtBottom = this.scroller.isAtEnd()
// 根据触摸位置和边界条件调整滚动速度
if (clickPercentY > 0.8 && !isAtBottom) {
this.scrollSpeed = Math.min(4, (clickPercentY - 0.8) * 20)
this.startSmoothScroll()
} else if (clickPercentY < 0.2 && !isAtTop) {
this.scrollSpeed = Math.max(-4, (0.2 - clickPercentY) * -20)
this.startSmoothScroll()
} else {
this.stopSmoothScroll()
}
// 处理元素交换逻辑
this.checkAndSwapItems()
}
// 安排下一次更新
setTimeout(() => {
this.startUpdateLoop()
}, 16) // 约60fps的更新频率
}
自动滚动实现
当用户拖拽元素到网格边缘时,组件会自动滚动以显示更多内容:
private startSmoothScroll() {
if (this.scrollTimer !== -1) {
return
}
this.scrollTimer = setInterval(() => {
// 获取当前滚动位置
const currentOffset = this.scroller.currentOffset()
// 检查是否在边界
const isAtTop = currentOffset.yOffset <= 0
const isAtBottom = this.scroller.isAtEnd()
// 根据方向和边界决定是否滚动
let actualScrollSpeed = this.scrollSpeed
// 边界检查
if ((this.scrollSpeed < 0 && isAtTop) || (this.scrollSpeed > 0 && isAtBottom)) {
actualScrollSpeed = 0
}
// 只有实际需要滚动时才执行
if (actualScrollSpeed !== 0) {
// 执行滚动
this.scroller.scrollTo({
xOffset: currentOffset.xOffset,
yOffset: currentOffset.yOffset + actualScrollSpeed
})
// 更新补偿值,保持拖拽元素跟随手指
this.dragRefOffsety -= actualScrollSpeed
}
}, 16) // 约60fps的更新频率
}
元素交换实现
当拖拽元素超过一定阈值时,会触发元素交换,实现排序功能:
// 将元素交换逻辑提取为单独的方法
private checkAndSwapItems() {
animateTo({ curve: curves.interpolatingSpring(0, 1, 400, 38), duration: 30 }, () => {
if (this.offsetY >= this.FIX_VP_Y / 2 &&
(this.offsetX <= this.FIX_VP_X / 2 && this.offsetX >= -this.FIX_VP_X / 2)
&& (this.dragIndex + this.row <= this.lastIndex)) {
//向下滑
this.down(this.dragIndex)
}
// 其他方向的交换逻辑...
})
}
// 元素交换方法
down(index: number): void {
if (!this.isDraggable(index + this.row)) {
return
}
this.offsetY -= this.FIX_VP_Y
this.dragRefOffsety += this.FIX_VP_Y
this.itemMove(index, index + this.row)
}
// 数组元素交换
itemMove(index: number, newIndex: number): void {
if (!this.isDraggable(newIndex)) {
return
}
let tmp = this.numbers.splice(index, 1)
this.numbers.splice(newIndex, 0, tmp[0])
}
技术难点与解决方案
1. 拖拽与滚动协调
难点:当拖拽元素到边缘区域触发自动滚动时,需要保持拖拽元素与手指的相对位置。
解决方案:
- 使用
dragRefOffsety变量记录滚动补偿值 - 在滚动时更新补偿值:
this.dragRefOffsety -= actualScrollSpeed - 计算元素位置时应用补偿:
this.offsetY = event.offsetY - this.dragRefOffsety
2. 边界条件处理
难点:在滚动到顶部或底部时,继续拖拽可能导致元素位置计算错误。
解决方案:
- 在滚动前检查边界条件
- 只有在实际发生滚动时才更新补偿值
- 在滚动逻辑中添加边界检查
// 边界检查
const isAtTop = currentOffset.yOffset <= 0
const isAtBottom = this.scroller.isAtEnd()
if ((this.scrollSpeed < 0 && isAtTop) || (this.scrollSpeed > 0 && isAtBottom)) {
actualScrollSpeed = 0
}
3. 拖拽元素抖动问题
难点:拖拽过程中,特别是在滚动时,拖拽元素会出现抖动,无法平滑跟随手指移动。
原因分析:
onActionUpdate的触发频率与滚动更新频率不同步- 位置更新和滚动补偿计算在不同的时间点执行
- 频繁的状态更新导致渲染不稳定
解决方案:
- 使用requestAnimationFrame模拟技术,创建统一的更新循环
- 将
onActionUpdate改为只记录最新事件,不直接更新UI - 在固定频率的更新循环中处理所有UI更新(位置计算、滚动处理、元素交换)
- 确保所有更新以相同的频率(16ms,约60fps)执行
// 修改onActionUpdate只记录事件
.onActionUpdate((event: GestureEvent) => {
// 只保存最新的事件,不直接更新UI
this.lastEvent = event
})
// 添加统一的更新循环
private startUpdateLoop() {
if (!this.isUpdating) return
// 处理最新的事件
if (this.lastEvent) {
// 更新位置、处理滚动、检查元素交换
// ...
}
// 安排下一次更新
setTimeout(() => {
this.startUpdateLoop()
}, 16)
}
4. 性能优化
难点:频繁的状态更新和动画计算可能导致性能问题。
解决方案:
- 使用固定的更新频率(16ms,约60fps)
- 减少动画时长,提高响应速度
- 只在必要时执行滚动和更新补偿值
- 将复杂逻辑分解为独立函数,提高代码可维护性
应用场景
这个可拖拽排序网格组件适用于多种应用场景:
- 应用启动器:允许用户自定义应用图标排序
- 照片墙:用户可以拖拽调整照片顺序
- 看板系统:拖拽调整任务卡片优先级
- 课程表/日程安排:拖拽调整日程顺序
- 电子商务:商品收藏夹自定义排序
扩展与优化方向
- 支持多指触控:实现缩放、旋转等更丰富的交互
- 支持不同大小的网格项:实现类似瀑布流的布局
- 添加拖拽预览效果:拖拽时显示目标位置的预览
- 支持分组和分类:实现跨组拖拽功能
- 添加持久化存储:保存用户的自定义排序
- 优化边缘检测:改进边缘滚动的触发逻辑,提供更自然的滚动体验
- 添加惯性滚动:拖拽结束后根据速度提供惯性滚动效果
开发过程中遇到的问题与解决方案
问题1:滚动跟随卡顿
问题描述:当拖拽元素到网格底部或顶部触发自动滚动时,滚动过程不流畅,出现明显卡顿。
原因分析:
- 滚动速度设置不合理
- 滚动逻辑与拖拽逻辑耦合
- 没有处理边界条件
解决方案:
- 优化滚动速度计算:
this.scrollSpeed = Math.min(4, (clickPercentY - 0.8) * 20) - 添加边界检查,避免无效滚动
- 使用
scroller.scrollTo方法替代其他滚动方式 - 在滚动时正确更新补偿值
问题2:拖拽元素抖动
问题描述:拖拽过程中,特别是在滚动时,拖拽元素会出现抖动,无法平滑跟随手指移动。
原因分析:
onActionUpdate的触发频率与滚动更新频率不同步- 位置更新和滚动补偿计算在不同的时间点执行
解决方案:
- 实现统一的更新循环,模拟requestAnimationFrame
- 将
onActionUpdate改为只记录最新事件,不直接更新UI - 在固定频率的更新循环中处理所有UI更新
- 确保所有更新以相同的频率执行
问题3:边界滚动问题
问题描述:在顶部向上拖拽或底部向下拖拽时,虽然Grid没有滚动,但拖拽元素位置仍在更新,导致元素与手指脱离。
原因分析:
- 在边界条件下,滚动补偿值仍在更新,但实际没有滚动
- 没有检查是否实际发生了滚动
解决方案:
- 添加边界检查:
isAtTop = currentOffset.yOffset <= 0和isAtBottom = this.scroller.isAtEnd() - 只有在实际需要滚动时才执行滚动和更新补偿值
- 在触发滚动前检查边界条件
总结
本文介绍的可拖拽排序网格组件,通过鸿蒙ArkUI框架的Grid、手势系统和动画能力,实现了流畅的拖拽排序体验。组件处理了拖拽与滚动协调、边界条件、元素抖动等技术难点,提供了一个可复用的UI解决方案。
通过使用统一的更新循环处理UI更新,我们成功解决了拖拽抖动问题,使组件能够提供流畅的用户体验。这种模式类似于游戏开发中的游戏循环,将输入处理、状态更新和渲染分离,以固定的频率执行,非常适合处理复杂的交互场景。
这个组件不仅展示了鸿蒙ArkUI的强大能力,也为开发者提供了实现类似功能的参考。通过进一步扩展和优化,可以满足更多复杂场景的需求。