HarmonyOS6 ArkUI 无障碍悬停事件(onAccessibilityHover)全面解析与实战演示

文章目录

    • 一、无障碍悬停事件核心概念
      • [1.1 普通 Hover 与无障碍 Hover 的区别](#1.1 普通 Hover 与无障碍 Hover 的区别)
      • [1.2 无障碍悬停事件工作流程](#1.2 无障碍悬停事件工作流程)
    • [二、核心 API 详解](#二、核心 API 详解)
      • [2.1 onAccessibilityHover(API 12+)](#2.1 onAccessibilityHover(API 12+))
        • 接口签名
        • [AccessibilityHoverEvent 字段说明](#AccessibilityHoverEvent 字段说明)
      • [2.2 AccessibilityHoverType 枚举(API 12+)](#2.2 AccessibilityHoverType 枚举(API 12+))
      • [2.3 onAccessibilityHoverTransparent(API 20+)](#2.3 onAccessibilityHoverTransparent(API 20+))
    • 三、基础示例:悬停视觉反馈
    • [四、进阶示例:利用 event.type 区分四种悬停类型](#四、进阶示例:利用 event.type 区分四种悬停类型)
    • 五、坐标系详解:三套坐标在实际开发中的应用
      • [5.1 三套坐标系对比](#5.1 三套坐标系对比)
      • [5.2 组件内坐标分区示例](#5.2 组件内坐标分区示例)
    • [六、onAccessibilityHoverTransparent 透传穿透示例](#六、onAccessibilityHoverTransparent 透传穿透示例)
    • 七、综合实战:无障碍导航菜单
    • 总结

一、无障碍悬停事件核心概念

1.1 普通 Hover 与无障碍 Hover 的区别

复制代码
普通 Hover(鼠标/手写笔悬停)
  → onHover(isHover: boolean)
  → 物理指针未接触屏幕时触发

无障碍 Hover(单指探索触摸)
  → onAccessibilityHover(isHover: boolean, event: AccessibilityHoverEvent)
  → 无障碍模式下,单指触摸滑动时系统将 Touch 转换为 Hover 事件
  → 每经过一个可识别组件,依次触发 HOVER_ENTER → HOVER_MOVE → HOVER_EXIT
  → 辅助工具据此朗读当前组件信息
对比项 普通 Hover 无障碍 Hover
触发方式 鼠标/手写笔悬停 无障碍模式下单指滑动
API onHover onAccessibilityHover
事件类型 true/false HOVER_ENTER/MOVE/EXIT/CANCEL
坐标系 相对组件 相对组件 + 相对窗口 + 相对屏幕(多系)
触发前提 无需无障碍模式 必须开启系统无障碍服务
核心用途 鼠标交互高亮 辅助工具感知、朗读触发、焦点高亮

1.2 无障碍悬停事件工作流程

复制代码
用户(视障/辅助需求)
       ↓
开启系统无障碍服务(设置 → 无障碍 → 畅读/TalkBack)
       ↓
单指在屏幕上慢速滑动探索
       ↓
系统将 TouchEvent 转换为 AccessibilityHoverEvent
       ↓
  手指进入组件区域 → HOVER_ENTER  ← 常用:朗读组件信息、高亮显示
  手指在组件内移动 → HOVER_MOVE   ← 常用:追踪坐标、更新指示器
  手指离开组件区域 → HOVER_EXIT   ← 常用:重置高亮状态
  事件被系统中断   → HOVER_CANCEL ← 常用:清理未完成的悬停状态

二、核心 API 详解

2.1 onAccessibilityHover(API 12+)

接口签名
typescript 复制代码
// API 12+ 接口签名
onAccessibilityHover(
  callback: AccessibilityCallback
): T

// 回调类型定义
type AccessibilityCallback =
  (isHover: boolean, event: AccessibilityHoverEvent) => void
参数 类型 说明
callback AccessibilityCallback 无障碍悬停事件回调
isHover boolean true = 手指进入组件区域(HOVER_ENTER);false = 手指离开区域
event AccessibilityHoverEvent 完整悬停事件对象(含类型、坐标等)
返回值 当前组件实例 T 支持链式调用
AccessibilityHoverEvent 字段说明
字段 类型 API 版本 说明
type AccessibilityHoverType 12+ 当前悬停动作类型
x number 12+ 相对组件左上角 X 坐标(单位:vp)
y number 12+ 相对组件左上角 Y 坐标(单位:vp)
windowX number 12+ 相对应用窗口左上角 X 坐标(单位:vp)
windowY number 12+ 相对应用窗口左上角 Y 坐标(单位:vp)
displayX number 12+ 相对当前屏幕左上角 X 坐标(单位:vp)
displayY number 12+ 相对当前屏幕左上角 Y 坐标(单位:vp)
globalDisplayX `number undefined` 20+
globalDisplayY `number undefined` 20+

2.2 AccessibilityHoverType 枚举(API 12+)

枚举值 数值 触发时机 推荐处理
HOVER_ENTER --- 手指首次进入组件区域 触发高亮、更新焦点、触发朗读提示
HOVER_MOVE --- 手指在组件内移动 追踪坐标,驱动自定义指示器
HOVER_EXIT --- 手指离开组件区域 重置高亮,清除提示
HOVER_CANCEL --- 悬停被系统中断(来电/弹窗等) 清理未完成状态,恢复默认样式

注意isHover 参数是 type 的简化版------HOVER_ENTERisHover = trueHOVER_EXITHOVER_CANCELisHover = falseHOVER_MOVEisHover = true。在只需区分"是否在组件上"的场景,isHover 已足够;若需精确区分四种类型,请使用 event.type

2.3 onAccessibilityHoverTransparent(API 20+)

当组件及其所有子组件均不可聚焦(accessibilityLevel = "no" 或无文本、无交互事件)时,onAccessibilityHover 不会触发。此时可在父容器 使用 onAccessibilityHoverTransparent 捕获穿透的原始 Touch 事件:

typescript 复制代码
// API 20+ 接口签名
onAccessibilityHoverTransparent(
  callback: AccessibilityTransparentCallback
): T

// 回调类型定义
type AccessibilityTransparentCallback = (event: TouchEvent) => void
参数 类型 说明
callback AccessibilityTransparentCallback 捕获穿透的原始 TouchEvent
event.type TouchType HOVER_ENTER/HOVER_MOVE/HOVER_EXIT/HOVER_CANCEL
event.touches[0] TouchObject 触摸点坐标信息

三、基础示例:悬停视觉反馈

最常见的用法是在手指悬停于组件时给出高亮反馈,让低视力用户清楚感知当前探索位置:

typescript 复制代码
// entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct BasicHoverDemo {
  @State hoverText: string = '未悬停'
  @State bgColor: string = '#F5F5F5'
  @State borderColor: string = '#E0E0E0'
  @State borderWidth: number = 1

  build() {
    Column({ space: 24 }) {
      Text('onAccessibilityHover 基础示例').fontSize(18).fontWeight(FontWeight.Bold).margin({ top: 32 })
      Text('在无障碍模式下,用单指慢速滑过按钮区域').fontSize(12).fontColor('#888888')

      // 被监听的按钮
      Button(this.hoverText)
        .width(240).height(80).borderRadius(16).fontSize(16).fontWeight(FontWeight.Bold)
        .backgroundColor(this.bgColor)
        .fontColor(this.hoverText === '悬停中 ▶' ? '#1B5E20' : '#333333')
        .border({ width: this.borderWidth, color: this.borderColor, style: BorderStyle.Solid })
        .shadow(this.hoverText === '悬停中 ▶'
          ? { radius: 12, color: '#401B5E20', offsetX: 0, offsetY: 4 }
          : { radius: 0, color: Color.Transparent, offsetX: 0, offsetY: 0 })
        .animation({ duration: 200, curve: Curve.EaseOut })
        .accessibilityText('无障碍悬停演示按钮')
        .accessibilityDescription('当无障碍手指悬停时,按钮将变为绿色高亮')
        .accessibilityLevel('yes')
        .onAccessibilityHover((isHover: boolean, event: AccessibilityHoverEvent) => {
          if (isHover) {
            this.hoverText = '悬停中 ▶'
            this.bgColor = '#E8F5E9'
            this.borderColor = '#2E7D32'
            this.borderWidth = 2
          } else {
            this.hoverText = '未悬停'
            this.bgColor = '#F5F5F5'
            this.borderColor = '#E0E0E0'
            this.borderWidth = 1
          }
          console.info(`[无障碍悬停] isHover=${isHover}, type=${event.type}`)
        })

      // 状态说明
      Column({ space: 6 }) {
        Text('当前状态:').fontSize(12).fontColor('#9E9E9E').width('100%')
        Text(this.hoverText === '悬停中 ▶' ? '✅ 手指正在组件上方' : '○ 手指不在组件上方')
          .fontSize(14).fontWeight(FontWeight.Bold)
          .fontColor(this.hoverText === '悬停中 ▶' ? '#1B5E20' : '#9E9E9E')
      }
      .width('80%').padding(14).backgroundColor('#FAFAFA').borderRadius(12)
      .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%').height('100%').alignItems(HorizontalAlign.Center).backgroundColor(Color.White)
  }
}

四、进阶示例:利用 event.type 区分四种悬停类型

isHover 只能区分"进入/离开"两种状态。若需精准处理 HOVER_ENTERHOVER_MOVEHOVER_EXITHOVER_CANCEL 四种场景,应使用 event.type

typescript 复制代码
// entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct HoverTypeDemo {
  @State currentType: string = '等待悬停...'
  @State typeColor: string = '#9E9E9E'
  @State posX: number = 0
  @State posY: number = 0
  @State windowX: number = 0
  @State windowY: number = 0
  @State log: string[] = ['等待无障碍悬停事件...']

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

  build() {
    Column({ space: 0 }) {
      // 顶部标题
      Row({ space: 10 }) {
        Text('♿').fontSize(20)
        Text('四种悬停类型追踪').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Color.White)
      }
      .width('100%').padding({ left: 20, right: 20, top: 14, bottom: 12 })
      .backgroundColor('#1565C0').justifyContent(FlexAlign.Start)

      Scroll() {
        Column({ space: 16 }) {

          // 大型探索区域
          Column({ space: 12 }) {
            Text('无障碍探索区域(单指滑过)')
              .fontSize(13).fontColor('#888888').width('100%')

            Column({ space: 8 }) {
              Text('在无障碍模式下').fontSize(15).fontColor('#555555')
              Text('单指在此区域滑动').fontSize(15).fontColor('#555555')
              Text('观察下方事件类型变化').fontSize(13).fontColor('#9E9E9E')
            }
            .width('100%').height(140)
            .justifyContent(FlexAlign.Center)
            .alignItems(HorizontalAlign.Center)
            .backgroundColor(this.typeColor === '#1B5E20' ? '#E8F5E9'
              : this.typeColor === '#1565C0' ? '#E3F2FD'
              : this.typeColor === '#E65100' ? '#FFF3E0' : '#F5F5F5')
            .borderRadius(16)
            .border({
              width: this.currentType === '等待悬停...' ? 1 : 2,
              color: this.typeColor,
              style: BorderStyle.Dashed
            })
            .animation({ duration: 200, curve: Curve.EaseOut })
            .accessibilityText('四种悬停类型演示区域')
            .accessibilityDescription('在此区域单指滑动,可以触发进入、移动、退出、取消四种无障碍悬停事件')
            .accessibilityLevel('yes')
            .onAccessibilityHover((isHover: boolean, event: AccessibilityHoverEvent) => {
              this.posX = Math.round(event.x)
              this.posY = Math.round(event.y)
              this.windowX = Math.round(event.windowX)
              this.windowY = Math.round(event.windowY)

              switch (event.type) {
                case AccessibilityHoverType.HOVER_ENTER:
                  this.currentType = 'HOVER_ENTER'
                  this.typeColor = '#1B5E20'
                  this.appendLog(`✅ HOVER_ENTER  (${this.posX}, ${this.posY})`)
                  break
                case AccessibilityHoverType.HOVER_MOVE:
                  this.currentType = 'HOVER_MOVE'
                  this.typeColor = '#1565C0'
                  this.appendLog(`↔ HOVER_MOVE   (${this.posX}, ${this.posY})`)
                  break
                case AccessibilityHoverType.HOVER_EXIT:
                  this.currentType = 'HOVER_EXIT'
                  this.typeColor = '#E65100'
                  this.appendLog(`○ HOVER_EXIT   (${this.posX}, ${this.posY})`)
                  break
                case AccessibilityHoverType.HOVER_CANCEL:
                  this.currentType = 'HOVER_CANCEL'
                  this.typeColor = '#9E9E9E'
                  this.appendLog(`✖ HOVER_CANCEL (${this.posX}, ${this.posY})`)
                  break
              }
            })
          }
          .width('90%').alignItems(HorizontalAlign.Start)

          // 实时状态卡片
          Column({ space: 10 }) {
            Text('实时事件状态').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#1565C0').width('100%')

            Row({ space: 12 }) {
              // 类型徽标
              Column({ space: 4 }) {
                Text(this.currentType).fontSize(11).fontWeight(FontWeight.Bold)
                  .fontColor(this.typeColor).textAlign(TextAlign.Center)
                Text('事件类型').fontSize(10).fontColor('#9E9E9E')
              }
              .width(120).padding({ top: 10, bottom: 10 })
              .backgroundColor('#F8F8F8').borderRadius(10)
              .border({ width: 1, color: this.typeColor, style: BorderStyle.Solid })

              // 坐标面板
              Column({ space: 4 }) {
                Text(`组件内:(${this.posX}, ${this.posY})`).fontSize(11).fontColor('#333333')
                Text(`窗口内:(${this.windowX}, ${this.windowY})`).fontSize(11).fontColor('#555555')
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Start)
            }
            .width('100%')

            // 四种类型说明
            ForEach([
              ['HOVER_ENTER', '#1B5E20', '手指进入区域', '触发高亮、播报信息'],
              ['HOVER_MOVE', '#1565C0', '手指在区域内移动', '追踪坐标、更新指示'],
              ['HOVER_EXIT', '#E65100', '手指离开区域', '重置样式、清除提示'],
              ['HOVER_CANCEL', '#9E9E9E', '系统中断悬停', '清理未完成状态'],
            ], (row: string[]) => {
              Row({ space: 10 }) {
                Text('●').fontSize(10).fontColor(row[1]).width(12)
                Text(row[0]).fontSize(11).fontWeight(FontWeight.Bold).fontColor(row[1]).width(110)
                Column({ space: 2 }) {
                  Text(row[2]).fontSize(10).fontColor('#555555').width('100%')
                  Text(row[3]).fontSize(9).fontColor('#AAAAAA').width('100%')
                }.layoutWeight(1).alignItems(HorizontalAlign.Start)
              }.width('100%')
            })
          }
          .width('90%').padding(14).backgroundColor('#F8FBFF').borderRadius(12)
          .border({ width: 1, color: '#BBDEFB', style: BorderStyle.Solid })
          .alignItems(HorizontalAlign.Start)

          // 事件日志
          Column({ space: 4 }) {
            Text('📋 悬停事件日志(最新在顶)').fontSize(11).fontColor('#9E9E9E').width('100%')
            ForEach(this.log, (item: string, idx: number) => {
              Text(item).fontSize(10)
                .fontColor(idx === 0 ? '#1565C0' : '#BDBDBD')
                .width('100%')
            })
          }
          .width('90%').padding(12).backgroundColor('#F9F9F9').borderRadius(12)
          .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%').alignItems(HorizontalAlign.Center).padding({ top: 16, bottom: 24 })
      }
      .layoutWeight(1).width('100%')
    }
    .width('100%').height('100%').backgroundColor('#FAFAFA')
  }
}

运行效果如图:


五、坐标系详解:三套坐标在实际开发中的应用

AccessibilityHoverEvent 提供了三套坐标系,适用于不同业务场景:

5.1 三套坐标系对比

坐标字段 原点 单位 典型用途
x / y 组件左上角 vp 在组件内绘制跟随指针的高亮点、判断组件内分区
windowX / windowY 应用窗口左上角 vp 在窗口级浮层(Overlay)定位悬停提示气泡
displayX / displayY 当前屏幕左上角 vp 跨窗口定位、截图区域标注
globalDisplayX / globalDisplayY 全局屏幕左上角(API 20+) vp 多屏幕扩展场景下的绝对坐标

5.2 组件内坐标分区示例

利用 event.x / event.y 实现在组件不同区域悬停时给出差异化的无障碍提示

typescript 复制代码
@Entry
@Component
struct ZoneHoverDemo {
  @State zoneLabel: string = '请将手指悬停至此区域'
  @State zoneColor: string = '#F5F5F5'
  private boxWidth: number = 300
  private boxHeight: number = 160

  build() {
    Column({ space: 20 }) {
      Text('坐标分区悬停示例').fontSize(18).fontWeight(FontWeight.Bold).margin({ top: 32 })
      Text('悬停在不同区域,获取不同提示').fontSize(12).fontColor('#888888')

      // 分为左/中/右三区
      Column() {
        Text(this.zoneLabel).fontSize(14).fontColor('#333333').textAlign(TextAlign.Center)
      }
      .width(this.boxWidth).height(this.boxHeight)
      .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
      .backgroundColor(this.zoneColor).borderRadius(16)
      .border({ width: 2, color: '#90CAF9', style: BorderStyle.Solid })
      .animation({ duration: 150, curve: Curve.EaseOut })
      .accessibilityText('三区坐标分区演示区域')
      .accessibilityDescription('此区域分为左、中、右三个分区,悬停时将提示当前分区信息')
      .onAccessibilityHover((isHover: boolean, event: AccessibilityHoverEvent) => {
        if (!isHover) {
          this.zoneLabel = '手指已离开'
          this.zoneColor = '#F5F5F5'
          return
        }
        // 按组件内 X 坐标分左/中/右三区
        const third = this.boxWidth / 3
        if (event.x < third) {
          this.zoneLabel = '← 左区(导航/返回)'
          this.zoneColor = '#E3F2FD'
        } else if (event.x < third * 2) {
          this.zoneLabel = '◎ 中区(主要内容)'
          this.zoneColor = '#E8F5E9'
        } else {
          this.zoneLabel = '→ 右区(操作/更多)'
          this.zoneColor = '#FFF3E0'
        }
        console.info(`[分区悬停] x=${event.x.toFixed(1)}, zone=${this.zoneLabel}`)
      })

      // 分区参考线说明
      Row({ space: 0 }) {
        Text('左区').fontSize(11).fontColor('#1565C0').layoutWeight(1).textAlign(TextAlign.Center)
          .padding(6).backgroundColor('#E3F2FD')
        Text('中区').fontSize(11).fontColor('#1B5E20').layoutWeight(1).textAlign(TextAlign.Center)
          .padding(6).backgroundColor('#E8F5E9')
        Text('右区').fontSize(11).fontColor('#E65100').layoutWeight(1).textAlign(TextAlign.Center)
          .padding(6).backgroundColor('#FFF3E0')
      }
      .width(this.boxWidth).borderRadius(8)
      .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })
      .accessibilityLevel('no')  // 参考线装饰性元素,对无障碍服务隐藏
    }
    .width('100%').height('100%').alignItems(HorizontalAlign.Center).backgroundColor(Color.White)
  }
}

六、onAccessibilityHoverTransparent 透传穿透示例

当子组件被标记为 accessibilityLevel("no") 不可聚焦时,无障碍悬停事件会穿透至父容器,此时在父容器使用 onAccessibilityHoverTransparent 可以捕获完整的触摸事件:

typescript 复制代码
// entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct HoverTransparentDemo {
  @State transparentLog: string[] = ['等待穿透事件...']
  @State eventType: string = '---'
  @State touchX: number = 0
  @State touchY: number = 0

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

  build() {
    Column({ space: 0 }) {
      Row({ space: 10 }) {
        Text('🔍').fontSize(18)
        Text('无障碍透传事件演示').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Color.White)
      }
      .width('100%').padding({ left: 20, right: 20, top: 14, bottom: 12 })
      .backgroundColor('#6A1B9A').justifyContent(FlexAlign.Start)

      Scroll() {
        Column({ space: 16 }) {

          // 说明卡片
          Column({ space: 6 }) {
            Text('💡 透传原理').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#6A1B9A').width('100%')
            Text('当区域内所有子组件均设置 accessibilityLevel("no")(不可聚焦)时,无障碍悬停事件会"穿透"子组件,由父容器通过 onAccessibilityHoverTransparent 捕获。')
              .fontSize(11).fontColor('#555555').lineHeight(18)
          }
          .width('90%').padding(12).backgroundColor('#F3E5F5').borderRadius(12)
          .border({ width: 1, color: '#CE93D8', style: BorderStyle.Solid })
          .alignItems(HorizontalAlign.Start)

          // 透传容器:子组件全部不可聚焦
          Column({ space: 12 }) {
            Text('透传区域(子组件全部不可聚焦)')
              .fontSize(12).fontColor('#9E9E9E').width('100%')

            Column({ space: 10 }) {
              // 三个不可聚焦的装饰性子组件
              Row({ space: 12 }) {
                ForEach(['装饰块A', '装饰块B', '装饰块C'], (label: string) => {
                  Column() {
                    Text(label).fontSize(12).fontColor('#888888')
                  }
                  .width(80).height(60).backgroundColor('#EEEEEE').borderRadius(10)
                  .justifyContent(FlexAlign.Center)
                  .accessibilityLevel('no')  // 关键:不可聚焦,事件将穿透至父容器
                })
              }
              .width('100%').justifyContent(FlexAlign.Center)

              Text('手指在此区域滑动,事件将穿透至父容器').fontSize(11).fontColor('#AAAAAA')
            }
            .width('100%').padding(16).backgroundColor('#FAFAFA').borderRadius(12)
            .border({ width: 2, color: '#CE93D8', style: BorderStyle.Dashed })
            // 父容器捕获穿透事件
            .onAccessibilityHoverTransparent((event: TouchEvent) => {
              const pt = event.touches[0]
              this.touchX = Math.round(pt.x)
              this.touchY = Math.round(pt.y)

              switch (event.type) {
                case TouchType.HOVER_ENTER:
                  this.eventType = 'HOVER_ENTER'
                  this.appendLog(`✅ 穿透 HOVER_ENTER  (${this.touchX}, ${this.touchY})`)
                  break
                case TouchType.HOVER_MOVE:
                  this.eventType = 'HOVER_MOVE'
                  this.appendLog(`↔ 穿透 HOVER_MOVE   (${this.touchX}, ${this.touchY})`)
                  break
                case TouchType.HOVER_EXIT:
                  this.eventType = 'HOVER_EXIT'
                  this.appendLog(`○ 穿透 HOVER_EXIT   (${this.touchX}, ${this.touchY})`)
                  break
                case TouchType.HOVER_CANCEL:
                  this.eventType = 'HOVER_CANCEL'
                  this.appendLog(`✖ 穿透 HOVER_CANCEL (${this.touchX}, ${this.touchY})`)
                  break
              }
            })
          }
          .width('90%').alignItems(HorizontalAlign.Start)

          // 实时状态
          Row({ space: 16 }) {
            Column({ space: 4 }) {
              Text(this.eventType).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#6A1B9A')
              Text('穿透事件类型').fontSize(10).fontColor('#9E9E9E')
            }
            Column().width(1).height(36).backgroundColor('#EEEEEE')
            Column({ space: 4 }) {
              Text(`(${this.touchX}, ${this.touchY})`).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#1565C0')
              Text('触摸坐标 (x, y)').fontSize(10).fontColor('#9E9E9E')
            }
          }
          .width('90%').padding(14).justifyContent(FlexAlign.SpaceEvenly)
          .backgroundColor('#F9F0FF').borderRadius(12)
          .border({ width: 1, color: '#E1BEE7', style: BorderStyle.Solid })

          // 穿透日志
          Column({ space: 4 }) {
            Text('📋 穿透事件日志').fontSize(11).fontColor('#9E9E9E').width('100%')
            ForEach(this.transparentLog, (item: string, idx: number) => {
              Text(item).fontSize(10)
                .fontColor(idx === 0 ? '#6A1B9A' : '#BDBDBD')
                .width('100%')
            })
          }
          .width('90%').padding(12).backgroundColor('#F9F9F9').borderRadius(12)
          .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%').alignItems(HorizontalAlign.Center).padding({ top: 16, bottom: 24 })
      }
      .layoutWeight(1).width('100%')
    }
    .width('100%').height('100%').backgroundColor('#FAFAFA')
  }
}

七、综合实战:无障碍导航菜单

以下综合示例将 onAccessibilityHover 的四种类型、坐标追踪与无障碍属性完整配置整合为一个可运行的无障碍导航菜单 ,直接替换 entry/src/main/ets/pages/Index.ets 即可运行:

typescript 复制代码
// entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct AccessibilityNavDemo {
  @State hoveredIndex: number = -1
  @State hoverLog: string[] = ['等待无障碍悬停...']
  @State totalEnter: number = 0
  @State totalExit: number = 0
  @State totalMove: number = 0
  @State lastCoord: string = '---'

  private navItems: object[] = [
    { icon: '🏠', label: '首页', desc: '返回应用首页,查看推荐内容' },
    { icon: '🔍', label: '搜索', desc: '搜索内容,支持语音输入' },
    { icon: '📚', label: '课程', desc: '查看所有课程,支持按类别筛选' },
    { icon: '💬', label: '消息', desc: '查看消息通知,包含系统和好友消息' },
    { icon: '👤', label: '我的', desc: '查看个人信息、设置和无障碍配置' },
  ]

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

  build() {
    Column({ space: 0 }) {
      // 顶部导航栏
      Row({ space: 10 }) {
        Text('♿').fontSize(20)
        Text('无障碍导航菜单演示').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Color.White)
      }
      .width('100%').padding({ left: 20, right: 20, top: 14, bottom: 12 })
      .backgroundColor('#0D47A1').justifyContent(FlexAlign.Start)

      Scroll() {
        Column({ space: 16 }) {

          // 统计区
          Row({ space: 0 }) {
            Column({ space: 4 }) {
              Text(`${this.totalEnter}`).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#1B5E20')
              Text('进入次数').fontSize(10).fontColor('#9E9E9E')
            }.layoutWeight(1)

            Column().width(1).height(40).backgroundColor('#E0E0E0')
            Column({ space: 4 }) {
              Text(`${this.totalMove}`).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#1565C0')
              Text('移动次数').fontSize(10).fontColor('#9E9E9E')
            }.layoutWeight(1)

            Column().width(1).height(40).backgroundColor('#E0E0E0')
            Column({ space: 4 }) {
              Text(`${this.totalExit}`).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E65100')
              Text('退出次数').fontSize(10).fontColor('#9E9E9E')
            }.layoutWeight(1)
          }
          .width('90%').padding({ top: 12, bottom: 12 }).justifyContent(FlexAlign.SpaceEvenly)
          .backgroundColor(Color.White).borderRadius(12)
          .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })

          // 悬停坐标显示
          Row({ space: 8 }) {
            Text('最近坐标:').fontSize(11).fontColor('#9E9E9E')
            Text(this.lastCoord).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#1565C0')
          }
          .width('90%').padding({ left: 10, right: 10, top: 6, bottom: 6 })
          .backgroundColor('#E3F2FD').borderRadius(8)

          // 导航菜单列表
          Text('导航菜单(在无障碍模式下滑动探索)')
            .fontSize(12).fontColor('#9E9E9E').width('90%')

          Column({ space: 8 }) {
            ForEach(this.navItems, (item: object, index: number) => {
              Row({ space: 14 }) {
                // 图标区
                Text((item as Record<string, string>)['icon']).fontSize(24)
                  .width(44).height(44)
                  .textAlign(TextAlign.Center).lineHeight(44)
                  .backgroundColor(this.hoveredIndex === index ? '#E3F2FD' : '#F5F5F5')
                  .borderRadius(10)
                  .animation({ duration: 150, curve: Curve.EaseOut })
                  .accessibilityLevel('no')

                // 文字区
                Column({ space: 3 }) {
                  Text((item as Record<string, string>)['label'])
                    .fontSize(15).fontWeight(FontWeight.Bold)
                    .fontColor(this.hoveredIndex === index ? '#0D47A1' : '#333333')
                    .animation({ duration: 150, curve: Curve.EaseOut })
                  Text((item as Record<string, string>)['desc'])
                    .fontSize(11).fontColor('#888888')
                    .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
                }.layoutWeight(1).alignItems(HorizontalAlign.Start)

                // 右箭头
                Text(this.hoveredIndex === index ? '▶' : '›')
                  .fontSize(16)
                  .fontColor(this.hoveredIndex === index ? '#0D47A1' : '#BDBDBD')
                  .fontWeight(this.hoveredIndex === index ? FontWeight.Bold : FontWeight.Normal)
                  .animation({ duration: 150, curve: Curve.EaseOut })
                  .accessibilityLevel('no')
              }
              .width('100%').height(68).padding({ left: 14, right: 14 })
              .backgroundColor(this.hoveredIndex === index ? '#F0F7FF' : Color.White)
              .borderRadius(14)
              .border({
                width: this.hoveredIndex === index ? 2 : 1,
                color: this.hoveredIndex === index ? '#1565C0' : '#F0F0F0',
                style: BorderStyle.Solid
              })
              .shadow(this.hoveredIndex === index
                ? { radius: 10, color: '#201565C0', offsetX: 0, offsetY: 3 }
                : { radius: 0, color: Color.Transparent, offsetX: 0, offsetY: 0 })
              .animation({ duration: 200, curve: Curve.EaseOut })
              // 无障碍属性完整配置
              .accessibilityText((item as Record<string, string>)['label'])
              .accessibilityDescription(`${(item as Record<string, string>)['desc']},点击进入`)
              .accessibilityLevel('yes')
              // 关键:监听四种无障碍悬停类型
              .onAccessibilityHover((isHover: boolean, event: AccessibilityHoverEvent) => {
                const label = (item as Record<string, string>)['label']
                const x = Math.round(event.x)
                const y = Math.round(event.y)
                this.lastCoord = `(${x}, ${y})`

                switch (event.type) {
                  case AccessibilityHoverType.HOVER_ENTER:
                    this.hoveredIndex = index
                    this.totalEnter++
                    this.appendLog(`✅ 进入 [${label}]  (${x}, ${y})`)
                    break
                  case AccessibilityHoverType.HOVER_MOVE:
                    this.totalMove++
                    // MOVE 事件频繁,只更新坐标,日志每5次记录一条
                    if (this.totalMove % 5 === 0) {
                      this.appendLog(`↔ 移动 [${label}]  ×${this.totalMove}`)
                    }
                    break
                  case AccessibilityHoverType.HOVER_EXIT:
                    if (this.hoveredIndex === index) this.hoveredIndex = -1
                    this.totalExit++
                    this.appendLog(`○ 退出 [${label}]  (${x}, ${y})`)
                    break
                  case AccessibilityHoverType.HOVER_CANCEL:
                    if (this.hoveredIndex === index) this.hoveredIndex = -1
                    this.appendLog(`✖ 取消 [${label}]`)
                    break
                }
              })
              .onClick(() => {
                this.appendLog(`🟢 点击 [${(item as Record<string, string>)['label']}]`)
              })
            })
          }.width('90%')

          // 事件日志
          Column({ space: 4 }) {
            Text('📋 无障碍悬停事件日志(最新在顶)').fontSize(11).fontColor('#9E9E9E').width('100%')
            ForEach(this.hoverLog, (item: string, idx: number) => {
              Text(item).fontSize(10)
                .fontColor(idx === 0 ? '#0D47A1' : '#BDBDBD')
                .width('100%')
            })
          }
          .width('90%').padding(12).backgroundColor('#F9F9F9').borderRadius(12)
          .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%').alignItems(HorizontalAlign.Center).padding({ top: 16, bottom: 24 })
      }
      .layoutWeight(1).width('100%')
    }
    .width('100%').height('100%').backgroundColor('#FAFAFA')
  }
}

总结

  1. 无障碍 Hover 与普通 Hover 的本质区别:前者由无障碍模式下的单指探索触发,后者由鼠标/触控板悬停触发,两者可共存于同一组件
  2. 四种事件类型HOVER_ENTER(进入)、HOVER_MOVE(移动)、HOVER_EXIT(退出)、HOVER_CANCEL(中断),isHover 是进入与非进入的简化区分,event.type 才能精确识别所有四种
  3. 三套坐标系 :组件内坐标(x/y)用于组件内分区逻辑;窗口坐标(windowX/Y)用于浮层定位;屏幕坐标(displayX/Y)用于跨窗口场景
  4. 透传穿透 :子组件全部不可聚焦时,使用父容器的 onAccessibilityHoverTransparent 捕获穿透的原始 TouchEvent
  5. HOVER_MOVE 频率控制:高频触发,建议计数限流(每 N 次处理一次),避免造成 UI 抖动或性能损耗
  6. HOVER_CANCEL 必须处理:系统中断场景下必须重置高亮状态,防止视觉残影影响用户体验

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

相关推荐
前端不太难1 小时前
从小项目到大型鸿蒙 App 的架构变化
架构·状态模式·harmonyos
aqi002 小时前
【送书活动】《鸿蒙HarmonyOS 6:应用开发从零基础到App上线》迎新送书啦
android·华为·harmonyos·鸿蒙
Oyster382894 小时前
告别“盲调”与“重编”!我写了一个鸿蒙 ArkUI 纯端侧的可视化调试神器,正式开源!
harmonyos·arkui
盐焗西兰花4 小时前
鸿蒙学习实战之路-Share Kit系列(7/17)-自定义分享面板操作区
linux·学习·harmonyos
xym6 小时前
Taskpool简单使用2
harmonyos
不爱吃糖的程序媛7 小时前
鸿蒙 Flutter 多引擎场景开发指导
flutter·华为·harmonyos
小雨青年7 小时前
鸿蒙 HarmonyOS 6 | 多媒体(05)全局播控 AVSession 接入与后台控制
华为·harmonyos
Keya7 小时前
鸿蒙平台实现高斯模糊的渐变色
harmonyos
大雷神9 小时前
HarmonyOS APP<玩转React>开源教程四:状态管理基础
react.js·开源·harmonyos