鸿蒙应用开发UI基础第四十节:手势冲突处理
本文摘要: 《鸿蒙应用开发UI基础第四十节》通过三个实战案例深入讲解手势冲突处理方案。案例一使用onGestureJudgeBegin实现区域化手势判定,解决上下层手势冲突;案例二通过shouldBuiltInRecognizerParallelWith和setEnabled实现嵌套滚动联动控制;案例三利用onTouchTestDone和preventBegin阻止组件手势抢占。每个案例包含完整代
·
鸿蒙应用开发UI基础第四十节:手势冲突处理 - 三独立案例实战
【学习目标】
- 掌握自定义手势判定(
onGestureJudgeBegin)解决区域化手势冲突的方法 - 掌握手势并行动态控制(
shouldBuiltInRecognizerParallelWith+setEnabled)解决嵌套滚动冲突 - 掌握阻止手势参与识别(
onTouchTestDone+preventBegin)解决组件手势抢占 - 能够独立分析手势冲突场景,并选择合适的技术方案
一、工程目录结构
GestureDemo/
├── entry/
│ └── src/
│ └── main/
│ ├── ets/
│ │ ├── entryability/
│ │ │ └── EntryAbility.ets
│ │ └── pages/
│ │ ├── Index.ets // 课程导航首页
│ │ ├── Case1_CustomGestureJudge.ets // 案例一:自定义手势判定
│ │ ├── Case2_NestedScroll.ets // 案例二:嵌套滚动联动
│ │ └── Case3_PreventGesture.ets // 案例三:阻止手势识别
│ ├── resources/
│ └── module.json5
└── build-profile.json5
二、案例一:自定义手势判定 - 区域化长按拦截
2.1 场景描述
在一个叠加布局中,上层透明视图绑定了长按手势,下层是可拖拽的图片。需求:长按上半区域时触发上层长按手势,长按下半区域时穿透给下层,触发拖拽操作。
2.2 技术原理
hitTestBehavior(HitTestMode.Transparent):让上层不阻塞下层触摸事件onGestureJudgeBegin:在长按手势即将成功前,根据触摸点Y坐标决定放行(CONTINUE)还是拒绝(REJECT)
2.3 完整代码文件
import { promptAction } from '@kit.ArkUI';
@Entry
@Component
struct Case1_CustomGestureJudge {
@State upperCount: number = 0; // 上半区长按触发次数
@State lowerCount: number = 0; // 下半区拖拽触发次数
build() {
Column() {
// 标题区
this.buildTitle();
// 演示核心区域
Stack({ alignContent: Alignment.Center }) {
// 下层:可拖拽的区域
Column() {
Text('下层拖拽区域')
.fontSize(16)
.fontColor(Color.White)
Text(`拖拽触发次数:${this.lowerCount}`)
.fontSize(12)
.fontColor('#CCCCCC')
.margin({ top: 8 })
}
.width('100%')
.height(300)
.backgroundColor(0x007AFF)
.borderRadius(16)
.draggable(true)
.onDragStart(() => {
this.lowerCount++;
promptAction.showToast({ message: '下层拖拽响应' });
})
// 上层:半透明遮罩,绑定长按手势,并通过判定实现半区拦截
Stack()
.width('100%')
.height(300)
.backgroundColor('rgba(255, 0, 0, 0.15)') // 仅视觉提示
.borderRadius(16)
.hitTestBehavior(HitTestMode.Transparent) // 关键:允许事件穿透
.gesture(
LongPressGesture({ duration: 500 })
.onAction(() => {
this.upperCount++;
promptAction.showToast({ message: '上半区长按触发' });
})
)
.onGestureJudgeBegin((gestureInfo: GestureInfo, event: BaseGestureEvent) => {
// 只处理长按手势
if (gestureInfo.type === GestureControl.GestureType.LONG_PRESS_GESTURE) {
const localY = event.fingerList[0]?.localY ?? 0;
// 上半区(0~150)放行,下半区(150~300)拒绝
if (localY < 150) {
return GestureJudgeResult.CONTINUE;
} else {
return GestureJudgeResult.REJECT;
}
}
return GestureJudgeResult.CONTINUE;
});
}
.width('95%')
.height(300)
.margin({ top: 20 })
// 状态提示
this.buildStatus()
}
.width('100%')
.height('100%')
.padding(12)
}
@Builder
buildTitle() {
Column({ space: 6 }) {
Text('案例一:自定义手势判定')
.fontSize(20)
.fontWeight(FontWeight.Bold)
Text('上半区(红透)长按触发,下半区长按穿透给下层拖拽')
.fontSize(14)
.fontColor('#666')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
@Builder
buildStatus() {
Column({ space: 8 }) {
Text(` 上半区长按触发次数:${this.upperCount}`)
.fontSize(14)
.margin({ top: 20 })
Text(`下半区拖拽触发次数:${this.lowerCount}`)
.fontSize(14)
}
.width('100%')
.padding(12)
.backgroundColor('#F5F5F5')
.borderRadius(12)
.margin({ top: 20 })
}
}
2.4 运行说明
- 长按红色半透明区域的上半部分(Y < 150),会弹出“上半区长按触发”
- 长按下半部分或直接拖拽任意区域,会触发下层拖拽,弹出“下层拖拽响应”
- 上层的长按手势通过
onGestureJudgeBegin实现了半区拦截,不影响下半区的穿透
三、案例二:手势并行动态控制 - 嵌套滚动联动
3.1 场景描述
一个外部Scroll容器,内部嵌套一个Scroll容器。期望:内部滚动到顶部后继续向下拉时,外部Scroll开始滚动;内部滚动到底部后继续向上推时,外部Scroll开始滚动。实现无缝嵌套滚动体验。
3.2 技术原理
shouldBuiltInRecognizerParallelWith:让内外两个Scroll的内置Pan手势建立并行关系onGestureRecognizerJudgeBegin:在手势即将成功前,根据边界状态(isBegin()/isEnd())初始化识别器使能parallelGesture+PanGesture:作为滚动监听器,在滚动过程中动态调用setEnabled()开关识别器
3.3 完整代码文件
@Entry
@Component
struct Case2_NestedScroll {
private outerScroller: Scroller = new Scroller();
private innerScroller: Scroller = new Scroller();
private childRecognizer?: GestureRecognizer;
private currentRecognizer?: GestureRecognizer;
private lastOffsetY: number = 0;
@State scrollState: string = '内部滚动中';
build() {
Column() {
// 标题区域
this.buildTitle();
// 状态条
Text(`当前状态:${this.scrollState}`)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.width('100%')
.padding(12)
.backgroundColor('#007AFF')
.borderRadius(10)
.margin({ bottom: 15 })
// 外部滚动容器
Scroll(this.outerScroller) {
Column({ space: 0 }) {
// 外部顶部
Text('外部区域 - 顶部')
.width('100%')
.height(90)
.backgroundColor('#E8EAF6')
.textAlign(TextAlign.Center)
.fontSize(16)
.fontColor('#3949AB')
.fontWeight(FontWeight.Medium)
.borderRadius(12)
.margin({ top: 15 })
// 内部滚动容器
Scroll(this.innerScroller) {
Column({ space: 0 }) {
Text('内部列表头部')
.width('100%')
.height(50)
.backgroundColor('#3949AB')
.textAlign(TextAlign.Center)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Medium)
ForEach([1, 2, 3, 4, 5, 6, 7, 8,9,10,11], (item: number) => {
Text(`列表项 ${item}`)
.width('100%')
.height(50)
.backgroundColor('#FFFFFF')
.borderRadius(10)
.margin({ top: 8 })
.textAlign(TextAlign.Center)
.fontSize(16)
.shadow({ radius: 2, color: '#00000010' })
})
Text('内部列表底部')
.width('100%')
.height(50)
.backgroundColor('#3949AB')
.textAlign(TextAlign.Center)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Medium)
.margin({ top: 8 })
}
.width('100%')
.padding(10)
}
.id('inner_scroll')
.width('100%')
.height(400)
.backgroundColor('#F5F7FA')
.borderRadius(16)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.None)
.margin({ top: 15 })
.shadow({ radius: 3, color: '#00000010' })
.onWillScroll(() => {
this.scrollState = '内部滚动中';
})
// 外部底部
Text('外部区域 - 底部')
.width('100%')
.height(90)
.backgroundColor('#E8EAF6')
.textAlign(TextAlign.Center)
.fontSize(16)
.fontColor('#3949AB')
.fontWeight(FontWeight.Medium)
.borderRadius(12)
.margin({ top: 15, bottom: 25 })
}
.width('100%')
}
.id('outer_scroll')
.width('100%')
.layoutWeight(1)
.backgroundColor('#F8F9FA')
.borderRadius(16)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.None)
.padding(5)
.shadow({ radius: 4, color: '#00000008' })
// 手势并行配置
.shouldBuiltInRecognizerParallelWith((current: GestureRecognizer, others: Array<GestureRecognizer>) => {
for (let i = 0; i < others.length; i++) {
const target = others[i].getEventTargetInfo();
if (target.getId() === 'inner_scroll' && others[i].isBuiltIn() &&
others[i].getType() === GestureControl.GestureType.PAN_GESTURE) {
this.currentRecognizer = current;
this.childRecognizer = others[i];
return others[i];
}
}
return undefined;
})
.onGestureRecognizerJudgeBegin((event: BaseGestureEvent, current: GestureRecognizer,
others: Array<GestureRecognizer>) => {
const target = current.getEventTargetInfo();
if (target && target.getId() === 'outer_scroll' && current.isBuiltIn() &&
current.getType() === GestureControl.GestureType.PAN_GESTURE) {
for (let i = 0; i < others.length; i++) {
const innerTarget = others[i].getEventTargetInfo() as ScrollableTargetInfo;
if (innerTarget instanceof ScrollableTargetInfo && innerTarget.getId() === 'inner_scroll') {
const panEvent = event as PanGestureEvent;
this.childRecognizer?.setEnabled(true);
this.currentRecognizer?.setEnabled(false);
if (innerTarget.isEnd() && panEvent && panEvent.offsetY < 0) {
this.childRecognizer?.setEnabled(false);
this.currentRecognizer?.setEnabled(true);
this.scrollState = '外部开始滚动';
}
if (innerTarget.isBegin() && panEvent && panEvent.offsetY > 0) {
this.childRecognizer?.setEnabled(false);
this.currentRecognizer?.setEnabled(true);
this.scrollState = '外部开始滚动';
}
break;
}
}
}
return GestureJudgeResult.CONTINUE;
})
.parallelGesture(
PanGesture()
.onActionUpdate((event: GestureEvent) => {
if (!this.childRecognizer || !this.currentRecognizer) return;
if (this.childRecognizer.getState() !== GestureRecognizerState.SUCCESSFUL ||
this.currentRecognizer.getState() !== GestureRecognizerState.SUCCESSFUL) {
return;
}
const innerTarget = this.childRecognizer.getEventTargetInfo() as ScrollableTargetInfo;
const outerTarget = this.currentRecognizer.getEventTargetInfo() as ScrollableTargetInfo;
if (!(innerTarget instanceof ScrollableTargetInfo) ||
!(outerTarget instanceof ScrollableTargetInfo)) return;
const deltaY = event.offsetY - this.lastOffsetY;
if (innerTarget.isEnd() && deltaY < 0) {
this.childRecognizer.setEnabled(false);
this.currentRecognizer.setEnabled(true);
this.scrollState = '已到底部,外部接手';
} else if (innerTarget.isBegin() && deltaY > 0) {
this.childRecognizer.setEnabled(false);
this.currentRecognizer.setEnabled(true);
this.scrollState = '已到顶部,外部接手';
} else {
this.childRecognizer.setEnabled(true);
this.currentRecognizer.setEnabled(false);
if (this.scrollState !== '内部滚动中') this.scrollState = '内部滚动中';
}
this.lastOffsetY = event.offsetY;
})
.onActionEnd(() => {
this.lastOffsetY = 0;
this.childRecognizer?.setEnabled(true);
this.currentRecognizer?.setEnabled(false);
})
)
}
.width('100%')
.height('100%')
.backgroundColor('#F1F3F5')
.padding({ top: 15, left: 12, right: 12, bottom: 12 })
}
@Builder
buildTitle() {
Column({ space: 6 }) {
Text('案例二:嵌套滚动联动')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#212529')
Text('内部滚动到底/顶后,继续滑动会自动滚动外部容器')
.fontSize(14)
.fontColor('#6C757D')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 10 })
}
}
3.4 运行说明
- 正常滑动内部列表,内部滚动正常响应
- 当内部列表滚动到顶部,继续向下拉时,外部Scroll开始向下滚动
- 当内部列表滚动到底部,继续向上推时,外部Scroll开始向上滚动
- 顶部状态栏会实时显示当前滚动状态
四、案例三:阻止手势参与识别 - Slider与父容器手势冲突
4.1 场景描述
一个视频播放器风格的界面,父容器绑定了多种手势:单击(暂停/播放)、双击(全屏)、长按(快进)、水平滑动(调整进度)。底部有一个Slider进度条。需求:拖拽Slider时,父容器的所有手势都不应触发,确保进度条操作流畅。
4.2 技术原理
- 给Slider设置唯一
id - 在Slider的
onTouchTestDone回调中,遍历本次命中测试收集到的所有手势识别器 - 对于
getEventTargetInfo().getId()不等于Slider id的识别器,调用preventBegin()阻止其参与识别 - 这样在触摸测试阶段就屏蔽了父容器的手势,性能最优
4.3 完整代码文件
@Entry
@Component
struct VideoPlayer {
@State progress: number = 30; // 播放进度 0-100
@State brightnessLevel: number = 50; // 亮度 0-100
@State volume: number = 70; // 音量 0-100
@State tapCount: number = 0;
@State doubleTapCount: number = 0;
@State longPressCount: number = 0;
@State isSliderInteracting: boolean = false;
private startX: number = 0;
private startY: number = 0;
build() {
Column() {
Column() {
// 视频画面
Column() {
Text('🎬 视频画面')
.fontSize(18)
.fontColor('#fff')
.margin({ bottom: 8 })
Text('←→亮度 ↑↓音量 单击暂停/播放 双击全屏')
.fontSize(14)
.fontColor('#ccc')
.margin({ bottom: 14 })
Row({ space: 14 }) {
Text(`单击:${this.tapCount}`).fontSize(14).fontColor('#fff')
Text(`双击:${this.doubleTapCount}`).fontSize(14).fontColor('#fff')
Text(`长按:${this.longPressCount}`).fontSize(14).fontColor('#fff')
}
Row({ space: 20 }) {
Text(`亮度 ${this.brightnessLevel}%`).fontSize(14).fontColor('#ffaa00')
Text(`音量 ${this.volume}%`).fontSize(14).fontColor('#00ffaa')
}
.margin({ top: 8 })
}
.width('100%')
.padding(15)
.layoutWeight(1)
// 进度条区域
Column() {
Text(`进度 ${this.progress.toFixed(0)}%`)
.fontSize(14)
.fontColor('#fff')
.margin({ bottom: 4 })
Slider({
value: this.progress,
min: 0,
max: 100,
style: SliderStyle.OutSet
})
.id('progress_slider')
.width('100%')
.trackColor('rgba(255,255,255,0.3)')
.selectedColor('#007AFF')
.blockColor('#fff')
.onChange((value: number, mode: SliderChangeMode) => {
this.progress = value;
if (mode === SliderChangeMode.Begin) {
this.isSliderInteracting = true;
} else if (mode === SliderChangeMode.End) {
this.isSliderInteracting = false;
// 不需要弹窗,已移除
}
})
.onTouchTestDone((_event: BaseGestureEvent, recognizers: Array<GestureRecognizer>) => {
recognizers.forEach(recognizer => {
if (recognizer.getEventTargetInfo().getId() !== 'progress_slider') {
recognizer.preventBegin();
}
});
})
}
.width('100%')
.padding(14)
.backgroundColor('rgba(0,0,0,0.5)')
}
.width('100%')
.height('60%')
.backgroundColor('#1A1A2E')
.borderRadius(16)
.gesture(
GestureGroup(GestureMode.Exclusive,
// 垂直滑动 → 音量(无弹窗,边界安全)
PanGesture({ direction: PanDirection.Vertical, distance: 10 })
.onActionStart((event: GestureEvent) => {
if (this.isSliderInteracting) return;
this.startY = event.fingerList[0].localY;
})
.onActionUpdate((event: GestureEvent) => {
if (this.isSliderInteracting) return;
const newY = event.fingerList[0].localY;
const deltaY = this.startY - newY; // 向上为正,增加音量
if (Math.abs(deltaY) > 5) {
let newVolume = this.volume + deltaY / 3; // 灵敏度降低,更平滑
newVolume = Math.min(100, Math.max(0, newVolume));
this.volume = newVolume;
this.startY = newY; // 重置起点,防止跳跃
}
}),
// 水平滑动 → 亮度
PanGesture({ direction: PanDirection.Horizontal, distance: 10 })
.onActionStart((event: GestureEvent) => {
if (this.isSliderInteracting) return;
this.startX = event.fingerList[0].localX;
})
.onActionUpdate((event: GestureEvent) => {
if (this.isSliderInteracting) return;
const newX = event.fingerList[0].localX;
const deltaX = newX - this.startX; // 向右为正,增加亮度
if (Math.abs(deltaX) > 5) {
let newBrightness = this.brightnessLevel + deltaX / 3;
newBrightness = Math.min(100, Math.max(0, newBrightness));
this.brightnessLevel = newBrightness;
this.startX = newX;
}
}),
// 长按(移除弹窗)
LongPressGesture({ duration: 500 })
.onAction(() => {
if (!this.isSliderInteracting) {
this.longPressCount++;
// 可执行快进等逻辑,但不弹窗
}
}),
// 双击全屏(无弹窗)
TapGesture({ count: 2 })
.onAction(() => {
if (!this.isSliderInteracting) {
this.doubleTapCount++;
// 切换全屏逻辑
}
}),
// 单击暂停/播放(无弹窗)
TapGesture({ count: 1 })
.onAction(() => {
if (!this.isSliderInteracting) {
this.tapCount++;
// 播放/暂停逻辑
}
})
)
)
}
.width('100%')
.height('100%')
.padding(14)
}
}
4.4 运行说明
- 点击、双击、长按、滑动视频播放区域(蓝色区域),会触发对应手势并计数
- 拖拽下方的Slider进度条时,只会触发Slider的进度变化,父容器的任何手势都不会响应
- 松开Slider后,再次点击视频区域,父容器手势恢复正常
五、三个案例对比总结
| 案例 | 核心技术 | 适用场景 | 介入阶段 |
|---|---|---|---|
| 案例一 | onGestureJudgeBegin |
根据坐标/业务状态条件性拦截手势 | 手势即将成功前 |
| 案例二 | shouldBuiltInRecognizerParallelWith + setEnabled |
嵌套滚动、复杂联动交互 | 手势识别过程中动态开关 |
| 案例三 | onTouchTestDone + preventBegin |
明确的手势隔离(如Slider与父容器手势) | 触摸测试阶段(最早) |
六、代码仓库
- 工程名称:GestureDemo
- 仓库地址:https://gitee.com/HarmonyOS-UI-Basics/harmony-os-ui-basics.git
七、下节预告
在之前的章节中我们充分学习使用了V1版本装饰器,下一节我们将正式进入状态管理 V2 的学习。本节将系统讲解:
- V2 装饰器家族(
@Local、@Param、@Event、@Provider/@Consumer等)的设计理念与使用场景 - 对比 V1 的
@State、@Prop、@Link,深入理解 V2 如何解决单向数据流、组件封装、性能优化等痛点 - 通过“计数器”、“父子组件通信”、“跨层级共享状态”等实战案例,快速上手状态管理 V2。
更多推荐




所有评论(0)