【高心星出品】

手势冲突及解决方案

接下来我们通过这些方法来解决以下一些常见的手势响应冲突问题以及解决方案。

滚动容器嵌套滚动容器事件冲突

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%')
  }
}

代码逻辑走读:

  1. 组件定义:使用@Entry@Component装饰器定义了一个名为GesturesConflictScene1的组件。
  2. 构建函数:定义了build函数,用于构建UI界面。
  3. 滚动容器:在build函数中,使用Scroll组件创建了一个可滚动的容器。
  4. 列布局:在滚动容器内,使用Column组件创建了一个垂直布局。
  5. 蓝色背景:在列布局中,第一个Column组件设置了高度为屏幕的30%,宽度为100%,并设置了蓝色背景。
  6. 列表:在蓝色背景的Column组件内,使用List组件创建了一个列表。
  7. 列表项:使用ForEach循环生成列表项,每个列表项是一个ListItem组件,包含一个Text组件,显示一个数字。
  8. 列表样式:设置列表的高度和宽度为100%,并禁用了边缘效果。
  9. 嵌套滚动:设置了嵌套滚动的模式,确保滚动行为按照指定的模式进行。
  10. 滚动容器样式:设置滚动容器的高度和宽度为100%。
List、Scroller等滚动容器嵌套Web组件,滑动事件冲突

比如List组件嵌套Web组件,当Web加载的网页中也包含滚动视图的时候,这时候上下滚动Web组件,不能和List列表整体一起滑动。这是因为Web的滑动事件和List组件的冲突,如果想让Web随List一起整体滚动,解决方案和前面的例子一样,给Web组件添加nestedScroll属性。

Web(
  // ...
)
  .nestedScroll({
    scrollForward: NestedScrollMode.PARENT_FIRST,
    scrollBackward: NestedScrollMode.SELF_FIRST
  })

代码逻辑走读:

  1. 定义了一个Web组件,该组件可能是一个用于显示网页内容的UI组件。
  2. 在Web组件内部调用了.nestedScroll()方法,该方法用于配置嵌套滚动行为。
  3. .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%')
    // The following gestures are mutually exclusive. After the gesture is successfully recognized, the gesture cannot be recognized by double-clicking
    .gesture(
      GestureGroup(GestureMode.Exclusive,
        TapGesture({ count: 1 })
          .onAction(() => {
            this.count1++;
          }),
        TapGesture({ count: 2 })
          .onAction(() => {
            this.count2++;
          })
      )
    )
  }
}

代码逻辑走读:

  1. 组件定义与状态初始化
    • 使用@Entry@Component装饰器定义了一个名为GesturesConflictScene2的组件。
    • 初始化了两个状态变量count1count2,用于记录两个手势的触发次数,初始值为0。
  2. UI构建
    • build方法中,使用Column布局组件创建了一个垂直布局。
    • Column中添加了一个Text组件,用于显示手势触发次数。文本内容包括两个手势的触发次数。
    • 设置了Text组件的字体大小为28。
    • 设置了Column组件的高度为200,宽度为100%。
  3. 手势处理
    • 使用GestureGroup组件将两个TapGesture组合在一起,并设置为互斥模式(GestureMode.Exclusive)。
    • 第一个TapGesture配置为单次点击(count: 1),并在触发时增加count1的值。
    • 第二个TapGesture配置为单次点击(count: 2),并在触发时增加count2的值。
    • 当任一手势成功识别时,另一个手势将被阻止,确保了手势的互斥性。

可以设置手势为并行识别来解决,设置对应的GestureMode为Parallel:

.gesture(
  GestureGroup(GestureMode.Parallel,
    TapGesture({ count: 2 })
      .onAction(() => {
        this.count2++;
      }),
    TapGesture({ count: 1 })
      .onAction(() => {
        this.count1++;
      })
  )
)

代码逻辑走读:

  1. 定义了一个手势组(GestureGroup),该组以并行模式(Parallel)运行,意味着两个手势操作将同时执行。
  2. 在手势组中,第一个手势操作是一个双击手势(TapGesture,count: 2),当检测到两次点击时,触发onAction回调函数,增加count2变量的值。
  3. 第二个手势操作是一个单击手势(TapGesture,count: 1),当检测到一次点击时,触发onAction回调函数,增加count1变量的值。

系统手势和自定义手势之间冲突

对于一般同类型的手势,系统手势优先于自定义手势执行,可以通过priorityGesture或者parallelGesture的方式来绑定自定义手势,例如下面这个示例:

图片长按手势响应失败或冲突,在Image控件上添加长按手势后,长按图片无法响应对应方法,而是图片放大的动画,示例代码如下:

@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) => {
              })// The long press action ends
              .onActionEnd(() => {
                this.getUIContext().getPromptAction().showToast({ message: 'Long Press' });
              })
          )
      }
      .width('100%')
    }
    .height('100%')
  }
}

代码逻辑走读:

  1. 定义了一个名为GesturesConflictScene3的组件,使用@Entry@Component装饰器标记为入口组件。
  2. 在组件内部,定义了一个状态变量message,初始值为’Hello World’。
  3. build()方法用于构建UI,返回一个Row布局。
  4. Row布局内部包含一个Column布局。
  5. Column布局中包含一个Text组件,显示message变量的值,字体大小为50,字体加粗。
  6. Column布局中还包含一个Image组件,显示应用媒体资源中的startIcon图标,设置图标的宽、高为360,顶部外边距为100。
  7. Image组件绑定了一个长按手势LongPressGesture,当长按手势触发时,没有执行任何动作;当长按手势结束时,通过onActionEnd回调,调用getUIContext().getPromptAction().showToast方法显示提示消息“Long Press”。
  8. 设置Column布局的宽度为100%。
  9. 设置Row布局的高度为100%。

这是因为Image组件内置的长按动画和用户自定义的长按手势LongPressGesture冲突了。可以使用priorityGesture绑定手势的方式替代gesture的方式,这样就会只响应自定义手势LongPressGesture了。如果需要两者都执行可以使用parallelGesture的绑定方式。

.priorityGesture(
  LongPressGesture({ repeat: true })
    .onAction((event: GestureEvent) => {
    })
    .onActionEnd(() => {
      this.getUIContext().getPromptAction().showToast({ message: 'Long Press' });
    })
  )

代码逻辑走读:

  1. 定义长按手势:使用LongPressGesture定义了一个长按手势,并通过repeat: true设置该手势在长按过程中可以重复触发。
  2. 设置动作回调:通过.onAction((event: GestureEvent) => { })为长按手势设置了一个动作回调函数,该函数在每次长按动作触发时执行。
  3. 设置动作结束回调:通过.onActionEnd(() => { })为长按手势设置了一个动作结束回调函数,该函数在长按动作结束时执行。
  4. 显示提示消息:在动作结束回调函数中,调用this.getUIContext().getPromptAction().showToast({ message: 'Long Press' });显示一个提示消息“Long Press”。

手势事件透传

和触摸事件一样,手势事件也可以通过hitTestBehavior属性来进行透传,例如下面这个示例,上层的Column组件设置hitTestBehavior属性为hitTestMode.none后,可以将滑动手势SwipeGesture透传给被覆盖的Column组件。hitTestMode.none:自身不接收事件,但不会阻塞兄弟组件和子组件继续做触摸测试。

@Entry
@Component
struct GesturesConflictScene4 {
  build() {
    Stack() {
      Column()// The bottom column
        .width('100%')
        .height('100%')
        .backgroundColor(Color.Black)
        .gesture(
          SwipeGesture({ direction: SwipeDirection.Horizontal })//水平方向滑动手势
            .onAction((event) => {
              if (event) {
                console.info('Column SwipeGesture');
              }
            })
        )
      Column()// The upper-level column
        .width(300)
        .height(100)
        .backgroundColor(Color.Red)
        .hitTestBehavior(HitTestMode.None)
    }
    .width(300)
    .height(300)
  }
}

代码逻辑走读:

  1. 定义一个名为GesturesConflictScene4的组件,使用@Entry@Component装饰器标记为入口组件。
  2. build方法中,创建一个堆叠布局Stack,包含两个列布局Column
  3. 下面的列布局设置为全宽全高,背景色为黑色,并添加一个水平滑动手势监听器SwipeGesture,当用户在该列布局上进行水平滑动时,通过onAction回调函数在控制台输出信息。
  4. 上面的列布局设置为宽300,高100,背景色为红色,并且设置为不参与命中测试(HitTestMode.None),这意味着该列布局不会响应点击或触摸事件。
  5. 堆叠布局的尺寸被设置为300x300,限制其显示区域。

多点触控场景下手势冲突

当一个页面中有多个组件可以响应手势事件,在多个手指触控的情况下,多个组件可能会同时响应手势事件,从而导致业务异常。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.');
        }),
    )
  }
}

代码逻辑走读:

  1. 组件定义与状态初始化
    • 使用@Entry@Component装饰器定义了一个名为GesturesConflictScene5的组件。
    • 初始化了一个状态变量message,其初始值为’Hello World’。
  2. 布局构建
    • build方法中,使用Column组件创建了一个垂直布局。
    • Column内部,使用Row组件创建了一个水平布局,其中包含两个按钮。
  3. 按钮配置
    • 第一个按钮Button1设置了宽度、高度,并通过.monopolizeEvents(true)使其独占事件,这意味着该按钮将拦截所有触摸事件。
    • 第二个按钮Button2设置了宽度、高度,并绑定了一个点击事件,当点击时,会在控制台输出信息。
  4. 文本显示
    • Row下方,使用Text组件显示message变量的内容,并设置了边距。
  5. 拖动与手势响应
    • Column组件设置了拖动开始的回调,并在拖动开始时在控制台输出信息。
    • 使用gesture方法为Column组件添加了一个单击手势,当手势触发时,会在控制台输出信息。
  6. 布局样式
    • Column组件设置了宽度为100%,并为RowText组件设置了边距。

动态控制自定义手势是否响应

在手势识别期间,开发者决定是否响应手势,例如下面的示例代码,通过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) {
          // Returning to the REJECT will fail the drag gesture
          return GestureJudgeResult.REJECT;
        } else if (gestureInfo.tag === 'tap1' && event.pressure > 10) {
          return GestureJudgeResult.CONTINUE
        }
        return GestureJudgeResult.CONTINUE;
      })
  }
}

代码逻辑走读:

  1. 定义了一个名为GesturesConflictScene6的组件,并使用@Entry@Component装饰器标记为入口组件。
  2. 在组件内部定义了一个状态变量message,初始值为’Hello World’。
  3. build方法中,使用Column组件创建一个垂直布局的容器,设置其宽度为100%,高度为200,背景颜色为棕色。
  4. 为容器添加了onDragStart事件处理函数,当拖拽开始时,会在控制台输出一条信息。
  5. 使用gesture方法为容器添加了一个单击手势TapGesture,并设置了标签为’tap1’,当单击手势触发时,会在控制台输出一条信息。
  6. 通过onGestureJudgeBegin方法定义了手势判断逻辑,当手势类型为长按或拖拽时,会输出相应的信息,并根据手势的不同返回相应的手势判断结果。
  7. 当手势类型为单击且标签为’tap1’时,还会根据事件的压力值判断是否继续处理手势。

父组件如何管理子组件手势

父子组件嵌套滚动发生手势冲突,父组件有机制可以干预子组件的手势响应。下面例子介绍了如何使用手势拦截增强,在外层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) { // External rolling container
        Column() {
          Text('Scroll Area')
            .width('100%')
            .height(150)
            .backgroundColor(0xFFFFFF)
            .borderRadius(15)
            .fontSize(16)
            .textAlign(TextAlign.Center)
            .margin({ top: 10 })
          Scroll(this.scroller2) { // internal rolling container
            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) // The scroll bar is always displayed
          .width('100%')
          .height(800)
        }.width('100%')
      }
      .id('outerScroll')
      .height(600)
      .scrollBar(BarState.Off) // The scroll bar is always displayed
      .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) { // Find the recognizer that will form the parallel gesture
              this.currentRecognizer = current; // Save the recognizer of the current component
              this.childRecognizer = others[i]; // Save the recognizer that will form the parallel gesture
              return others[i]; // Return the recognizer that will form the parallel gesture
            }
          }
        }
        return undefined;
      })
      .onGestureRecognizerJudgeBegin((event: BaseGestureEvent, current: GestureRecognizer,
        others: Array<GestureRecognizer>) => { // When the recognizer is about to succeed, set the recognizer enabling status according to the current component status
        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') { // Find the corresponding parallel recognizer on the response chain
                    let panEvent = event as PanGestureEvent;
                    if (target.isEnd()) { // isEnd returns whether the current rolling container component is at the bottom of the dynamic control status of the recognizer based on the current component status and movement direction
                      if (panEvent && panEvent.offsetY < 0) {
                        this.childRecognizer.setEnabled(false) // When it's the end, pull up
                        this.currentRecognizer.setEnabled(true)
                      } else {
                        this.childRecognizer.setEnabled(true)
                        this.currentRecognizer.setEnabled(false)
                      }
                    } else if (target.isBegin()) {
                      if (panEvent.offsetY > 0) { // Pull down at the beginning
                        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)
  }
}

代码逻辑走读:

  1. 组件定义与初始化
    • 定义了两个Scroller对象scrollerscroller2,用于控制外部和内部滚动区域的滚动。
    • 初始化了一个数字数组arr,用于在内部滚动区域中显示。
    • 创建了两个GestureRecognizer对象childRecognizercurrentRecognizer,用于保存手势识别器。
  2. 组件构建
    • 使用Stack布局,内部包含一个外部滚动区域Scroll(this.scroller)和一个内部滚动区域Scroll(this.scroller2)
    • 外部滚动区域包含一个Column,内部有滚动区域的文本显示和内部滚动区域。
    • 内部滚动区域包含一个Column,内部有滚动区域的文本显示和一个ForEach循环,用于显示数组arr中的每个数字。
  3. 手势识别器设置
    • 使用shouldBuiltInRecognizerParallelWith方法,确保外部滚动区域的手势识别器与内部滚动区域的手势识别器并行。
    • 使用onGestureRecognizerJudgeBegin方法,根据手势事件和组件状态,动态设置手势识别器的启用状态,避免手势冲突。
  4. 滚动区域样式与事件处理
    • 设置滚动区域的样式,包括背景颜色、边框圆角、字体大小和对齐方式。
    • 处理滚动区域的滚动事件,确保内部滚动区域的滚动与外部滚动区域的滚动不冲突。

总结

手势冲突在界面开发中往往不可避免,特别是在复杂的应用界面中。针对不同的冲突场景和手势交互需求,需要选择合适的解决方案。可以参考前面介绍的影响触摸测试因素,以及手势响应控制里面的方法,进行尝试。

  • Grid、List、Scroll、Swiper、WaterFlow等滚动容器的嵌套,可以尝试使用nestedScroll属性来解决视图滚动冲突的问题。
  • 对于单个组件组合手势的使用产生的冲突,以及自定义手势和系统手势冲突,可以尝试使用组合手势中的顺序识别、并行识别和互斥识别来解决。
  • 对于多层组件手势响应冲突,可以参考多层级手势事件。
  • 如果需要将系统手势和比其优先级高的手势做并行化处理,并可以动态控制手势事件的触发,可以参考手势拦截增强。
  • 如果只是需要动态控制自定义手势是否响应,可以参考自定义手势判定。
  • 对于多点触控产生的手势冲突可以参考事件独占控制。
Logo

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

更多推荐