概述
在复杂的应用界面中,多个组件嵌套时同时绑定手势事件,或者同一个组件同时绑定多个手势,都有可能导致手势事件产生冲突,达不到用户的预期效果。
本文从事件响应的机制入手,介绍手势触发的基本流程,以及如何响应手势事件,了解背后的执行原理,并用来解决冲突问题等。主要包括以下内容:
- 事件响应链收集
- 手势响应优先级
- 手势响应控制
- 常见手势冲突问题
事件响应链收集
在HarmonyOS开发中,[触摸事件](onTouch事件)是用户与设备交互的基础,是所有手势事件组成的基础,有Down,Move,Up,Cancel四种[触摸事件的类型]。手势均由触摸事件组成,例如,点击为Down+Up,滑动为Down+一系列Move+Up。
触摸事件的分发由[触摸测试](TouchTest)结果决定,其结果会直接决定哪些控件的事件加入事件响应链(事件响应成员组成的链表),并最终按照响应链顺序判定是否消费。因此了解触摸事件的响应链收集过程,有助于开发者处理手势事件冲突问题。
ArkUI事件响应链收集,根据右子树(按组件布局的先后层级)优先的后序遍历流程。下面通过一个示例来介绍响应链收集的流程,示例伪代码如下:
build() {
StackA() {
ComponentB() {
ComponentC()
}
ComponentD() {
ComponentE()
}
}
}
其中A是最外层组件,B和D是A的子组件,C是B的子组件,E是D的子组件。界面效果示例以及组件树结构图如下:
用户触摸的动作如果发生在组件C上,事件响应链收集的流程如下,根据右子树(按组件布局的先后层级)优先的后序遍历流程,因为触摸点不在右边的树上,所以事件会从左边树的C节点开始往上传,触摸事件(onTouch事件)是冒泡事件默认会向上一直传递下去,直到被消费或者丢弃,允许多个组件同时触发。最终收集到的响应链是C->B->A。
用户触摸的动作如果发生在组件E上,事件响应链收集的流程如下,根据右子树优先的后序遍历流程,所以事件会从右边树的D节点开始往上传。虽然触摸点在组件D和组件B的交集上,但组件D的[hitTestBehavior]属性默认为HitTestMode.Default,D组件收集到事件后会阻塞兄弟节点(组件B),所以没有收集组件A的左子树,最终收集到的响应链是E->D->A。
上面介绍的事件响应链是系统默认的行为,如果需要改变响应的成员,比如触摸组件E的时候,希望把事件传递给B,该怎么实现呢?开发者可以通过设置D组件的hitTestMode属性为HitTestMode.None或者HitTestMode.Transparent来实现,比如设置为HitTestMode.Transparent,那么组件D自身进行触摸测试,同时不阻塞兄弟及父组件。最终收集到的响应链是E->D->B->A。
又例如触摸E组件的时候,只希望E响应触摸事件,不让其它组件响应触摸事件。可以通过[stopPropagation]来阻止事件冒泡,阻止触摸事件往上传递;也可以通过设置E组件的hitTestMode属性为HitTestMode.Block来实现,那么最终收集到的响应链成员只有组件E。
除了hitTestMode和stopPropagation,影响事件响应链的更多因素可以参考:[触摸测试控制]
手势响应
前面根据事件响应链收集,确定了响应链成员和事件响应的顺序。然而往往在处理一些业务的时候,需要给组件/不同组件添加更多的手势和事件,比如onClick、API手势gesture 等等,那么哪个事件会得到响应呢?这就需要了解手势响应的优先级了,本节将主要介绍手势的优先级和手势的控制。
手势响应优先级
手势按是否为系统内置手势,可以分为以下两类:
- 系统手势:系统控件默认实现的手势(系统内置手势),即调用某些通用事件内置的手势,比如拖拽,onClick;比如bindmenu内置的点击事件,bindcontextmenu内置的长按手势。
- 自定义手势:通过绑定手势API,例如使用gesture声明的事件回调,绑定长按手势事件方法。
除了触摸事件(onTouch事件)外的所有手势与事件,均是通过基础手势或者组合手势实现的。例如,拖拽事件是由长按手势和滑动手势组成的一个顺序手势。
在默认情况下,这些手势为非冒泡事件,当父组件和子组件绑定同类型的手势时,父子组件绑定的手势事件会发生竞争,子组件会优先识别绑定的手势。
因此,除非显式声明允许多个手势同时成功,否则同一时间只会有一个手势响应。
- 当父子组件均绑定同一类手势时,子组件优先于父组件触发。
- 当同一个组件同时绑定多个手势时,先达到手势触发条件的手势优先触发。
- 当同一个组件绑定相同事件类型的系统手势和自定义手势时,系统手势会优先响应。比如自定义手势TapGesture和系统手势onClick都是单击事件,但是会优先响应onClick事件。
图1 手势响应优先级(从左至右,优先级由高到低)
手势响应控制
上面介绍了手势默认的优先级顺序,在父子组件嵌套时,父子组件均绑定了手势或事件,或者同一个组件同时绑定多个手势时,根据业务逻辑可能需要对手势是否需要响应、分发给谁响应、响应的顺序等做出控制。那么有哪些控制手段呢?下面列举了一些手势响应的控制方法。
1 手势绑定
[绑定手势方法]
设置绑定手势的方法可以实现在多层级场景下,当父组件与子组件绑定了相同的手势时,设置不同的绑定手势方法有不同的响应优先级。手势绑定支持常规手势绑定方法(gesture)、带优先级手势绑定方法(priorityGesture)、并行手势绑定方法(parallelGesture)。
绑定手势方法 | 功能规格 | 配参1 | 配参2 | 约束 |
---|---|---|---|---|
gesture | 绑定手势事件,父子组件交叠区域均绑定,响应子组件 | GestureType | GestureMask | 与通用事件抢占 |
priorityGesture | 当父组件配置priorityGesture时,优先识别父组件priorityGesture绑定的手势。 | GestureType | GestureMask | 与通用事件抢占 |
parallelGesture | 父组件绑定parallelGesture时,父子组件相同的手势事件都可以触发 | GestureType | GestureMask | 无 |
前面讲到的手势的优先级是默认的,在加入了priorityGesture和parallelGesture绑定方法后,手势的响应顺序如下图所示:
图2 手势响应优先级(从左至右,优先级由高到低)
GestureMask枚举说明
名称 | 描述 |
---|---|
Normal | 不屏蔽子组件的手势,按照默认手势识别顺序进行识别。 |
IgnoreInternal | 屏蔽子组件的手势,包括子组件上的系统内置的手势,如子组件为List组件时,内置的滑动手势同样会被屏蔽。 若父子组件区域存在部分重叠,则只会屏蔽父子组件重叠的部分。 |
不同手势绑定配参方案规格
父手势 | 子手势 | GestureMask(父) | 交叠区域相同事件响应方 | 交叠区域不同事件响应方 |
---|---|---|---|---|
gesture | gesture | default | 子组件 | 各自响应 |
gesture | gesture | IgnoreInternal | 父组件 | 父组件 |
priorityGesture | gesture | default | 父组件 | 各自响应 |
priorityGesture | gesture | IgnoreInternal | 父组件 | 父组件 |
parallelGesture | gesture | default | 各自响应 | 各自响应 |
parallelGesture | gesture | IgnoreInternal | 父组件 | 父组件 |
[组合手势](GestureGroup)
手势组合是指多种手势组合为复合手势,通过GestureGroup属性,可以给同一个组件添加多个手势,支持连续识别、并行识别和互斥识别模式。开发者可以根据业务需求,选择合适的组合模式。
接口 | 可选模式 | 描述 | 注册事件 |
---|---|---|---|
GestureGroup | Sequence | 手势顺序队列,需要按预定的手势组顺序执行,有一个失败则全部失败 | onCancel |
GestureGroup | Parallel | 手势组合,直到所有已识别的手势执行完 | 无 |
GestureGroup | Exclusive | 互斥识别,成功完成一个手势,则完成手势任务 | 无 |
2 [独占事件控制]
通过monopolizeEvents属性设置组件是否独占事件,事件范围包括组件自带的事件和开发者自定义的点击、触摸、手势事件。先响应事件的控件作为第一响应者,在手指离开屏幕前其他组件不会响应任何事件。
在一个窗口内,设置了独占控制的组件上的事件如果首先响应,则本次交互只允许此组件上设置的事件响应,窗口内其他组件上的事件不会响应。
如果开发者通过[parallelGesture]绑定了与子组件同时触发的手势,如[PanGesture],子组件设置了独占控制且首个响应事件,则父组件的手势不会响应。
3 [自定义手势判定]
为组件提供自定义手势判定能力。开发者可根据需要,在手势识别期间,根据自己的业务逻辑来决定是否响应手势。使用[onGestureJudgeBegin]方法对手势进行判定,开发者可以根据自身业务逻辑,选择是否响应自定义手势。
4 [手势拦截增强]
为组件提供手势拦截能力。开发者可根据需要,将系统内置手势和响应链上更高优先级的手势做并行化处理,并可以动态控制手势事件的触发。
5 [responseRegion]和[hitTestBehavior]
[影响触摸测试的因素]同样也可能会影响到手势的响应流程。例如responseRegion属性和hitTestBehavior属性可以控制Touch事件的分发,从而可以影响到onTouch事件和手势的响应。而绑定手势方法属性可以控制手势的竞争从而影响手势的响应,但不会影响到onTouch事件。
6 ArkUI组件自身的属性控制手势响应
ArkUI组件自身的属性,也可以对手势事件的响应做出控制。例如Grid、List、Scroll、Swiper、WaterFlow等滚动容器组件提供了nestedScroll属性,来解决和父组件的嵌套滚动的冲突问题;例如Swiper组件的[disableSwipe]可以设置禁用组件滑动切换的功能;又例如List组件可以通过设置[enableScrollInteraction]属性来设置是否支持手势滚动列表。
常见手势冲突问题
前面列举了一些常用的手势响应的控制方法,接下来我们通过这些方法来解决以下一些常见的手势响应冲突问题。
滚动容器嵌套滚动容器事件冲突
1 Scroll组件嵌套List组件滑动事件冲突
Scroll组件嵌套List组件,子组件List组件的滑动手势优先级高于父组件Scroll的滑动手势,所以当List列表滚动时,不会响应Scroll组件的滚动事件,List不会和Scroll一起滚动。如果需要List和Scroll组件同步滚动可以使用nestedScroll属性来解决,设置向前向后两个方向上的嵌套滚动模式,实现与父组件的滚动联动。
使用nestedScroll属性设置List组件的嵌套滚动方式,NestedScrollMode设置成SELF_FIRST时,List组件滚动到页面边缘后,父组件继续滚动。NestedScrollMode设置为PARENT_FIRST时,父组件先滚动,滚动至边缘后通知List组件继续滚动。示例代码如下:
@Entry
@Component
struct GesturesConflictScene1 {
build() {
Scroll() {
Column() {
Column()
.height('30%')
.width('100%')
.backgroundColor(Color.Blue)
List() {
ForEach([1, 2, 3, 4, 5, 6], (item: string) => {
ListItem() {
Text(item.toString())
.height(300)
.fontSize(50)
.fontWeight(FontWeight.Bold)
}
}, (item: number) => item.toString())
}
.edgeEffect(EdgeEffect.None)
.nestedScroll({
scrollForward: NestedScrollMode.PARENT_FIRST,
scrollBackward: NestedScrollMode.SELF_FIRST
})
.height('100%')
.width('100%')
}
}
.height('100%')
.width('100%')
}
}
2 List、Scroller等滚动容器嵌套Web组件,滑动事件冲突
比如List组件嵌套Web组件,当Web加载的网页中也包含滚动视图的时候,这时候上下滚动Web组件,不能和List列表整体一起滑动。这是因为Web的滑动事件和List组件的冲突,如果想让Web随List一起整体滚动,解决方案和前面的例子一样,给Web组件添加nestedScroll属性。
Web(...)
.nestedScroll({
scrollForward: NestedScrollMode.PARENT_FIRST,
scrollBackward: NestedScrollMode.SELF_FIRST
})
使用组合手势同时绑定多个同类型手势冲突
例如给组件同时设置单击和双击的点击手势TapGesture,按如下方式设置发现双击手势失效了,这是因为在互斥识别的组合手势中,手势会按声明的顺序进行识别,若有一个手势识别成功,则结束手势识别。因为单击手势放在了前面,所以当双击的时候会优先识别了单击手势,单击成功后后面的双击回调就不会执行了。
@Entry
@Component
struct GesturesConflictScene2 {
@State count1: number = 0;
@State count2: number = 0;
build() {
Column() {
Text('Exclusive gesture\n' + 'tapGesture count is 1:' + this.count1 + '\ntapGesture count is 2:' + this.count2 +
'\n')
.fontSize(28)
}
.height(200)
.width('100%')
//以下组合手势为互斥并别,单击手势识别成功后,双击手势会识别失败
.gesture(
GestureGroup(GestureMode.Exclusive,
TapGesture({ count: 1 })
.onAction(() => {
this.count1++;
}),
TapGesture({ count: 2 })
.onAction(() => {
this.count2++;
})
)
)
}
}
可以调整下手势的声明顺序来使双击生效:
.gesture(
GestureGroup(GestureMode.Exclusive,
TapGesture({ count: 2 })
.onAction(() => {
this.count2++;
}),
TapGesture({ count: 1 })
.onAction(() => {
this.count1++;
})
)
)
系统手势和自定义手势之间冲突
对于一般同类型的手势,系统手势优先于自定义手势执行,可以通过priorityGesture或者parallelGesture的方式来绑定自定义手势 , 例如下面这个示例 :
图片长按手势响应失败或冲突,在Image控件上添加长按手势后,长按图片无法响应对应方法,而是图片放大的动画,示例代码如下:
import { promptAction } from '@kit.ArkUI';
@Entry
@Component
struct GesturesConflictScene3 {
@State message: string = 'Hello World';
build() {
Row() {
Column() {
Text(this.message)
.fontSize(50)
.fontWeight(FontWeight.Bold)
Image($r('app.media.startIcon'))
.margin({ top: 100 })
.width(360)
.height(360)
.gesture(
LongPressGesture({ repeat: true })
.onAction((event: GestureEvent) => {
})// 长按动作一结束触发
.onActionEnd(() => {
promptAction.showToast({ message: 'Long Press' });
})
)
}
.width('100%')
}
.height('100%')
}
}
这是因为Image组件内置的长按动画和用户自定义的长按手势LongPressGesture冲突了。可以使用priorityGesture绑定手势的方式替代gesture的方式,这样就会只响应自定义手势LongPressGesture了。如果需要两者都执行可以使用parallelGesture的绑定方式。
.priorityGesture(
LongPressGesture({ repeat: true })
.onAction((event: GestureEvent) => {
})// 长按动作一结束触发
.onActionEnd(() => {
promptAction.showToast({ message: 'Long Press' });
})
)
手势事件透传
和触摸事件一样,手势事件也可以通过hitTestBehavior属性来进行透传,例如下面这个示例,上层的Column组件设置hitTestBehavior属性为hitTestMode.none后,可以将滑动手势SwipeGesture透传给被覆盖的Column组件。hitTestMode.none:自身不接收事件,但不会阻塞兄弟组件和子组件继续做触摸测试。
@Entry
@Component
struct GesturesConflictScene4 {
build() {
Stack() {
Column()//底层的Column
.width('100%')
.height('100%')
.backgroundColor(Color.Black)
.gesture(
SwipeGesture({ direction: SwipeDirection.Horizontal })//水平方向滑动手势
.onAction((event) => {
if (event) {
console.info('Column SwipeGesture');
}
})
)
Column()//上层的Column
.width(300)
.height(100)
.backgroundColor(Color.Red)
.hitTestBehavior(HitTestMode.None)
}
.width(300)
.height(300)
}
}
多点触控场景下手势冲突
当一个页面中有多个组件可以响应手势事件,在多个手指触控的情况下,多个组件可能会同时响应手势事件,从而导致业务异常。ArkUI提供了手势独占的属性[monopolizeEvents],设置需要单独响应事件的组件的monopolizeEvents属性为true,可以解决这一问题。
例如下面这个示例,给按钮Button1设置了.monopolizeEvents(true)之后,当手指首先触摸在Button1之后,在手指离开之前,其它组件的手势和事件都不会触发。
@Entry
@Component
struct GesturesConflictScene5 {
@State message: string = 'Hello World';
build() {
Column() {
Row({ space: 20 }) {
Button('Button1')
.width(100)
.height(40)
.monopolizeEvents(true)
Button('Button2')
.width(200)
.height(50)
.onClick(() => {
console.info('GesturesConflictScene5 Button2 click');
})
}
.margin(20)
Text(this.message)
.margin(15)
}
.width('100%')
.onDragStart(() => {
console.info('GesturesConflictScene5 Drag start.');
})
.gesture(
TapGesture({ count: 1 })
.onAction(() => {
console.info('GesturesConflictScene5 TapGesture onAction.');
}),
)
}
}
动态控制自定义手势是否响应
在手势识别期间,开发者决定是否响应手势,例如下面的示例代码,通过[onGestureJudgeBegin]回调方法在手势识别期间进行判定,当手势为GestureType.DRAG的时候,不响应该手势,所以会使定义的onDragStart事件失效。
@Entry
@Component
struct GesturesConflictScene6 {
@State message: string = 'Hello World';
build() {
Column()
.width('100%')
.height(200)
.backgroundColor(Color.Brown)
.onDragStart(() => {
console.info('GesturesConflictScene6 Drag start.');
})
.gesture(
TapGesture({ count: 1 })
.tag('tap1')
.onAction(() => {
console.info('GesturesConflictScene6 TapGesture onAction.');
}),
)
.onGestureJudgeBegin((gestureInfo: GestureInfo, event: BaseGestureEvent) => {
if (gestureInfo.type === GestureControl.GestureType.LONG_PRESS_GESTURE) {
let longPressEvent = event as LongPressGestureEvent;
console.info('GesturesConflictScene6: ' + longPressEvent.repeat);
}
if (gestureInfo.type === GestureControl.GestureType.DRAG) {
// 返回 REJECT 会使拖动手势失败
return GestureJudgeResult.REJECT;
} else if (gestureInfo.tag === 'tap1' && event.pressure > 10) {
return GestureJudgeResult.CONTINUE
}
return GestureJudgeResult.CONTINUE;
})
}
}
父组件如何管理子组件手势
父子组件嵌套滚动发生手势冲突,父组件有机制可以干预子组件的手势响应。下面例子介绍了如何使用[手势拦截增强],在外层Scroll组件的[shouldBuiltInRecognizerParallelWith]和[onGestureRecognizerJudgeBegin]回调中,动态控制内外层Scroll手势事件的滚动。
1 首先在父组件Scroll的shouldBuiltInRecognizerParallelWith方法中收集需做并行处理的手势。下面示例代码中收集到了子组件的手势识别器childRecognizer,使其和父组件的手势识别器currentRecognizer并行处理。
2 调用onGestureRecognizerJudgeBegin方法,判断滚动组件是否滑动划到顶部或者底部,做业务逻辑处理,通过动态控制手势识别器是否可用,来决定并行处理器的childRecognizer和currentRecognizer是否可用。
@Entry
@Component
struct GesturesConflictScene7 {
scroller: Scroller = new Scroller();
scroller2: Scroller = new Scroller();
private arr: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
private childRecognizer: GestureRecognizer = new GestureRecognizer();
private currentRecognizer: GestureRecognizer = new GestureRecognizer();
build() {
Stack({ alignContent: Alignment.TopStart }) {
Scroll(this.scroller) { // 外部滚动容器
Column() {
Text('Scroll Area')
.width('100%')
.height(150)
.backgroundColor(0xFFFFFF)
.borderRadius(15)
.fontSize(16)
.textAlign(TextAlign.Center)
.margin({ top: 10 })
Scroll(this.scroller2) { // 内部滚动容器
Column() {
Text('Scroll Area2')
.width('100%')
.height(150)
.backgroundColor(0xFFFFFF)
.borderRadius(15)
.fontSize(16)
.textAlign(TextAlign.Center)
.margin({ top: 10 })
Column() {
ForEach(this.arr, (item: number) => {
Text(item.toString())
.width('100%')
.height(200)
.backgroundColor(0xFFFFFF)
.borderRadius(15)
.fontSize(20)
.textAlign(TextAlign.Center)
.margin({ top: 10 })
}, (item: string) => item)
}
.width('100%')
}
}
.id('innerScroll')
.scrollBar(BarState.Off) // 滚动条常驻显示
.width('100%')
.height(800)
}.width('100%')
}
.id('outerScroll')
.height(600)
.scrollBar(BarState.Off) // 滚动条常驻显示
.shouldBuiltInRecognizerParallelWith((current: GestureRecognizer, others: Array<GestureRecognizer>) => {
for (let i = 0; i < others.length; i++) {
let target = others[i].getEventTargetInfo();
if (target) {
if (target.getId() === 'innerScroll' && 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>) => { // 在识别器即将要成功时,根据当前组件状态,设置识别器使能状态
if (current) {
let target = current.getEventTargetInfo();
if (target) {
if (target.getId() === 'outerScroll' && current.isBuiltIn() &&
current.getType() === GestureControl.GestureType.PAN_GESTURE) {
if (others) {
for (let i = 0; i < others.length; i++) {
let target = others[i].getEventTargetInfo() as ScrollableTargetInfo;
if (target instanceof ScrollableTargetInfo && target.getId() == 'innerScroll') { // 找到响应链上对应并行的识别器
let panEvent = event as PanGestureEvent;
if (target.isEnd()) { // isEnd返回当前滚动类容器组件是否在底部 根据当前组件状态以及移动方向动态控制识别器使能状态
if (panEvent && panEvent.offsetY < 0) {
this.childRecognizer.setEnabled(false) // 到底上拉
this.currentRecognizer.setEnabled(true)
} else {
this.childRecognizer.setEnabled(true)
this.currentRecognizer.setEnabled(false)
}
} else if (target.isBegin()) {
if (panEvent.offsetY > 0) { // 开始的时候下拉
this.childRecognizer.setEnabled(false)
this.currentRecognizer.setEnabled(true)
} else {
this.childRecognizer.setEnabled(true)
this.currentRecognizer.setEnabled(false)
}
} else {
this.childRecognizer.setEnabled(true)
this.currentRecognizer.setEnabled(false)
}
}
}
}
}
}
}
return GestureJudgeResult.CONTINUE;
})
}
.width('100%')
.height('100%')
.backgroundColor(0xF1F3F5)
.padding(12)
}
}
总结
手势冲突在界面开发中往往不可避免,特别是在复杂的应用界面中。针对不同的冲突场景和手势交互需求,需要选择合适的解决方案。可以参考前面介绍的影响触摸测试因素,以及手势响应控制里面的方法,进行尝试。