HarmonyOS ArkUI交互事件与手势处理全解析:从基础到高级实践
文章目录
- 一、交互事件
- 1.1 通用事件
- 1.1.1 事件分发
- 1.1.1.1 触摸测试
- 1. 触摸测试基本流程
- 2. 触摸测试控制
- 3. 自定义事件拦截
- 4. 禁用控制
- 5. 触摸热区设置
- 6. 安全组件
- 1.1.1.2 事件响应链收集
- 1.1.2 触屏事件
- 1.1.3 键鼠事件
- 1.1.3.1 鼠标事件
- 1.1.3.2 按键事件
- 1.1.4 焦点事件
- 1.1.5 拖拽事件
- 1.2 使用手势事件
- 1.2.1 绑定手势方法
- 1.2.2 单一手势
- 1.2.3 组合手势
- 1.2.4 多层手势事件
- 1.2.4.1 默认多层级手势事件
- 1.2.4 .2 自定义控制的多层级手势事件
- 1.2.5 手势拦截
- 二、手势事件冲突
- 2.1 手势响应
- 2.1.1 手势响应优先级
- 2.1.2 手势响应控制
- 2.2 总结
一、交互事件
1.1 通用事件
1.1.1 事件分发
事件分发是指ArkUI收到用户操作生成的触控事件,通过触摸测试,将触控事件分发至哥哥组件形成事件的过程。
触控事件是触摸测试的输入,根据用户操作方式的不同,可以划分为Touch类触控事件和Mouse类触控事件。
- Touch类触控事件指触摸生成的触控事件,输入源包含: finger(手指在屏幕上的滑动)、pen(手写笔在屏幕滑动)、mouse(鼠标操作)、touchpad(触控板操作),可以触发触摸事件、点击事件、拖拽事件和手势事件。
- Mouse类触控事件是指鼠标操作生成的触控事件,输入源包含:mouse(鼠标操作)、touchpad(触控板操作)、joystick(手柄操作),可以触发触摸事件、点击事件、拖拽事件、手势事件和鼠标事件。
不论是Touch类触控事件还是Mouse类触控事件,最后触发的事件均是通过触摸测试决定最终分发到的组件。触摸测试决定了ArkUI事件响应链生成、触控事件分发以及组件绑定事件的触发。
1.1.1.1 触摸测试
触摸测试是指当ArkUI收到了Touch类触控事件或者Mouse类触控事件的起始事件,基于所收到的事件的坐标,进行组件响应区域的测试判定并收集事件响应链的过程。
设置一下属性影响触摸测试流程:
- hitTestBehavior:触摸测试控制
- interceptTouch:事件自定义拦截
- responseRegion:触摸热区设置
- enabled:禁用控制
- 安全控件:HarmonyOS ArkUI安全控件开发指南:粘贴、保存与位置控件的实现与隐私保护实践
- 其他属性设置:透明度/组件下线
1. 触摸测试基本流程
接收到起始事件后,系统自上而下、自右向左的遍历组件树,收集每个组件上绑定的手势和事件,然后将这些信息逐级向上冒泡至父组件进行整合,最终构建完整的事件响应链。
2. 触摸测试控制
在组件上绑定触摸测试控制时,可能会影响到兄弟节点以及父子节点的触摸测试。子组件对父组件的触摸测试影响程度,取决于最后一个未被阻塞触摸测试的子组件状态。
可以通过配置触摸测试控制,来实现阻塞组件自身或其他组件的触摸测试。
-
HitTestMode.Default:默认不配hitTestBehavior属性的效果,自身如果命中会阻塞兄弟组件,但是不阻塞子组件。
-
HitTestMode.None:自身不接受事件,但不会阻塞兄弟组件/子组件继续做触摸测试
-
HitTestMode.Block:阻塞子组件的触摸测试,如果自身触摸测试命中,会阻塞兄弟组件及父组件的触摸测试。
-
HitTestMode.Transparent:自身进行触摸测试,同时不阻塞兄弟组件及父组件。
3. 自定义事件拦截
onTouchIntercept(callback: Callback<TouchEvent, HitTestMode>);
当用户执行按下操作时,将触发组件上绑定的自定义事件拦截的回调。开发者可根据应用状态,动态调整组件的hitTestBehavior属性,进而影响触控测试的流程。
参数名 | 参数类型 | 必填 | 参数描述 |
---|---|---|---|
callback | Callback<TouchEvent,HitTestMode> | 是 | 给组件绑定自定义事件拦截回调,并使能在做触摸测试时回调此函数。 |
示例
@Entry
@Component
struct Index {isPolygon(event: TouchEvent) {return true;}build(){Row(){Column(){Text("hello world").backgroundColor(Color.Blue).fontSize(50).fontWeight(FontWeight.Bold).onClick(()=>{console.log("Text click");})}.width(400).height(300).backgroundColor(Color.Pink).onClick(()=>{console.log("Column click");})// 调用onTouchIntercept修改该组件的HitTestMode属性.onTouchIntercept((event : TouchEvent) => {console.log("OnTouchIntercept + " + JSON.stringify(event));if (this.isPolygon(event)) {return HitTestMode.None}return HitTestMode.Default})}.width('100%')}
}
4. 禁用控制
enabled(value: boolean);
设置了禁用控制的组件,组件自身和其子组件不会发起触摸测试过程,会直接返回组件的父组件继续触摸测试
参数名 | 类型 | 必填 | 说明 |
---|---|---|---|
value | boolean | 是 | 值为true表示组件可交互,响应点击等操作。值为false表示组件不可交互,不响应点击等操作。默认值:true |
示例
@Entry
@Component
struct EnabledExample {build() {Flex({ justifyContent: FlexAlign.SpaceAround }) {// 点击没有反应Button('disable').enabled(false).backgroundColor(0x317aff).opacity(0.4)Button('enable').backgroundColor(0x317aff)}.width('100%').padding({ top: 5 })}
}
5. 触摸热区设置
responseRegion(value: Array<Rectangle> | Rectangle);
设置一个或多个触摸热区。
触摸热区设置会影响触屏/鼠标类的触摸测试。根据触摸测试的基本流程,仅当事件的坐标命中组件的触摸热区时,该组件绑定的手势和事件才会被收集并进入事件响应链。开发者可以通过调整组建的触摸热区来控制触摸测试流程。若触摸热区被设置为0,或定义为不可触控区域,事件将直接回传给父节点,以进行后续的触摸测试。
参数名 | 类型 | 必填 | 说明 |
---|---|---|---|
value | Array<Rectangle]> | Rectangle | 是 | 设置一个或多个触摸热区,包括位置和大小。默认触摸热区为整个组件,默认值:{x:0,y:0,width:‘100%’,height:‘100%’} |
@Entry
@Component
struct TouchTargetExample {@State text: string = ""build() {Column({ space: 20 }) {Text("{x:0,y:0,width:'50%',height:'100%'}")// 热区宽度为按钮的一半,点击右侧无响应Button("button1").responseRegion({ x: 0, y: 0, width: '50%', height: '100%' }).onClick(() => {this.text = 'button1 clicked'})// 为一个组件添加多个热区Text("[{x:'100%',y:0,width:'50%',height:'100%'}," +"\n{ x: 0, y: 0, width: '50%', height: '100%' }]")Button("button2").responseRegion([{ x: '100%', y: 0, width: '50%', height: '100%' }, // 第一个热区宽度为按钮的一半,点击按钮右侧宽度一半区域,点击事件生效{ x: 0, y: 0, width: '50%', height: '100%' } // 第二个热区宽度为按钮的一半,点击button2左半边,点击事件生效]).onClick(() => {this.text = 'button2 clicked'})// 热区大小为整个按钮,且下移一个按钮高度,点击button3下方按钮大小区域,点击事件生效Text("{x:0,y:'100%',width:'100%',height:'100%'}")Button("button3").responseRegion({ x: 0, y: '100%', width: '100%', height: '100%' }).onClick(() => {this.text = 'button3 clicked'})Text(this.text).margin({ top: 50 })}.width('100%').margin({ top: 10 })}
}
6. 安全组件
安全组件当前对触摸测试影响:如果有组件的z序比安全组件的z序靠前,且遮盖安全组件,则安全组件事件直接返回到父节点继续触摸测试。
1.1.1.2 事件响应链收集
在HarmonyOS开发中,触摸事件时用户与设备交互的基础,时所有手势事件组成的基础,触摸事件的分发由触摸测试结果决定,其结果会直接决定哪些空间的事件加入事件响应链,并在最终按照响应链顺序判定是否消费。
AekUI事件响应链收集,根据右子树优先的后续遍历流程
build() {StackA() {ComponentB() {ComponentC()}ComponentD() {ComponentE()}}
}
其中A是最外层组件,B和D是A的子组件,C是B的子组件,E是D的子组件。
用户触摸的动作发生在组件C上,事件响应链的收集流程如下,根据右子树优先的后续遍历流程,因为触摸点不在右边的树上,所以事件会从左边树的C节点开始往上传,触摸事件是冒泡事件默认会向上一直传递下去,知道被消费或者丢弃,允许多个组件同时触发。
用户触摸的动作发生在组件E上,事件响应链收集的流程如下,根据右子树的优先的后续遍历流程,所以时间会从右边树的D节点开始往上传。虽然触摸点在组件D和B的交集上,但组件D的hitTestBehavior属性默认为HitTestMode.Default,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
1.1.2 触屏事件
触屏事件指当手指/手写笔在组件上按下、滑动、抬起触发的回调事件。
‘
点击事件
点击事件时通过手指或手写笔做出一次完整的按下和抬起动作。
onClick(event: (event?: ClickEvent) => void)
触摸事件
当手指或手写笔在组件上触碰时,会触发不同动作所对应的事件响应,包括按下(Down)、滑动(Move)、抬起(Up)事件:
onTouch(event: (event?: TouchEvent) => void)
- event.type为TouchType.Down:表示手指按下。
- event.type为TouchType.Up:表示手指抬起。
- event.type为TouchType.Move:表示手指按住移动。
- event.type为TouchType.Cancel:表示打断取消当前手指操作。
1.1.3 键鼠事件
键鼠事件指键盘,鼠标外接设备的输入事件。
1.1.3.1 鼠标事件
鼠标事件传递到ArkUI之后,会先判断鼠标事件是否是左键的按下/抬起/移动,然后做出不同响应:
- 是:鼠标事件先转换成相同位置的触摸事件,执行触摸事件的碰撞测试、手势判断和回调响应。接着去执行鼠标事件的碰撞测试和回调响应。
- 否:事件仅用于执行鼠标事件的碰撞测试和回调响应。
说明
所有单指可响应的触摸事件/手势事件,均可通过鼠标左键来操作和响应。例如当我们需要开发单击Button跳转页面的功能、且需要支持手指点击和鼠标左键点击,那么只绑定一个点击事件(onClick)就可以实现该效果。若需要针对手指和鼠标左键的点击实现不一样的效果,可以在onClick回调中,使用回调参数中的source字段即可判断出当前触发事件的来源是手指还是鼠标。
onHover
onHover(event: (isHover: boolean) => void)
鼠标悬浮事件回调。参数isHover类型为boolean,表示鼠标进入组件或离开组件。该事件不支持自定义冒泡设置,默认父子冒泡。
若组件绑定了该接口,当鼠标指针从组件外部进入到该组件的瞬间会触发事件回调,参数isHover等于true;鼠标指针离开组件的瞬间也会触发该事件回调,参数isHover等于false。
说明
事件冒泡:在一个树形结构中,当子节点处理完一个事件后,再将该事件交给它的父节点处理。
onMouse
onMouse(event: (event?: MouseEvent) => void)
鼠标事件回调。绑定该API的组件每当鼠标指针在该组件内产生行为(MouseAction)时,触发事件回调,参数为MouseEvent对象,表示触发此次的鼠标事件。该事件支持自定义冒泡设置,默认父子冒泡。常用于开发者自定义的鼠标行为逻辑处理。
说明
按键(MouseButton)的值:Left/Right/Middle/Back/Forward 均对应鼠标上的实体按键,当这些按键被按下或松开时触发这些按键的事件。None表示无按键,会出现在鼠标没有按键按下或松开的状态下,移动鼠标所触发的事件中。
hoverEffect
hoverEffect(value: HoverEffect)
鼠标悬浮态效果设置的通用属性。参数类型为HoverEffect,HoverEffect提供的Auto、Scale、Highlight效果均为固定效果
1.1.3.2 按键事件
按键事件数据流
按键事件由外设键盘等设备触发,经驱动和多模处理转换后发送给当前获焦的窗口,窗口获取到事件后,会尝试分发三次事件。三次分发的优先顺序如下,一旦事件被消费,则跳过后续分发流程。
- 首先分发给ArkUI框架用于触发获焦组件绑定的onKeyPreIme回调和页面快捷键。
- 再向输入法分发,输入法会消费按键用作输入。
- 再次将事件发给ArkUI框架,用于响应系统默认Key事件(例如走焦),以及获焦组件绑定的onKeyEvent回调。
因此,当某输入框组件获焦,且打开了输入法,此时大部分按键事件均会被输入法消费。例如字母键会被输入法用来往输入框中输入对应字母字符、方向键会被输入法用来切换选中备选词。如果在此基础上给输入框组件绑定了快捷键,那么快捷键会优先响应事件,事件也不再会被输入法消费。
按键事件到ArkUI框架之后,会先找到完整的父子节点获焦链。从叶子节点到根节点,逐一发送按键事件。
Web组件的KeyEvent流程与上述过程有所不同。对于Web组件,不会在onKeyPreIme返回false时候,去匹配快捷。而是第三次按键派发中,Web对于未消费的KeyEvent会通过ReDispatch重新派发回ArkUI。在ReDispatch中再执行匹配快捷键等操作。
onKeyEvent & onKeyPreIme
onKeyEvent(event: (event: KeyEvent) => void): TonKeyPreIme(event: Callback<KeyEvent, boolean>): T
1.1.4 焦点事件
焦点、焦点链和走焦
- 焦点:指向当前应用界面上唯一的一个可交互元素,当用户使用键盘、电视遥控器、车机摇杆/旋钮等非指向性输入设备与应用程序进行间接交互时,基于焦点的导航和交互式重要的输入手段。
- 焦点链:在应用的组件树形结构种,当一个组件获得焦点时,从根节点到该组件节点的整条路径上的所有节点都会被视为处于焦点状态,形成一条连续的焦点链。
- 走焦:指焦点在应用内的组件之间转移的行为。这一过程对用户是透明的,单可以通过onFocus和onBlur事件来捕捉这些变化。
焦点态
用来指向当前获焦组件的样式。
- 显示规则:默认情况下焦点态不会显示,只有当应用进入激活状态后,焦点态才会显示。因此,虽然获得焦点的组件不一定显示焦点态,但焦点态的组件必然是获得焦点的。大部分组件内置了焦点态样式,同样可以使用样式接口进行自定义,一旦自定义,组件将不再显示内置的焦点态样式。在焦点链之中,若多个组件同时拥有焦点态,系统将采用子组件优先的策略,优先显示子组件的焦点态,并且仅显示一个焦点态。
- 进入激活态:使用外接键盘按下TAB键/使用FocusController的activate方法才进入焦点的激活态,进入激活态之后,才可以使用TAB键/方向键进行走焦。首次用来激活焦点态的TAB键不会触发走焦。
- 退出激活态:当应用收到FocusController的active方法/点击事件,焦点的激活态会退出。
层级页面
层级页面是焦点框架中特定容器组件的同城,涵盖Page、Dialog、SheetPage、ModalPage、Menu、Popup、NavBar、NavDestination等。这些组件通常具有以下关键特性。
- 视觉层级独立性:从视觉呈现上看,这些组件独立于其他页面内容,并通常位于其上方,形成视觉上的层级差异。
- 焦点跟随:此类组件在首次创建并展示之后,会立即将应用内焦点抢占。
- 走焦范围限制:当焦点位于这些组件内部时,用户无法通过键盘按键将焦点转移到组件外部的其他元素上,焦点移动仅限于组件内部。
在一个应用程序中,任何时候都至少存在一个层级页面组件,并且该组件会持有当前焦点。当该层级页面关闭或不在可见时,焦点会自动转移到下一个可用的层级页面组件上,确保用户交互的连贯性和一致性。
说明
Popup组件在focusable属性(组件属性,非通用属性)为false的时候,不会有第2条特性。
NavBar、NavDestination没有第3条特性,对于它们的走焦范围,是与它们的首个父层级页面相同的。
根容器
根容器时层级页面内的概念,当某个层级页面首次创建并展示时,根据层级页面的特性,焦点会立即被该页面抢占。此时,该层级页面所在焦点链的末端节点将成为默认焦点,而这个默认焦点通常位于该层级页面的根容器上。
在缺省状态下,层级页面的默认焦点位于其根容器上,但可以通过defaultFocus属性来自定义这一行为。
当焦点位于根容器时,首次按下TAB键不仅会使焦点进入激活状态,还会触发焦点向子组件的传递。如果子组件本身也是一个容器,则焦点会继续向下传递,直至到达叶子节点。传递规则是:优先传递给上一次获得焦点的子节点,如果不存在这样的节点,则默认传递给第一个子节点。
1.1.5 拖拽事件
拖拽事件提供了一种通过鼠标或手势触屏传递数据的机制,即从一个组件位置拖出(drag)数据并将其拖入(drop)到另一个组件位置,以触发响应。在这一过程中,拖出方提供数据,而拖入方负责接收和处理数据。这一操作使用户能够便捷地移动、复制或删除指定内容。
基本概念
- 拖拽操作:在可响应拖出的组件上长按并滑动以触发拖拽行为,当用户释放手指或鼠标时,拖拽操作即告结束。
- 拖拽背景(背板):用户拖动数据时的形象化表示。开发者可以通过onDragStart的CustomerBuilder]或DragItemInfo进行设置,也可以通过dragPreview通用属性进行自定义。
- 拖拽内容:被拖动的数据,使用UDMF统一API UnifiedData 进行封装,确保数据的一致性和安全性。
- 拖出对象:触发拖拽操作并提供数据的组件,通常具有响应拖拽的特性。
- 拖入目标:可接收并处理拖动数据的组件,能够根据拖入的数据执行相应的操作。
- 拖拽点:鼠标或手指与屏幕的接触位置,用于判断是否进入组件范围。判定依据是接触点是否位于组件的范围内。
1.2 使用手势事件
1.2.1 绑定手势方法
-
gesture(常规手势绑定方法)
gesture为通用的一种手势绑定方法,可以将手势绑定到对应的组件上。
.gesture(gesture: GestureType, mask?: GestureMask)
例如,可以将点击手势TapGesture通过gesture手势将方法绑定到Text组件上。
@Entry @Component struct Index {build() {Column() {Text('Gesture').fontSize(28)// 采用gesture手势绑定方法绑定TapGesture.gesture(TapGesture().onAction(() => {console.info('TapGesture is onAction');}))}.height(200).width(250)} }
prioityGesture(带优先级的手势绑定方法)
.priorityGesture(gesture: GestureType, mask?: GestureMask)
priorityGesture是带优先级的手势绑定方法,可以在组件上绑定优先识别的手势。当父组件使用priorityGesture绑定与父子组件同类型的手势时,父组件会优先识别通过priorityGesture绑定的手势。
长按手势时,设置触发长按的最短时间小的组件会优先响应,会忽略priorityGesture设置。
例如,当父组件Column和子组件Text同时绑定TapGesture手势时,父组件以带优先级手势priorityGesture的形式进行绑定时,优先相应父组件绑定的TapGesture。
@Entry
@Component
struct Index {build() {Column() {Text('Gesture').fontSize(28).gesture(TapGesture().onAction(() => {console.info('Text TapGesture is onAction');}))}.height(200).width(250)// 设置为priorityGesture时,点击文本区域会忽略Text组件的TapGesture手势事件,优先响应父组件Column的TapGesture手势事件.priorityGesture(TapGesture().onAction(() => {console.info('Column TapGesture is onAction');}), GestureMask.IgnoreInternal)}
}
parallelGesture(并行手势绑定方法)
.parallelGesture(gesture: GestureType, mask?: GestureMask)
parallelGesture是并行手势绑定方法,可以在父子组件上绑定可以同时响应的相同手势。
在默认情况下,手势事件为非冒泡事件,当父子组件上绑定相同的手势时,父子组件绑定的手势事件会发生竞争,最多只有一个组件的手势能够获得响应。而当父组件绑定了并行手势parallelGesture时,父子组件相同的手势事件都可以触发,实现类似冒泡效果。
@Entry
@Component
struct Index {build() {Column() {Text('Gesture').fontSize(28).gesture(TapGesture().onAction(() => {console.info('Text TapGesture is onAction');}))}.height(200).width(250)// 设置为parallelGesture时,点击文本区域会同时响应父组件Column和子组件Text的TapGesture手势事件.parallelGesture(TapGesture().onAction(() => {console.info('Column TapGesture is onAction');}), GestureMask.Normal)}
}
1.2.2 单一手势
点击手势(TapGesture)
TapGesture(value?:{count?:number, fingers?:number})
点击手势支持单次点击和多次点击,拥有两个可选参数:
- count:声明该点击手势识别的连续点击次数。默认值为1,若设置小于1的非法值会被转化为默认值。如果配置多次点击,上一次抬起和下一次按下的超时时间为300毫秒。
- fingers:用于声明触发点击的手指数量,最小值为1,最大值为10,默认值为1。当配置多指时,若第一根手指按下300毫秒内未有足够的手指数按下则手势识别失败。
@Entry
@Component
struct Index {@State value: string = "";build() {Column() {Text('Click twice').fontSize(28).gesture(// 绑定count为2的TapGestureTapGesture({ count: 2 }).onAction((event: GestureEvent|undefined) => {if(event){this.value = JSON.stringify(event.fingerList[0]);}}))Text(this.value)}.height(200).width(250).padding(20).border({ width: 3 }).margin(30)}
}
长按手势(LongPressGesture)
LongPressGesture(value?:{fingers?:number, repeat?:boolean, duration?:number})
长按手势用于触发长按手势事件,拥有三个可选参数:
- fingers:用于声明触发长按手势所需要的最少手指数量,最小值为1,最大值为10,默认值为1。
- repeat:用于声明是否连续触发事件回调,默认值为false。
- duration:用于声明触发长按所需的最短时间,单位为毫秒,默认值为500。
@Entry
@Component
struct Index {@State count: number = 0;build() {Column() {Text('LongPress OnAction:' + this.count).fontSize(28).gesture(// 绑定可以重复触发的LongPressGestureLongPressGesture({ repeat: true }).onAction((event: GestureEvent|undefined) => {if(event){if (event.repeat) {this.count++;}}}).onActionEnd(() => {this.count = 0;}))}.height(200).width(250).padding(20).border({ width: 3 }).margin(30)}
}
拖动手势(PanGesture)
PanGesture(value?:{ fingers?:number, direction?:PanDirection, distance?:number})
拖动手势用于触发拖动手势事件,滑动达到最小滑动距离(默认值为5vp)时拖动手势识别成功,拥有三个可选参数:
- fingers:用于声明触发拖动手势所需要的最少手指数量,最小值为1,最大值为10,默认值为1。
- direction:用于声明触发拖动的手势方向,此枚举值支持逻辑与(&)和逻辑或(|)运算。默认值为Pandirection.All。
- distance:用于声明触发拖动的最小拖动识别距离,单位为vp,默认值为5。
@Entry
@Component
struct Index {@State offsetX: number = 0;@State offsetY: number = 0;@State positionX: number = 0;@State positionY: number = 0;build() {Column() {Text('PanGesture Offset:\nX: ' + this.offsetX + '\n' + 'Y: ' + this.offsetY).fontSize(28).height(200).width(300).padding(20).border({ width: 3 })// 在组件上绑定布局位置信息.translate({ x: this.offsetX, y: this.offsetY, z: 0 }).gesture(// 绑定拖动手势PanGesture().onActionStart((event: GestureEvent|undefined) => {console.info('Pan start');})// 当触发拖动手势时,根据回调函数修改组件的布局位置信息.onActionUpdate((event: GestureEvent|undefined) => {if(event){this.offsetX = this.positionX + event.offsetX;this.offsetY = this.positionY + event.offsetY;}}).onActionEnd(() => {this.positionX = this.offsetX;this.positionY = this.offsetY;}))}.height(200).width(250)}
}
捏合手势(PinchGesture)
PinchGesture(value?:{fingers?:number, distance?:number})
捏合手势用于触发捏合手势事件,拥有两个可选参数:
- fingers:用于声明触发捏合手势所需要的最少手指数量,最小值为2,最大值为5,默认值为2。
- distance:用于声明触发捏合手势的最小距离,单位为vp,默认值为5。
@Entry
@Component
struct Index {@State scaleValue: number = 1;@State pinchValue: number = 1;@State pinchX: number = 0;@State pinchY: number = 0;build() {Column() {Column() {Text('PinchGesture scale:\n' + this.scaleValue)Text('PinchGesture center:\n(' + this.pinchX + ',' + this.pinchY + ')')}.height(200).width(300).border({ width: 3 }).margin({ top: 100 })// 在组件上绑定缩放比例,可以通过修改缩放比例来实现组件的缩小或者放大.scale({ x: this.scaleValue, y: this.scaleValue, z: 1 }).gesture(// 在组件上绑定三指触发的捏合手势PinchGesture({ fingers: 3 }).onActionStart((event: GestureEvent|undefined) => {console.info('Pinch start');})// 当捏合手势触发时,可以通过回调函数获取缩放比例,从而修改组件的缩放比例.onActionUpdate((event: GestureEvent|undefined) => {if(event){this.scaleValue = this.pinchValue * event.scale;this.pinchX = event.pinchCenterX;this.pinchY = event.pinchCenterY;}}).onActionEnd(() => {this.pinchValue = this.scaleValue;console.info('Pinch end');}))}}
}
旋转手势(RotationGesture)
RotationGesture(value?:{fingers?:number, angle?:number})
旋转手势用于触发旋转手势事件,拥有两个可选参数:
- fingers:用于声明触发旋转手势所需要的最少手指数量,最小值为2,最大值为5,默认值为2。
- angle:用于声明触发旋转手势的最小改变度数,单位为deg,默认值为1。
@Entry
@Component
struct Index {@State angle: number = 0;@State rotateValue: number = 0;build() {Column() {Text('RotationGesture angle:' + this.angle).fontSize(28)// 在组件上绑定旋转布局,可以通过修改旋转角度来实现组件的旋转.rotate({ angle: this.angle }).gesture(RotationGesture().onActionStart((event: GestureEvent|undefined) => {console.info('RotationGesture is onActionStart');})// 当旋转手势生效时,通过旋转手势的回调函数获取旋转角度,从而修改组件的旋转角度.onActionUpdate((event: GestureEvent|undefined) => {if(event){this.angle = this.rotateValue + event.angle;}console.info('RotationGesture is onActionEnd');})// 当旋转结束抬手时,固定组件在旋转结束时的角度.onActionEnd(() => {this.rotateValue = this.angle;console.info('RotationGesture is onActionEnd');}).onActionCancel(() => {console.info('RotationGesture is onActionCancel');})).height(200).width(300).padding(20).border({ width: 3 }).margin(100)}}
}
滑动手势(SwipeGesture)
SwipeGesture(value?:{fingers?:number, direction?:SwipeDirection, speed?:number})
滑动手势用于触发滑动事件,当滑动速度大于100vp/s时可以识别成功,拥有三个可选参数:
- fingers:用于声明触发滑动手势所需要的最少手指数量,最小值为1,最大值为10,默认值为1。
- direction:用于声明触发滑动手势的方向,此枚举值支持逻辑与(&)和逻辑或(|)运算。默认值为SwipeDirection.All。
- speed:用于声明触发滑动的最小滑动识别速度,单位为vp/s,默认值为100。
@Entry
@Component
struct Index {@State rotateAngle: number = 0;@State speed: number = 1;build() {Column() {Column() {Text("SwipeGesture speed\n" + this.speed)Text("SwipeGesture angle\n" + this.rotateAngle)}.border({ width: 3 }).width(300).height(200).margin(100)// 在Column组件上绑定旋转,通过滑动手势的滑动速度和角度修改旋转的角度.rotate({ angle: this.rotateAngle }).gesture(// 绑定滑动手势且限制仅在竖直方向滑动时触发SwipeGesture({ direction: SwipeDirection.Vertical })// 当滑动手势触发时,获取滑动的速度和角度,实现对组件的布局参数的修改.onAction((event: GestureEvent|undefined) => {if(event){this.speed = event.speed;this.rotateAngle = event.angle;}}))}}
}
1.2.3 组合手势
组合手势由多种单一手势组合而成,通过在GestureGroup中使用不同的GestureMode来声明该组合手势的类型,支持顺序识别、并行识别、互斥识别三种类型。
GestureGroup(mode:GestureMode, gesture:GestureType[])
- mode:为GestureMode枚举类。用于声明该组合手势的类型。
- gesture:由多个手势组合而成的数组。用于声明组合成该组合手势的各个手势。
顺序识别
顺序识别组合手势对应的GestureMode为Sequence。顺序识别组合手势将手势的注册顺序识别手势,知道手势识别成功。当顺序识别组合手势中有一个手势识别失败时,后续手势识别均失败。顺序识别手势组仅有最后一个手势可以响应onActionEnd。
以一个由长按手势和拖动手势组合而成的连续手势为例:
在一个Column组件上绑定了translate属性,通过修改该属性可以设置组件的位置移动。然后在该组件上绑定LongPressGesture和PanGesture组合而成的Sequence组合手势。当触发LongPressGesture时,更新显示的数字。当长按后进行拖动时,根据拖动手势的回调函数,实现组件的拖动。
@Entry
@Component
struct Index {@State offsetX: number = 0;@State offsetY: number = 0;@State count: number = 0;@State positionX: number = 0;@State positionY: number = 0;@State borderStyles: BorderStyle = BorderStyle.Solidbuild() {Column() {Text('sequence gesture\n' + 'LongPress onAction:' + this.count + '\nPanGesture offset:\nX: ' + this.offsetX + '\n' + 'Y: ' + this.offsetY).fontSize(28)}.margin(10).borderWidth(1)// 绑定translate属性可以实现组件的位置移动.translate({ x: this.offsetX, y: this.offsetY, z: 0 }).height(250).width(300)//以下组合手势为顺序识别,当长按手势事件未正常触发时不会触发拖动手势事件.gesture(// 声明该组合手势的类型为Sequence类型GestureGroup(GestureMode.Sequence,// 该组合手势第一个触发的手势为长按手势,且长按手势可多次响应LongPressGesture({ repeat: true })// 当长按手势识别成功,增加Text组件上显示的count次数.onAction((event: GestureEvent|undefined) => {if(event){if (event.repeat) {this.count++;}}console.info('LongPress onAction');}).onActionEnd(() => {console.info('LongPress end');}),// 当长按之后进行拖动,PanGesture手势被触发PanGesture().onActionStart(() => {this.borderStyles = BorderStyle.Dashed;console.info('pan start');})// 当该手势被触发时,根据回调获得拖动的距离,修改该组件的位移距离从而实现组件的移动.onActionUpdate((event: GestureEvent|undefined) => {if(event){this.offsetX = (this.positionX + event.offsetX);this.offsetY = this.positionY + event.offsetY;}console.info('pan update');}).onActionEnd(() => {this.positionX = this.offsetX;this.positionY = this.offsetY;this.borderStyles = BorderStyle.Solid;})).onCancel(() => {console.log("sequence gesture canceled")}))}
}
说明
拖拽事件是一种典型的顺序识别组合手势事件,由长按手势事件和滑动手势事件组合而成。只有先长按达到长按手势事件预设置的时间后进行滑动才会触发拖拽事件。如果长按事件未达到或者长按后未进行滑动,拖拽事件均识别失败。
并行识别
并行识别组合手势对应的GestureMode为Parallel/并行识别组合手势中注册的手势同时进行识别,知道所有手势识别结束。并行识别手势组合中的手势进行识别时互不影响。
以在一个Column组件上绑定点击手势和双击手势组成的并行识别手势为例,由于单击手势和双击手势是并行识别,因此两个手势可以同时进行识别,二者互不干涉。
@Entry
@Component
struct Index {@State count1: number = 0;@State count2: number = 0;build() {Column() {Text('Parallel gesture\n' + 'tapGesture count is 1:' + this.count1 + '\ntapGesture count is 2:' + this.count2 + '\n').fontSize(28)}.height(200).width('100%')// 以下组合手势为并行并别,单击手势识别成功后,若在规定时间内再次点击,双击手势也会识别成功.gesture(GestureGroup(GestureMode.Parallel,TapGesture({ count: 1 }).onAction(() => {this.count1++;}),TapGesture({ count: 2 }).onAction(() => {this.count2++;})))}
}
说明
当由单击手势和双击手势组成一个并行识别组合手势后,在区域内进行点击时,单击手势和双击手势将同时进行识别。
当只有单次点击时,单击手势识别成功,双击手势识别失败。
当有两次点击时,若两次点击相距时间在规定时间内(默认规定时间为300毫秒),触发两次单击事件和一次双击事件。
当有两次点击时,若两次点击相距时间超出规定时间,触发两次单击事件不触发双击事件。
互斥识别
互斥识别组合手势对应的GestureMode为Exclusive。互斥识别组合手势中注册的手势将同时进行识别,若有一个手势识别成功,则结束手势识别,其他所有手势识别失败。
以在一个Column组件上绑定单击手势和双击手势组合而成的互斥识别组合手势为例。若先绑定单击手势后绑定双击手势,由于单击手势只需要一次点击即可触发而双击手势需要两次,每次的点击事件均被单击手势消费而不能积累成双击手势,所以双击手势无法触发。若先绑定双击手势后绑定单击手势,则触发双击手势不触发单击手势。
@Entry
@Component
struct Index {@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++;})))}
}
说明
当由单击手势和双击手势组成一个互斥识别组合手势后,在区域内进行点击时,单击手势和双击手势将同时进行识别。
当只有单次点击时,单击手势识别成功,双击手势识别失败。
当有两次点击时,手势响应取决于绑定手势的顺序。若先绑定单击手势后绑定双击手势,单击手势在第一次点击时即宣告识别成功,此时双击手势已经失败。即使在规定时间内进行了第二次点击,双击手势事件也不会进行响应,此时会触发单击手势事件的第二次识别成功。若先绑定双击手势后绑定单击手势,则会响应双击手势不响应单击手势。
1.2.4 多层手势事件
多层级手势事件指父子组件套件时,父子组件均绑定了手势或事件。在该场景下,手势或者事件的响应受到多个因素的影响,相互之间发生传递和竞争,容易出现预期外的响应。
1.2.4.1 默认多层级手势事件
触摸事件
触摸事件时所有手势组成的基础,由Down,Move,Up,Cancel四种。手势均由触摸事件组成,例如,点击成为Down+Up,华东为Down+一系列Move+Up。触摸事件具有特殊性:
- 监听了obTouch事件的组件。若在手指落下时被触摸则均会收到onTouch事件的回调,被触摸受到触摸热区和触摸控制影响。
- onTouch事件的回调是闭环的。若一个组件收到了手指id为0的Down事件,后续也会收到手指id为0的Move事件和Up事件。
- onTouch事件回调是一致的。若一个组件收到了手指id为0的Down事件为收到手指id为1的Down事件,则后续只会收到手指id为0的touch事件,不会收到手指id为1的后续touch事件。
对于一般的容器组件(例如:Column),父子组件之间onTouch事件能够同时触发,兄弟组件之间onTouch事件根据布局进行触发。
ComponentA() {ComponentB().onTouch(() => {})ComponentC().onTouch(() => {})
}.onTouch(() => {})
组件B和组件C作为组件A的子组件,当触摸到组件B或者组件C时,组件A也会被触摸到。onTouch事件允许多个组件同时触发,因此,当触摸组件B时,会触发组件A和组件B的onTouch回调,不会触发组件C的onTouch回调。当触摸组件C时,会触发组件A和组件C的onTouch回调,不触发组件B的回调。特殊的容器组件,如Stack等组件,由于子组件之间存在着堆叠关系,子组件的布局也互相存在遮盖关系。所以,父子组件之间onTouch事件能够同时触发,兄弟组件之间onTouch事件会存在遮盖关系。
Stack A() {ComponentB().onTouch(() => {})ComponentC().onTouch(() => {})
}.onTouch(() => {})
组件B和组件C作为Stack A的子组件,组件C覆盖在组件B上。当触摸到组件B或者组件C时,Stack A也会被触摸到。onTouch事件允许多个组件同时触发,因此,当触摸组件B和组件C的重叠区域时,会触发Stack A和组件C的onTouch回调,不会触发组件B的onTouch回调(组件B被组件C遮盖)。
手势与事件
除了触摸事件(onTouch事件)外的所有手势与事件,均是通过基础手势或者组合手势实现的。例如,拖拽事件是由长按手势和滑动手势组成的一个顺序手势。
在未显式声明的情况下,同一时间,一根手指对应的手势组中只会有一个手势获得成功从而触发所设置的回调。
因此,除非显式声明允许多个手势同时成功,同一时间只会有一个手势响应。
响应优先级遵循以下条件:
1.当父子组件均绑定同一类手势时,子组件优先于父组件触发。
2.当一个组件绑定多个手势时,先达到手势触发条件的手势优先触发。
ComponentA() {ComponentB().gesture(TapGesture({count: 1}))
}.gesture(TapGesture({count: 1}))
当父组件和子组件均绑定点击手势时,子组件的优先级高于父组件。
因此,当在B组件上进行点击时,组件B所绑定的TapGesture的回调会被触发,而组件A所绑定的TapGesture的回调不会被触发。
ComponentA()
.gesture(GestureGroup(GestureMode.Exclusive,TapGesture({count: 1}),PanGesture({distance: 5}))
)
当组件A上绑定了由点击和滑动手势组成的互斥手势组时,先达到手势触发条件的手势触发对应的回调。
若使用者做了一次点击操作,则响应点击对应的回调。若使用者进行了一次滑动操作并且滑动距离达到了阈值,则响应滑动对应的回调。
1.2.4 .2 自定义控制的多层级手势事件
可以通过设置属性,控制默认的多层级手势事件竞争流程,更好的实现手势事件。
目前,responseRegion属性和hitTestBehavior属性可以控制Touch事件的分发,从而可以影响到onTouch事件和手势响应。而绑定手势方法属性可以控制手势的竞争从而影响手势的响应,但不能影响到onTouch事件。
responseRegion对手势和事件的控制
responseRegion属性可以实现组件的响应区域范围的变化。响应区域范围可以超出或者小于组件的布局范围。
ComponentA() {ComponentB().onTouch(() => {}).gesture(TapGesture({count: 1})).responseRegion({Rect1, Rect2, Rect3})
}
.onTouch(() => {})
.gesture(TapGesture({count: 1}))
.responseRegion({Rect4})
当组件A绑定了.responseRegion({Rect4})的属性后,所有落在Rect4区域范围的触摸事件和手势可被组件A对应的回调响应。
当组件B绑定了.responseRegion({Rect1, Rect2, Rect3})的属性后,所有落在Rect1,Rect2和Rect3区域范围的触摸事件和手势可被组件B对应的回调响应。
当绑定了responseRegion后,手势与事件的响应区域范围将以所绑定的区域范围为准,而不是以布局区域为准,可能出现布局相关区域不响应手势与事件的情况。
此外,responseRegion属性支持由多个Rect组成的数组作为入参,以支持更多开发需求。
hitTestBehavior对手势和事件的控制
hitTestBehavior属性可以实现在复杂的多层级场景下,一些组件能够响应手势和事件,而一些组件不能响应手势和事件。
ComponentA() {ComponentB().onTouch(() => {}).gesture(TapGesture({count: 1}))ComponentC() {ComponentD().onTouch(() => {}).gesture(TapGesture({count: 1}))}.onTouch(() => {}).gesture(TapGesture({count: 1})).hitTestBehavior(HitTestMode.Block)
}
.onTouch(() => {})
.gesture(TapGesture({count: 1}))
HitTestMode.Block自身会响应触摸测试,阻塞子节点和兄弟节点的触摸测试,从而导致子节点和兄弟节点的onTouch事件和手势均无法触发。
当组件C未设置hitTestBehavior时,点击组件D区域,组件A、组件C和组件D的onTouch事件会触发,组件D的点击手势会触发。
当组件C设置了hitTestBehavior为HitTestMode.Block时,点击组件D区域,组件A和组件C的onTouch事件会触发,组件D的onTouch事件未触发。同时,由于组件D的点击手势因为被阻塞而无法触发,组件C的点击手势会触发。
Stack A() {ComponentB().onTouch(() => {}).gesture(TapGesture({count: 1}))ComponentC().onTouch(() => {}).gesture(TapGesture({count: 1})).hitTestBehavior(HitTestMode.Transparent)
}
.onTouch(() => {})
.gesture(TapGesture({count: 1}))
HitTestMode.Transparent自身响应触摸测试,不会阻塞兄弟节点的触摸测试。
当组件C未设置hitTestBehavior时,点击组件B和组件C的重叠区域时,Stack A和组件C的onTouch事件会触发,组件C的点击事件会触发,组件B的onTouch事件和点击手势均不触发。
而当组件C设置hitTestBehavior为HitTestMode.Transparent时,点击组件B和组件C的重叠区域,组件A和组件C不受到影响与之前一致,组件A和组件C的onTouch事件会触发,组件C的点击手势会触发。而组件B因为组件C设置了HitTestMode.Transparent,组件B也收到了Touch事件,从而组件B的onTouch事件和点击手势触发。
ComponentA() {ComponentB().onTouch(() => {}).gesture(TapGesture({count: 1}))
}
.onTouch(() => {})
.gesture(TapGesture({count: 1}))
.hitTestBehavior(HitTestMode.None)
HitTestMode.None自身不响应触摸测试,不会阻塞子节点和兄弟节点的触摸控制。
当组件A未设置hitTestBehavior时,点击组件B区域时,组件A和组件B的onTouch事件均会触发,组件B的点击手势会触发。
当组件A设置hitTestBehavior为HitTestMode.None时,点击组件B区域时,组件B的onTouch事件触发,而组件A的onTouch事件无法触发,组件B的点击手势触发。
针对简单的场景,建议在单个组件上绑定hitTestBehavior。
针对复杂场景,建议在多个组件上绑定不同的hitTestBehavior来控制Touch事件的分发。
绑定收拾方法对手势的控制
设置绑定手势的方法可以实现在多层级场景下,当父组件与子组件绑定了相同的手势时,设置不同的绑定手势方法有不同的响应优先级。
当父组件使用.gesture绑定手势,父子组件所绑定手势类型相同时,子组件优先于父组件响应。
ComponentA() {ComponentB().gesture(TapGesture({count: 1}))
}
.gesture(TapGesture({count: 1}))
当父子组件均正常绑定点击手势时,子组件优先于父组件响应。
此时,单击组件B区域范围,组件B的点击手势会触发,组件A的点击手势不会触发。
如果以带优先级的方式绑定手势,则可使得父组件所绑定手势的响应优先级高于子组件。
ComponentA() {ComponentB().gesture(TapGesture({count: 1}))
}
.priorityGesture(TapGesture({count: 1}))
当父组件以.priorityGesture的形式绑定手势时,父组件所绑定的手势优先级高于子组件。
此时,单击组件B区域范围,组件A的点击手势会触发,组件B的点击手势不会触发。
如果需要父子组件所绑定的手势不发生冲突,均可响应,则可以使用并行的方式在父组件绑定手势。
ComponentA() {ComponentB().gesture(TapGesture({count: 1}))
}
.parallelGesture(TapGesture({count: 1}))
当父组件以.parallelGesture的形式绑定手势时,父组件和子组件所绑定的手势均可触发。
此时,单击组件B区域范围,组件A和组件B的点击手势均会触发。
1.2.5 手势拦截
手势拦截主要用于确保手势按需执行,有效解决手势冲突问题。典型应用场景包括:嵌套滚动、通过过滤组件响应手势的范围来优化交互体验。手势拦截主要采用手势触发控制和手势响应控制两种方法实现。
手势触发控制
手势触发控制是指,在系统判定阈值以满足的条件下,应用可自行判断是否拦截手势,使手势操作失败。
手势触发控制涉及以下接口。
接口 | 说明 |
---|---|
onGestureJudgeBegin | 用于手势拦截,是通用事件。在手势满足系统触发阈值场景下,回调给应用判断是否拦截手势。 |
onGestureRecognizerJudgeBegin | 用于手势拦截、获取手势识别器和初始化手势识别器开闭状态。是onGestureJudgeBegin接口的能力扩展,可以代替onGestureJudgeBegin接口。获取手势识别器时,会获取一次交互中手势响应链上的所有手势识别器,以及当前即将触发的手势识别器,初始化手势的激活状态。 |
以下示例中,Image和Stack两个组件位于同一区域。长按Stack组件的上半部分可触发挂载在Stack组件上的长按手势,长按Stack组件的下半部分则会响应Image组件的拖拽操作。
-
Image组件设置拖拽。
Image($r('sys.media.ohos_app_icon')).draggable(true).onDragStart(()=>{promptAction.showToast({ message: "Drag 下半区蓝色区域,Image响应" });}).width('200vp').height('200vp')
-
Stack组件设置手势。
Stack() {}.width('200vp').height('200vp').hitTestBehavior(HitTestMode.Transparent).gesture(GestureGroup(GestureMode.Parallel,LongPressGesture().onAction((event: GestureEvent) => {promptAction.showToast({ message: "LongPressGesture 长按上半区 红色区域,红色区域响应" });}).tag("longpress")))
-
Stack组件设置拦截。
.onGestureJudgeBegin((gestureInfo: GestureInfo, event: BaseGestureEvent) => {// 如果是长按类型手势,判断点击的位置是否在上半区if (gestureInfo.type == GestureControl.GestureType.LONG_PRESS_GESTURE) {if (event.fingerList.length > 0 && event.fingerList[0].localY < 100) {return GestureJudgeResult.CONTINUE;} else {return GestureJudgeResult.REJECT;}}return GestureJudgeResult.CONTINUE; })
-
代码完整示例。
import { promptAction } from '@kit.ArkUI';@Entry @Component struct Index {scroller: Scroller = new Scroller();build() {Scroll(this.scroller) {Column({ space: 8 }) {Text("包括上下两层组件,上层组件绑定长按手势,下层组件绑定拖拽。其中上层组件下半区域绑定手势拦截,使该区域响应下层拖拽手势。").width('100%').fontSize(20).fontColor('0xffdd00')Stack({ alignContent: Alignment.Center }) {Column() {// 模拟上半区和下半区Stack().width('200vp').height('100vp').backgroundColor(Color.Red)Stack().width('200vp').height('100vp').backgroundColor(Color.Blue)}.width('200vp').height('200vp')// Stack的下半区是绑定了拖动手势的图像区域。Image($r('sys.media.ohos_app_icon')).draggable(true).onDragStart(()=>{promptAction.showToast({ message: "Drag 下半区蓝色区域,Image响应" });}).width('200vp').height('200vp')// Stack的上半区是绑定了长按手势的浮动区域。Stack() {}.width('200vp').height('200vp').hitTestBehavior(HitTestMode.Transparent).gesture(GestureGroup(GestureMode.Parallel,LongPressGesture().onAction((event: GestureEvent) => {promptAction.showToast({ message: "LongPressGesture 长按上半区 红色区域,红色区域响应" });}).tag("longpress"))).onGestureJudgeBegin((gestureInfo: GestureInfo, event: BaseGestureEvent) => {// 如果是长按类型手势,判断点击的位置是否在上半区if (gestureInfo.type == GestureControl.GestureType.LONG_PRESS_GESTURE) {if (event.fingerList.length > 0 && event.fingerList[0].localY < 100) {return GestureJudgeResult.CONTINUE;} else {return GestureJudgeResult.REJECT;}}return GestureJudgeResult.CONTINUE;})}.width('100%')}.width('100%')}} }
手势响应控制
手势响应控制,指的是手势已经成功识别,但仍然可以通过调用API接口控制手势回调是否能够响应
手势响应控制的前提是手势识别成功,如果手势不成功也不会产生手势回调响应。
- 业务手势作业流:指真正触发UI变化的业务手势,比如使页面滚动的PanGesture,触发点击的TapGesture等。
- 监听手势作业流:指在监听手势运行的过程中,应根据上下文业务状态变化动态控制手势识别器的启停,例如在组件于嵌套坤东过程中是否已滑至边缘。这一监听事件可借助一个使用并行手势绑定的PanGesture实现,或者采用Touch事件来完成。
- 设置手势并行:此步骤并非必须,典型场景实在嵌套滚动中,设置外部组件的滚动收拾与内部的滚动手势并行。
- 动态开闭手势:指通过手势识别器的setEnable方法,控制手势是否响应用户回调。
手势响应控制涉及以下接口。
接口 | 说明 |
---|---|
shouldBuiltInRecognizerParallelWith | 用于设置系统组件内置手势与其他手势并行。 |
onGestureRecognizerJudgeBegin | 用于手势拦截,获取手势识别器,初始化手势识别器开闭状态。 |
parallelGesture | 可使开发者定义的手势,与比他优先级高的手势并行。 |
以下示例是两个Scroll组件的嵌套滚动场景,使用手势控制的api去控制外部组件和内部组件的嵌套滚动联动。
-
使用shouldBuiltInRecognizerParallelWith接口设置外部Scroll组件的PanGesture手势,与内部Scroll组件的PanGesture手势并行。
.shouldBuiltInRecognizerParallelWith((current: GestureRecognizer, others: Array<GestureRecognizer>) => {for (let i = 0; i < others.length; i++) {let target = others[i].getEventTargetInfo();if (target.getId() == "inner" && others[i].isBuiltIn() && others[i].getType() == GestureControl.GestureType.PAN_GESTURE) { // 找到将要组成并行手势的识别器this.currentRecognizer = current; // 保存当前组件的识别器this.childRecognizer = others[i]; // 保存将要组成并行手势的识别器return others[i]; // 返回和当前手势将要组成并行手势的识别器}}return undefined; })
-
使用onGestureRecognizerJudgeBegin接口获取到Scroll组件的PanGesture手势识别器,同时根据内外Scroll组件的边界条件,初始化内外手势的开闭状态。
.onGestureRecognizerJudgeBegin((event: BaseGestureEvent, current: GestureRecognizer, others: Array<GestureRecognizer>) => { // 在识别器即将要成功时,根据当前组件状态,设置识别器使能状态 let target = current.getEventTargetInfo();if (target.getId() == "outer" && current.isBuiltIn() && current.getType() == GestureControl.GestureType.PAN_GESTURE) {for (let i = 0; i < others.length; i++) {let target = others[i].getEventTargetInfo() as ScrollableTargetInfo;if (target instanceof ScrollableTargetInfo && target.getId() == "inner") { // 找到响应链上对应并行的识别器let panEvent = event as PanGestureEvent;this.childRecognizer.setEnabled(true);this.currentRecognizer.setEnabled(false);if (target.isEnd()) { // 根据当前组件状态以及移动方向动态控制识别器使能状态if (panEvent && panEvent.offsetY < 0) {this.childRecognizer.setEnabled(false);this.currentRecognizer.setEnabled(true);}} else if (target.isBegin()) {if (panEvent.offsetY > 0) {this.childRecognizer.setEnabled(false);this.currentRecognizer.setEnabled(true);}}}}}return GestureJudgeResult.CONTINUE; })
-
设置监听手势,监听Scroll组件状态,动态调整手势开闭状态,以使手势响应。
.parallelGesture( // 绑定一个Pan手势作为动态控制器PanGesture().onActionUpdate((event: GestureEvent)=>{if (this.childRecognizer.getState() != GestureRecognizerState.SUCCESSFUL || this.currentRecognizer.getState() != GestureRecognizerState.SUCCESSFUL) { // 如果识别器状态不是SUCCESSFUL,则不做控制return;}let target = this.childRecognizer.getEventTargetInfo() as ScrollableTargetInfo;let currentTarget = this.currentRecognizer.getEventTargetInfo() as ScrollableTargetInfo;if (target instanceof ScrollableTargetInfo && currentTarget instanceof ScrollableTargetInfo) {this.childRecognizer.setEnabled(true);this.currentRecognizer.setEnabled(false);if (target.isEnd()) { // 在移动过程中实时根据当前组件状态,控制识别器的开闭状态if ((event.offsetY - this.lastOffset) < 0) {this.childRecognizer.setEnabled(false);if (currentTarget.isEnd()) {this.currentRecognizer.setEnabled(false);} else {this.currentRecognizer.setEnabled(true);}}} else if (target.isBegin()) {if ((event.offsetY - this.lastOffset) > 0) {this.childRecognizer.setEnabled(false);if (currentTarget.isBegin()) {this.currentRecognizer.setEnabled(false);} else {this.currentRecognizer.setEnabled(true);}}}}this.lastOffset = event.offsetY}) )
-
代码完整示例。
@Entry @Component struct FatherControlChild {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();private lastOffset: number = 0;build() {Stack({ alignContent: Alignment.TopStart }) {Scroll(this.scroller) { // 外部滚动容器Column() {Text("Scroll Area").width('90%').height(150).backgroundColor(0xFFFFFF).borderRadius(15).fontSize(16).textAlign(TextAlign.Center).margin({ top: 10 })Scroll(this.scroller2) { // 内部滚动容器Column() {Text("Scroll Area2").width('90%').height(150).backgroundColor(0xFFFFFF).borderRadius(15).fontSize(16).textAlign(TextAlign.Center).margin({ top: 10 })Column() {ForEach(this.arr, (item: number) => {Text(item.toString()).width('90%').height(150).backgroundColor(0xFFFFFF).borderRadius(15).fontSize(16).textAlign(TextAlign.Center).margin({ top: 10 })}, (item: string) => item)}.width('100%')}}.id("inner").width('100%').height(800)}.width('100%')}.id("outer").height(600).scrollable(ScrollDirection.Vertical) // 滚动方向纵向.scrollBar(BarState.On) // 滚动条常驻显示.scrollBarColor(Color.Gray) // 滚动条颜色.scrollBarWidth(10) // 滚动条宽度.edgeEffect(EdgeEffect.None).shouldBuiltInRecognizerParallelWith((current: GestureRecognizer, others: Array<GestureRecognizer>) => {for (let i = 0; i < others.length; i++) {let target = others[i].getEventTargetInfo();if (target.getId() == "inner" && 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>) => { // 在识别器即将要成功时,根据当前组件状态,设置识别器使能状态 let target = current.getEventTargetInfo();if (target.getId() == "outer" && current.isBuiltIn() && current.getType() == GestureControl.GestureType.PAN_GESTURE) {for (let i = 0; i < others.length; i++) {let target = others[i].getEventTargetInfo() as ScrollableTargetInfo;if (target instanceof ScrollableTargetInfo && target.getId() == "inner") { // 找到响应链上对应并行的识别器let panEvent = event as PanGestureEvent;this.childRecognizer.setEnabled(true);this.currentRecognizer.setEnabled(false);if (target.isEnd()) { // 根据当前组件状态以及移动方向动态控制识别器使能状态if (panEvent && panEvent.offsetY < 0) {this.childRecognizer.setEnabled(false);this.currentRecognizer.setEnabled(true);}} else if (target.isBegin()) {if (panEvent.offsetY > 0) {this.childRecognizer.setEnabled(false);this.currentRecognizer.setEnabled(true);}}}}}return GestureJudgeResult.CONTINUE;}).parallelGesture( // 绑定一个Pan手势作为动态控制器PanGesture().onActionUpdate((event: GestureEvent)=>{if (this.childRecognizer.getState() != GestureRecognizerState.SUCCESSFUL || this.currentRecognizer.getState() != GestureRecognizerState.SUCCESSFUL) { // 如果识别器状态不是SUCCESSFUL,则不做控制return;}let target = this.childRecognizer.getEventTargetInfo() as ScrollableTargetInfo;let currentTarget = this.currentRecognizer.getEventTargetInfo() as ScrollableTargetInfo;if (target instanceof ScrollableTargetInfo && currentTarget instanceof ScrollableTargetInfo) {this.childRecognizer.setEnabled(true);this.currentRecognizer.setEnabled(false);if (target.isEnd()) { // 在移动过程中实时根据当前组件状态,控制识别器的开闭状态if ((event.offsetY - this.lastOffset) < 0) {this.childRecognizer.setEnabled(false);if (currentTarget.isEnd()) {this.currentRecognizer.setEnabled(false);} else {this.currentRecognizer.setEnabled(true);}}} else if (target.isBegin()) {if ((event.offsetY - this.lastOffset) > 0) {this.childRecognizer.setEnabled(false)if (currentTarget.isBegin()) {this.currentRecognizer.setEnabled(false);} else {this.currentRecognizer.setEnabled(true);}}}}this.lastOffset = event.offsetY;}))}.width('100%').height('100%').backgroundColor(0xDCDCDC)} }
二、手势事件冲突
在复杂的应用界面之中,多个组件嵌套时同时绑定手势事件,或者同一个组件同时绑定多个手势,都有可能导致手势事件产生冲突。
2.1 手势响应
根据事件的响应链收集,确定了响应链成员和事件响应的顺序。然而往往在处理一些业务时,需要给组件/不同组件添加更多二的手势和事件,比如onClick、API手势gesture等等,那么那个事件会得到响应呢?
2.1.1 手势响应优先级
手势按是否为系统内置手势,可以分为以下两类:
- 系统手势:系统控件默认实现的手势(系统内置手势),即调用某些通用事件内置的手势,比如拖拽、onClick;比如bindMenu内置的点击事件。
- 自定义手势:通过绑定手势API,例如使用gesture声明的事件回调,绑定长按手势事件回调。
除了触摸事件外的所有手势和事件,均是通过基础手势或者组合手势实现的。例如,拖拽事件是由长按手势和滑动手势组成的一个顺序手势。
在默认情况下,这些手势为非冒泡事件,当父组件和子组件绑定同类型的手势时,父子组件绑定的手势事件会发生竞争,子组件会优先识别绑定的手势
因此,除非显示声明允许多个手势同时成功,否则同一时间只有一个手势响应。
- 当父子组件均绑定同一类手势时,子组件优先于父组件的触发。
- 当同一个组件同时绑定多个手势时,先达到手势触发条件的手势优先触发。
- 当同一个组件绑定相同时间类型的系统手势和自定义手势时,系统手势会优先响应。
2.1.2 手势响应控制
上面介绍了手势默认的优先级顺序,在父子组件嵌套时,父子组件均绑定了手势或事件,或者同一个租价女同事绑定多个手势时,根据业务逻辑可能需要对手势是否需要响应、分发给谁响应、相应的顺序等做出控制。那么有哪些控制手段呢?
-
手势绑定
绑定手势方法
设置绑定手势的方法可以实现在多层级场景下,当父组件与子组件绑定了的相同的手势时,设置不同的绑定的手势方法有不同的响应优先级。手势绑定支持常规手势绑定方法(gesture)、带优先级手势绑定方法(priorityGesture)、并行手势绑定方法(parallelGesture)
绑定手势方法 功能规格 配参1 配参2 约束 gesture 绑定手势事件,父子组件交叠区域均绑定,响应子组件 GestureType GestureMask 与通用事件抢占 priorityGesture 当父组件配置priorityGesture时,优先识别父组件priorityGesture绑定的手势。 GestureType GestureMask 与通用事件抢占 parallelGesture 父组件绑定parallelGesture时,父子组件相同的手势事件都可以触发 GestureType GestureMask 无 前面讲到的手势的优先级是默认的,在加入了priorityGesture和parallelGesture绑定方法后,手势的响应顺序如下图所示:
不同手势绑定配参方案规格
父手势 子手势 GestureMask(父) 交叠区域相同事件响应方 交叠区域不同事件响应方 gesture gesture default 子组件 各自响应 gesture gesture IgnoreInternal 父组件 父组件 priorityGesture gesture default 父组件 各自响应 priorityGesture gesture IgnoreInternal 父组件 父组件 parallelGesture gesture default 各自响应 各自响应 parallelGesture gesture IgnoreInternal 父组件 父组件 组合手势
手势组合是指多种手势组合为复合手势,通过GestureGroup属性,可以给同一个组件添加多个手势,支持连续识别、并行识别和互斥识别模式。开发者可以根据业务需求,选择合适的组合模式。
接口 可选模式 描述 注册事件 GestureGroup Sequence 手势顺序队列,需要按预定的手势组顺序执行,有一个失败则全部失败 onCancel GestureGroup Parallel 手势组合,直到所有已识别的手势执行完 无 GestureGroup Exclusive 互斥识别,成功完成一个手势,则完成手势任务 无 -
独占事件控制
monopolizeEvents(monopolize: boolean)
通过monopolizeEvents属性设置组件是否独占事件,时间范围包括组件自带的事件和开发者自定义的点击、触摸、手势事件。先响应事件的控件作为第一响应者,在手指离开屏幕前其他组件不会响应任何事件。
在一个窗口内,设置了独占控制的组件上的事件如果首先相应,则本次交互只允许此组件上设置的事件响应,窗口内其他组件上的事件不会相应。
如果通过parallelGesture绑定了与子组件同时触发的手势,入PanGesture,子组件设置了独占资源切首个响应事件,则父组件手势不会相应。
-
自定义手势判定
onGestureJudgeBegin(callback: (gestureInfo: GestureInfo, event: BaseGestureEvent) => GestureJudgeResult): T
为组件提供自定义手势判定能力。可根据需要,在手势识别期间,根据自己的业务逻辑来决定是否响应手势。使用onGestureJudgeBegin方法对手势进行判定。
-
手势拦截增强
shouldBuiltInRecognizerParallelWith(callback: ShouldBuiltInRecognizerParallelWithCallback): T
为组件提供手势拦截能力,将系统内置手势和响应链上更高优先级的手势做并行化处理,并可以动态控制手势事件的触发。
-
responseRegion和hitTestBehavior
影响触摸测试的因素同样也可能会影响到手势的响应流程。例如responseRegion属性和hitTestBehavior属性可以控制Touch事件的分发,从而可以影响到onTouch事件和手势的响应。而绑定手势方法属性可以控制手势的竞争从而影响手势的响应,但不会影响到onTouch事件。
-
ArkUI组件自身的属性控制手势响应
ArkUI组件自身的属性,也可以对手势事件的响应做出控制。例如Grid、List、Scroll、Swiper、WaterFlow等滚动容器组件提供了nestedScroll属性,来解决和父组件的嵌套滚动的冲突问题;例如Swiper组件的disableSwipe可以设置禁用组件滑动切换的功能;又例如List组件可以通过设置enableScrollInteraction属性来设置是否支持手势滚动列表。
2.2 总结
手势冲突在界面开发中往往不可避免,特别是在复杂的应用界面中。针对不同的冲突场景和手势交互需求,需要选择合适的解决方案。可以参考前面介绍的影响触摸测试因素,以及手势响应控制里面的方法,进行尝试。
- Grid、List、Scroll、Swiper、WaterFlow等滚动容器的嵌套,可以尝试使用nestedScroll属性来解决视图滚动冲突的问题。
- 对于单个组件组合手势的使用产生的冲突,以及自定义手势和系统手势冲突,可以尝试使用组合手势中的顺序识别、并行识别和互斥识别来解决。
- 对于多层组件手势响应冲突,可以参考多层级手势事件。
- 如果需要将系统手势和比其优先级高的手势做并行化处理,并可以动态控制手势事件的触发,可以参考手势拦截增强。
- 如果只是需要动态控制自定义手势是否响应,可以参考自定义手势判定。
- 对于多点触控产生的手势冲突可以参考独占事件控制。