HarmonyOS 6.0 自定义指令与手势组合:从单指到多指的交互进阶

做鸿蒙开发一段时间后,你一定会碰到手势冲突的问题。Scroll里面嵌了个可拖拽的子组件,手指一滑,到底是Scroll滚动还是子组件拖动?父组件绑了长按,子组件也绑了长按,到底谁响应?单击和双击放一起,为什么双击永远触发不了?

这些问题归根到底,都是对手势识别机制理解不够深入。今天这篇就把HarmonyOS NEXT的手势体系从头到尾捋一遍,从单一手势到组合手势,从手势优先级到冲突解决,看完你就能在项目里把各种交互安排得明明白白。


一、手势识别流程:触摸事件到手势回调

先搞清楚底层机制。用户手指触碰屏幕的那一刻,系统并不是直接告诉你"这是一个点击"或"这是一个拖拽",而是走了一套完整的识别流程:

触摸事件产生 -> 触摸测试(TouchTest) -> 响应链收集 -> 手势识别器匹配 -> 回调触发

具体来说:

  1. 手指按下时产生Down事件,移动时产生Move事件,抬起时产生Up事件。所有手势本质上都是这些原始触摸事件的组合------点击是Down+Up,拖拽是Down+一系列Move+Up,长按是Down+持续等待+Up。

  2. 触摸测试决定哪些组件参与事件分发。ArkUI采用"右子树优先的后序遍历",从布局层级最深的子组件开始,沿组件树向上收集,形成响应链。

  3. 响应链上的每个组件如果有绑定的手势识别器,就会尝试匹配当前触摸序列。子组件的手势默认优先于父组件。

  4. 手势识别器根据各自的判定规则(点击次数、滑动距离、按压时长等)决定是否识别成功,成功则触发对应的回调。

理解了这个流程,后面所有关于手势冲突、优先级的问题都能找到根源。


二、TapGesture:单击、双击与三次点击

点击手势是最基础的手势类型,但它的细节比很多人想象的要多。

typescript 复制代码
TapGesture(value?: { count?: number; fingers?: number })

两个参数:

  • count:连续点击次数,默认1。设为2就是双击,设为3就是三次点击。注意,两次点击之间的超时时间是300ms,超过300ms就视为两次独立的单击。
  • fingers:触发点击的手指数量,默认1,最大10。多指点击要求所有手指在300ms内依次按下。

来看一个同时支持单击和双击的例子:

typescript 复制代码
@Entry
@Component
struct TapGestureDemo {
  @State message: string = '请点击或双击'

  build() {
    Column() {
      Text(this.message)
        .fontSize(20)
        .margin(10)
      Column() {
        Text('点击区域')
          .fontSize(24)
          .fontColor(Color.White)
      }
      .width(200)
      .height(200)
      .backgroundColor('#4CAF50')
      .justifyContent(FlexAlign.Center)
      .borderRadius(12)
      .gesture(
        GestureGroup(GestureMode.Exclusive,
          TapGesture({ count: 2 })
            .onAction(() => {
              this.message = '双击触发'
            }),
          TapGesture({ count: 1 })
            .onAction(() => {
              this.message = '单击触发'
            })
        )
      )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

这里有个关键点:双击手势必须放在单击手势前面。因为GestureMode.Exclusive是互斥识别,先注册的手势先判定。如果单击放前面,你第一次按下抬起时单击就已经识别成功了,双击根本没机会触发。这个坑踩过的人不少。


三、LongPressGesture:长按识别与repeat回调

长按手势在列表项操作、弹出菜单等场景非常常见。

typescript 复制代码
LongPressGesture(value?: { fingers?: number; repeat?: boolean; duration?: number })

三个参数:

  • fingers:最少手指数量,默认1,最大10。
  • repeat:是否持续触发回调,默认false。设为true后,长按期间每经过一个duration间隔就会触发一次onAction。
  • duration:触发长按的最短时间,默认500ms。

repeat是长按手势最有用的特性。比如实现一个数字递增器,长按后数字持续增长:

typescript 复制代码
@Entry
@Component
struct LongPressDemo {
  @State count: number = 0

  build() {
    Column() {
      Text('计数: ' + this.count)
        .fontSize(36)
        .fontWeight(FontWeight.Bold)
        .margin(20)

      Text('长按递增')
        .fontSize(20)
        .padding(20)
        .backgroundColor('#FF9800')
        .borderRadius(8)
        .gesture(
          LongPressGesture({ repeat: true, duration: 200 })
            .onAction((event: GestureEvent) => {
              if (event.repeat) {
                this.count++
              } else {
                this.count++
              }
            })
            .onActionEnd(() => {
              console.info('长按结束,当前值: ' + this.count)
            })
        )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

一个容易忽略的细节:首次长按成功时event.repeat为false,后续每次重复触发时event.repeat为true。所以如果你想在长按期间持续响应,不要只在repeat为true时处理,首次触发也要处理。

另外要注意,Text、TextInput、Image等组件默认支持拖拽,当长按手势的duration设为500ms或更长时,系统会优先响应拖拽而非长按。如果duration小于500ms,则优先响应长按。这个优先级是系统内置的,不受priorityGesture影响。


四、PanGesture:拖拽跟手与速度获取

拖拽手势是实现组件跟手移动的核心。

typescript 复制代码
PanGesture(value?: { fingers?: number; direction?: PanDirection; distance?: number })

三个参数:

  • fingers:最少手指数量,默认1,最大10。
  • direction:拖拽方向,默认PanDirection.All,支持位运算组合,如PanDirection.Horizontal | PanDirection.Vertical。
  • distance:最小识别距离,默认5vp。滑动距离小于此值不会触发拖拽。

拖拽跟手的关键在于onActionUpdate中持续更新组件位置,onActionEnd中记录最终位置:

typescript 复制代码
@Entry
@Component
struct PanGestureDemo {
  @State offsetX: number = 0
  @State offsetY: number = 0
  @State positionX: number = 0
  @State positionY: number = 0

  build() {
    Column() {
      Row() {
        Column() {
          Text('拖我')
            .fontSize(20)
            .fontColor(Color.White)
        }
        .width(80)
        .height(80)
        .backgroundColor('#2196F3')
        .borderRadius(8)
        .justifyContent(FlexAlign.Center)
        .translate({ x: this.offsetX, y: this.offsetY, z: 0 })
        .gesture(
          PanGesture({ direction: PanDirection.All })
            .onActionStart(() => {
              console.info('拖拽开始')
            })
            .onActionUpdate((event: GestureEvent) => {
              this.offsetX = this.positionX + event.offsetX
              this.offsetY = this.positionY + event.offsetY
            })
            .onActionEnd(() => {
              this.positionX = this.offsetX
              this.positionY = this.offsetY
            })
        )
      }
      .width('100%')
      .height(300)
      .backgroundColor('#F5F5F5')
      .borderRadius(12)
    }
    .width('100%')
    .height('100%')
    .padding(20)
  }
}

实现惯性滑动是PanGesture的进阶用法。GestureEvent提供了velocityX和velocityY两个属性,表示手指离手时的瞬间速度。利用这个速度可以计算惯性滑动距离,再配合animateTo实现自然的减速效果:

typescript 复制代码
PanGesture()
  .onActionUpdate((event: GestureEvent) => {
    this.offsetX = this.positionX + event.offsetX
    this.offsetY = this.positionY + event.offsetY
  })
  .onActionEnd((event: GestureEvent) => {
    let endX: number = this.offsetX + event.velocityX * 0.5
    let endY: number = this.offsetY + event.velocityY * 0.5
    animateTo({
      duration: 800,
      curve: Curve.EaseOut
    }, () => {
      this.offsetX = endX
      this.offsetY = endY
    })
    this.positionX = endX
    this.positionY = endY
  })

velocityX的单位是vp/s,正值表示从左往右,负值表示从右往左。velocityY正值表示从上往下。0.5这个系数是经验值,相当于假设摩擦力在0.5秒内将速度衰减为零。


五、PinchGesture:双指缩放

图片查看器、地图缩放这类场景离不开捏合手势。

typescript 复制代码
PinchGesture(value?: { fingers?: number; distance?: number })

两个参数:

  • fingers:最少手指数量,默认2,最大5。
  • distance:最小识别距离,默认5vp。

捏合手势的核心数据是event.scale------缩放比例。值大于1表示放大,小于1表示缩小。每次手势开始时scale从1开始,所以需要累乘之前的缩放值:

typescript 复制代码
@Entry
@Component
struct PinchGestureDemo {
  @State scaleValue: number = 1
  @State pinchValue: number = 1

  build() {
    Column() {
      Image($r('app.media.icon'))
        .width(200)
        .height(200)
        .objectFit(ImageFit.Contain)
        .scale({ x: this.scaleValue, y: this.scaleValue, z: 1 })
        .gesture(
          PinchGesture({ fingers: 2 })
            .onActionStart(() => {
              console.info('捏合开始')
            })
            .onActionUpdate((event: GestureEvent) => {
              this.scaleValue = this.pinchValue * event.scale
            })
            .onActionEnd(() => {
              this.pinchValue = this.scaleValue
            })
        )

      Text('当前缩放: ' + this.scaleValue.toFixed(2))
        .fontSize(16)
        .margin({ top: 20 })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

这里有两条重要经验:

第一,scale要累乘而不是累加。每次手势的event.scale是相对于本次手势起始点的比例,所以要用上一次手势结束时的缩放值乘以当前的event.scale。

第二,注意缩放边界。不加限制的话,用户可以把图片缩到极小或放到极大。建议在onActionUpdate中做clamp处理:

typescript 复制代码
.onActionUpdate((event: GestureEvent) => {
  let newScale: number = this.pinchValue * event.scale
  this.scaleValue = Math.max(0.5, Math.min(3.0, newScale))
})

另外,event.pinchCenterX和event.pinchCenterY提供捏合中心点坐标,可以实现以手指中心为锚点的缩放效果,而不是默认的组件中心点缩放。


六、RotationGesture:双指旋转

旋转手势和捏合手势经常配合使用,实现图片的自由变换。

typescript 复制代码
RotationGesture(value?: { fingers?: number; angle?: number })

两个参数:

  • fingers:最少手指数量,默认2,最大5。
  • angle:最小旋转角度,默认1度。

event.angle表示旋转角度,正值顺时针,负值逆时针。和缩放一样,需要累加之前的旋转角度:

typescript 复制代码
@Entry
@Component
struct RotationGestureDemo {
  @State angle: number = 0
  @State rotateValue: number = 0

  build() {
    Column() {
      Image($r('app.media.icon'))
        .width(200)
        .height(200)
        .objectFit(ImageFit.Contain)
        .rotate({ angle: this.angle })
        .gesture(
          RotationGesture()
            .onActionStart(() => {
              console.info('旋转开始')
            })
            .onActionUpdate((event: GestureEvent) => {
              this.angle = this.rotateValue + event.angle
            })
            .onActionEnd(() => {
              this.rotateValue = this.angle
            })
        )

      Text('旋转角度: ' + this.angle.toFixed(1) + '度')
        .fontSize(16)
        .margin({ top: 20 })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

旋转、缩放、拖拽三合一的图片查看器,需要把三种手势用GestureGroup组合起来,后面会详细讲。


七、GestureGroup三种模式:顺序、互斥与并行

单个手势能解决的问题有限,真正复杂的交互需要组合手势。GestureGroup就是干这个的。

typescript 复制代码
GestureGroup(mode: GestureMode, ...gesture: GestureType[])

GestureMode三种模式,各有各的用途:

7.1 Sequence(顺序识别)

手势按注册顺序依次识别,前一个识别成功后才轮到下一个。任何一个手势识别失败,整个组合都失败

最经典的场景就是拖拽------先长按再拖动:

typescript 复制代码
@Entry
@Component
struct SequenceGestureDemo {
  @State offsetX: number = 0
  @State offsetY: number = 0
  @State positionX: number = 0
  @State positionY: number = 0
  @State isLongPressed: boolean = false

  build() {
    Column() {
      Column() {
        Text('长按后拖动')
          .fontSize(20)
          .fontColor(Color.White)
      }
      .width(120)
      .height(120)
      .backgroundColor(this.isLongPressed ? '#F44336' : '#4CAF50')
      .borderRadius(8)
      .justifyContent(FlexAlign.Center)
      .translate({ x: this.offsetX, y: this.offsetY, z: 0 })
      .gesture(
        GestureGroup(GestureMode.Sequence,
          LongPressGesture({ duration: 500 })
            .onAction(() => {
              this.isLongPressed = true
            })
            .onActionEnd(() => {
              this.isLongPressed = false
            }),
          PanGesture()
            .onActionUpdate((event: GestureEvent) => {
              this.offsetX = this.positionX + event.offsetX
              this.offsetY = this.positionY + event.offsetY
            })
            .onActionEnd(() => {
              this.positionX = this.offsetX
              this.positionY = this.offsetY
              this.isLongPressed = false
            })
        )
        .onCancel(() => {
          this.isLongPressed = false
        })
      )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

Sequence模式下有个重要特性:只有最后一个手势会触发onActionEnd,前面的手势不会触发onActionEnd。因为从系统角度看,整个组合手势还没结束,后续手势还在识别中。如果你需要在中间某个手势结束时做处理,要用onAction而不是onActionEnd。

另外,Sequence组合手势有onCancel回调------当前面的手势识别成功但后续手势识别失败时触发。比如长按成功后没有拖动而是直接抬起手指,这时整个组合取消,onCancel触发。

7.2 Exclusive(互斥识别)

所有手势同时开始识别,第一个识别成功的手势会"吃掉"整个组合,其余手势全部取消。

单击和双击共存就是最典型的互斥场景:

typescript 复制代码
.gesture(
  GestureGroup(GestureMode.Exclusive,
    TapGesture({ count: 2 })
      .onAction(() => {
        console.info('双击')
      }),
    TapGesture({ count: 1 })
      .onAction(() => {
        console.info('单击')
      })
  )
)

互斥模式的判定逻辑:当你按下并快速抬起一次,单击手势此时还不会立刻识别成功------它会等待300ms看是否有第二次点击。如果在300ms内第二次点击到来,双击手势识别成功,单击手势取消。如果300ms内没有第二次点击,单击手势识别成功。

这就是为什么双击必须放在前面------不是因为注册顺序决定优先级,而是因为双击的判定窗口天然包含了单击,双击放前面可以先完成判定。

7.3 Parallel(并行识别)

所有手势同时识别,互相不影响,各回各的调。

图片查看器需要同时支持拖动和缩放,用Parallel模式:

typescript 复制代码
@Entry
@Component
struct ParallelGestureDemo {
  @State offsetX: number = 0
  @State offsetY: number = 0
  @State positionX: number = 0
  @State positionY: number = 0
  @State scaleValue: number = 1
  @State pinchValue: number = 1
  @State angle: number = 0
  @State rotateValue: number = 0

  build() {
    Column() {
      Image($r('app.media.icon'))
        .width(200)
        .height(200)
        .objectFit(ImageFit.Contain)
        .translate({ x: this.offsetX, y: this.offsetY, z: 0 })
        .scale({ x: this.scaleValue, y: this.scaleValue, z: 1 })
        .rotate({ angle: this.angle })
        .gesture(
          GestureGroup(GestureMode.Parallel,
            PanGesture()
              .onActionUpdate((event: GestureEvent) => {
                this.offsetX = this.positionX + event.offsetX
                this.offsetY = this.positionY + event.offsetY
              })
              .onActionEnd(() => {
                this.positionX = this.offsetX
                this.positionY = this.offsetY
              }),
            PinchGesture()
              .onActionUpdate((event: GestureEvent) => {
                this.scaleValue = this.pinchValue * event.scale
              })
              .onActionEnd(() => {
                this.pinchValue = this.scaleValue
              }),
            RotationGesture()
              .onActionUpdate((event: GestureEvent) => {
                this.angle = this.rotateValue + event.angle
              })
              .onActionEnd(() => {
                this.rotateValue = this.angle
              })
          )
        )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

Parallel模式下要注意:虽然手势互不影响,但状态变量是共享的。如果多个手势同时修改同一个state,可能产生竞态。设计状态时要确保每个手势操作独立的state,或者用组合属性(如一个transform对象包含translate、scale、rotate)来避免冲突。


八、priorityGesture vs parallelGesture:父子组件手势优先级

默认情况下,子组件的gesture绑定的手势优先于父组件。这是合理的------你点按钮时,应该响应按钮的点击而不是整个页面的点击。但有时候需要打破这个规则。

8.1 gesture:子组件优先

最基础的绑定方式。当父子组件绑定同类型手势时,子组件优先识别。

8.2 priorityGesture:父组件优先

typescript 复制代码
@Entry
@Component
struct PriorityGestureDemo {
  @State parentMsg: string = ''
  @State childMsg: string = ''

  build() {
    Column() {
      Column() {
        Text('子组件: ' + this.childMsg)
          .fontSize(18)
          .fontColor(Color.White)
          .gesture(
            TapGesture()
              .onAction(() => {
                this.childMsg = '子组件响应'
              })
          )
      }
      .width(200)
      .height(150)
      .backgroundColor('#4CAF50')
      .justifyContent(FlexAlign.Center)
      .borderRadius(8)
      .priorityGesture(
        TapGesture()
          .onAction(() => {
            this.parentMsg = '父组件优先响应'
            this.childMsg = '子组件被忽略'
          }),
        GestureMask.IgnoreInternal
      )

      Text('父组件: ' + this.parentMsg)
        .fontSize(18)
        .margin({ top: 20 })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .padding(20)
  }
}

priorityGesture让父组件的手势优先于子组件识别。第二个参数GestureMask.IgnoreInternal表示屏蔽子组件的所有手势,包括系统内置手势(如List的滑动)。如果用GestureMask.Normal,则只忽略同类型手势的竞争,不影响其他类型。

8.3 parallelGesture:父子同时响应

typescript 复制代码
@Entry
@Component
struct ParallelGestureParentDemo {
  @State parentMsg: string = ''
  @State childMsg: string = ''

  build() {
    Column() {
      Column() {
        Text('子组件: ' + this.childMsg)
          .fontSize(18)
          .fontColor(Color.White)
          .gesture(
            TapGesture()
              .onAction(() => {
                this.childMsg = '子组件响应'
              })
          )
      }
      .width(200)
      .height(150)
      .backgroundColor('#FF5722')
      .justifyContent(FlexAlign.Center)
      .borderRadius(8)
      .parallelGesture(
        TapGesture()
          .onAction(() => {
            this.parentMsg = '父组件也响应'
          })
      )

      Text('父组件: ' + this.parentMsg)
        .fontSize(18)
        .margin({ top: 20 })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .padding(20)
  }
}

parallelGesture实现了类似事件冒泡的效果------子组件响应的同时,父组件也响应。这在需要"子组件处理自身逻辑,同时通知父组件"的场景很有用。

三种绑定方式的优先级总结

绑定方式 子组件gesture 父组件gesture 说明
gesture + gesture 优先 不响应 默认行为,子组件优先
gesture + priorityGesture 不响应 优先 父组件抢占
gesture + parallelGesture 响应 响应 两者都响应

九、组合手势实战:点击+长按+拖拽组合

把前面学的知识串起来,实现一个实际场景------列表项支持单击打开、长按选中、长按后拖拽排序:

typescript 复制代码
@Entry
@Component
struct CombinedGestureDemo {
  @State offsetX: number = 0
  @State offsetY: number = 0
  @State positionX: number = 0
  @State positionY: number = 0
  @State statusMsg: string = '等待操作'
  @State isSelected: boolean = false
  @State isDragging: boolean = false

  build() {
    Column() {
      Text(this.statusMsg)
        .fontSize(18)
        .margin({ bottom: 20 })

      Column() {
        Text('操作区')
          .fontSize(22)
          .fontColor(Color.White)
        Text('单击打开 / 长按选中 / 长按后拖动')
          .fontSize(14)
          .fontColor('#E0E0E0')
          .margin({ top: 8 })
      }
      .width(220)
      .height(160)
      .backgroundColor(this.isSelected ? '#FF5722' : (this.isDragging ? '#9C27B0' : '#3F51B5'))
      .borderRadius(12)
      .justifyContent(FlexAlign.Center)
      .translate({ x: this.offsetX, y: this.offsetY, z: 0 })
      .gesture(
        GestureGroup(GestureMode.Exclusive,
          GestureGroup(GestureMode.Sequence,
            LongPressGesture({ duration: 500 })
              .onAction(() => {
                this.isSelected = true
                this.statusMsg = '已选中,拖动移动'
              })
              .onActionEnd(() => {
                if (!this.isDragging) {
                  this.isSelected = true
                  this.statusMsg = '长按选中'
                }
              }),
            PanGesture()
              .onActionStart(() => {
                this.isDragging = true
                this.statusMsg = '拖拽中...'
              })
              .onActionUpdate((event: GestureEvent) => {
                this.offsetX = this.positionX + event.offsetX
                this.offsetY = this.positionY + event.offsetY
              })
              .onActionEnd(() => {
                this.positionX = this.offsetX
                this.positionY = this.offsetY
                this.isDragging = false
                this.statusMsg = '拖拽结束,位置已更新'
              })
          ),
          TapGesture({ count: 1 })
            .onAction(() => {
              this.statusMsg = '单击打开'
            })
        )
      )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

这个组合的思路是:外层用Exclusive互斥模式,把"长按+拖拽的Sequence组合"和"单击"互斥。如果用户直接单击,TapGesture先识别成功,Sequence组合取消。如果用户长按,Sequence组合开始识别,LongPressGesture先成功,之后如果拖动则PanGesture成功,整个Sequence完成。如果长按后不拖动直接抬起,Sequence组合的onCancel触发,但此时isSelected已经设为true了,长按选中效果保留。


十、手势冲突解决:实战中的棘手问题

手势冲突是日常开发中最头疼的问题之一,这里集中讲两个最常见的冲突场景。

10.1 Scroll遇上Pan

Scroll组件内部通过PanGesture实现滚动,所以当你在Scroll的子组件上绑定PanGesture时,两者会产生竞争。默认情况下子组件的PanGesture会"吃掉"Scroll的滚动------因为子组件优先。

解决方案有几种:

方案一:用LongPressGesture做前置判断

把PanGesture改成LongPressGesture + PanGesture的Sequence组合,只有长按后才能拖动,普通滑动仍然归Scroll处理:

typescript 复制代码
.gesture(
  GestureGroup(GestureMode.Sequence,
    LongPressGesture({ duration: 300 })
      .onAction(() => {
        console.info('准备拖拽')
      }),
    PanGesture()
      .onActionUpdate((event: GestureEvent) => {
        this.offsetX = this.positionX + event.offsetX
        this.offsetY = this.positionY + event.offsetY
      })
      .onActionEnd(() => {
        this.positionX = this.offsetX
        this.positionY = this.offsetY
      })
  )
)

方案二:调整distance参数

PanGesture的distance默认是5vp,你可以把它设大一点,让Scroll的短距离滑动不被子组件拦截:

typescript 复制代码
.gesture(
  PanGesture({ distance: 30 })
    .onActionUpdate((event: GestureEvent) => {
      this.offsetX = this.positionX + event.offsetX
    })
)

方案三:限制拖拽方向

如果子组件只需要水平拖拽,而Scroll是垂直滚动,用direction参数限制拖拽方向:

typescript 复制代码
.gesture(
  PanGesture({ direction: PanDirection.Horizontal, distance: 10 })
    .onActionUpdate((event: GestureEvent) => {
      this.offsetX = this.positionX + event.offsetX
    })
)

方向不同就不会冲突,各走各的路。

10.2 父子组件手势冲突

当父组件和子组件绑定了同类型手势时,默认子组件优先。但你的业务可能需要父组件优先或者两者都响应。

父组件优先:用priorityGesture

typescript 复制代码
Column() {
  Text('子组件')
    .gesture(
      TapGesture()
        .onAction(() => {
          console.info('子组件响应')
        })
    )
}
.priorityGesture(
  TapGesture()
    .onAction(() => {
      console.info('父组件优先')
    }),
  GestureMask.IgnoreInternal
)

两者都响应:用parallelGesture

typescript 复制代码
Column() {
  Text('子组件')
    .gesture(
      TapGesture()
        .onAction(() => {
          console.info('子组件响应')
        })
    )
}
.parallelGesture(
  TapGesture()
    .onAction(() => {
      console.info('父组件也响应')
    })
)

子组件阻止冒泡:用onTouch + stopPropagation

如果子组件响应后不希望父组件收到事件,可以在onTouch中调用stopPropagation:

typescript 复制代码
Text('子组件')
  .onTouch((event: TouchEvent) => {
    event.stopPropagation()
  })
  .gesture(
    TapGesture()
      .onAction(() => {
        console.info('子组件响应,阻止冒泡')
      })
  )

10.3 GestureMask屏蔽策略

GestureMask是绑定手势时的第二个参数,控制对手势的屏蔽范围:

效果
GestureMask.Normal 不屏蔽子组件手势,按默认优先级识别
GestureMask.IgnoreInternal 屏蔽子组件所有手势,包括系统内置手势(如List滚动)

IgnoreInternal很暴力但很有效。当你发现子组件的手势怎么都抢不过来的时候,加一个IgnoreInternal往往就能解决。但要注意,它会把子组件的所有交互都屏蔽掉,包括按钮点击、列表滚动等,慎用。


十一、手势识别全流程总结

把整个手势系统串起来看,从手指触碰屏幕到回调触发,完整流程如下:

  1. Down事件:手指按下,系统开始触摸测试,确定响应链。
  2. 响应链收集:从触摸点所在的最深层子组件开始,沿组件树向上收集所有绑定手势的组件。
  3. 手势识别器初始化:响应链上每个组件的手势识别器开始监听后续触摸事件。
  4. Move事件:手指移动,各识别器根据自身规则判断是否满足触发条件。
  5. 识别结果判定
    • 单一手势:满足条件即识别成功,触发onAction。
    • GestureGroup.Sequence:按顺序依次判定,前一个成功后判定下一个。
    • GestureGroup.Exclusive:同时判定,第一个成功的胜出。
    • GestureGroup.Parallel:同时判定,互不影响。
  6. Up事件:手指抬起,触发识别成功手势的onActionEnd。
  7. Cancel事件:如果识别过程中被其他手势抢占,触发onActionCancel。

不同组件绑定的手势之间还存在父子竞争关系,由绑定方式(gesture/priorityGesture/parallelGesture)和GestureMask共同决定。


实用技巧与常见坑

场景 常见问题 解决方案
单击+双击共存 双击触发不了 Exclusive模式下双击放前面,单击放后面
Sequence组合 前面的手势onActionEnd不触发 Sequence只有最后一个手势会触发onActionEnd,中间手势用onAction处理
Scroll内子组件拖拽 滑动被Scroll吃掉 改用LongPress+Pan的Sequence,或调大distance,或限制direction
父子组件同类型手势 始终子组件响应 父组件用priorityGesture,配合GestureMask.IgnoreInternal
图片缩放后重置 每次缩放从原点开始 pinchValue记录上一次缩放值,新缩放值 = pinchValue * event.scale
旋转角度累加错误 旋转不连续 rotateValue记录上次角度,新角度 = rotateValue + event.angle
长按与拖拽冲突 长按后不能拖拽 duration设为500ms时系统优先响应拖拽,把duration调小到400ms以下
多手势修改同一state 状态混乱 每个手势操作独立的state变量,避免并发修改
Tab切换与Pan冲突 左右滑动被Pan拦截 Tab场景把PanGesture的distance设为1,让Tab更灵敏
parallelGesture + 点击/双击 父子组件只响应单击 已知限制:parallelGesture配合点击和双击时父子均只响应单击,改用其他通信方式
List子组件的Pan List滚动不了 父List用priorityGesture抢回滚动权,或子组件用Sequence限制拖拽触发条件
onGestureJudgeBegin 需要动态控制手势响应 在回调中返回GestureJudgeResult.REJECT可拒绝特定手势,返回CONTINUE继续识别

最后再说一句,手势系统虽然看起来复杂,但核心就是三个维度:手势类型决定识别什么,绑定方式决定谁优先,组合模式决定多个手势怎么协作。把这三条想清楚,大部分手势问题都能迎刃而解。遇到更复杂的场景,善用onGestureJudgeBegin回调做动态决策,那是手势系统的终极武器,不过那就是另一个话题了。

相关推荐
胡琦博客1 小时前
HarmonyOS 智能工具箱(二):OCR 文字识别工具
华为·ocr·harmonyos
FrameNotWork1 小时前
HarmonyOS 6.0 数据可视化图表
华为·信息可视化·harmonyos
程序员黑豆2 小时前
鸿蒙应用开发:6种图片加载方式详解
前端·华为·harmonyos
qizayaoshuap2 小时前
# ❌ 井字棋 — 鸿蒙ArkTS Minimax AI算法与博弈系统设计
人工智能·算法·华为·harmonyos
Catrice03 小时前
HarmonyOS ArkTS 实战:实现一个校园就业信息与校招应用
华为·harmonyos
TheBestRucy3 小时前
RAG知识库问答系统落地:从向量检索到上下文增强的全链路实践
人工智能·python·langchain·aigc·交互
math_hongfan3 小时前
鸿蒙Flutter setState机制深入理解
学习·flutter·华为·harmonyos·鸿蒙
●VON4 小时前
鸿蒙 PC Markdown 编辑器内部隐私与安全评审
安全·华为·编辑器·harmonyos·鸿蒙
木木子224 小时前
# [特殊字符] 音乐播放器 — 鸿蒙ArkTS播放控制与列表管理
华为·harmonyos