鸿蒙应用开发UI基础第三十九节:触摸事件与手势交互全解 - 从基础解析到实战演示
本文摘要: 本文系统讲解了鸿蒙应用开发中的手势交互机制,主要内容包括: 触摸事件底层原理,解析Down/Move/Up/Cancel事件流及其核心属性 事件分发与冒泡机制,演示onTouch回调与stopPropagation拦截方法 6种基础手势的使用场景和触发规则 3种手势绑定方式的优先级差异及冲突解决方案 组合手势实现复杂交互的方法 手势异常问题的排查思路 文章通过TouchBaseDemo
【学习目标】
- 吃透触摸事件的底层逻辑,理解手势交互的本质是对触摸事件流的规则化识别
- 掌握事件分发、命中测试、响应链、冒泡拦截的核心机制,从根源理清事件流向
- 熟练使用鸿蒙6种基础手势,明确每种手势的触发规则、核心参数与适用场景
- 掌握3种手势绑定方式的优先级与行为差异,彻底解决父子组件手势冲突问题
- 运用3种组合手势模式,实现长按拖动、单击双击共存等复杂业务交互
- 能基于底层机制,定位并解决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 必掌握属性
我们只需要掌握开发中高频使用的核心属性:
- 事件状态:
event.type→ 获取当前事件类型(Down/Move/Up/Cancel) - 触点信息:
event.touches→ 屏幕上所有触摸点的数组(多指操作必备),每个触点包含:x/y:触摸点相对于当前组件的坐标(最常用,手势计算核心)windowX/windowY:触摸点相对于应用窗口的坐标id:手指唯一标识,多指操作时区分不同手指
- 冒泡控制:
event.stopPropagation()→ 阻止事件向上冒泡给父组件 - 高精度数据:
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:触发所需的按压时长,默认500msrepeat:是否重复触发,默认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:触发所需的手指数量,默认2angle:触发旋转的最小角度,默认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() |
低 | 父子组件同时响应(并行冒泡) | 埋点统计、父子联动交互 |
关键补充说明
priorityGesture()可传入第二个参数GestureMask精细化控制:GestureMask.Normal:仅屏蔽子组件上的同类型手势GestureMask.IgnoreInternal:完全屏蔽子组件上的所有手势
- 不同类型的手势,不受绑定方式优先级影响,谁先满足触发条件,谁先响应
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
八、下节预告
学习手势干预,解决滚动容器吞手势、父子手势抢占、多手势互斥等问题,实现稳定流畅的交互。重点掌握自定义手势判定、手势并行动态控制、阻止手势参与识别。
更多推荐



所有评论(0)