标签 GestureMode 下的文章

在 ArkUI 里,单个手势(点击、长按、滑动、缩放…)已经够好用,但一旦你要做这种交互:

  • 长按后才能拖动
  • 同一区域支持 单击 / 双击 且行为不同
  • 两个手势要 同时识别 或互斥

就会发现仅靠 TapGesture / PanGesture 这些基础手势不太好管理——这时候就轮到主角 GestureGroup 登场了。

本文定位就是一篇可以直接发社区的实战向自学笔记,按这几个问题展开:

  1. GestureGroup 是什么?解决什么问题?
  2. 三种 GestureMode 到底怎么选?
  3. 如何正确组合单击 / 双击、长按 + 拖动?
  4. onCancel 在真实项目中有什么用?

一、GestureGroup 是什么?

官方一句话定义:

GestureGroup 用来把多个基础手势组合在一起,根据指定的识别模式统一管理。
  • API Version 7 开始支持
  • 元服务 从 API 11 开始支持
  • 系统能力:SystemCapability.ArkUI.ArkUI.Full

核心接口只有一个:

GestureGroup(mode: GestureMode, ...gesture: GestureType[])

参数说明:

  • mode: GestureMode(必填)
    组合手势的“识别策略”,即三种模式:Sequence / Parallel / Exclusive
  • ...gesture: GestureType[](可选)

    • 一个或多个基础手势实例(TapGestureLongPressGesturePanGesture 等)
    • 如果这里不填,那这个 GestureGroup 相当于白写,组合识别不生效
⚠️ 官方特别说明:
当一个组件要同时支持 单击 + 双击 时,必须把双击放前面,单击放后面,才能正确识别。

二、GestureMode 三种模式,搞清区别就成功一半

GestureMode 枚举定义了组合手势的识别方式:

enum GestureMode {
  Sequence,   // 顺序识别
  Parallel,   // 并发识别
  Exclusive   // 互斥识别
}

2.1 Sequence:顺序识别(默认值)

按照注册顺序,一个一个识别。前面的失败,后面的都不会触发。

特点:

  • 只有当 前一个手势识别完成,才会进入下一个手势识别;
  • 任意一个中途失败,后面的通通不再识别;
  • 在顺序识别模式下,只有最后一个手势能触发 onActionEnd 事件

典型场景:

  • 长按后才允许拖动(长按没触发,就不让拖)
  • 双击成功则不再触发单击回调
  • 复杂手势链:长按 → 拖动 → 抬手触发某种状态收束

2.2 Parallel:并发识别

所有手势同时识别,互不干扰。

特点:

  • 注册的所有手势“并行”识别;
  • 各自成功或失败 互不影响
  • 适合“多个手势可以同时成立”的场景。

典型场景:

  • 同一组件既要识别 PinchGesture(缩放)又要识别 RotateGesture(旋转);
  • 类似“边拖动边缩放”的复杂交互。

2.3 Exclusive:互斥识别

所有手势一起识别,谁先成功,就“赢”,其余都视为失败。

特点:

  • 有点像“抢占式”的识别模式;
  • 一旦其中一个手势识别成功:

    • 其他手势立即失败;
    • 结束整个组合手势识别。

典型场景:

  • 同区域要么触发“滑动删除”,要么触发“点击打开”,不能两者都触发;
  • 导航区域:水平滑动切换 Tab vs 垂直滑动滚动列表,二选一。

三、事件:onCancel 什么时候触发?

GestureGroup 自己只有一个事件:

onCancel(event: () => void)

含义:

  • 手势识别成功后,如果收到触摸取消事件,会触发这个回调;
  • 常见情况:

    • 系统打断(来电、系统弹窗)
    • 父组件拦截或其它手势优先级更高
    • 触摸被提前终止

在实际项目里,onCancel 通常用来做:

  • 恢复 UI 状态(比如把变成虚线的边框改回实线);
  • 取消动画、清理资源
  • 重置一些临时的状态变量,避免后续交互异常。

四、官方示例拆解:长按 + 拖动(顺序识别)

先看一下官方示例的完整版,然后逐块拆解思路。

// xxx.ets
@Entry
@Component
struct GestureGroupExample {
  @State count: number = 0;
  @State offsetX: number = 0;
  @State offsetY: number = 0;
  @State positionX: number = 0;
  @State positionY: number = 0;
  @State borderStyles: BorderStyle = BorderStyle.Solid;

  build() {
    Column() {
      Text('sequence gesture\n' +
        'LongPress onAction:' + this.count + '\n' +
        'PanGesture offset:\nX: ' + this.offsetX + '\n' +
        'Y: ' + this.offsetY)
        .fontSize(15)
    }
    .translate({ x: this.offsetX, y: this.offsetY, z: 0 })
    .height(150)
    .width(200)
    .padding(20)
    .margin(20)
    .border({ width: 3, style: this.borderStyles })
    .gesture(
      // 顺序识别:长按成功后,才会识别拖动
      GestureGroup(GestureMode.Sequence,
        LongPressGesture({ repeat: true })
          .onAction((event?: GestureEvent) => {
            if (event && event.repeat) {
              this.count++
            }
            console.info('LongPress onAction')
          }),
        PanGesture()
          .onActionStart(() => {
            this.borderStyles = BorderStyle.Dashed
            console.info('pan start')
          })
          .onActionUpdate((event?: GestureEvent) => {
            if (event) {
              this.offsetX = this.positionX + event.offsetX
              this.offsetY = this.positionY + event.offsetY
            }
            console.info('pan update')
          })
          .onActionEnd(() => {
            this.positionX = this.offsetX
            this.positionY = this.offsetY
            this.borderStyles = BorderStyle.Solid
            console.info('pan end')
          })
      )
        .onCancel(() => {
          console.info('sequence gesture canceled')
        })
    )
  }
}

4.1 交互效果总结

  • 用户先长按卡片:

    • 长按过程中,count 会累加;
  • 长按识别完成后,才会开始识别拖动:

    • 拖动时卡片跟着移动(offsetX / offsetY 更新);
    • 边框样式变成虚线,松手恢复实线;
  • 如果中途被取消,走 onCancel

4.2 关键点解读

  1. 必须用 Sequence 模式

    GestureGroup(GestureMode.Sequence, LongPressGesture(...), PanGesture())

    想要“长按 → 再拖动”这样的链式交互,最自然就是顺序识别。

  2. 位移计算通过“起始位置 + 偏移量”完成

    this.offsetX = this.positionX + event.offsetX
    this.offsetY = this.positionY + event.offsetY
    • positionX / positionY 记录上一次拖动结束的位置;
    • event.offsetX / offsetY 是当前手势中的增量;
    • 松手时把当前 offset 写回 position,即新起点。
  3. 只在 PanGesture 的 onActionEnd 收尾

    • 因为 Sequence 模式下只有最后一个手势能触发 onActionEnd
    • 恰好我们希望拖动结束时写入最终位置、恢复边框样式。

五、经典场景:单击 + 双击共存怎么写?

这是 GestureGroup 出现频率最高的需求之一。

5.1 思路

  • TapGesture 写两个手势:

    • 一个 count: 2 表示双击;
    • 一个 count: 1 表示单击;
  • 使用 GestureGroup(GestureMode.Sequence, 双击, 单击)
  • 双击优先识别,成功后单击不会再触发。

5.2 示例代码

@Entry
@Component
struct TapGestureGroupDemo {
  @State singleCount: number = 0;
  @State doubleCount: number = 0;

  build() {
    Column() {
      Text(`单击次数:${this.singleCount}`)
        .fontSize(16)
      Text(`双击次数:${this.doubleCount}`)
        .fontSize(16)
        .margin({ bottom: 12 })

      Text('点击这个区域测试单击/双击')
        .fontSize(18)
        .padding(20)
        .backgroundColor('#EEEEEE')
        .borderRadius(12)
        .gesture(
          GestureGroup(
            GestureMode.Sequence,
            // 一定要把双击放前面!
            TapGesture({ count: 2 })
              .onAction(() => {
                this.doubleCount++;
                console.info('double tap');
              }),
            TapGesture({ count: 1 })
              .onAction(() => {
                this.singleCount++;
                console.info('single tap');
              })
          )
        )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }
}

✅ 小结:

  • 双击写在前面 → 既能识别双击,又不会误触单击;
  • 用 Sequence 模式就够了,不需要 Parallel / Exclusive。

六、Parallel / Exclusive 模式实战思路示例

这里给两个思路示例,你可以按需带入自己项目。

6.1 Parallel:缩放 + 旋转同时识别

伪代码示意:

Shape()
  .width(200)
  .height(200)
  .gesture(
    GestureGroup(GestureMode.Parallel,
      PinchGesture()
        .onActionUpdate(e => {
          // 根据 e.scale 处理缩放
        }),
      RotationGesture()
        .onActionUpdate(e => {
          // 根据 e.angle 处理旋转
        })
    )
  )
  • 两个手势同时识别,互不阻塞;
  • 更适合“画布类”、“图片编辑器”等交互。

6.2 Exclusive:滑动删除 vs 点击打开二选一

思路:

  • 给同一个 Item 区域同时注册:

    • 一个 PanGesture(水平滑动触发删除);
    • 一个 TapGesture(点击进入详情);
  • GestureGroup(GestureMode.Exclusive, PanGesture, TapGesture)
  • 用户如果滑动成功,就进入删除逻辑,不再触发点击。

伪代码示意:

Row()
  .width('100%')
  .height(60)
  .gesture(
    GestureGroup(GestureMode.Exclusive,
      PanGesture({ direction: PanDirection.Horizontal })
        .onActionEnd(e => {
          // 滑到一定距离后,触发删除
        }),
      TapGesture({ count: 1 })
        .onAction(() => {
          // 打开详情页
        })
    )
  )

七、GestureGroup 使用小结 & 常见坑

最后快速帮你盘一遍重点:

  1. 基本语法

    .gesture(
      GestureGroup(GestureMode.Sequence | Parallel | Exclusive, 手势1, 手势2, ...)
        .onCancel(() => { ... })
    )
  2. mode 选型建议

    • 顺序链条(长按 → 拖动、双击优先于单击):Sequence
    • 多手势同时有效(缩放 + 旋转):Parallel
    • 多手势竞争,一个成功其他失败(滑动 vs 点击):Exclusive
  3. Tap + 双击 必须注意顺序

    • 双击手势写前面,单击写后面;
    • 否则单击会先被识别,导致双击识别不到。
  4. Sequence 模式 only 最后一个 onActionEnd 生效

    • 需要在“最后一个手势”的 onActionEnd 里做收尾逻辑;
    • 上层流程性操作,尽量放在最后一个手势里处理。
  5. onCancel 用来兜底清理状态

    • onActionEnd 不同:onCancel 是“被打断”的收尾;
    • 避免 UI 卡在“选中态 / 虚线边框 / 半透明”等中间状态。

到这里,GestureGroup 的核心思路和常见用法基本都过了一遍。建议你:

  • 先把官方的长按 + 拖动例子跑起来;
  • 再自己写一个 单击 + 双击共存 的小 Demo;
  • 然后根据项目需求,尝试用 Parallel / Exclusive 把原来复杂的 if/else 手势逻辑慢慢收敛到 GestureGroup 上。

用熟之后,你会发现:组合手势本身没那么难,难的是想清楚交互规则,而 GestureGroup 正好帮你把“规则”变成清晰的代码结构。