HarmonyOS6 ArkUI 子组件触摸测试控制(onChildTouchTest)全面解析与实战演示

文章目录

    • [一、onChildTouchTest 核心概念](#一、onChildTouchTest 核心概念)
      • [1.1 触摸测试流程与干预时机](#1.1 触摸测试流程与干预时机)
      • [1.2 API 定义](#1.2 API 定义)
      • [1.3 TouchTestInfo 接口详解](#1.3 TouchTestInfo 接口详解)
      • [1.4 TouchResult 接口详解](#1.4 TouchResult 接口详解)
      • [1.5 TouchTestStrategy 枚举详解](#1.5 TouchTestStrategy 枚举详解)
    • 二、基础用法:三种策略演示
      • [2.1 DEFAULT ------ 系统默认行为](#2.1 DEFAULT —— 系统默认行为)
      • [2.2 FORWARD_COMPETITION ------ 竞争转发](#2.2 FORWARD_COMPETITION —— 竞争转发)
      • [2.3 FORWARD ------ 独占转发](#2.3 FORWARD —— 独占转发)
    • 三、可运行完整示例:策略交互对比
      • [3.1 功能说明](#3.1 功能说明)
    • [四、进阶场景:List 滚动与 Button 点击竞争](#四、进阶场景:List 滚动与 Button 点击竞争)
      • [4.1 场景说明](#4.1 场景说明)
      • [4.2 策略选型对比](#4.2 策略选型对比)
    • 五、实战场景:基于坐标的自定义分区控制
      • [5.1 场景说明](#5.1 场景说明)
    • 六、综合实战:四场景触摸测试演示页面
      • [6.1 功能说明](#6.1 功能说明)
      • [6.2 完整可运行代码](#6.2 完整可运行代码)
    • 总结

一、onChildTouchTest 核心概念

1.1 触摸测试流程与干预时机

ArkUI 的触摸事件处理流程分为两个阶段:

复制代码
用户手指触碰屏幕
       ↓
  【阶段一:触摸测试 Touch HitTest】
  从根节点向叶节点遍历,找出所有命中组件
       ↓
  ← 此处 onChildTouchTest 触发 →
  父容器拿到子组件信息,返回 TouchResult
  告知框架:哪个子组件参与 + 采用什么策略
       ↓
  【阶段二:事件分发 Event Dispatch】
  确定事件消费者,触发 onTouch/onClick/手势回调

onChildTouchTest阶段一 结束前插入,回调时框架已知道哪些子组件在触点范围内(放入 TouchTestInfo[] 数组),开发者在回调中返回 TouchResult 指导后续分发策略。

1.2 API 定义

onChildTouchTest 是 ArkUI 容器类组件通用属性,可链式调用:

typescript 复制代码
// 接口签名(API 10+)
onChildTouchTest(event: (value: TouchTestInfo[]) => TouchResult): T
参数 类型 说明
event (value: TouchTestInfo[]) => TouchResult 触摸测试回调,必须同步返回 TouchResult
value TouchTestInfo[] 当前触点范围内所有设置了 id 的子组件信息数组
返回值 当前组件实例 T 支持链式调用

关键点 :只有子组件设置了 .id('xxx') 属性 ,才会出现在 value 数组中;未设置 id 的子组件不参与回调。
版本说明onChildTouchTestAPI version 10 开始支持;在 attributeModifier 中使用需 API version 20 ;原子化服务支持从 API 12 起。

1.3 TouchTestInfo 接口详解

TouchTestInfo 描述每个子组件在本次触摸测试中的上下文信息:

字段 类型 说明
windowX number(vp) 触点相对于窗口左上角的 X 坐标
windowY number(vp) 触点相对于窗口左上角的 Y 坐标
parentX number(vp) 触点相对于父组件左上角的 X 坐标
parentY number(vp) 触点相对于父组件左上角的 Y 坐标
x number(vp) 触点相对于子组件自身左上角的 X 坐标
y number(vp) 触点相对于子组件自身左上角的 Y 坐标
rect RectResult 子组件的位置和尺寸信息
id string 子组件的唯一标识(对应 .id('xxx') 设置的值)

RectResult 字段:

字段 类型 说明
x number(vp) 子组件左上角相对于父组件的 X 坐标
y number(vp) 子组件左上角相对于父组件的 Y 坐标
width number(vp) 子组件宽度
height number(vp) 子组件高度

1.4 TouchResult 接口详解

TouchResult 是回调函数的返回值,用于指导框架后续事件分发:

字段 类型 是否必填 说明
strategy TouchTestStrategy 必填 后续触摸测试与事件分发策略
id string 条件必填 目标子组件 id;strategyFORWARD_COMPETITIONFORWARD必须指定

1.5 TouchTestStrategy 枚举详解

TouchTestStrategyonChildTouchTest 的核心决策枚举,共三种策略:

枚举值 数值 含义 适用场景
TouchTestStrategy.DEFAULT 0 默认行为:按系统原始命中状态分发,不干预子组件竞争 无需干预时的兜底返回
TouchTestStrategy.FORWARD_COMPETITION 1 竞争转发 :强制将事件转发给指定子组件(id),同时兄弟节点是否也能接收由系统继续决定 List 滚动与 Button 点击共存
TouchTestStrategy.FORWARD 2 独占转发 :仅将事件转发给指定子组件,阻止所有兄弟节点接收 精确指定唯一响应者

三策略核心区别DEFAULT 不干预;FORWARD_COMPETITION 加入竞争;FORWARD 独占事件。


二、基础用法:三种策略演示

2.1 DEFAULT ------ 系统默认行为

typescript 复制代码
// 子组件必须设置 id 才能出现在 value 数组中
Column() {
  Button('按钮 A').height(44).id('btnA')
  Button('按钮 B').height(44).id('btnB')
}
.onChildTouchTest((infos: TouchTestInfo[]) => {
  console.info(`触点范围内子组件数:${infos.length}`)
  infos.forEach(info => {
    console.info(`  子组件 id=${info.id},parentX=${info.parentX.toFixed(0)},parentY=${info.parentY.toFixed(0)}`)
  })
  // 返回 DEFAULT:不干预,系统按原始逻辑分发
  return { strategy: TouchTestStrategy.DEFAULT }
})

运行结果如图:

2.2 FORWARD_COMPETITION ------ 竞争转发

typescript 复制代码
// 场景:List 与 Button 同处一个容器
// 希望 List 也能收到触摸事件参与滚动,同时 Button 正常响应 onClick
Stack() {
  List() {
    // 列表项...
  }.id('myList')
  Button('操作按钮').id('myBtn')
}
.onChildTouchTest((infos: TouchTestInfo[]) => {
  // 检查触点是否命中 List
  const listInfo = infos.find(info => info.id === 'myList')
  if (listInfo) {
    // FORWARD_COMPETITION:让 List 参与竞争,但不阻止 Button 正常响应
    return { strategy: TouchTestStrategy.FORWARD_COMPETITION, id: 'myList' }
  }
  return { strategy: TouchTestStrategy.DEFAULT }
})

2.3 FORWARD ------ 独占转发

typescript 复制代码
// 场景:需要强制让某个子组件独占所有触摸事件
Column() {
  Text('被独占的组件').id('exclusive').onTouch((e: TouchEvent) => {
    console.info('独占收到触摸事件')
  })
  Button('这个不会收到').id('ignored').onClick(() => {
    console.info('此回调不会触发')
  })
}
.onChildTouchTest((infos: TouchTestInfo[]) => {
  const hasExclusive = infos.some(info => info.id === 'exclusive')
  if (hasExclusive) {
    // FORWARD:独占,只有 'exclusive' 组件收到事件
    return { strategy: TouchTestStrategy.FORWARD, id: 'exclusive' }
  }
  return { strategy: TouchTestStrategy.DEFAULT }
})

三、可运行完整示例:策略交互对比

3.1 功能说明

以下示例提供三个可切换的策略演示卡片,分别展示 TouchTestStrategy 三种策略的实际效果,并实时记录事件触发日志:

typescript 复制代码
// entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct ChildTouchTestDemo {
  @State selectedStrategy: number = 0  // 0=DEFAULT, 1=FORWARD_COMPETITION, 2=FORWARD
  @State eventLog: string[] = ['等待触摸事件...']
  @State btnACount: number = 0
  @State btnBCount: number = 0
  @State containerCount: number = 0

  private strategyNames: string[] = ['DEFAULT', 'FORWARD_COMPETITION', 'FORWARD']
  private strategyColors: string[] = ['#E3F2FD', '#E8F5E9', '#FFF3E0']
  private strategyDesc: string[] = [
    '不干预,系统原始分发',
    '转发给 A,同时 B 也可能响应',
    '只有 A 独占,B 不会响应'
  ]

  private appendLog(msg: string): void {
    this.eventLog = [msg, ...this.eventLog.slice(0, 9)]
  }

  private getStrategy(): TouchResult {
    switch (this.selectedStrategy) {
      case 1:
        return { strategy: TouchTestStrategy.FORWARD_COMPETITION, id: 'childA' }
      case 2:
        return { strategy: TouchTestStrategy.FORWARD, id: 'childA' }
      default:
        return { strategy: TouchTestStrategy.DEFAULT }
    }
  }

  build() {
    Column({ space: 0 }) {
      // 标题栏
      Row({ space: 8 }) {
        Text('🧩').fontSize(22)
        Text('子组件触摸测试控制演示').fontSize(17).fontWeight(FontWeight.Bold).fontColor(Color.White)
      }
      .width('100%').padding({ left: 20, right: 20, top: 16, bottom: 14 })
      .backgroundColor('#1B5E20').justifyContent(FlexAlign.Start)

      Scroll() {
        Column({ space: 16 }) {
          // 策略选择器
          Text('选择 TouchTestStrategy').fontSize(12).fontColor('#9E9E9E').width('90%').margin({ top: 16 })
          Column({ space: 6 }) {
            ForEach(this.strategyNames, (name: string, idx: number) => {
              Row({ space: 10 }) {
                Text(idx.toString())
                  .width(22).height(22).textAlign(TextAlign.Center).borderRadius(11)
                  .fontSize(11).fontColor(Color.White)
                  .backgroundColor(this.selectedStrategy === idx ? '#1B5E20' : '#BDBDBD')
                Text(name).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#333333').layoutWeight(1)
                Text(this.strategyDesc[idx]).fontSize(11).fontColor('#888888')
              }
              .width('90%').padding({ left: 12, right: 12, top: 10, bottom: 10 })
              .backgroundColor(this.selectedStrategy === idx ? this.strategyColors[idx] : '#F9F9F9')
              .borderRadius(10)
              .border({ width: this.selectedStrategy === idx ? 1.5 : 1,
                color: this.selectedStrategy === idx ? '#1B5E20' : '#E0E0E0',
                style: BorderStyle.Solid })
              .animation({ duration: 150, curve: Curve.EaseOut })
              .onClick(() => {
                this.selectedStrategy = idx
                this.btnACount = 0
                this.btnBCount = 0
                this.containerCount = 0
                this.eventLog = [`切换策略:TouchTestStrategy.${name}`]
              })
            })
          }

          // 演示区
          Text('触摸下方区域测试').fontSize(12).fontColor('#9E9E9E').width('90%')

          // 容器(绑定 onChildTouchTest)
          Column({ space: 14 }) {
            Text(`容器(策略:${this.strategyNames[this.selectedStrategy]})`)
              .fontSize(12).fontColor('#555555').fontWeight(FontWeight.Bold)

            // 子组件 A(被策略指向的组件)
            Row({ space: 10 }) {
              Text('A').width(28).height(28).textAlign(TextAlign.Center).borderRadius(14)
                .fontSize(13).fontWeight(FontWeight.Bold).fontColor(Color.White).backgroundColor('#1B5E20')
              Text('子组件 A(策略目标)').fontSize(13).fontColor('#333333').layoutWeight(1)
              Text(`×${this.btnACount}`).fontSize(13).fontColor('#1B5E20').fontWeight(FontWeight.Bold)
            }
            .width('100%').height(50).padding({ left: 14, right: 14 })
            .backgroundColor('#E8F5E9').borderRadius(10)
            .border({ width: 1, color: '#A5D6A7', style: BorderStyle.Solid })
            .id('childA')
            .onClick(() => {
              this.btnACount++
              this.appendLog(`✅ 子组件 A 点击 ×${this.btnACount}`)
            })

            // 子组件 B(对照组件)
            Row({ space: 10 }) {
              Text('B').width(28).height(28).textAlign(TextAlign.Center).borderRadius(14)
                .fontSize(13).fontWeight(FontWeight.Bold).fontColor(Color.White).backgroundColor('#FF6F00')
              Text('子组件 B(对照组件)').fontSize(13).fontColor('#333333').layoutWeight(1)
              Text(`×${this.btnBCount}`).fontSize(13).fontColor('#FF6F00').fontWeight(FontWeight.Bold)
            }
            .width('100%').height(50).padding({ left: 14, right: 14 })
            .backgroundColor('#FFF3E0').borderRadius(10)
            .border({ width: 1, color: '#FFCC80', style: BorderStyle.Solid })
            .id('childB')
            .onClick(() => {
              this.btnBCount++
              this.appendLog(`🟠 子组件 B 点击 ×${this.btnBCount}`)
            })

            Row({ space: 20 }) {
              Text(`容器点击:×${this.containerCount}`).fontSize(11).fontColor('#9E9E9E')
              Text('点击以上两个子组件测试效果').fontSize(11).fontColor('#BDBDBD')
            }
          }
          .width('90%').padding(16)
          .backgroundColor(Color.White).borderRadius(16)
          .border({ width: 1.5, color: '#E0E0E0', style: BorderStyle.Solid })
          .onChildTouchTest((infos: TouchTestInfo[]) => {
            const ids = infos.map(i => i.id).join(', ')
            this.appendLog(`🔍 onChildTouchTest: [${ids || '无id子组件'}] → ${this.strategyNames[this.selectedStrategy]}`)
            return this.getStrategy()
          })
          .onClick(() => {
            this.containerCount++
            this.appendLog(`📦 容器 onClick ×${this.containerCount}`)
          })

          // 事件日志
          Column({ space: 4 }) {
            Row() {
              Text('事件日志').fontSize(11).fontColor('#9E9E9E').layoutWeight(1)
              Text('(最新在顶)').fontSize(10).fontColor('#BDBDBD')
            }.width('100%')
            ForEach(this.eventLog, (item: string, idx: number) => {
              Text(item)
                .fontSize(11).fontColor(idx === 0 ? '#1B5E20' : '#BDBDBD').width('100%')
            })
          }
          .width('90%').padding(12).backgroundColor('#F9F9F9').borderRadius(12)
          .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })
          .alignItems(HorizontalAlign.Start)

          // 策略说明卡片
          Column({ space: 8 }) {
            Text('💡 三种策略说明').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#1B5E20')
            ForEach([
              ['DEFAULT', '不干预,按系统原始逻辑', '简单场景兜底'],
              ['FORWARD_COMPETITION', '加入竞争,id 子组件优先参与', 'List+Button 共存'],
              ['FORWARD', '独占转发给 id 子组件', '精确唯一响应者'],
            ], (row: string[]) => {
              Row({ space: 8 }) {
                Text(row[0]).fontSize(10).fontWeight(FontWeight.Bold).fontColor('#333333').width(130)
                Text(row[1]).fontSize(10).fontColor('#666666').layoutWeight(1)
                Text(row[2]).fontSize(10).fontColor('#888888').width(90)
              }.width('100%')
            })
          }
          .width('90%').padding(14).backgroundColor('#F1F8E9').borderRadius(12)
          .border({ width: 1, color: '#C5E1A5', style: BorderStyle.Solid })
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%').alignItems(HorizontalAlign.Center).padding({ bottom: 24 })
      }
      .layoutWeight(1).width('100%')
    }
    .width('100%').height('100%').backgroundColor(Color.White)
  }
}

运行效果如图:


四、进阶场景:List 滚动与 Button 点击竞争

4.1 场景说明

这是 onChildTouchTest 最典型的应用场景:页面中有一个可滚动的 List 和多个 Button,默认情况下拖动 List 区域可能因为事件被 Button 消费而无法滚动,使用 FORWARD_COMPETITION 可完美解决此问题:

typescript 复制代码
// entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct ListButtonCompetitionDemo {
  @State log: string[] = ['等待操作...']
  @State scrollCount: number = 0
  @State btnClickCount: number = 0

  private appendLog(msg: string): void {
    this.log = [msg, ...this.log.slice(0, 9)]
  }

  build() {
    Column({ space: 0 }) {
      Text('List 滚动 vs Button 点击').fontSize(18).fontWeight(FontWeight.Bold)
        .width('100%').textAlign(TextAlign.Center).padding(16)
        .backgroundColor('#1B5E20').fontColor(Color.White)

      Column({ space: 0 }) {
        // 统计信息
        Row({ space: 20 }) {
          Column({ space: 4 }) {
            Text(`${this.scrollCount}`).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#1B5E20')
            Text('滚动次数').fontSize(11).fontColor('#9E9E9E')
          }
          Column().width(1).height(40).backgroundColor('#E0E0E0')
          Column({ space: 4 }) {
            Text(`${this.btnClickCount}`).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FF6F00')
            Text('按钮点击').fontSize(11).fontColor('#9E9E9E')
          }
        }
        .width('100%').padding({ top: 12, bottom: 12 }).justifyContent(FlexAlign.SpaceEvenly)
        .backgroundColor(Color.White).border({ width: { bottom: 1 }, color: '#F0F0F0', style: BorderStyle.Solid })

        // 核心区域:List + 悬浮 Button
        Stack({ alignContent: Alignment.BottomEnd }) {
          // 可滚动 List
          List({ space: 0 }) {
            ForEach(Array.from({ length: 20 }, (_, i) => i), (idx: number) => {
              ListItem() {
                Row({ space: 12 }) {
                  Text(`${idx + 1}`).fontSize(13).fontColor('#BDBDBD').width(32).textAlign(TextAlign.Center)
                  Text(`列表项 ${idx + 1} --- 向上滑动可滚动,点击右侧按钮测试`).fontSize(13).fontColor('#333333').layoutWeight(1)
                }
                .width('100%').height(56).padding({ left: 12, right: 12 })
                .backgroundColor(idx % 2 === 0 ? Color.White : '#FAFAFA')
                .border({ width: { bottom: 1 }, color: '#F5F5F5', style: BorderStyle.Solid })
                .onClick(() => {
                  this.appendLog(`📄 列表项 ${idx + 1} 点击`)
                })
              }
            })
          }
          .width('100%').height(320)
          .id('scrollList')
          .onScrollIndex((start: number) => {
            this.scrollCount++
            this.appendLog(`📜 List 滚动,首项 index=${start},次数 ×${this.scrollCount}`)
          })

          // 悬浮操作按钮(右下角)
          Column({ space: 8 }) {
            Button('➕ 新增').height(40).width(80).fontSize(12).borderRadius(20)
              .backgroundColor('#1B5E20').fontColor(Color.White)
              .id('fabAdd')
              .onClick(() => {
                this.btnClickCount++
                this.appendLog(`➕ 新增按钮点击 ×${this.btnClickCount}`)
              })
            Button('🔍 搜索').height(40).width(80).fontSize(12).borderRadius(20)
              .backgroundColor('#FF6F00').fontColor(Color.White)
              .id('fabSearch')
              .onClick(() => {
                this.btnClickCount++
                this.appendLog(`🔍 搜索按钮点击 ×${this.btnClickCount}`)
              })
          }
          .padding({ right: 16, bottom: 16 })
        }
        // 关键:在 Stack 上设置 onChildTouchTest
        .width('100%').height(320)
        .onChildTouchTest((infos: TouchTestInfo[]) => {
          this.appendLog(`触摸测试:命中子组件 [${infos.map(i => i.id).join(', ')}]`)
          // 只要 List 在触点范围内,就给它竞争权,让滑动手势能被 List 捕获
          const listHit = infos.find(info => info.id === 'scrollList')
          if (listHit) {
            return { strategy: TouchTestStrategy.FORWARD_COMPETITION, id: 'scrollList' }
          }
          return { strategy: TouchTestStrategy.DEFAULT }
        })
      }
      .layoutWeight(1).width('100%')

      // 事件日志
      Column({ space: 3 }) {
        Text('事件日志(最新在顶)').fontSize(11).fontColor('#9E9E9E').width('100%')
        ForEach(this.log.slice(0, 6), (item: string, idx: number) => {
          Text(item).fontSize(10).fontColor(idx === 0 ? '#1B5E20' : '#BDBDBD').width('100%')
        })
      }
      .width('100%').padding(12).backgroundColor('#F9F9F9')
      .border({ width: { top: 1 }, color: '#E0E0E0', style: BorderStyle.Solid })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%').height('100%').backgroundColor(Color.White)
  }
}

4.2 策略选型对比

以下三种策略在"List + Button"场景中的差异:

策略 拖动 List 点击 Button 适用
DEFAULT ❌ 可能无法滚动 ✅ 正常响应 无 List 嵌套时
FORWARD_COMPETITION ✅ 正常滚动 ✅ 正常响应 List + Button 共存(推荐)
FORWARD ✅ 正常滚动 ❌ 被阻止响应 仅需滚动,不要按钮

五、实战场景:基于坐标的自定义分区控制

5.1 场景说明

结合 TouchTestInfo 中的坐标信息,可以实现按触点位置决定分发策略,例如将容器分为上下两区:

typescript 复制代码
// entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct ZonedChildTouchDemo {
  @State log: string[] = ['等待触摸...']
  @State zone: string = '---'
  @State topCount: number = 0
  @State bottomCount: number = 0

  private appendLog(msg: string): void {
    this.log = [msg, ...this.log.slice(0, 9)]
  }

  build() {
    Column({ space: 16 }) {
      Text('分区子组件触摸控制').fontSize(20).fontWeight(FontWeight.Bold).margin({ top: 24 })
      Text('上半区:FORWARD 独占子组件 A  |  下半区:DEFAULT 正常分发').fontSize(11).fontColor('#888888')

      Text(`当前触摸区:${this.zone}`).fontSize(13).fontColor('#1B5E20').fontWeight(FontWeight.Bold)
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .backgroundColor('#E8F5E9').borderRadius(8)

      // 容器高度 280vp,上半区 y<140,下半区 y>=140
      Column({ space: 0 }) {
        // 上半区(蓝色背景)
        Column({ space: 10 }) {
          Text('上半区(FORWARD 独占 A)').fontSize(12).fontColor('#1565C0').fontWeight(FontWeight.Bold)
          Row({ space: 14 }) {
            // 子组件 A(被 FORWARD 指向)
            Column({ space: 4 }) {
              Text('A').fontSize(18).fontWeight(FontWeight.Bold).fontColor(Color.White)
              Text(`×${this.topCount}`).fontSize(12).fontColor('#90CAF9')
            }
            .width(70).height(56).justifyContent(FlexAlign.Center)
            .backgroundColor('#1565C0').borderRadius(12)
            .id('zoneA')
            .onClick(() => { this.topCount++; this.appendLog(`✅ 子组件 A 点击 ×${this.topCount}`) })

            // 子组件 B(上半区被阻止)
            Column({ space: 4 }) {
              Text('B').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#BDBDBD')
              Text('被阻止').fontSize(10).fontColor('#E0E0E0')
            }
            .width(70).height(56).justifyContent(FlexAlign.Center)
            .backgroundColor('#E3F2FD').borderRadius(12)
            .border({ width: 1, color: '#BBDEFB', style: BorderStyle.Dashed })
            .id('zoneB')
            .onClick(() => { this.appendLog('⚠️ 子组件 B(上半区)被 FORWARD 阻止,此行不触发') })
          }
        }
        .width('100%').height(140).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
        .backgroundColor('#E3F2FD')

        // 下半区(绿色背景)
        Column({ space: 10 }) {
          Text('下半区(DEFAULT 正常分发)').fontSize(12).fontColor('#2E7D32').fontWeight(FontWeight.Bold)
          Row({ space: 14 }) {
            // 子组件 A(下半区正常)
            Column({ space: 4 }) {
              Text('A').fontSize(18).fontWeight(FontWeight.Bold).fontColor(Color.White)
              Text(`×${this.topCount}`).fontSize(12).fontColor('#A5D6A7')
            }
            .width(70).height(56).justifyContent(FlexAlign.Center)
            .backgroundColor('#2E7D32').borderRadius(12)
            .id('zoneA2')
            .onClick(() => { this.topCount++; this.appendLog(`✅ 子组件 A 下半区点击 ×${this.topCount}`) })

            // 子组件 B(下半区正常)
            Column({ space: 4 }) {
              Text('B').fontSize(18).fontWeight(FontWeight.Bold).fontColor(Color.White)
              Text(`×${this.bottomCount}`).fontSize(12).fontColor('#A5D6A7')
            }
            .width(70).height(56).justifyContent(FlexAlign.Center)
            .backgroundColor('#43A047').borderRadius(12)
            .id('zoneB2')
            .onClick(() => { this.bottomCount++; this.appendLog(`🟢 子组件 B 下半区点击 ×${this.bottomCount}`) })
          }
        }
        .width('100%').height(140).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
        .backgroundColor('#E8F5E9')
      }
      .width('90%').borderRadius(16)
      .border({ width: 1.5, color: '#E0E0E0', style: BorderStyle.Solid })
      .clip(true)
      // 关键:按 parentY 坐标分区返回不同策略
      .onChildTouchTest((infos: TouchTestInfo[]) => {
        const y = infos[0]?.parentY ?? 0
        if (y < 140) {
          // 上半区:FORWARD 独占 zoneA
          this.zone = `上半区 (y=${y.toFixed(0)}) → FORWARD → A`
          this.appendLog(`⬆ 上半区 y=${y.toFixed(0)},FORWARD 独占 zoneA`)
          return { strategy: TouchTestStrategy.FORWARD, id: 'zoneA' }
        } else {
          // 下半区:DEFAULT 正常分发
          this.zone = `下半区 (y=${y.toFixed(0)}) → DEFAULT`
          this.appendLog(`⬇ 下半区 y=${y.toFixed(0)},DEFAULT 正常分发`)
          return { strategy: TouchTestStrategy.DEFAULT }
        }
      })

      // 事件日志
      Column({ space: 4 }) {
        Text('事件日志').fontSize(11).fontColor('#9E9E9E').width('100%')
        ForEach(this.log, (item: string, idx: number) => {
          Text(item).fontSize(11).fontColor(idx === 0 ? '#1B5E20' : '#BDBDBD').width('100%')
        })
      }
      .width('90%').padding(12).backgroundColor('#F9F9F9').borderRadius(12)
      .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%').height('100%').alignItems(HorizontalAlign.Center).backgroundColor(Color.White)
  }
}

六、综合实战:四场景触摸测试演示页面

6.1 功能说明

以下综合示例整合本文所有核心知识点,通过 Tab 切换展示四个场景:

  1. 策略对比 :三种 TouchTestStrategy 实时切换,查看子组件 A/B 响应差异
  2. List 竞争FORWARD_COMPETITION 解决 List 滚动与 Button 点击共存问题
  3. 分区控制parentY 坐标驱动上下分区返回不同策略
  4. TouchTestInfo 探针 :实时打印 TouchTestInfo 数组所有字段,帮助理解接口数据结构

6.2 完整可运行代码

typescript 复制代码
// entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct ChildTouchAllDemo {
  // ── Tab 控制 ──────────────────────────────────────
  @State activeTab: number = 0
  private tabs: string[] = ['🔀 策略', '📜 List', '↕ 分区', '🔬 探针']

  // ── Tab0:策略对比 ────────────────────────────────
  @State t0Strategy: number = 0
  @State t0Log: string[] = ['等待触摸...']
  @State t0ACount: number = 0
  @State t0BCount: number = 0
  private strategyNames: string[] = ['DEFAULT', 'FORWARD_COMPETITION', 'FORWARD']
  private strategyBg: string[] = ['#E3F2FD', '#E8F5E9', '#FFF3E0']

  // ── Tab1:List 竞争 ────────────────────────────────
  @State t1Log: string[] = ['等待操作...']
  @State t1Scroll: number = 0
  @State t1Btn: number = 0

  // ── Tab2:分区控制 ────────────────────────────────
  @State t2Log: string[] = ['等待触摸...']
  @State t2Zone: string = '---'
  @State t2ACount: number = 0
  @State t2BCount: number = 0

  // ── Tab3:探针 ────────────────────────────────────
  @State t3Log: string[] = ['等待触摸...']
  @State t3InfoDisplay: string = '暂无数据'

  private appendLog(arr: string[], msg: string): string[] {
    return [msg, ...arr.slice(0, 7)]
  }

  private getT0Strategy(): TouchResult {
    if (this.t0Strategy === 1) return { strategy: TouchTestStrategy.FORWARD_COMPETITION, id: 'tab0A' }
    if (this.t0Strategy === 2) return { strategy: TouchTestStrategy.FORWARD, id: 'tab0A' }
    return { strategy: TouchTestStrategy.DEFAULT }
  }

  @Builder
  Tab0() {
    Column({ space: 12 }) {
      // 策略选择
      Column({ space: 5 }) {
        ForEach(this.strategyNames, (name: string, idx: number) => {
          Text(name).fontSize(11).width('92%').textAlign(TextAlign.Center)
            .padding({ top: 8, bottom: 8 })
            .backgroundColor(this.t0Strategy === idx ? this.strategyBg[idx] : '#F5F5F5')
            .fontColor(this.t0Strategy === idx ? '#1B5E20' : '#888888')
            .fontWeight(this.t0Strategy === idx ? FontWeight.Bold : FontWeight.Normal)
            .borderRadius(8)
            .border({ width: this.t0Strategy === idx ? 1.5 : 0.5,
              color: this.t0Strategy === idx ? '#2E7D32' : '#E0E0E0',
              style: BorderStyle.Solid })
            .animation({ duration: 120, curve: Curve.EaseOut })
            .onClick(() => {
              this.t0Strategy = idx
              this.t0ACount = 0; this.t0BCount = 0
              this.t0Log = [`切换:TouchTestStrategy.${name}`]
            })
        })
      }.margin({ top: 12 })

      // 演示区
      Column({ space: 10 }) {
        Text(`策略:${this.strategyNames[this.t0Strategy]}`).fontSize(11).fontColor('#555555')
        Row({ space: 14 }) {
          Column({ space: 4 }) {
            Text('A(目标)').fontSize(11).fontColor(Color.White).fontWeight(FontWeight.Bold)
            Text(`×${this.t0ACount}`).fontSize(13).fontColor('#A5D6A7').fontWeight(FontWeight.Bold)
          }
          .width(100).height(56).justifyContent(FlexAlign.Center)
          .backgroundColor('#1B5E20').borderRadius(12)
          .id('tab0A')
          .onClick(() => { this.t0ACount++; this.t0Log = this.appendLog(this.t0Log, `✅ A 点击 ×${this.t0ACount}`) })

          Column({ space: 4 }) {
            Text('B(对照)').fontSize(11).fontColor('#FF6F00').fontWeight(FontWeight.Bold)
            Text(`×${this.t0BCount}`).fontSize(13).fontColor('#FF6F00').fontWeight(FontWeight.Bold)
          }
          .width(100).height(56).justifyContent(FlexAlign.Center)
          .backgroundColor('#FFF3E0').borderRadius(12)
          .border({ width: 1, color: '#FFCC80', style: BorderStyle.Solid })
          .id('tab0B')
          .onClick(() => { this.t0BCount++; this.t0Log = this.appendLog(this.t0Log, `🟠 B 点击 ×${this.t0BCount}`) })
        }
      }
      .width('92%').padding(14)
      .backgroundColor(Color.White).borderRadius(14)
      .border({ width: 1.5, color: '#E0E0E0', style: BorderStyle.Solid })
      .onChildTouchTest((infos: TouchTestInfo[]) => {
        this.t0Log = this.appendLog(this.t0Log,
          `onChildTouchTest: [${infos.map(i => i.id).join(', ')}]`)
        return this.getT0Strategy()
      })

      Column({ space: 3 }) {
        ForEach(this.t0Log, (item: string, idx: number) => {
          Text(item).fontSize(10).fontColor(idx === 0 ? '#1B5E20' : '#BDBDBD').width('100%')
        })
      }
      .width('92%').padding(10).backgroundColor('#F9F9F9').borderRadius(10)
      .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%').alignItems(HorizontalAlign.Center)
  }

  @Builder
  Tab1() {
    Column({ space: 10 }) {
      Row({ space: 24 }) {
        Column({ space: 3 }) {
          Text(`${this.t1Scroll}`).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#1B5E20')
          Text('滚动次数').fontSize(10).fontColor('#9E9E9E')
        }
        Column({ space: 3 }) {
          Text(`${this.t1Btn}`).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FF6F00')
          Text('按钮点击').fontSize(10).fontColor('#9E9E9E')
        }
      }
      .margin({ top: 12 }).justifyContent(FlexAlign.SpaceEvenly).width('92%')
      .padding({ top: 10, bottom: 10 })
      .backgroundColor('#F9F9F9').borderRadius(10)

      Stack({ alignContent: Alignment.BottomEnd }) {
        List({ space: 0 }) {
          ForEach(Array.from({ length: 15 }, (_, i) => i), (idx: number) => {
            ListItem() {
              Row({ space: 10 }) {
                Text(`${idx + 1}`).fontSize(12).fontColor('#BDBDBD').width(28)
                Text(`列表项 ${idx + 1},向上滑动以滚动`).fontSize(12).fontColor('#333333').layoutWeight(1)
              }
              .width('100%').height(52).padding({ left: 14, right: 14 })
              .backgroundColor(idx % 2 === 0 ? Color.White : '#FAFAFA')
              .border({ width: { bottom: 1 }, color: '#F5F5F5', style: BorderStyle.Solid })
            }
          })
        }
        .width('100%').height(260)
        .id('tab1List')
        .onScrollIndex((start: number) => {
          this.t1Scroll++
          this.t1Log = this.appendLog(this.t1Log, `📜 List 滚动,顶部 index=${start},×${this.t1Scroll}`)
        })

        Column({ space: 8 }) {
          Button('➕').height(44).width(44).borderRadius(22)
            .backgroundColor('#1B5E20').fontColor(Color.White).fontSize(18)
            .id('tab1FabA')
            .onClick(() => { this.t1Btn++; this.t1Log = this.appendLog(this.t1Log, `➕ 新增按钮 ×${this.t1Btn}`) })
          Button('🔍').height(44).width(44).borderRadius(22)
            .backgroundColor('#FF6F00').fontColor(Color.White).fontSize(18)
            .id('tab1FabB')
            .onClick(() => { this.t1Btn++; this.t1Log = this.appendLog(this.t1Log, `🔍 搜索按钮 ×${this.t1Btn}`) })
        }
        .padding({ right: 12, bottom: 12 })
      }
      .width('92%').height(260).borderRadius(14)
      .border({ width: 1.5, color: '#E0E0E0', style: BorderStyle.Solid })
      .onChildTouchTest((infos: TouchTestInfo[]) => {
        const listHit = infos.find(i => i.id === 'tab1List')
        if (listHit) {
          this.t1Log = this.appendLog(this.t1Log, `触摸测试:List 命中 → FORWARD_COMPETITION`)
          return { strategy: TouchTestStrategy.FORWARD_COMPETITION, id: 'tab1List' }
        }
        return { strategy: TouchTestStrategy.DEFAULT }
      })

      Column({ space: 3 }) {
        ForEach(this.t1Log, (item: string, idx: number) => {
          Text(item).fontSize(10).fontColor(idx === 0 ? '#1B5E20' : '#BDBDBD').width('100%')
        })
      }
      .width('92%').padding(10).backgroundColor('#F9F9F9').borderRadius(10)
      .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%').alignItems(HorizontalAlign.Center)
  }

  @Builder
  Tab2() {
    Column({ space: 10 }) {
      Text(`当前区域:${this.t2Zone}`).fontSize(12).fontColor('#1B5E20').fontWeight(FontWeight.Bold)
        .margin({ top: 12 }).padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .backgroundColor('#E8F5E9').borderRadius(8)

      Column({ space: 0 }) {
        Column({ space: 8 }) {
          Text('上半区(FORWARD → A)').fontSize(11).fontColor('#1565C0').fontWeight(FontWeight.Bold)
          Row({ space: 12 }) {
            Column({ space: 3 }) {
              Text('A').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Color.White)
              Text(`×${this.t2ACount}`).fontSize(11).fontColor('#90CAF9')
            }
            .width(64).height(48).justifyContent(FlexAlign.Center)
            .backgroundColor('#1565C0').borderRadius(10)
            .id('tab2A').onClick(() => { this.t2ACount++; this.t2Log = this.appendLog(this.t2Log, `✅ A ×${this.t2ACount}`) })

            Column({ space: 3 }) {
              Text('B').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#BDBDBD')
              Text('被阻止').fontSize(10).fontColor('#E0E0E0')
            }
            .width(64).height(48).justifyContent(FlexAlign.Center)
            .backgroundColor('#E3F2FD').borderRadius(10)
            .border({ width: 1, color: '#BBDEFB', style: BorderStyle.Dashed })
            .id('tab2B').onClick(() => { this.t2Log = this.appendLog(this.t2Log, '⚠️ B 上半区被 FORWARD 阻止') })
          }
        }
        .width('100%').height(120).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
        .backgroundColor('#E3F2FD')

        Column({ space: 8 }) {
          Text('下半区(DEFAULT 正常)').fontSize(11).fontColor('#2E7D32').fontWeight(FontWeight.Bold)
          Row({ space: 12 }) {
            Column({ space: 3 }) {
              Text('A').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Color.White)
              Text(`×${this.t2ACount}`).fontSize(11).fontColor('#A5D6A7')
            }
            .width(64).height(48).justifyContent(FlexAlign.Center)
            .backgroundColor('#2E7D32').borderRadius(10)
            .id('tab2A2').onClick(() => { this.t2ACount++; this.t2Log = this.appendLog(this.t2Log, `✅ A 下半区 ×${this.t2ACount}`) })

            Column({ space: 3 }) {
              Text('B').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Color.White)
              Text(`×${this.t2BCount}`).fontSize(11).fontColor('#A5D6A7')
            }
            .width(64).height(48).justifyContent(FlexAlign.Center)
            .backgroundColor('#43A047').borderRadius(10)
            .id('tab2B2').onClick(() => { this.t2BCount++; this.t2Log = this.appendLog(this.t2Log, `🟢 B 下半区 ×${this.t2BCount}`) })
          }
        }
        .width('100%').height(120).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
        .backgroundColor('#E8F5E9')
      }
      .width('92%').borderRadius(14)
      .border({ width: 1.5, color: '#E0E0E0', style: BorderStyle.Solid })
      .clip(true)
      .onChildTouchTest((infos: TouchTestInfo[]) => {
        const y = infos[0]?.parentY ?? 0
        if (y < 120) {
          this.t2Zone = `上半区 y=${y.toFixed(0)} → FORWARD A`
          this.t2Log = this.appendLog(this.t2Log, `⬆ y=${y.toFixed(0)} → FORWARD tab2A`)
          return { strategy: TouchTestStrategy.FORWARD, id: 'tab2A' }
        }
        this.t2Zone = `下半区 y=${y.toFixed(0)} → DEFAULT`
        this.t2Log = this.appendLog(this.t2Log, `⬇ y=${y.toFixed(0)} → DEFAULT`)
        return { strategy: TouchTestStrategy.DEFAULT }
      })

      Column({ space: 3 }) {
        ForEach(this.t2Log, (item: string, idx: number) => {
          Text(item).fontSize(10).fontColor(idx === 0 ? '#1B5E20' : '#BDBDBD').width('100%')
        })
      }
      .width('92%').padding(10).backgroundColor('#F9F9F9').borderRadius(10)
      .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%').alignItems(HorizontalAlign.Center)
  }

  @Builder
  Tab3() {
    Column({ space: 10 }) {
      Text('触摸以下区域,查看 TouchTestInfo 字段').fontSize(11).fontColor('#888888').margin({ top: 12 })

      // 探针区域
      Column({ space: 10 }) {
        Row({ space: 12 }) {
          Column({ space: 4 }) {
            Text('子组件 P').fontSize(11).fontWeight(FontWeight.Bold).fontColor(Color.White)
            Text('id = probeP').fontSize(9).fontColor('#90CAF9')
          }
          .width(100).height(56).justifyContent(FlexAlign.Center)
          .backgroundColor('#7B1FA2').borderRadius(12)
          .id('probeP')
          .onClick(() => { this.t3Log = this.appendLog(this.t3Log, '✅ probeP onClick 触发') })

          Column({ space: 4 }) {
            Text('子组件 Q').fontSize(11).fontWeight(FontWeight.Bold).fontColor(Color.White)
            Text('id = probeQ').fontSize(9).fontColor('#CE93D8')
          }
          .width(100).height(56).justifyContent(FlexAlign.Center)
          .backgroundColor('#AB47BC').borderRadius(12)
          .id('probeQ')
          .onClick(() => { this.t3Log = this.appendLog(this.t3Log, '✅ probeQ onClick 触发') })
        }
      }
      .width('92%').padding(16)
      .backgroundColor(Color.White).borderRadius(14)
      .border({ width: 1.5, color: '#CE93D8', style: BorderStyle.Solid })
      .onChildTouchTest((infos: TouchTestInfo[]) => {
        // 打印所有 TouchTestInfo 字段
        let display = `命中 ${infos.length} 个子组件\n`
        infos.forEach((info, i) => {
          display += `[${i}] id=${info.id}\n`
          display += `  parentX/Y: (${info.parentX.toFixed(1)}, ${info.parentY.toFixed(1)})\n`
          display += `  windowX/Y: (${info.windowX.toFixed(1)}, ${info.windowY.toFixed(1)})\n`
          display += `  x/y(子组件): (${info.x.toFixed(1)}, ${info.y.toFixed(1)})\n`
          display += `  rect: x=${info.rect.x.toFixed(0)},y=${info.rect.y.toFixed(0)} ${info.rect.width.toFixed(0)}×${info.rect.height.toFixed(0)}\n`
        })
        this.t3InfoDisplay = display
        this.t3Log = this.appendLog(this.t3Log, `触摸测试:[${infos.map(i => i.id).join(', ')}]`)
        return { strategy: TouchTestStrategy.DEFAULT }
      })

      // TouchTestInfo 详情展示
      Column({ space: 4 }) {
        Text('TouchTestInfo 字段详情').fontSize(11).fontColor('#7B1FA2').fontWeight(FontWeight.Bold).width('100%')
        Text(this.t3InfoDisplay)
          .fontSize(10).fontColor('#4A148C').width('100%')
          .fontFamily('monospace')
      }
      .width('92%').padding(12).backgroundColor('#F3E5F5').borderRadius(12)
      .border({ width: 1, color: '#CE93D8', style: BorderStyle.Solid })
      .alignItems(HorizontalAlign.Start)

      Column({ space: 3 }) {
        ForEach(this.t3Log, (item: string, idx: number) => {
          Text(item).fontSize(10).fontColor(idx === 0 ? '#7B1FA2' : '#BDBDBD').width('100%')
        })
      }
      .width('92%').padding(10).backgroundColor('#F9F9F9').borderRadius(10)
      .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%').alignItems(HorizontalAlign.Center)
  }

  build() {
    Column({ space: 0 }) {
      Text('ArkUI 子组件触摸测试控制综合演示')
        .fontSize(16).fontWeight(FontWeight.Bold).fontColor(Color.White)
        .width('100%').textAlign(TextAlign.Center)
        .padding({ top: 16, bottom: 12 })
        .backgroundColor('#1B5E20')

      Row() {
        ForEach(this.tabs, (tab: string, index: number) => {
          Text(tab)
            .fontSize(12).layoutWeight(1).textAlign(TextAlign.Center)
            .fontColor(this.activeTab === index ? '#1B5E20' : '#9E9E9E')
            .fontWeight(this.activeTab === index ? FontWeight.Bold : FontWeight.Normal)
            .padding({ top: 10, bottom: 10 })
            .border({ width: { bottom: this.activeTab === index ? 2 : 0 },
              color: '#1B5E20', style: BorderStyle.Solid })
            .onClick(() => { this.activeTab = index })
        })
      }
      .width('100%').backgroundColor(Color.White)
      .border({ width: { bottom: 1 }, color: '#E0E0E0', style: BorderStyle.Solid })

      Scroll() {
        Column() {
          if (this.activeTab === 0) {
            this.Tab0()
          } else if (this.activeTab === 1) {
            this.Tab1()
          } else if (this.activeTab === 2) {
            this.Tab2()
          } else {
            this.Tab3()
          }
        }
        .width('100%').padding({ bottom: 24 })
      }
      .layoutWeight(1).width('100%')
    }
    .width('100%').height('100%').backgroundColor(Color.White)
  }
}

总结

  1. id 是前提 :只有设置了 .id() 的子组件才会出现在 TouchTestInfo[] 数组中,使用前必须为子组件添加唯一标识
  2. 三种策略DEFAULT(不干预)、FORWARD_COMPETITION(竞争参与,解决 List 滚动与 Button 共存经典问题)、FORWARD(独占事件,精确路由到指定子组件)
  3. id 必须配套FORWARDFORWARD_COMPETITION 策略必须同时提供 id 字段,否则自动降级为 DEFAULT
  4. 容器专属onChildTouchTest 只能用于 ColumnRowStackList 等容器组件,叶子组件不支持
  5. 坐标驱动 :借助 TouchTestInfoparentX/Y 字段,可实现基于触点位置的分区策略控制
  6. 与 onTouchIntercept 互补onTouchIntercept 控制自身,onChildTouchTest 控制子组件;复杂场景可组合使用

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!

相关推荐
fei_sun5 小时前
【鸿蒙智能硬件】(一)环境配置
华为·harmonyos
大雷神5 小时前
HarmonyOS APP<玩转React>开源教程一:开发环境搭建与项目创建
harmonyos
HarmonyOS_SDK6 小时前
高德地图携手HamonyOS SDK,首发鸿蒙AR实景步导
harmonyos
是稻香啊9 小时前
HarmonyOS6 overlay 叠加层属性使用指南
harmonyos6
●VON11 小时前
【鸿蒙PC】在 HarmonyOS 上跑 Electron?手把手教你搞定桌面欢迎页!(Mac版)
开发语言·macos·华为·electron·电脑·harmonyos
深念Y11 小时前
华为重启门自救记:从打火机“邪修”到入坑电子维修之路
华为·鸿蒙·硬件·电子·数码·维修·虚焊
前端不太难12 小时前
一个真实鸿蒙 App 的工程目录结构
华为·状态模式·harmonyos
是稻香啊12 小时前
HarmonyOS6 ArkUI 无障碍事件(Accessibility Event)全面解析与实战演示
harmonyos6
枫叶丹412 小时前
【HarmonyOS 6.0】聚合链接(App Linking)实战:从创建配置到应用跳转
开发语言·华为·harmonyos