【学习目标】

  1. 吃透触摸事件的底层逻辑,理解手势交互的本质是对触摸事件流的规则化识别
  2. 掌握事件分发、命中测试、响应链、冒泡拦截的核心机制,从根源理清事件流向
  3. 熟练使用鸿蒙6种基础手势,明确每种手势的触发规则、核心参数与适用场景
  4. 掌握3种手势绑定方式的优先级与行为差异,彻底解决父子组件手势冲突问题
  5. 运用3种组合手势模式,实现长按拖动、单击双击共存等复杂业务交互
  6. 能基于底层机制,定位并解决90%的手势响应异常

一、工程目录结构

GestureDemo/
├── entry/
│   └── src/
│       └── main/
│           ├── ets/
│           │   ├── entryability/
│           │   │   └── EntryAbility.ets
│           │   └── pages/
│           │       ├── Index.ets              // 课程导航首页
│           │       ├── TouchBaseDemo.ets     // 触摸事件底层演示
│           │       ├── HitTestDemo.ets       // 命中测试与事件分发演示
│           │       ├── BasicGestures.ets     // 6种基础手势全量示例
│           │       ├── GestureBinding.ets    // 3种绑定方式与冲突解决
│           │       ├── CombinedGestures.ets  // 组合手势复杂交互实战
│           │       └── GestureDebug.ets      // 手势异常问题排查与解决
│           ├── resources/
│           └── module.json5
└── build-profile.json5

二、底层基石:触摸事件与交互基础机制

所有手势的本质,都是系统对底层触摸事件流的封装与规则化判定。想要真正搞懂手势,必须先搞懂「用户触摸屏幕后,事件到底经历了什么」。

2.1 触摸事件的本质:完整事件流

任何一次屏幕触摸,都会产生一条固定的事件流,所有手势均基于这条事件流做识别:

Down(手指按下) → Move(手指移动) → Up(手指抬起) → Cancel(事件异常终止)
核心事件类型说明
事件类型 触发时机 核心作用 必处理场景
Down 手指接触屏幕的瞬间 事件流的起点,触发系统命中测试、生成响应链 交互状态初始化(如按钮按下态)
Move 手指按下后在屏幕上滑动 持续触发,为滑动、缩放、旋转手势提供数据 拖拽、跟手动画、手势持续更新
Up 手指离开屏幕的瞬间 事件流正常终点,完成手势最终判定 点击、快滑手势的触发确认
Cancel 事件被系统异常中断 事件流异常终点,等价于Up 必须同步处理,避免组件状态卡死(如按住屏幕时切后台、折叠屏切换、被弹窗打断)
触摸事件核心入口:onTouch

onTouch 是所有触摸事件的底层回调接口,所有组件均可绑定,触摸动作触发时会回调完整的触摸信息:

// 基础语法
onTouch((event: TouchEvent) => void): T
核心回调对象 TouchEvent 必掌握属性

我们只需要掌握开发中高频使用的核心属性:

  1. 事件状态event.type → 获取当前事件类型(Down/Move/Up/Cancel)
  2. 触点信息event.touches → 屏幕上所有触摸点的数组(多指操作必备),每个触点包含:
    • x/y:触摸点相对于当前组件的坐标(最常用,手势计算核心)
    • windowX/windowY:触摸点相对于应用窗口的坐标
    • id:手指唯一标识,多指操作时区分不同手指
  3. 冒泡控制event.stopPropagation() → 阻止事件向上冒泡给父组件
  4. 高精度数据event.getHistoricalPoints() → 获取当前帧的历史触摸点,用于手写板、高精度绘图场景
事件冒泡与拦截
  • 冒泡规则:触摸事件会沿着响应链,从最内层的叶子组件,逐层向上传递给父组件,直到根节点
  • 拦截规则:任意一层组件调用 event.stopPropagation(),即可终止事件继续向上传递
  • 关键避坑:终止冒泡只会阻止父组件的onTouch事件接收,不会中断父组件上绑定的手势响应
  • 必做规范:终止冒泡时,必须对Down/Move/Up/Cancel全类型事件统一处理,避免上层组件只收到部分事件导致状态异常
触摸事件示例(TouchBaseDemo.ets)

@Entry
@Component
struct TouchBaseDemo {
  // 分别记录子组件和父组件的事件状态
  @State childEvent: string = '未触发';
  @State parentEvent: string = '未触发';
  @State touchX: number = 0;
  @State touchY: number = 0;

  // 子组件阻止冒泡(父收不到)
  @State stopBubble: boolean = false;

  // 父组件拦截(子收不到)
  @State parentIntercept: boolean = false;

  build() {
    Scroll() {
      Column({ space: 20 }) {
        this.titleBuilder();
        this.touchAreaBuilder();
        this.switchBuilder();
        this.touchInfoBuilder();
      }
      .width('100%')
      .padding({
        top: 12,
        bottom: 20
      });
    }
    .width('100%')
    .height('100%');
  }

  @Builder
  titleBuilder() {
    Text('触摸事件底层演示')
      .fontSize(18)
      .fontWeight(FontWeight.Medium)
      .width('95%');
  }

  @Builder
  touchAreaBuilder() {
    Column() {
      Text('父组件区域')
        .fontSize(14)
        .fontColor('#666');

      // 子组件
      Stack() {
        Text('触摸测试区域')
          .fontColor(Color.White)
          .fontWeight(FontWeight.Bold);
      }
      .width(200)
      .height(200)
      .backgroundColor(0x007AFF)
      .borderRadius(12)
      .onTouch((event?: TouchEvent) => {
        if (!event) {
          return;
        }

        if (this.parentIntercept) {
          return;
        }

        this.updateChildEvent(event);

        if (this.stopBubble) {
          event.stopPropagation();
        }
      });
    }
    .width('95%')
    .height(300)
    .backgroundColor(0xE5F2FF)
    .borderRadius(12)
    .justifyContent(FlexAlign.Center)
    .onTouch((event?: TouchEvent) => {
      if (!event) {
        return;
      }
      this.updateParentEvent(event);
    });
  }

  @Builder
  switchBuilder() {
    Column({ space: 12 }) {
      Row() {
        Text('子组件阻止冒泡')
          .fontSize(14);

        Toggle({
          type: ToggleType.Switch,
          isOn: this.stopBubble
        })
          .onChange((value) => {
            this.stopBubble = value;
          });
      }
      .width('95%')
      .justifyContent(FlexAlign.SpaceBetween);

      Row() {
        Text('父组件拦截子组件')
          .fontSize(14);

        Toggle({
          type: ToggleType.Switch,
          isOn: this.parentIntercept
        })
          .onChange((value) => {
            this.parentIntercept = value;
          });
      }
      .width('95%')
      .justifyContent(FlexAlign.SpaceBetween);
    }
    .width('95%');
  }

  @Builder
  touchInfoBuilder() {
    Column({ space: 8 }) {
      Text(`子组件状态:${this.childEvent}`)
        .fontSize(14)
        .width('100%');

      Text(`父组件状态:${this.parentEvent}`)
        .fontSize(14)
        .width('100%');

      Text(`坐标:X=${this.touchX.toFixed(1)} Y=${this.touchY.toFixed(1)}`)
        .fontSize(14)
        .width('100%');
    }
    .width('95%')
    .padding(15)
    .backgroundColor(0xF5F5F5)
    .borderRadius(12);
  }

  // 更新子组件事件
  updateChildEvent(event: TouchEvent) {
    if (event.type === TouchType.Down) {
      this.childEvent = '按下';
    } else if (event.type === TouchType.Move) {
      this.childEvent = '移动';
    } else if (event.type === TouchType.Up) {
      this.childEvent = '抬起';
    } else if (event.type === TouchType.Cancel) {
      this.childEvent = '取消';
    }

    if (event.touches.length > 0) {
      this.touchX = event.touches[0].x;
      this.touchY = event.touches[0].y;
    }
  }

  // 更新父组件事件
  updateParentEvent(event: TouchEvent) {
    if (event.type === TouchType.Down) {
      this.parentEvent = '按下';
    } else if (event.type === TouchType.Move) {
      this.parentEvent = '移动';
    } else if (event.type === TouchType.Up) {
      this.parentEvent = '抬起';
    } else if (event.type === TouchType.Cancel) {
      this.parentEvent = '取消';
    }

    if (event.touches.length > 0) {
      this.touchX = event.touches[0].x;
      this.touchY = event.touches[0].y;
    }
  }
}

2.2 事件分发全流程

用户触摸屏幕后,事件会经过3个核心阶段,最终触发我们写的回调函数:

用户触摸屏幕
    ↓
【阶段1:事件产生】硬件上报 → ArkUI渲染管线接收
    ↓
【阶段2:收集响应链+事件分发】命中测试 → 生成响应链 → 事件分发
    ↓
【阶段3:回调触发】符合条件的触摸事件/手势回调执行
核心核心:命中测试(HitTest/触摸测试)

命中测试是整个交互流程的根基,决定了哪些组件能响应本次触摸,在手指按下(Down事件)的瞬间执行,一次完整触摸流程仅执行一次。

1. 命中测试执行规则
  • 遍历规则:系统从页面根节点开始,自上而下、自右向左(Z序优先,后写的组件在上层) 遍历组件树
  • 判定规则:组件的响应热区包含触摸坐标、组件未被禁用/移除,才会被判定为「命中」
  • 响应链生成:命中的组件会按「叶子节点→父节点→根节点」的顺序,形成本次交互的事件响应链

    示例:用户点击了父容器中的按钮,响应链顺序为:按钮 → 父容器 → 页面根节点

2. 干预命中测试的3种核心方式

开发中我们可以通过3种方式,控制组件是否能被命中、是否能加入响应链,这是解决手势穿透、点击无响应的核心手段:

干预方式 核心作用 对应接口 核心特性
自定义响应热区 修改组件的触摸响应范围 responseRegion 静态配置,可扩大/缩小/分割响应区域,解决小按钮难点击问题
命中测试行为控制 静态控制组件/父子/兄弟组件的命中规则 hitTestBehavior 静态配置,编译期确定行为,解决事件穿透/屏蔽问题
自定义事件拦截 动态控制命中测试行为 onTouchIntercept 动态回调,可根据业务状态实时调整,适合动态开关交互的场景
3. hitTestBehavior 核心枚举值速记
枚举值 核心行为 一句话总结
Default 默认行为:自身和子组件均可响应触摸,阻塞被自身遮盖的下层/兄弟组件 自己+孩子能点,挡住下面
Block 阻塞子组件:自身可响应触摸,子组件无法响应,同时阻塞下层/兄弟组件 自己能点,孩子不能点
Transparent 事件穿透:自身和子组件可响应触摸,不阻塞下层/兄弟组件 大家都能点(穿透)
None 自身不响应:自身不接收触摸事件,但子组件可以正常响应 自己不点,孩子能点
BLOCK_HIERARCHY 阻塞层级:阻塞所有低优先级的兄弟节点与父节点接收事件 我独占,下面全都别点)
BLOCK_DESCENDANTS 阻塞所有后代:自身不响应触摸,子/孙等所有后代节点均不可响应 我+孩子全都不能点(API 20+)
import { promptAction } from '@kit.ArkUI';

@Entry
@Component
struct HitTestDemo {
  // 动态开关:是否拦截子组件触摸事件(true=拦截,false=不拦截)
  @State interceptEnabled: boolean = false;

  build() {
    Scroll() {
      Column({ space: 20 }) {
        // 页面标题
        Text("命中测试与事件分发")
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .width('95%')
          .margin({ top: 10 });

        Text("掌握三种触摸控制方式:自定义热区 | 事件穿透 | 动态拦截")
          .fontSize(14)
          .fontColor('#666')
          .width('95%');

        // 1. 自定义响应热区
        this.responseRegionBuilder();

        // 2. 事件穿透(Transparent)
        this.hitTestTransparentBuilder();

        // 3. 动态触摸拦截 onTouchIntercept
        this.touchInterceptBuilder();
      }
      .width('100%')
      .padding({ top: 12, bottom: 30 });
    }
    .width('100%')
    .height('100%');
  }

  // 1. 自定义响应热区:扩大/修改组件点击范围
  @Builder
  responseRegionBuilder() {
    Column({ space: 12 }) {
      Text("1. 自定义响应热区 responseRegion")
        .fontSize(16)
        .fontWeight(FontWeight.Bold);

      Text("作用:不改变视觉大小,仅修改触摸响应区域")
        .fontSize(12)
        .fontColor('#666');

      Text("演示:按钮仅左右 30% 可点击,中间不可点击")
        .fontSize(12)
        .fontColor('#999');

      Button('热区测试按钮')
        .width(200)
        .height(45)
        .onClick(() => {
          promptAction.showToast({ message: '按钮有效点击' });
        })
        // 自定义热区:左右两块区域响应触摸
        .responseRegion([
          { x: 0, y: 0, width: '30%', height: '100%' },
          { x: '70%', y: 0, width: '30%', height: '100%' }
        ]);
    }
    .width('95%')
    .padding(15)
    .backgroundColor(0xF5F5F5)
    .borderRadius(12);
  }

  // 2. 事件穿透:上层不拦截下层点击
  @Builder
  hitTestTransparentBuilder() {
    Column({ space: 12 }) {
      Text("2. 事件穿透 hitTestBehavior")
        .fontSize(16)
        .fontWeight(FontWeight.Bold);

      Text("作用:上层组件允许触摸事件穿透到下层")
        .fontSize(12)
        .fontColor('#666');

      Text("演示:点击蒙层可触发底层按钮")
        .fontSize(12)
        .fontColor('#999');

      Stack() {
        // 下层按钮
        Button('底层可点击按钮')
          .width(220)
          .height(200)
          .onTouch((event) => {
            if (event.type === TouchType.Down) {
             setTimeout(()=>{
               promptAction.showToast({ message: "底层按钮触发" });
             },1000)
            }
          })

        // 上层蒙层:设置透明穿透
        Column()
          .width('100%')
          .height('100%')
          .backgroundColor('rgba(0,0,0,0.4)')
          .hitTestBehavior(HitTestMode.Transparent)
          .onTouch((event) => {
            if (event.type === TouchType.Down) {
              promptAction.showToast({ message: "上层蒙层触发" });
            }
          })
      }
      .width('95%')
      .height(240)
      .borderRadius(12);
    }
    .width('95%')
    .padding(15)
    .backgroundColor(0xF5F5F5)
    .borderRadius(12);
  }

  // 3. 动态触摸拦截:父组件动态控制子组件是否可点击
  @Builder
  touchInterceptBuilder() {
    Column({ space: 12 }) {
      Text("3. 动态触摸拦截 onTouchIntercept")
        .fontSize(16)
        .fontWeight(FontWeight.Bold);

      Text("作用:父组件动态决定是否拦截子组件触摸事件")
        .fontSize(12)
        .fontColor('#666');

      // 开关区域
      Row() {
        Text(`拦截状态:${this.interceptEnabled ? '已拦截' : '未拦截'}`)
          .fontSize(14);

        Toggle({ type: ToggleType.Switch, isOn: this.interceptEnabled })
          .onChange(v => {
            this.interceptEnabled = v;
          });
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding(12)
      .backgroundColor('#f5f5f5')
      .borderRadius(8);

      // 状态提示文字
      Text(this.interceptEnabled
        ? "✅ 已拦截:子组件无法点击"
        : "❌ 未拦截:子组件可点击")
        .fontSize(13)
        .fontColor(this.interceptEnabled ? Color.Red : Color.Green);

      // 父容器
      Column() {
        // 子按钮
        Button('子组件测试按钮')
          .width(180)
          .height(50)
          .onClick(() => {
            promptAction.showToast({ message: '子组件点击成功' });
          });
      }
      .width('95%')
      .height(160)
      .backgroundColor(0xE5F2FF)
      .justifyContent(FlexAlign.Center)
      .borderRadius(12)
      // ✅ 动态拦截触摸:返回 HitTestMode 控制事件流向
      .onTouchIntercept((event: TouchEvent): HitTestMode => {
        // Block   = 拦截事件,子组件无法响应
        // Default = 不拦截,事件正常传递
        return this.interceptEnabled ? HitTestMode.Block : HitTestMode.Default;
      });
    }
    .width('95%')
    .padding(15)
    .backgroundColor(0xF5F5F5)
    .borderRadius(12);
  }
}
onTouch和onClick的核心区别

把上边onTouch替换成onClick看看他们的区别,很明显只有蒙版响应。

特性 onClick 点击事件 onTouch 触摸事件
封装级别 高层级语义封装 低层级原始触摸事件
事件类型 只有一次完整点击触发 包含 Down/Move/Up/Cancel 多阶段
事件传播 不冒泡、不穿透 支持冒泡、支持穿透
消费机制 被一个组件消费后终止 可多层级同时接收
HitTest透明穿透 上层响应后,下层不响应 可实现多层同时响应
使用场景 普通按钮、点击交互 自定义手势、拖拽、多层穿透点击

三、上层应用:6种基础手势全解

搞懂了底层触摸事件和分发机制,我们再看手势就会非常清晰:手势就是系统封装好的、对触摸事件流的特定判定规则,帮我们省去了复杂的事件流判断逻辑。

核心知识点

所有手势都基于触摸事件流构建,核心差异在于「触发判定规则」,所有手势都有统一的生命周期回调:

  • onActionStart:手势识别成功,正式开始
  • onActionUpdate:手势持续更新(如滑动、缩放过程中持续触发)
  • onActionEnd:手势正常结束(手指抬起)
  • onActionCancel:手势被系统中断/竞争失败

1. 点击手势(TapGesture)

底层触发逻辑

触摸事件流满足:Down → 短时间内无明显位移 → Up,系统判定为点击手势。

核心参数
  • count:点击次数,默认1(可设置双击、三击)
  • fingers:触发所需的手指数量,默认1
关键说明
  • 等价关系:onClick 底层等价于 TapGesture({count:1}),共用同一套竞争机制
  • 避坑提示:单击+双击直接共存时,单击会有300ms左右延迟,需用互斥组合手势消除
实战代码
@Builder tapGestureBuilder() {
  Column({ space: 12 }) {
    Text('1. 点击手势(单击/双击)')
      .fontSize(16)
      .fontWeight(FontWeight.Medium);

    Button(`单击次数:${this.tapCount}`)
      .gesture(
        TapGesture({ count: 1 })
          .onAction(() => {
            this.tapCount++;
          })
      );

    Button(`双击次数:${this.doubleTapCount}`)
      .gesture(
        TapGesture({ count: 2 })
          .onAction(() => {
            this.doubleTapCount++;
          })
      );
  }
  .padding(15)
  .backgroundColor(0xF5F5F5)
  .borderRadius(12)
  .width('95%');
}

2. 长按手势(LongPressGesture)

底层触发逻辑

触摸事件流满足:Down → 保持指定时长无明显位移,系统判定为长按手势。

核心参数
  • duration:触发所需的按压时长,默认500ms
  • repeat:是否重复触发,默认false(开启后长按期间会持续触发onAction)
关键说明
  • 长按与其他手势冲突时,先满足触发条件者优先,不受绑定方式影响
  • 典型场景:长按删除、长按激活拖拽、长按重复触发(如加减计数器)
实战代码
@Builder longPressBuilder() {
  Column({ space: 12 }) {
    Text('2. 长按手势(重复触发)')
      .fontSize(16)
      .fontWeight(FontWeight.Medium);

    Text(`长按触发次数:${this.longPressCount}`);

    Button('长按我')
      .width(120)
      .height(40)
      .gesture(
        LongPressGesture({ repeat: true, duration: 500 })
          .onAction(() => {
            this.longPressCount++;
          })
          .onActionEnd(() => {
            this.longPressCount = 0;
          })
      );
  }
  .padding(15)
  .backgroundColor(0xF5F5F5)
  .borderRadius(12)
  .width('95%');
}

3. 滑动手势(PanGesture)

底层触发逻辑

触摸事件流满足:Down → 移动距离超过系统阈值(默认5vp),系统判定为滑动手势,移动过程中持续触发更新。

核心参数
  • direction:滑动方向,默认全方向,可指定水平/垂直方向
  • distance:触发滑动的最小距离,默认5vp
关键说明
  • 底层关联:Scroll、List、Swiper等可滚动组件,底层均基于PanGesture实现
  • 实现技巧:用「基准位置+增量偏移」两套变量,实现无抖动的连续滑动
实战代码
@Builder panBuilder() {
  Column({ space: 12 }) {
    Text('3. 滑动手势')
      .fontSize(16)
      .fontWeight(FontWeight.Medium);

    Text(`偏移量:X=${this.panOffsetX.toFixed(1)} Y=${this.panOffsetY.toFixed(1)}`);

    Stack() {
      Text('滑动我')
        .fontColor(Color.White)
        .fontWeight(FontWeight.Bold);
    }
    .width(100)
    .height(100)
    .backgroundColor(0x007AFF)
    .borderRadius(12)
    .translate({ x: this.panOffsetX, y: this.panOffsetY })
    .gesture(
      PanGesture()
        .onActionUpdate((e: GestureEvent) => {
          // 基准位置+增量偏移,实现连续滑动
          this.panOffsetX = this.basePositionX + e.offsetX;
          this.panOffsetY = this.basePositionY + e.offsetY;
        })
        .onActionEnd(() => {
          // 滑动结束,更新基准位置
          this.basePositionX = this.panOffsetX;
          this.basePositionY = this.panOffsetY;
        })
    );
  }
  .padding(15)
  .backgroundColor(0xF5F5F5)
  .borderRadius(12)
  .width('95%');
}

4. 捏合手势(PinchGesture)

底层触发逻辑

双指触摸事件流满足:Down → 双指距离发生变化,系统判定为捏合手势,用于缩放场景。

核心参数
  • fingers:触发所需的手指数量,默认2(仅支持2-5指)
关键说明
  • event.scale相对缩放值(相对于上一次回调的缩放比例),必须用「基准缩放值×相对值」计算总缩放
  • 典型场景:图片缩放、地图缩放、内容放大查看
实战代码
@Builder pinchBuilder() {
  Column({ space: 12 }) {
    Text('4. 捏合手势(双指缩放)')
      .fontSize(16)
      .fontWeight(FontWeight.Medium);

    Text(`缩放比例:${this.scaleValue.toFixed(2)}`);

    Text('双指缩放演示')
      .width(150)
      .height(150)
      .backgroundColor(0xEAEAEA)
      .textAlign(TextAlign.Center)
      .scale({ x: this.scaleValue, y: this.scaleValue })
      .gesture(
        PinchGesture()
          .onActionUpdate((e: GestureEvent) => {
            this.scaleValue = this.baseScale * e.scale;
          })
          .onActionEnd(() => {
            this.baseScale = this.scaleValue;
          })
      );
  }
  .padding(15)
  .backgroundColor(0xF5F5F5)
  .borderRadius(12)
  .width('95%');
}

5. 旋转手势(RotationGesture)

底层触发逻辑

双指触摸事件流满足:Down → 双指绕中心旋转角度超过阈值(默认1°),系统判定为旋转手势。

核心参数
  • fingers:触发所需的手指数量,默认2
  • angle:触发旋转的最小角度,默认1°
关键说明
  • event.angle角度增量(相对于上一次回调的旋转角度),顺时针为正,逆时针为负
  • 必须用「基准角度+增量角度」计算总旋转角度
实战代码
@Builder rotateBuilder() {
  Column({ space: 12 }) {
    Text('5. 旋转手势(双指旋转)')
      .fontSize(16)
      .fontWeight(FontWeight.Medium);

    Text(`旋转角度:${this.rotateAngle.toFixed(1)}°`);

    Text('双指旋转演示')
      .width(150)
      .height(150)
      .backgroundColor(0xEAEAEA)
      .textAlign(TextAlign.Center)
      .rotate({ angle: this.rotateAngle })
      .gesture(
        RotationGesture()
          .onActionUpdate((e: GestureEvent) => {
            this.rotateAngle = this.baseRotate + e.angle;
          })
          .onActionEnd(() => {
            this.baseRotate = this.rotateAngle;
          })
      );
  }
  .padding(15)
  .backgroundColor(0xF5F5F5)
  .borderRadius(12)
  .width('95%');
}

6. 快滑手势(SwipeGesture)

底层触发逻辑

触摸事件流满足:Down → 快速移动 → Up,且手指抬起时速度超过系统阈值(100vp/s),系统判定为快滑手势。

核心参数
  • direction:快滑方向,默认全方向,可指定上下左右单方向
关键说明
  • 与PanGesture的核心区别:Pan是跟手触发(位移判定),Swipe是离手触发(速度判定),二者互斥
  • 典型场景:页面切换、卡片删除、列表项侧滑操作
实战代码
@Builder swipeBuilder() {
  Column({ space: 12 }) {
    Text('6. 快滑手势(离手触发)')
      .fontSize(16)
      .fontWeight(FontWeight.Medium);

    Text(`快滑方向:${this.swipeDirection}`);

    Stack() {
      Text('快滑我')
        .fontColor(Color.White)
        .fontWeight(FontWeight.Bold);
    }
    .width(150)
    .height(100)
    .backgroundColor(0xFF9500)
    .borderRadius(12)
    .gesture(
      SwipeGesture({ direction: SwipeDirection.All })
        .onAction((event: GestureEvent | undefined) => {
          if (!event) return;
          // 根据角度判断快滑方向
          const angle = event.angle;
          if (angle > -45 && angle < 45) {
            this.swipeDirection = "向右";
          } else if (angle > 45 && angle < 135) {
            this.swipeDirection = "向下";
          } else if (angle > -135 && angle < -45) {
            this.swipeDirection = "向上";
          } else {
            this.swipeDirection = "向左";
          }
          promptAction.showToast({ message: `快滑方向:${this.swipeDirection}` });
        })
    );
  }
  .padding(15)
  .backgroundColor(0xF5F5F5)
  .borderRadius(12)
  .width('95%');
}

基础手势完整页面(BasicGestures.ets)

import { promptAction } from '@kit.ArkUI';

@Entry
@Component
struct BasicGestures {
  // 点击手势状态
  @State tapCount: number = 0;
  @State doubleTapCount: number = 0;
  // 长按手势状态
  @State longPressCount: number = 0;
  // 滑动手势状态
  @State panOffsetX: number = 0;
  @State panOffsetY: number = 0;
  @State basePositionX: number = 0;
  @State basePositionY: number = 0;
  // 捏合手势状态
  @State scaleValue: number = 1;
  @State baseScale: number = 1;
  // 旋转手势状态
  @State rotateAngle: number = 0;
  @State baseRotate: number = 0;
  // 快滑手势状态
  @State swipeDirection: string = '无';

  build() {
    Scroll() {
      Column({ space: 16 }) {
        this.tapGestureBuilder();
        this.longPressBuilder();
        this.panBuilder();
        this.pinchBuilder();
        this.rotateBuilder();
        this.swipeBuilder();
      }
      .padding(12)
      .width('100%');
    }
    .height('100%');
  }

  // 复制上文所有@Builder方法到此处即可运行
}

为了全部能展示,根组件使用Scroll ,但是swipeBuilder 组件失去了向上向下快速滑动的响应,左右快速滑动有反应。因为Scroller的内置华东手势抢走了swipe手势,这就是手势冲突。

  // .onTouchTestDone((event, recognizers) => {
      //   for (let i = 0; i < recognizers.length; i++) {
      //     let recognizer = recognizers[i];
      //     // 根据类型禁用所有滑动手势
      //     if (recognizer.getType() == GestureControl.GestureType.PAN_GESTURE) {
      //       recognizer.preventBegin();
      //     };
      //   };
      // })

四、手势绑定规则:3种绑定方式与优先级

手势绑定方式,直接决定了父子组件之间,谁先响应、谁后响应、谁不响应,是解决父子手势冲突的核心手段。

核心规则对照表

绑定方式 优先级 核心行为 典型适用场景
gesture() 子组件优先,同类型手势父组件不响应 普通按钮、组件默认交互
priorityGesture() 父组件优先,可屏蔽子组件手势 遮罩层、全局手势、需要父组件优先响应的场景
parallelGesture() 父子组件同时响应(并行冒泡) 埋点统计、父子联动交互

关键补充说明

  1. priorityGesture() 可传入第二个参数 GestureMask 精细化控制:
    • GestureMask.Normal:仅屏蔽子组件上的同类型手势
    • GestureMask.IgnoreInternal:完全屏蔽子组件上的所有手势
  2. 不同类型的手势,不受绑定方式优先级影响,谁先满足触发条件,谁先响应

1. 默认绑定 gesture() —— 子组件优先

@Builder normalGestureBuilder() {
  Column({ space: 10 }) {
    Text('1. 默认绑定 gesture():子组件优先')
      .fontSize(16)
      .fontWeight(FontWeight.Medium);
    Text('点击蓝色子组件:仅子组件响应,父组件不会触发')
      .fontSize(12)
      .fontColor('#999');

    Column() {
      Text('子组件')
        .width(100)
        .height(100)
        .backgroundColor(0x007AFF)
        .fontColor(Color.White)
        .textAlign(TextAlign.Center)
        .gesture(
          TapGesture()
            .onAction(() => {
              console.info('【子组件】响应');
            })
        );
    }
    .width('100%')
    .height(150)
    .backgroundColor(0xE5F2FF)
    .justifyContent(FlexAlign.Center)
    .gesture(
      TapGesture()
        .onAction(() => {
          console.info('【父组件】响应');
        })
    );
  }
  .padding(15)
  .backgroundColor(0xF5F5F5)
  .borderRadius(12)
  .width('95%');
}

2. 优先级绑定 priorityGesture() —— 父组件优先

@Builder priorityGestureBuilder() {
  Column({ space: 10 }) {
    Text('2. 优先级绑定 priorityGesture():父组件优先')
      .fontSize(16)
      .fontWeight(FontWeight.Medium);
    Text('点击绿色子组件:仅父组件响应,子组件完全被屏蔽')
      .fontSize(12)
      .fontColor('#999');

    Column() {
      Text('子组件')
        .width(100)
        .height(100)
        .backgroundColor(0x34C759)
        .fontColor(Color.White)
        .textAlign(TextAlign.Center)
        .gesture(
          TapGesture()
            .onAction(() => {
              console.info('【子组件】不会执行');
            })
        );
    }
    .width('100%')
    .height(150)
    .backgroundColor(0xE8F8ED)
    .justifyContent(FlexAlign.Center)
    .priorityGesture(
      TapGesture()
        .onAction(() => {
          console.info('【父组件】优先响应');
        }),
      GestureMask.IgnoreInternal
    );
  }
  .padding(15)
  .backgroundColor(0xF5F5F5)
  .borderRadius(12)
  .width('95%');
}

3. 并行绑定 parallelGesture() —— 父子同时响应

@Builder parallelGestureBuilder() {
  Column({ space: 10 }) {
    Text('3. 并行绑定 parallelGesture():父子同时响应')
      .fontSize(16)
      .fontWeight(FontWeight.Medium);
    Text('点击橙色子组件:父子组件先后触发,都能响应')
      .fontSize(12)
      .fontColor('#999');

    Column() {
      Text('子组件')
        .width(100)
        .height(100)
        .backgroundColor(0xFF9500)
        .fontColor(Color.White)
        .textAlign(TextAlign.Center)
        .gesture(
          TapGesture()
            .onAction(() => {
              console.info('【子组件】响应');
            })
        );
    }
    .width('100%')
    .height(150)
    .backgroundColor(0xFFF4E5)
    .justifyContent(FlexAlign.Center)
    .parallelGesture(
      TapGesture()
        .onAction(() => {
          console.info('【父组件】并行响应');
        })
    );
  }
  .padding(15)
  .backgroundColor(0xF5F5F5)
  .borderRadius(12)
  .width('95%');
}

手势绑定页面(GestureBinding.ets)

import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN = 0x0001;
const TAG = "GestureDemo";

@Entry
@Component
struct GestureBinding {
  build() {
    Scroll() {
      Column({ space: 16 }) {
        this.normalGestureBuilder();
        this.priorityGestureBuilder();
        this.parallelGestureBuilder();
      }
      .padding(12)
      .width('100%');
    }
    .height('100%');
  }

  // 复制上文所有@Builder方法到此处即可运行
}

五、复杂交互:3种组合手势模式

对于长按拖动、单击双击共存、缩放旋转同时操作等复杂场景,我们需要通过 GestureGroup 组合手势来实现,系统提供3种识别模式。

核心模式对照表

组合模式 核心规则 典型适用场景
Sequence 顺序模式 按注册顺序依次识别,前一个手势成功,后续才会继续识别 长按激活后才能拖动、密码手势绘制
Parallel 并行模式 多个手势同时识别,互不干扰,各自独立触发 图片缩放+旋转同时操作、多指复合手势
Exclusive 互斥模式 多个手势竞争触发,一个手势成功后,其余立刻取消 单击双击共存、滑动与点击互斥

关键补充

  • 互斥模式下,注册顺序决定优先级,写在前面的手势会优先判定
  • 组合手势会作为一个整体,参与外部的手势竞争

1. 顺序组合 Sequence:长按激活后滑动

@Builder sequenceBuilder() {
  Column({ space: 12 }) {
    Text('1. 顺序组合 Sequence:长按激活后滑动')
      .fontSize(16)
      .fontWeight(FontWeight.Medium);
    Text('必须先长按激活,才能拖动组件,直接滑动无效')
      .fontSize(12)
      .fontColor('#999');

    Text(`偏移量:X=${this.seqOffsetX.toFixed(1)} Y=${this.seqOffsetY.toFixed(1)}`);

    Stack() {
      Text('长按滑动')
        .fontColor(Color.White);
    }
    .width(120)
    .height(120)
    .backgroundColor(0x007AFF)
    .borderRadius(12)
    .translate({ x: this.seqOffsetX, y: this.seqOffsetY })
    .gesture(
      GestureGroup(
        GestureMode.Sequence,
        // 第一步:长按激活
        LongPressGesture({ duration: 500 })
          .onAction(() => {
            console.info('长按激活成功');
          }),
        // 第二步:滑动拖动
        PanGesture()
          .onActionUpdate((e: GestureEvent) => {
            this.seqOffsetX = this.seqBaseX + e.offsetX;
            this.seqOffsetY = this.seqBaseY + e.offsetY;
          })
          .onActionEnd(() => {
            this.seqBaseX = this.seqOffsetX;
            this.seqBaseY = this.seqOffsetY;
          })
      )
    );
  }
  .padding(15)
  .backgroundColor(0xF5F5F5)
  .borderRadius(12)
  .width('95%');
}

2. 并行组合 Parallel:缩放+旋转同时操作

@Builder parallelGroupBuilder() {
  Column({ space: 12 }) {
    Text('2. 并行组合 Parallel:缩放+旋转同时操作')
      .fontSize(16)
      .fontWeight(FontWeight.Medium);
    Text('双指可同时进行缩放和旋转,两个手势互不干扰')
      .fontSize(12)
      .fontColor('#999');

    Text(`缩放:${this.paraScale.toFixed(2)}  旋转:${this.paraRotate.toFixed(1)}°`);

    Stack() {
      Text('双指操作')
        .fontColor(Color.White);
    }
    .width(150)
    .height(150)
    .backgroundColor(0x34C759)
    .borderRadius(12)
    .scale({ x: this.paraScale, y: this.paraScale })
    .rotate({ angle: this.paraRotate })
    .gesture(
      GestureGroup(
        GestureMode.Parallel,
        // 缩放手势
        PinchGesture()
          .onActionUpdate((e: GestureEvent) => {
            this.paraScale = this.paraBaseScale * e.scale;
          })
          .onActionEnd(() => {
            this.paraBaseScale = this.paraScale;
          }),
        // 旋转手势
        RotationGesture()
          .onActionUpdate((e: GestureEvent) => {
            this.paraRotate = this.paraBaseRotate + e.angle;
          })
          .onActionEnd(() => {
            this.paraBaseRotate = this.paraRotate;
          })
      )
    );
  }
  .padding(15)
  .backgroundColor(0xF5F5F5)
  .borderRadius(12)
  .width('95%');
}

3. 互斥组合 Exclusive:单击双击互斥

@Builder exclusiveBuilder() {
  Column({ space: 12 }) {
    Text('3. 互斥组合 Exclusive:单击双击只触发一个')
      .fontSize(16)
      .fontWeight(FontWeight.Medium);
    Text('双击时不会触发单击,彻底解决单击延迟问题')
      .fontSize(12)
      .fontColor('#999');

    Text(`单击次数:${this.exclSingleTap}  双击次数:${this.exclDoubleTap}`);

    Button('点击测试')
      .width(140)
      .height(40)
      .gesture(
        GestureGroup(
          GestureMode.Exclusive,
          // 双击写在前面,优先判定
          TapGesture({ count: 2 })
            .onAction(() => {
              this.exclDoubleTap++;
            }),
          // 单击写在后面,双击失败才会触发
          TapGesture({ count: 1 })
            .onAction(() => {
              this.exclSingleTap++;
            })
        )
      );
  }
  .padding(15)
  .backgroundColor(0xF5F5F5)
  .borderRadius(12)
  .width('95%');
}

完整组合手势页面(CombinedGestures.ets)

@Entry
@Component
struct CombinedGestures {
  // 顺序组合状态
  @State seqOffsetX: number = 0;
  @State seqOffsetY: number = 0;
  @State seqBaseX: number = 0;
  @State seqBaseY: number = 0;
  // 并行组合状态
  @State paraScale: number = 1;
  @State paraBaseScale: number = 1;
  @State paraRotate: number = 0;
  @State paraBaseRotate: number = 0;
  // 互斥组合状态
  @State exclSingleTap: number = 0;
  @State exclDoubleTap: number = 0;

  build() {
    Scroll() {
      Column({ space: 16 }) {
        this.sequenceBuilder();
        this.parallelGroupBuilder();
        this.exclusiveBuilder();
      }
      .padding(12)
      .width('100%');
    }
    .height('100%');
  }

  // 复制上文所有@Builder方法到此处即可运行
}

六、实战问题解决:从底层解决手势异常

基于前面的底层机制,我们可以直接定位并解决开发中90%的手势异常问题,核心是「从现象找底层原因,从原因给解决方案」。

常见手势问题与解决方案对照表(不包含组件内置手势)

问题现象 底层原因 解决策略
子组件手势总被父组件抢走 父组件使用了priorityGesture,或父组件手势先满足触发条件 1. 子组件改用priorityGesture提升优先级;2. 调整手势触发阈值,让子组件手势先满足条件
双击时,单击事件也会触发 单击识别速度更快,在双击判定完成前已触发 使用Exclusive互斥组合手势,双击写在前,单击写在后
蒙层盖住了底层组件,底层组件无法点击 蒙层默认HitTestMode.Default 阻塞下层触摸 给蒙层设置hitTestBehavior(HitTestMode.Transparent),实现事件穿透
小按钮很难点击,经常点不中 按钮视觉尺寸小,触摸响应区域不足 responseRegion扩大按钮的响应热区,不改变视觉大小的同时提升点击体验
手势触发后,组件状态卡死 只处理了onActionEnd未处理onActionCancel,事件异常终止时没有重置状态 所有手势的结束逻辑,必须同时在onActionEnd和onActionCancel中处理

七、代码仓库

  • 工程名称:GestureDemo
  • 仓库地址:https://gitee.com/HarmonyOS-UI-Basics/harmony-os-ui-basics.git

八、下节预告

学习手势干预,解决滚动容器吞手势、父子手势抢占、多手势互斥等问题,实现稳定流畅的交互。重点掌握自定义手势判定、手势并行动态控制、阻止手势参与识别。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐