顺序手势组封面图

做拖拽排序功能的时候,碰到一个很头疼的问题:用户随便一碰就开始拖了,经常误操作。后来发现用GestureMode.Sequence可以要求用户先长按激活编辑模式,然后才能拖拽。这个交互方式在iOS和Android上都很常见,HarmonyOS6也支持,而且用起来比想象中简单。

顺序手势组是什么

GestureMode.Sequence的意思是:组内的手势必须按照定义的顺序依次触发,前一个手势成功后,下一个手势才开始监听。

举个具体例子,如果你定义了一个顺序手势组:[LongPressGesture, PanGesture],那用户必须先长按触发成功,然后手指不松开开始拖动,PanGesture才会生效。如果用户只是点一下就拖,PanGesture根本不会被识别。

这个特性天然适合做"先选中再操作"的交互。比如列表项的拖拽排序——先长按选中某个item,然后拖拽到新位置。或者画板里先长按选中一个图形,然后拖拽移动。

长按+拖拽的交互设计

顺序手势组概念图

这个案例的交互流程是这样的:

第一步:用户长按屏幕上的元素,持续500毫秒以上,元素进入"编辑模式",视觉上会有明显的变化(颜色改变、放大、出现阴影等)。

第二步:长按成功后,手指不松开继续拖动,元素跟随手指移动。

第三步:手指松开,元素停在当前位置,退出编辑模式。

整个流程非常自然,用户在操作中会得到清晰的步骤反馈。

完整案例代码

@Entry
@Component
struct SequenceGestureDemo {
  @State step: string = '步骤1: 长按激活编辑模式'
  @State stepNum: number = 1
  @State isEditing: boolean = false
  @State offsetX: number = 0
  @State offsetY: number = 0
  @State startOffsetX: number = 0
  @State startOffsetY: number = 0
  @State elementColor: string = '#6C5CE7'
  @State elementScale: number = 1
  @State hintOpacity: number = 1
  @State longPressCount: number = 0
  @State dragCount: number = 0

  resetState() {
    this.step = '步骤1: 长按激活编辑模式'
    this.stepNum = 1
    this.isEditing = false
    this.elementColor = '#6C5CE7'
    this.elementScale = 1
  }

  build() {
    Column() {
      Text('顺序手势组')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1A1A2E')
        .margin({ top: 30, bottom: 6 })

      Text('先长按激活,再拖拽移动')
        .fontSize(14)
        .fontColor('#8E8E93')
        .margin({ bottom: 16 })

      // 步骤指示器
      Row() {
        this.StepIndicator(1, '长按激活', this.stepNum >= 1)
        this.StepArrow()
        this.StepIndicator(2, '拖拽移动', this.stepNum >= 2)
        this.StepArrow()
        this.StepIndicator(3, '松手完成', this.stepNum >= 3)
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)

      // 当前步骤文字
      Text(this.step)
        .fontSize(15)
        .fontWeight(FontWeight.Medium)
        .fontColor(this.isEditing ? '#4CAF50' : '#8E8E93')
        .margin({ top: 12 })
        .animation({ duration: 200 })

      // 统计信息
      Row() {
        Text(`长按激活: ${this.longPressCount}`)
          .fontSize(13)
          .fontColor('#8E8E93')
        Text(`|`)
          .fontSize(13)
          .fontColor('#E0E0E0')
          .margin({ left: 12, right: 12 })
        Text(`拖拽完成: ${this.dragCount}`)
          .fontSize(13)
          .fontColor('#8E8E93')
      }
      .margin({ top: 8 })

      // 操作区域
      Stack() {
        // 提示文字
        if (this.hintOpacity > 0) {
          Text('长按下方方块 500ms 进入编辑模式')
            .fontSize(14)
            .fontColor('#C7C7CC')
            .opacity(this.hintOpacity)
        }

        // 可操作元素
        Column() {
          Text(this.isEditing ? '拖拽中...' : '长按我')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
        }
        .width(120)
        .height(120)
        .justifyContent(FlexAlign.Center)
        .backgroundColor(this.elementColor)
        .borderRadius(20)
        .shadow(this.isEditing
          ? { radius: 20, color: '#40000000', offsetX: 0, offsetY: 8 }
          : { radius: 8, color: '#20000000', offsetX: 0, offsetY: 2 })
        .scale({ x: this.elementScale, y: this.elementScale })
        .translate({ x: this.offsetX, y: this.offsetY })
        .animation(this.isEditing ? undefined : { duration: 300, curve: Curve.EaseOut })
        .gesture(
          GestureGroup(GestureMode.Sequence,
            LongPressGesture({ duration: 500 })
              .onAction(() => {
                this.isEditing = true
                this.step = '步骤2: 手指不松,开始拖拽'
                this.stepNum = 2
                this.elementColor = '#FF6B6B'
                this.elementScale = 1.1
                this.hintOpacity = 0
                this.longPressCount += 1
              }),

            PanGesture()
              .onActionUpdate((e: GestureEvent) => {
                this.offsetX = this.startOffsetX + e.offsetX
                this.offsetY = this.startOffsetY + e.offsetY
              })
              .onActionEnd(() => {
                this.startOffsetX = this.offsetX
                this.startOffsetY = this.offsetY
                this.step = '步骤3: 拖拽完成!'
                this.stepNum = 3
                this.elementColor = '#4CAF50'
                this.elementScale = 1
                this.dragCount += 1

                // 1.5秒后重置
                setTimeout(() => {
                  this.resetState()
                }, 1500)
              })
          )
        )
      }
      .width('100%')
      .height(350)
      .backgroundColor('#F5F6FA')
      .borderRadius(20)
      .margin({ top: 16 })
      .clip(true)

      // 操作说明
      Column() {
        Text('操作说明')
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .fontColor('#1A1A2E')
          .margin({ bottom: 8 })

        Text('1. 长按方块500毫秒,方块变红表示激活')
          .fontSize(13)
          .fontColor('#8E8E93')
        Text('2. 保持手指不松开,开始拖动方块')
          .fontSize(13)
          .fontColor('#8E8E93')
          .margin({ top: 4 })
        Text('3. 松开手指,方块变绿表示完成')
          .fontSize(13)
          .fontColor('#8E8E93')
          .margin({ top: 4 })
        Text('4. 直接拖动不会生效,必须先长按')
          .fontSize(13)
          .fontColor('#FF6B6B')
          .margin({ top: 4 })
      }
      .width('90%')
      .padding(16)
      .backgroundColor('#FAFAFA')
      .borderRadius(12)
      .alignItems(HorizontalAlign.Start)
      .margin({ top: 16 })

      // 重置按钮
      Button('重置位置')
        .fontSize(15)
        .fontColor('#6C5CE7')
        .backgroundColor('#F5F6FA')
        .borderRadius(12)
        .width('40%')
        .height(44)
        .margin({ top: 16 })
        .onClick(() => {
          animateTo({ duration: 400, curve: Curve.EaseOut }, () => {
            this.offsetX = 0
            this.offsetY = 0
            this.startOffsetX = 0
            this.startOffsetY = 0
          })
          this.resetState()
        })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
  }

  @Builder
  StepIndicator(num: number, label: string, active: boolean) {
    Column() {
      Text(`${num}`)
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(active ? '#FFFFFF' : '#C7C7CC')
        .width(32)
        .height(32)
        .textAlign(TextAlign.Center)
        .backgroundColor(active ? '#6C5CE7' : '#E0E0E0')
        .borderRadius(16)

      Text(label)
        .fontSize(11)
        .fontColor(active ? '#6C5CE7' : '#C7C7CC')
        .margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  StepArrow() {
    Text('→')
      .fontSize(18)
      .fontColor('#C7C7CC')
      .margin({ top: -10 })
  }
}

运行效果如图:
在这里插入图片描述

代码解析

顺序手势组的核心代码就这几行:

GestureGroup(GestureMode.Sequence,
  LongPressGesture({ duration: 500 }).onAction(...),
  PanGesture().onActionUpdate(...)
)

系统会先监听LongPressGesture,只有长按成功(500ms不松手)之后,才会开始监听PanGesture。如果用户没有长按就直接拖,PanGesture不会有任何响应。

长按成功后的回调里,我做了三件事:把isEditing设为true、改变元素颜色为红色、放大元素到1.1倍。这些视觉反馈告诉用户"编辑模式已激活,现在可以拖了"。

PanGesture的onActionUpdate就是常规的拖拽逻辑了,把偏移量累加到offsetX和offsetY上。onActionEnd的时候把步骤状态更新为"完成",然后1.5秒后自动重置。

步骤指示器那部分用了三个StepIndicator和一个箭头,根据stepNum的值来决定哪个步骤高亮。这个纯UI展示的部分,但能让用户很清楚地知道当前处于哪一步。

踩坑:长按后手指移动的问题

这里有一个容易搞混的地方。长按成功后,用户的手指其实还按在屏幕上。这时候系统会自动开始监听PanGesture,用户只要移动手指就会触发拖拽。

但有些同学反映,长按后手指稍微抖了一下,PanGesture就开始触发了,体验不太好。解决方案是在PanGesture里加一个距离阈值(distance参数),比如设置distance为10,这样手指移动超过10个像素才会开始触发拖拽,可以有效防止误触。

长按时长的选择

LongPressGesture的duration参数决定了用户要按多久才能触发长按。500ms是一个比较常见的值,但实际项目中你可能需要根据场景调整。

对于"进入编辑模式"这种不频繁的操作,500ms到800ms比较合适,可以有效防止误触发。对于"开始拖拽"这种需要快速响应的操作,300ms到500ms更合理,用户不需要等太久就能开始操作。

还有一点值得注意,长按的触发时间对用户来说是一个隐性信息——用户看不到进度条,只能凭感觉判断"应该按了够久了吧"。所以配合一个视觉反馈非常重要,比如元素在长按过程中逐渐放大或者出现一个环形进度条,让用户知道"再坚持一下就可以了"。

顺序手势 vs 手动状态管理

有同学可能会问,不用Sequence模式,自己在PanGesture里判断是否已经长按了行不行?当然可以,但代码会复杂很多。你需要自己维护一个"是否已激活"的状态,在PanGesture的回调里先检查这个状态,未激活就不处理偏移量。

用GestureMode.Sequence的好处是把这些状态管理的工作交给了系统。系统会自动处理手势之间的衔接、超时、取消等各种边界情况,你只需要写成功路径的代码就行。代码量少了一半,bug也少了一半。

PC端注意事项

在HarmonyOS6 PC端,长按操作对应的是鼠标按住不放。和触屏的长按体验不太一样——鼠标按住一个位置500ms比手指按住500ms要累。所以PC端的长按时长可以适当缩短,比如300ms就够了。

还有一个细节,PC端鼠标点击和长按之间的区分更容易出问题。用户有时候只是想点击,但稍微多按了一会儿就触发了长按。建议PC端给长按加一个更明确的视觉提示,比如一个进度条动画,让用户知道"再按一会儿就激活了"。

顺序手势组的其他组合方式

除了"长按+拖拽"这个经典组合,顺序手势组还可以有其他搭配。比如"双击+拖拽"——先双击某个元素选中它,然后拖拽到新位置。或者"捏合+拖拽"——先双指缩放确认操作对象,然后拖拽调整位置。

另一个有意思的用法是"滑动+长按"。用户先滑动到某个位置,然后在目标位置上长按确认。这种操作在某些安全要求高的场景里用得上——比如删除操作,用户需要先滑到要删除的item上,然后长按确认,两个步骤缺一不可。

状态管理的最佳实践

顺序手势涉及多个步骤,状态管理比单个手势复杂。我的建议是用一个步骤状态变量(比如stepNum)来统一管理当前处于哪一步,然后根据这个变量来决定UI显示什么、哪些操作可用。

不要为每个步骤单独维护一个布尔值——比如isLongPressed、isDragging等。这样做会导致状态组合爆炸(2的N次方种可能),调试起来非常痛苦。用一个步骤编号来线性管理状态,每一步对应一个明确的编号,代码清晰得多。

另外,在顺序手势的回调里要注意及时重置状态。比如长按成功了但用户没有继续拖拽就松手了,这时候需要把状态重置到初始步骤。如果不重置,下次用户再长按的时候状态就会乱掉。建议在LongPressGesture的onAction回调里加一个超时机制——如果长按后一定时间内没有后续的PanGesture触发,就自动重置回初始状态。

写在最后

顺序手势组是做"先选中再操作"交互的最佳方案。长按+拖拽这个组合在各种列表编辑、画板工具、图片标注场景里都用得到。关键是把步骤反馈做好,让用户随时知道自己在哪个阶段,别让人家猜。

Logo

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

更多推荐