ArkUI实现可拖拽排序网格组件详解

315 阅读8分钟

功能概述

本文介绍了一个基于鸿蒙ArkUI框架开发的可拖拽排序网格组件。该组件实现了以下核心功能:

  1. 多列网格布局展示数据
  2. 支持元素拖拽重新排序
  3. 支持多方向拖拽(上、下、左、右及对角线方向)
  4. 自动滚动功能(当拖拽到边缘区域时)
  5. 平滑的动画过渡效果

这个组件可以应用于多种场景,如应用图标排序、照片墙管理、任务卡片排序等需要用户自定义排序的界面。

效果预览

effect.gif

技术实现

核心数据结构

@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手势实现,主要包含三个事件处理:

  1. onActionStart:开始拖拽时记录初始状态并启动更新循环
  2. onActionUpdate:仅记录最新事件,不直接更新UI
  3. 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. 应用启动器:允许用户自定义应用图标排序
  2. 照片墙:用户可以拖拽调整照片顺序
  3. 看板系统:拖拽调整任务卡片优先级
  4. 课程表/日程安排:拖拽调整日程顺序
  5. 电子商务:商品收藏夹自定义排序

扩展与优化方向

  1. 支持多指触控:实现缩放、旋转等更丰富的交互
  2. 支持不同大小的网格项:实现类似瀑布流的布局
  3. 添加拖拽预览效果:拖拽时显示目标位置的预览
  4. 支持分组和分类:实现跨组拖拽功能
  5. 添加持久化存储:保存用户的自定义排序
  6. 优化边缘检测:改进边缘滚动的触发逻辑,提供更自然的滚动体验
  7. 添加惯性滚动:拖拽结束后根据速度提供惯性滚动效果

开发过程中遇到的问题与解决方案

问题1:滚动跟随卡顿

问题描述:当拖拽元素到网格底部或顶部触发自动滚动时,滚动过程不流畅,出现明显卡顿。

原因分析

  • 滚动速度设置不合理
  • 滚动逻辑与拖拽逻辑耦合
  • 没有处理边界条件

解决方案

  • 优化滚动速度计算:this.scrollSpeed = Math.min(4, (clickPercentY - 0.8) * 20)
  • 添加边界检查,避免无效滚动
  • 使用scroller.scrollTo方法替代其他滚动方式
  • 在滚动时正确更新补偿值

问题2:拖拽元素抖动

问题描述:拖拽过程中,特别是在滚动时,拖拽元素会出现抖动,无法平滑跟随手指移动。

原因分析

  • onActionUpdate的触发频率与滚动更新频率不同步
  • 位置更新和滚动补偿计算在不同的时间点执行

解决方案

  • 实现统一的更新循环,模拟requestAnimationFrame
  • onActionUpdate改为只记录最新事件,不直接更新UI
  • 在固定频率的更新循环中处理所有UI更新
  • 确保所有更新以相同的频率执行

问题3:边界滚动问题

问题描述:在顶部向上拖拽或底部向下拖拽时,虽然Grid没有滚动,但拖拽元素位置仍在更新,导致元素与手指脱离。

原因分析

  • 在边界条件下,滚动补偿值仍在更新,但实际没有滚动
  • 没有检查是否实际发生了滚动

解决方案

  • 添加边界检查:isAtTop = currentOffset.yOffset <= 0isAtBottom = this.scroller.isAtEnd()
  • 只有在实际需要滚动时才执行滚动和更新补偿值
  • 在触发滚动前检查边界条件

总结

本文介绍的可拖拽排序网格组件,通过鸿蒙ArkUI框架的Grid、手势系统和动画能力,实现了流畅的拖拽排序体验。组件处理了拖拽与滚动协调、边界条件、元素抖动等技术难点,提供了一个可复用的UI解决方案。

通过使用统一的更新循环处理UI更新,我们成功解决了拖拽抖动问题,使组件能够提供流畅的用户体验。这种模式类似于游戏开发中的游戏循环,将输入处理、状态更新和渲染分离,以固定的频率执行,非常适合处理复杂的交互场景。

这个组件不仅展示了鸿蒙ArkUI的强大能力,也为开发者提供了实现类似功能的参考。通过进一步扩展和优化,可以满足更多复杂场景的需求。

完整代码