手势系统:TapGesture、LongPressGesture的组合与竞争(30)

在 HarmonyOS 的 ArkUI 框架中,手势识别是提升应用交互体验的关键。当 TapGesture(点击手势)与 LongPressGesture(长按手势)绑定在同一组件上时,由于两者在触发条件上存在时间维度的竞争,如果处理不当,极易引发手势冲突。

一、 冲突根源:时间阈值的竞争

点击手势的触发条件是"按下并抬起",而长按手势需要"按下并保持一定时间"。当用户执行长按操作时,系统在未达到长按时间阈值前,会将其视为潜在的单击手势。如果两者被组合成并行手势,长按操作时两者都会被触发。

冲突复现代码(并行模式)

javascript 复制代码
@Entry
@Component
struct GestureConflictDemo {
  @State message: string = '请尝试长按此区域';

  build() {
    Column() {
      Text(this.message)
        .fontSize(24)
        .padding(20)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    // 【冲突根源】:使用并行模式(Parallel)
    // 系统会同时开始识别这两个手势
    .gesture(
      GestureGroup(GestureMode.Parallel,
        TapGesture()
          .onAction(() => {
            this.message = '触发了点击手势';
          }),
        LongPressGesture({ duration: 500 }) // 假设长按阈值为 500ms
          .onAction(() => {
            this.message = '触发了长按手势';
          })
      )
    )
  }
}

运行效果与冲突分析:

当用户在这段代码上执行长按操作时,会发生以下现象:

  1. 0 ~ 500ms 期间:用户手指按下,系统同时开始识别点击和长按。
  2. 达到 500ms 时 :长按手势识别成功,触发 触发了长按手势
  3. 用户抬起手指时 :由于"按下并抬起"的条件已经满足,点击手势再次被触发 ,UI 瞬间变回 触发了点击手势

最终结果:用户明明是想执行"长按",但抬起手指的瞬间,UI 却闪烁成了"点击"的结果。这就是时间阈值竞争导致的典型冲突。

对比:正确的互斥模式(Exclusive)

为了解决上述冲突,只需将 GestureMode.Parallel 改为 GestureMode.Exclusive,并将长按声明在前面

javascript 复制代码
.gesture(
  GestureGroup(GestureMode.Exclusive, // 改为互斥模式
    LongPressGesture({ duration: 500 }) // 1. 优先等待长按的 500ms 阈值
      .onAction(() => {
        this.message = '触发了长按手势';
      }),
    TapGesture() // 2. 如果 500ms 内抬起,长按失败,点击接管
      .onAction(() => {
        this.message = '触发了点击手势';
      })
  )
)

修复后的效果

  • 快速点击:按下 -> 快速抬起(< 500ms) -> 长按失败 -> 点击成功。
  • 长按 :按下 -> 保持 500ms -> 长按成功(互斥机制生效,点击手势被彻底拒绝) -> 抬起手指时,由于点击手势已被拒绝,不会再触发任何回调。完美解决冲突!

二、 解决方案:使用互斥识别(Exclusive)

为了彻底解决这一竞争问题,需要将这两种手势放入一个**互斥识别组合手势(GestureMode.Exclusive)**中。在互斥模式下,系统会同时开始识别,但一旦某个手势识别成功,其他手势将立即失败。

核心代码示例
javascript 复制代码
@Entry
@Component
struct TapAndLongPressDemo {
  @State message: string = '点击或长按此区域';

  build() {
    Column() {
      Text(this.message)
        .fontSize(24)
        .padding(20)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    // 使用互斥组合手势
    .gesture(
      GestureGroup(GestureMode.Exclusive,
        // 【关键】将长按手势声明在前
        // 因为长按需要等待时间阈值,先声明可确保系统优先等待长按的判定
        LongPressGesture()
          .onAction(() => {
            this.message = '触发长按手势';
          }),
        // 点击手势在后
        TapGesture()
          .onAction(() => {
            this.message = '触发点击手势';
          })
      )
    )
  }
}

三、 声明顺序的致命影响

在互斥识别模式下,手势的声明顺序至关重要

  1. 先长按,后点击(推荐):系统优先等待长按的时间阈值。如果用户快速抬起,长按失败,点击手势接管并成功触发;如果用户持续按住,长按成功,点击手势被拒绝。
  2. 先点击,后长按(错误) :由于点击手势只需一次"按下-抬起"即可宣告成功,用户的每一次按下都会立即被点击手势消费。这会导致长按手势永远无法积累到规定时间,从而彻底失效
错误示范:先点击,后长按(长按彻底失效)
javascript 复制代码
@Entry
@Component
struct WrongOrderDemo {
  @State message: string = '请尝试长按此区域';

  build() {
    Column() {
      Text(this.message)
        .fontSize(24)
        .padding(20)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .gesture(
      GestureGroup(GestureMode.Exclusive,
        // 【致命错误】:点击手势声明在最前面
        // 只要手指按下再抬起,点击手势就会立刻宣告成功
        TapGesture()
          .onAction(() => {
            this.message = '触发了点击手势';
          }),
        // 长按手势永远无法获得足够的判定时间,彻底失效
        LongPressGesture({ duration: 500 })
          .onAction(() => {
            this.message = '触发了长按手势';
          })
      )
    )
  }
}

运行结果:无论您按多久,只要手指一离开屏幕,UI 永远只会显示"触发了点击手势"。长按手势形同虚设。

正确示范:先长按,后点击(完美互斥)
javascript 复制代码
@Entry
@Component
struct RightOrderDemo {
  @State message: string = '请尝试长按或点击此区域';

  build() {
    Column() {
      Text(this.message)
        .fontSize(24)
        .padding(20)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .gesture(
      GestureGroup(GestureMode.Exclusive,
        // 【正确做法】:长按手势声明在最前面
        // 系统会优先开始识别长按,并耐心等待 500ms 的阈值
        LongPressGesture({ duration: 500 })
          .onAction(() => {
            this.message = '触发了长按手势';
          }),
        // 如果用户在 500ms 内抬起了手指,长按判定失败
        // 此时系统才会将事件交给点击手势处理
        TapGesture()
          .onAction(() => {
            this.message = '触发了点击手势';
          })
      )
    )
  }
}

运行结果

  • 快速点击:按下 -> 快速抬起(< 500ms) -> 长按失败 -> 点击手势成功触发。
  • 长按:按下 -> 保持超过 500ms -> 长按手势成功触发 -> 此时点击手势被互斥机制彻底拒绝,后续抬起手指不会触发任何回调。

总结 :在 ArkUI 的互斥手势组中,声明顺序就是优先级顺序。耗时较长、需要等待时间阈值的手势(如长按、双击),必须永远声明在耗时短、触发快的手势(如单击)之前。

四、 进阶场景:结合并行手势(Parallel)

在实际业务中,可能需要当前组件同时具备"点击/长按"互斥能力,且该组件的手势需要与子节点的手势并行识别。此时,可以先将点击和长按绑定为互斥组,再通过 .parallelGesture() 将其与子节点手势绑定为并行关系:

javascript 复制代码
.parallelGesture(
  GestureGroup(GestureMode.Exclusive,
    TapGesture({ count: 1, fingers: 1 })
      .onAction(() => { console.info('触发单击'); }),
    LongPressGesture({ repeat: false })
      .onAction(() => { console.info('触发长按'); })
  )
)

通过合理使用 GestureGroup 的互斥模式并严格把控声明顺序,即可完美解决 TapGestureLongPressGesture 的竞争问题。

1、 组合手势的三大模式扩展

除了 Exclusive(互斥识别),ArkUI 还支持另外两种组合模式,适用于不同的业务场景:

  1. 并行识别(Parallel):多个手势同时识别,互不干涉。例如,在同一个区域内,用户单击触发事件 A,双击同时触发事件 B(注意:并行模式下,双击会先触发一次单击,再触发双击)。
  2. 顺序识别(Sequence):按照注册顺序依次识别,前一个手势成功后才触发下一个。典型场景是"长按后拖拽":必须先长按达到时间阈值,随后的拖拽(PanGesture)才会生效。

并行识别(Parallel):单击与双击共存

在并行模式下,系统会同时开始识别。当用户快速双击时,会先触发一次单击,随后触发一次双击。

javascript 复制代码
@Entry
@Component
struct ParallelGestureDemo {
  @State count1: number = 0;
  @State count2: number = 0;

  build() {
    Column() {
      Text(`单击次数: ${this.count1}\n双击次数: ${this.count2}`)
        .fontSize(24)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .gesture(
      GestureGroup(GestureMode.Parallel,
        TapGesture({ count: 1 })
          .onAction(() => {
            this.count1++;
          }),
        TapGesture({ count: 2 })
          .onAction(() => {
            this.count2++;
          })
      )
    )
  }
}

顺序识别(Sequence):长按后拖拽

必须严格满足"先长按,后拖拽"的顺序。如果直接拖拽,或者长按时间不够,拖拽手势都不会生效。

javascript 复制代码
@Entry
@Component
struct SequenceGestureDemo {
  @State offsetX: number = 0;
  @State offsetY: number = 0;
  @State positionX: number = 0;
  @State positionY: number = 0;

  build() {
    Column() {
      Text('长按后拖拽我')
        .fontSize(24)
        .padding(20)
    }
    .width(200)
    .height(100)
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#E0E0E0')
    .translate({ x: this.offsetX, y: this.offsetY })
    .gesture(
      GestureGroup(GestureMode.Sequence,
        // 1. 必须先触发长按
        LongPressGesture({ duration: 500 })
          .onAction(() => {
            console.info('长按成功,可以开始拖拽');
          }),
        // 2. 长按成功后,才允许拖拽
        PanGesture()
          .onActionUpdate((event: GestureEvent | undefined) => {
            if (event) {
              this.offsetX = this.positionX + event.offsetX;
              this.offsetY = this.positionY + event.offsetY;
            }
          })
          .onActionEnd(() => {
            this.positionX = this.offsetX;
            this.positionY = this.offsetY;
          })
      )
    )
  }
}
2、 自定义手势与系统内置事件的竞争

当组件拥有系统内置手势(如 Image 的长按放大动画、Swiper 的滑动翻页)时,自定义手势通常会失败。

  • 解决方案 :使用 .priorityGesture() 替代 .gesture()。这会赋予自定义手势更高的优先级,使其覆盖系统内置手势。若希望两者同时触发,可使用 .parallelGesture()

Image 等组件存在系统内置手势(如长按放大)时,普通的 .gesture() 会被系统拦截。使用 .priorityGesture() 可以强制让自定义手势优先执行。

javascript 复制代码
import { promptAction } from '@kit.ArkUI';

@Entry
@Component
struct PriorityGestureDemo {
  build() {
    Column() {
      Text('长按图片测试')
        .fontSize(20)
        .margin({ bottom: 20 })
      
      Image($r('app.media.startIcon'))
        .width(200)
        .height(200)
        // 使用 priorityGesture 覆盖系统默认的长按行为
        .priorityGesture(
          LongPressGesture()
            .onAction(() => {
              promptAction.showToast({ message: '自定义长按手势生效!' });
            })
        )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}
3、 父子组件的手势竞争规则

在 ArkUI 的事件引擎中,手势事件遵循"组件树自上而下传递"的规则:

  • 默认规则 :当父子组件绑定了同类手势时,子组件优先于父组件触发。一旦子组件消费了该事件,父组件的回调将不会被触发。
  • 事件透传 :如果希望上层组件不拦截手势,将其透传给底层组件,可以使用 .hitTestBehavior(HitTestMode.None) 属性。

默认规则:子组件优先

当父子组件都绑定了同类型手势时,子组件会"吃掉"事件,父组件不会响应。

javascript 复制代码
@Entry
@Component
struct ParentChildDefaultDemo {
  @State parentMsg: string = '';
  @State childMsg: string = '';

  build() {
    Column() {
      Text(`父组件: ${this.parentMsg}`)
        .fontSize(18)
        .margin({ bottom: 10 })
      Text(`子组件: ${this.childMsg}`)
        .fontSize(18)
        .margin({ bottom: 20 })

      // 父组件绑定点击
      Column() {
        Text('点击此区域(子组件优先)')
          .fontSize(20)
          .padding(30)
          .backgroundColor('#BBDEFB')
          // 子组件绑定点击
          .gesture(
            TapGesture()
              .onAction(() => {
                this.childMsg = '子组件触发了点击';
              })
          )
      }
      .padding(50)
      .backgroundColor('#E3F2FD')
      .gesture(
        TapGesture()
          .onAction(() => {
            // 只有点击子组件外部(父组件区域)才会触发
            this.parentMsg = '父组件触发了点击';
          })
      )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

事件透传:HitTestMode.None

通过设置 HitTestMode.None,可以让上层组件对触摸事件完全"透明",手势会直接穿透到下层组件。

javascript 复制代码
@Entry
@Component
struct HitTestTransparentDemo {
  @State bottomMsg: string = '';
  @State topMsg: string = '';

  build() {
    Stack() {
      // 底层组件
      Column() {
        Text(`底层组件: ${this.bottomMsg}`)
          .fontSize(20)
      }
      .width(300)
      .height(300)
      .backgroundColor('#C8E6C9')
      .justifyContent(FlexAlign.Center)
      .gesture(
        TapGesture()
          .onAction(() => {
            this.bottomMsg = '底层被点击了!';
          })
      )

      // 上层组件(覆盖在底层之上)
      Column() {
        Text('上层遮罩层')
          .fontSize(20)
      }
      .width(300)
      .height(150)
      .backgroundColor('#FFCDD2')
      .justifyContent(FlexAlign.Center)
      // 【核心】设置事件透传,上层不拦截事件,直接交给底层
      .hitTestBehavior(HitTestMode.None) 
      .gesture(
        TapGesture()
          .onAction(() => {
            // 因为设置了 None,这个回调永远不会触发
            this.topMsg = '上层被点击了!';
          })
      )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}
4、动态手势竞争控制

在复杂场景中(例如视频播放器中,子组件的滑动手势与父组件 Swiper 的滑动翻页冲突),静态的优先级往往无法满足需求。此时可以使用 onGestureRecognizerJudgeBegin 回调进行动态裁决:

核心逻辑 :在子组件滑动时,通过回调判断当前是否处于"长按"状态。如果未长按,则返回 GestureJudgeResult.REJECT 拒绝子组件的滑动手势,从而让父组件 Swiper 的滑动翻页成功;如果已长按,则返回 CONTINUE 允许子组件滑动(例如调节音量)。

javascript 复制代码
.gesture(
  GestureGroup(GestureMode.Parallel,
    LongPressGesture().onAction(() => { this.isLongPress = true; }),
    PanGesture().onActionStart(() => { /* 调节音量逻辑 */ })
  )
)
// 动态裁决手势竞争
.onGestureRecognizerJudgeBegin(
  (event, current, others) => {
    if (current.getType() !== GestureControl.GestureType.PAN_GESTURE) {
      return GestureJudgeResult.CONTINUE;
    }
    // 只有在长按状态下,才允许子组件的滑动手势生效
    return this.isLongPress ? GestureJudgeResult.CONTINUE : GestureJudgeResult.REJECT;
  }
)

通过灵活运用组合手势、优先级修饰符以及动态裁决机制,开发者可以完美解决各种极端的手势冲突问题,打造出丝滑且符合直觉的交互体验。