拖拽排序封面图

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

拖拽排序的核心思路

拖拽排序概念图

HarmonyOS6目前没有原生的拖拽排序组件(至少我写这篇文章的时候还没有好用的),所以得自己实现。核心思路其实不复杂:

  1. 用一个数组来维护列表数据
  2. 长按某个item进入"拖拽模式",被拖拽的item浮起来
  3. 拖拽过程中记录手指位置,实时计算应该插入到哪个位置
  4. 松手后执行数组的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效果,一个体验不错的拖拽排序列表就搞定了。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐