鸿蒙应用开发UI基础第四十节:手势冲突处理 - 三独立案例实战

【学习目标】

  1. 掌握自定义手势判定(onGestureJudgeBegin)解决区域化手势冲突的方法
  2. 掌握手势并行动态控制(shouldBuiltInRecognizerParallelWith + setEnabled)解决嵌套滚动冲突
  3. 掌握阻止手势参与识别(onTouchTestDone + preventBegin)解决组件手势抢占
  4. 能够独立分析手势冲突场景,并选择合适的技术方案

一、工程目录结构

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。
Logo

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

更多推荐