文章目录
-
- 一、无障碍悬停事件核心概念
-
- [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_ENTER时isHover = true,HOVER_EXIT和HOVER_CANCEL时isHover = false。HOVER_MOVE时isHover = 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_ENTER、HOVER_MOVE、HOVER_EXIT、HOVER_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')
}
}
总结
- 无障碍 Hover 与普通 Hover 的本质区别:前者由无障碍模式下的单指探索触发,后者由鼠标/触控板悬停触发,两者可共存于同一组件
- 四种事件类型 :
HOVER_ENTER(进入)、HOVER_MOVE(移动)、HOVER_EXIT(退出)、HOVER_CANCEL(中断),isHover是进入与非进入的简化区分,event.type才能精确识别所有四种 - 三套坐标系 :组件内坐标(
x/y)用于组件内分区逻辑;窗口坐标(windowX/Y)用于浮层定位;屏幕坐标(displayX/Y)用于跨窗口场景 - 透传穿透 :子组件全部不可聚焦时,使用父容器的
onAccessibilityHoverTransparent捕获穿透的原始 TouchEvent - HOVER_MOVE 频率控制:高频触发,建议计数限流(每 N 次处理一次),避免造成 UI 抖动或性能损耗
- HOVER_CANCEL 必须处理:系统中断场景下必须重置高亮状态,防止视觉残影影响用户体验
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!