HarmonyOS6 ArkUI 无障碍事件(Accessibility Event)全面解析与实战演示

文章目录

    • 一、无障碍事件核心概念
      • [1.1 无障碍服务工作原理](#1.1 无障碍服务工作原理)
      • [1.2 三大无障碍事件 API 总览](#1.2 三大无障碍事件 API 总览)
    • [二、onAccessibilityFocus 焦点监听](#二、onAccessibilityFocus 焦点监听)
      • [2.1 API 定义](#2.1 API 定义)
      • [2.2 基础用法](#2.2 基础用法)
      • [2.3 焦点高亮反馈](#2.3 焦点高亮反馈)
    • [三、onAccessibilityActionIntercept 动作拦截](#三、onAccessibilityActionIntercept 动作拦截)
      • [3.1 API 定义](#3.1 API 定义)
      • [3.2 AccessibilityAction 枚举](#3.2 AccessibilityAction 枚举)
      • [3.3 AccessibilityActionInterceptResult 枚举](#3.3 AccessibilityActionInterceptResult 枚举)
      • [3.4 基础拦截示例](#3.4 基础拦截示例)
    • 四、无障碍属性速查
      • [4.1 核心无障碍属性汇总](#4.1 核心无障碍属性汇总)
      • [4.2 accessibilityLevel 枚举说明](#4.2 accessibilityLevel 枚举说明)
    • 五、可运行完整示例:无障碍焦点与动作拦截演示
    • 六、实战场景:无障碍属性完整配置
      • [6.1 accessibilityVirtualNode ------ Canvas 虚拟节点](#6.1 accessibilityVirtualNode —— Canvas 虚拟节点)
      • [6.2 accessibilityGroup ------ 分组聚焦](#6.2 accessibilityGroup —— 分组聚焦)
    • 七、综合实战:无障碍友好的登录表单
      • [7.1 功能说明](#7.1 功能说明)
      • [7.2 完整可运行代码](#7.2 完整可运行代码)
    • 总结

一、无障碍事件核心概念

1.1 无障碍服务工作原理

复制代码
用户(视障/辅助需求)
       ↓
  辅助工具(屏幕朗读器 TalkBack/无障碍扫描)
       ↓
  遍历 UI 树,读取节点的无障碍属性
  ↓(accessibilityText / accessibilityDescription / accessibilityLevel)
  组件节点向辅助工具暴露语义信息
       ↓
  用户通过辅助手势触发无障碍操作(点击/焦点移动等)
  ← 此处 onAccessibilityFocus 触发(焦点变化)
  ← 此处 onAccessibilityActionIntercept 触发(动作拦截)
       ↓
  决策:继续执行 / 拦截 / 向父传递

1.2 三大无障碍事件 API 总览

API 触发时机 API 版本 核心用途
onAccessibilityFocus 组件获焦或失焦时 18+ 监听焦点变化,更新 UI 状态、触发朗读提示
onAccessibilityActionIntercept 无障碍操作触发前 20+ 拦截点击等动作,实现二次确认或自定义逻辑
无障碍属性(accessibilityText 等) 渲染/树构建时 10+ 为组件提供语义化描述,供辅助工具朗读

二、onAccessibilityFocus 焦点监听

2.1 API 定义

typescript 复制代码
// 接口签名(API 18+)
onAccessibilityFocus(callback: AccessibilityFocusCallback): T

// 回调类型定义
type AccessibilityFocusCallback = (isFocus: boolean) => void
参数 类型 说明
callback AccessibilityFocusCallback 无障碍焦点变化回调
isFocus boolean true = 获得焦点;false = 失去焦点
返回值 当前组件实例 T 支持链式调用

2.2 基础用法

typescript 复制代码
// 监听 Button 的无障碍焦点变化
Button('提交表单')
  .height(48)
  .borderRadius(10)
  .backgroundColor('#1B5E20')
  .fontColor(Color.White)
  .accessibilityText('提交表单按钮')
  .accessibilityDescription('点击后将提交当前填写的表单数据')
  .onAccessibilityFocus((isFocus: boolean) => {
    if (isFocus) {
      console.info('[无障碍] 提交按钮获得焦点,屏幕朗读器将朗读按钮信息')
    } else {
      console.info('[无障碍] 提交按钮失去焦点')
    }
  })
  .onClick(() => {
    console.info('表单提交')
  })

2.3 焦点高亮反馈

实际开发中,常借助 onAccessibilityFocus 为获焦组件增加视觉高亮,帮助低视力用户感知当前焦点位置:

typescript 复制代码
@Component
struct AccessibleCard {
  @State isFocused: boolean = false
  label: string = '操作项'
  description: string = ''

  build() {
    Row({ space: 12 }) {
      Text('🔷').fontSize(18)
      Column({ space: 4 }) {
        Text(this.label).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333333')
        if (this.description) {
          Text(this.description).fontSize(11).fontColor('#888888')
        }
      }.layoutWeight(1).alignItems(HorizontalAlign.Start)
      Text('›').fontSize(18).fontColor('#BDBDBD')
    }
    .width('100%').height(64).padding({ left: 16, right: 16 })
    .backgroundColor(this.isFocused ? '#E8F5E9' : Color.White)
    .border({
      width: this.isFocused ? 2 : 1,
      color: this.isFocused ? '#2E7D32' : '#F0F0F0',
      style: BorderStyle.Solid
    })
    .borderRadius(12)
    .shadow(this.isFocused ? { radius: 8, color: '#402E7D32', offsetX: 0, offsetY: 2 } : { radius: 0, color: Color.Transparent, offsetX: 0, offsetY: 0 })
    .animation({ duration: 200, curve: Curve.EaseOut })
    .accessibilityText(this.label)
    .accessibilityDescription(this.description || `${this.label},点击以执行操作`)
    .accessibilityLevel('yes')
    .onAccessibilityFocus((isFocus: boolean) => {
      this.isFocused = isFocus
      console.info(`[无障碍焦点] ${this.label}: isFocus=${isFocus}`)
    })
  }
}

三、onAccessibilityActionIntercept 动作拦截

3.1 API 定义

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

// 回调类型定义
type AccessibilityActionInterceptCallback =
  (action: AccessibilityAction) => AccessibilityActionInterceptResult
参数/返回 类型 说明
action AccessibilityAction 当前触发的无障碍动作枚举
返回值(回调) AccessibilityActionInterceptResult 告知框架如何处理此动作

3.2 AccessibilityAction 枚举

枚举值 数值 说明
AccessibilityAction.UNDEFINED_ACTION 0 未定义动作
AccessibilityAction.ACCESSIBILITY_CLICK 1 无障碍点击(辅助工具触发的点击)

3.3 AccessibilityActionInterceptResult 枚举

枚举值 数值 说明 是否执行组件自身逻辑 是否向父传递
ACTION_INTERCEPT 0 完全拦截:不执行组件自身逻辑,不向父传递
ACTION_CONTINUE 1 拦截后继续:执行自定义逻辑后,仍执行组件自身逻辑
ACTION_RISE 2 向父传递:不执行自身逻辑,将事件向父组件传递

三种结果选型建议 :需要弹出确认框再决定是否执行 → ACTION_INTERCEPT;需要额外埋点但仍正常执行 → ACTION_CONTINUE;需要父组件统一处理 → ACTION_RISE

3.4 基础拦截示例

typescript 复制代码
// 危险操作二次确认:无障碍点击触发确认弹窗
@Component
struct DangerButton {
  @State isOn: boolean = false

  build() {
    Toggle({ type: ToggleType.Switch, isOn: this.isOn })
      .selectedColor('#D32F2F')
      .accessibilityText(this.isOn ? '开关已开启' : '开关已关闭')
      .accessibilityDescription('该操作将影响系统设置,开启前需要确认')
      .onAccessibilityActionIntercept((action: AccessibilityAction) => {
        if (action === AccessibilityAction.ACCESSIBILITY_CLICK) {
          // 弹出确认对话框,拦截系统默认点击
          AlertDialog.show({
            title: '无障碍操作确认',
            message: `当前开关为【${this.isOn ? '开启' : '关闭'}】状态,确认要切换吗?`,
            primaryButton: {
              value: '确认切换',
              fontColor: '#D32F2F',
              action: () => {
                this.isOn = !this.isOn
                console.info(`[无障碍] 开关已切换为 ${this.isOn}`)
              }
            },
            secondaryButton: {
              value: '取消',
              action: () => {
                console.info('[无障碍] 用户取消切换')
              }
            }
          })
          // 拦截系统默认点击,由弹窗决定后续逻辑
          return AccessibilityActionInterceptResult.ACTION_INTERCEPT
        }
        return AccessibilityActionInterceptResult.ACTION_CONTINUE
      })
  }
}

四、无障碍属性速查

4.1 核心无障碍属性汇总

属性 类型 API 版本 说明
accessibilityText(value) `string Resource` 10+
accessibilityDescription(value) `string Resource` 10+
accessibilityLevel(value) `"auto" "yes" "no"
accessibilityGroup(value) boolean 10+ 将子组件合并为一个可聚焦单元
accessibilityVirtualNode(builder) CustomBuilder 11+ 为自绘组件(Canvas)提供虚拟无障碍节点

4.2 accessibilityLevel 枚举说明

说明 典型场景
"auto" 系统综合判断(默认) 大多数普通组件
"yes" 明确可被无障碍识别 重要按钮、表单控件
"no" 不被识别(自身),子组件不受影响 装饰性组件(背景色块等)
"no-hide-descendants" 自身及所有后代均不可识别 广告区、装饰性图形区
typescript 复制代码
// 装饰性分割线:对无障碍服务隐藏,不占用朗读焦点
Divider()
  .color('#F0F0F0')
  .accessibilityLevel('no')

// 重要按钮:明确标记可识别,并提供完整语义信息
Button('立即购买')
  .backgroundColor('#E53935')
  .fontColor(Color.White)
  .accessibilityLevel('yes')
  .accessibilityText('立即购买按钮')
  .accessibilityDescription('点击后跳转到支付页面,请确认已选择商品规格')

五、可运行完整示例:无障碍焦点与动作拦截演示

以下示例演示 onAccessibilityFocusonAccessibilityActionIntercept 的实际效果,并实时记录事件日志:

typescript 复制代码
// entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct AccessibilityEventDemo {
  @State focusLog: string[] = ['等待无障碍焦点事件...']
  @State actionLog: string[] = ['等待无障碍动作事件...']
  @State switchState: boolean = false
  @State focusedItem: string = '无'
  @State interceptCount: number = 0
  @State continueCount: number = 0

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

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

  build() {
    Column({ space: 0 }) {
      // 标题栏
      Row({ space: 10 }) {
        Text('♿').fontSize(22)
        Text('无障碍事件演示').fontSize(18).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 }) {

          // ── 区域1:焦点监听 ──────────────────────────────
          Column({ space: 10 }) {
            Text('① onAccessibilityFocus 焦点监听').fontSize(13)
              .fontWeight(FontWeight.Bold).fontColor('#1B5E20').width('100%')
            Text('当前焦点组件:').fontSize(11).fontColor('#9E9E9E').width('100%')
            Text(this.focusedItem).fontSize(13).fontColor('#1B5E20').fontWeight(FontWeight.Bold)
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .backgroundColor('#E8F5E9').borderRadius(8).width('100%')

            // 三个可聚焦的卡片
            ForEach(['搜索框', '筛选按钮', '排序选项'], (label: string) => {
              Row({ space: 12 }) {
                Text(label === '搜索框' ? '🔍' : label === '筛选按钮' ? '⚙️' : '↕️').fontSize(18)
                Text(label).fontSize(14).fontColor('#333333').layoutWeight(1)
                Text(this.focusedItem === label ? '▶ 焦点在此' : '')
                  .fontSize(10).fontColor('#2E7D32').fontWeight(FontWeight.Bold)
              }
              .width('100%').height(52).padding({ left: 14, right: 14 })
              .backgroundColor(this.focusedItem === label ? '#E8F5E9' : Color.White)
              .borderRadius(10)
              .border({
                width: this.focusedItem === label ? 2 : 1,
                color: this.focusedItem === label ? '#2E7D32' : '#EEEEEE',
                style: BorderStyle.Solid
              })
              .animation({ duration: 180, curve: Curve.EaseOut })
              .accessibilityLevel('yes')
              .accessibilityText(label)
              .accessibilityDescription(`${label},点击以执行${label}操作`)
              .onAccessibilityFocus((isFocus: boolean) => {
                if (isFocus) {
                  this.focusedItem = label
                  this.appendFocusLog(`✅ [获焦] ${label}`)
                } else {
                  if (this.focusedItem === label) this.focusedItem = '无'
                  this.appendFocusLog(`○ [失焦] ${label}`)
                }
              })
              .onClick(() => {
                this.appendFocusLog(`点击 ${label}`)
              })
            })

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

          // ── 区域2:动作拦截 ──────────────────────────────
          Column({ space: 10 }) {
            Text('② onAccessibilityActionIntercept 动作拦截').fontSize(13)
              .fontWeight(FontWeight.Bold).fontColor('#B71C1C').width('100%')

            Row({ space: 16 }) {
              Column({ space: 4 }) {
                Text(`${this.interceptCount}`).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#D32F2F')
                Text('拦截次数').fontSize(10).fontColor('#9E9E9E')
              }
              Column().width(1).height(36).backgroundColor('#EEEEEE')
              Column({ space: 4 }) {
                Text(`${this.continueCount}`).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#1B5E20')
                Text('放行次数').fontSize(10).fontColor('#9E9E9E')
              }
              Column().width(1).height(36).backgroundColor('#EEEEEE')
              Column({ space: 4 }) {
                Text(this.switchState ? '开' : '关').fontSize(20).fontWeight(FontWeight.Bold)
                  .fontColor(this.switchState ? '#1B5E20' : '#9E9E9E')
                Text('开关状态').fontSize(10).fontColor('#9E9E9E')
              }
            }
            .width('100%').justifyContent(FlexAlign.SpaceEvenly).padding({ top: 8, bottom: 8 })
            .backgroundColor('#FFF5F5').borderRadius(10)

            // Switch:无障碍点击拦截,弹确认框
            Row({ space: 14 }) {
              Column({ space: 4 }) {
                Text('高风险开关').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#333333')
                Text('无障碍点击将弹出确认框').fontSize(10).fontColor('#888888')
              }.layoutWeight(1).alignItems(HorizontalAlign.Start)

              Toggle({ type: ToggleType.Switch, isOn: this.switchState })
                .selectedColor('#D32F2F')
                .width(52).height(28)
                .accessibilityText(this.switchState ? '高风险开关:已开启' : '高风险开关:已关闭')
                .accessibilityDescription('开启此开关将影响重要系统配置,请谨慎操作')
                .onAccessibilityActionIntercept((action: AccessibilityAction) => {
                  if (action === AccessibilityAction.ACCESSIBILITY_CLICK) {
                    this.interceptCount++
                    this.appendActionLog(`🛑 [拦截] ACCESSIBILITY_CLICK → 弹确认框`)
                    AlertDialog.show({
                      title: '⚠️ 无障碍操作确认',
                      message: `开关当前为【${this.switchState ? '开启' : '关闭'}】\n确认要切换状态吗?此操作影响系统配置。`,
                      primaryButton: {
                        value: '确认切换',
                        fontColor: '#D32F2F',
                        action: () => {
                          this.switchState = !this.switchState
                          this.appendActionLog(`✅ 确认切换 → 开关=${this.switchState}`)
                        }
                      },
                      secondaryButton: {
                        value: '取消',
                        action: () => {
                          this.appendActionLog('○ 取消操作')
                        }
                      }
                    })
                    return AccessibilityActionInterceptResult.ACTION_INTERCEPT
                  }
                  return AccessibilityActionInterceptResult.ACTION_CONTINUE
                })
                .onChange((isOn: boolean) => {
                  // 普通手动点击不经过拦截,直接触发 onChange
                  this.switchState = isOn
                  this.continueCount++
                  this.appendActionLog(`🟢 [放行] onChange → 开关=${isOn}`)
                })
            }
            .width('100%').padding(14).backgroundColor(Color.White).borderRadius(12)
            .border({ width: 1, color: '#FFCDD2', style: BorderStyle.Solid })

            // 动作日志
            Column({ space: 3 }) {
              Text('动作拦截日志').fontSize(11).fontColor('#9E9E9E').width('100%')
              ForEach(this.actionLog, (item: string, idx: number) => {
                Text(item).fontSize(10).fontColor(idx === 0 ? '#D32F2F' : '#BDBDBD').width('100%')
              })
            }
            .padding(10).backgroundColor('#F9F9F9').borderRadius(10)
            .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })
            .alignItems(HorizontalAlign.Start).width('100%')
          }
          .width('90%').padding(16).backgroundColor(Color.White).borderRadius(16)
          .border({ width: 1, color: '#FFEBEE', style: BorderStyle.Solid })

          // ── 区域3:拦截结果说明 ──────────────────────────
          Column({ space: 8 }) {
            Text('💡 三种拦截结果说明').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#1B5E20')
            ForEach([
              ['ACTION_INTERCEPT', '完全拦截', '❌ 不执行自身  ❌ 不向父传递', '危险操作二次确认'],
              ['ACTION_CONTINUE', '拦截后继续', '✅ 仍执行自身  ❌ 不向父传递', '埋点/日志+正常执行'],
              ['ACTION_RISE', '向父传递', '❌ 不执行自身  ✅ 向父传递', '父组件统一处理'],
            ], (row: string[]) => {
              Column({ space: 3 }) {
                Row({ space: 8 }) {
                  Text(row[0]).fontSize(10).fontWeight(FontWeight.Bold).fontColor('#B71C1C').width(145)
                  Text(row[1]).fontSize(10).fontColor('#555555').layoutWeight(1)
                }.width('100%')
                Row({ space: 8 }) {
                  Text('').width(145)
                  Text(row[2]).fontSize(9).fontColor('#888888').layoutWeight(1)
                }.width('100%')
                Row({ space: 8 }) {
                  Text('').width(145)
                  Text(`场景:${row[3]}`).fontSize(9).fontColor('#AAAAAA').layoutWeight(1)
                }.width('100%')
              }
              .width('100%').padding({ top: 5, bottom: 5 })
              .border({ width: { bottom: 1 }, color: '#F5F5F5', style: BorderStyle.Solid })
            })
          }
          .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({ top: 16, bottom: 24 })
      }
      .layoutWeight(1).width('100%')
    }
    .width('100%').height('100%').backgroundColor('#FAFAFA')
  }
}

运行效果如图:


六、实战场景:无障碍属性完整配置

6.1 accessibilityVirtualNode ------ Canvas 虚拟节点

Canvas 是自绘制组件,无障碍服务无法感知其内容。通过 accessibilityVirtualNode 提供虚拟节点,让屏幕朗读器也能理解图表内容:

typescript 复制代码
// entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct CanvasAccessibilityDemo {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)

  build() {
    Column({ space: 16 }) {
      Text('Canvas 无障碍虚拟节点').fontSize(18).fontWeight(FontWeight.Bold).margin({ top: 24 })
      Text('屏幕朗读器将读取虚拟节点描述,而非空白Canvas').fontSize(12).fontColor('#888888')

      // Canvas 组件添加虚拟无障碍节点
      Canvas(this.ctx)
        .width(320).height(180)
        .borderRadius(16)
        .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })
        .onReady(() => {
          // 绘制简单柱状图
          const data = [60, 85, 45, 92, 70]
          const colors = ['#EF5350', '#42A5F5', '#66BB6A', '#FFA726', '#AB47BC']
          data.forEach((val, i) => {
            this.ctx.fillStyle = colors[i]
            this.ctx.fillRect(20 + i * 60, 180 - val * 1.5, 40, val * 1.5)
          })
          // 绘制标签
          this.ctx.fillStyle = '#555555'
          this.ctx.font = '12px sans-serif'
          ;['一月', '二月', '三月', '四月', '五月'].forEach((label, i) => {
            this.ctx.fillText(label, 20 + i * 60, 175)
          })
        })
        // 关键:提供虚拟无障碍节点
        .accessibilityVirtualNode(() => {
          Column({ space: 4 }) {
            Text('月度销售额柱状图').accessibilityText('月度销售额柱状图,共五个月份数据')
            Text('一月:60万').accessibilityText('一月销售额 60万元')
            Text('二月:85万').accessibilityText('二月销售额 85万元,环比增长 41.7%')
            Text('三月:45万').accessibilityText('三月销售额 45万元,环比下降 47.1%')
            Text('四月:92万').accessibilityText('四月销售额 92万元,环比增长 104.4%')
            Text('五月:70万').accessibilityText('五月销售额 70万元,环比下降 23.9%')
          }
          .accessibilityGroup(true)
          .accessibilityLevel('no')  // 虚拟节点本身不需要被单独识别
        })
    }
    .width('100%').height('100%').alignItems(HorizontalAlign.Center).backgroundColor(Color.White)
  }
}

6.2 accessibilityGroup ------ 分组聚焦

将多个相关子组件合并为一个无障碍聚焦单元,减少屏幕朗读器的焦点跳转次数:

typescript 复制代码
// 商品卡片:价格+标题+评分合并为一个无障碍节点
Column({ space: 6 }) {
  Image($r('app.media.product'))
    .width('100%').height(120).borderRadius(8)
    .accessibilityLevel('no')  // 图片隐藏,由分组文本描述

  Text('HarmonyOS6 开发者手册').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333333')
  Row({ space: 6 }) {
    Text('⭐ 4.8').fontSize(12).fontColor('#FF9800')
    Text('1,234 人评价').fontSize(11).fontColor('#9E9E9E')
  }
  Text('¥ 99.00').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#D32F2F')
}
.width(160).padding(12).backgroundColor(Color.White).borderRadius(12)
// 分组:四个子组件合并为一个可聚焦单元
.accessibilityGroup(true)
.accessibilityText('HarmonyOS6 开发者手册,售价 99 元,评分 4.8 分,1234 人评价')
.accessibilityDescription('点击查看商品详情页')
.accessibilityLevel('yes')

七、综合实战:无障碍友好的登录表单

7.1 功能说明

以下综合示例演示一个完整的无障碍友好登录表单,整合本文所有核心知识点:

  1. 输入框焦点监听onAccessibilityFocus 驱动高亮边框和辅助提示文字
  2. 登录按钮动作拦截onAccessibilityActionIntercept 在无障碍模式下增加确认步骤
  3. 无障碍属性完整配置 :每个控件均设置 accessibilityText + accessibilityDescription + accessibilityLevel
  4. 分组 :表单控件用 accessibilityGroup 合理分组

7.2 完整可运行代码

typescript 复制代码
// entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct AccessibilityLoginDemo {
  @State username: string = ''
  @State password: string = ''
  @State usernameFocused: boolean = false
  @State passwordFocused: boolean = false
  @State loginBtnFocused: boolean = false
  @State log: string[] = ['等待操作...']
  @State loginCount: number = 0
  @State interceptCount: number = 0
  @State rememberMe: boolean = false

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

  build() {
    Column({ space: 0 }) {
      // 顶部标题
      Column({ space: 6 }) {
        Text('♿ 无障碍友好登录').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#1B5E20')
        Text('所有控件均配置了完整无障碍语义').fontSize(12).fontColor('#888888')
      }
      .width('100%').padding({ top: 40, bottom: 24 })
      .alignItems(HorizontalAlign.Center).backgroundColor(Color.White)

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

          // 用户名输入框
          Column({ space: 6 }) {
            Row({ space: 6 }) {
              Text('用户名').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#333333')
              if (this.usernameFocused) {
                Text('▶ 焦点在此').fontSize(10).fontColor('#1B5E20').fontWeight(FontWeight.Bold)
              }
            }.width('100%')

            TextInput({ placeholder: '请输入用户名', text: this.username })
              .height(48).borderRadius(10).fontSize(14)
              .backgroundColor(this.usernameFocused ? '#F1F8E9' : '#F5F5F5')
              .border({
                width: this.usernameFocused ? 2 : 1,
                color: this.usernameFocused ? '#2E7D32' : '#E0E0E0',
                style: BorderStyle.Solid
              })
              .animation({ duration: 180, curve: Curve.EaseOut })
              .onChange((val: string) => { this.username = val })
              .accessibilityText('用户名输入框')
              .accessibilityDescription('请输入您的账号用户名,仅支持字母和数字,长度 6 到 20 位')
              .accessibilityLevel('yes')
              .onAccessibilityFocus((isFocus: boolean) => {
                this.usernameFocused = isFocus
                this.appendLog(`[焦点] 用户名输入框 isFocus=${isFocus}`)
              })

            if (this.usernameFocused) {
              Text('💡 仅支持字母和数字,长度 6-20 位').fontSize(11).fontColor('#2E7D32').width('100%')
            }
          }.width('90%').alignItems(HorizontalAlign.Start)

          // 密码输入框
          Column({ space: 6 }) {
            Row({ space: 6 }) {
              Text('密码').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#333333')
              if (this.passwordFocused) {
                Text('▶ 焦点在此').fontSize(10).fontColor('#1B5E20').fontWeight(FontWeight.Bold)
              }
            }.width('100%')

            TextInput({ placeholder: '请输入密码', text: this.password })
              .type(InputType.Password)
              .height(48).borderRadius(10).fontSize(14)
              .backgroundColor(this.passwordFocused ? '#F1F8E9' : '#F5F5F5')
              .border({
                width: this.passwordFocused ? 2 : 1,
                color: this.passwordFocused ? '#2E7D32' : '#E0E0E0',
                style: BorderStyle.Solid
              })
              .animation({ duration: 180, curve: Curve.EaseOut })
              .onChange((val: string) => { this.password = val })
              .accessibilityText('密码输入框')
              .accessibilityDescription('请输入您的登录密码,密码区分大小写,至少 8 位')
              .accessibilityLevel('yes')
              .onAccessibilityFocus((isFocus: boolean) => {
                this.passwordFocused = isFocus
                this.appendLog(`[焦点] 密码输入框 isFocus=${isFocus}`)
              })

            if (this.passwordFocused) {
              Text('💡 密码区分大小写,至少 8 位').fontSize(11).fontColor('#2E7D32').width('100%')
            }
          }.width('90%').alignItems(HorizontalAlign.Start)

          // 记住我(Toggle)
          Row({ space: 12 }) {
            Toggle({ type: ToggleType.Checkbox, isOn: this.rememberMe })
              .width(22).height(22).selectedColor('#1B5E20')
              .accessibilityText(this.rememberMe ? '记住我:已勾选' : '记住我:未勾选')
              .accessibilityDescription('勾选后,下次打开应用将自动填充账号信息')
              .accessibilityLevel('yes')
              .onChange((isOn: boolean) => {
                this.rememberMe = isOn
                this.appendLog(`[切换] 记住我 → ${isOn}`)
              })
            Text('记住登录状态').fontSize(13).fontColor('#555555')
            Text(this.rememberMe ? '(已勾选)' : '').fontSize(11).fontColor('#2E7D32')
          }
          .width('90%').justifyContent(FlexAlign.Start)
          .accessibilityGroup(true)
          .accessibilityText(this.rememberMe ? '记住我,已勾选' : '记住我,未勾选')
          .accessibilityDescription('勾选以在下次启动时自动填充登录信息')

          // 登录按钮
          Button('立即登录')
            .width('90%').height(50).borderRadius(12).fontSize(16).fontWeight(FontWeight.Bold)
            .backgroundColor(this.loginBtnFocused ? '#2E7D32' : '#1B5E20')
            .fontColor(Color.White)
            .shadow(this.loginBtnFocused
              ? { radius: 12, color: '#601B5E20', offsetX: 0, offsetY: 4 }
              : { radius: 0, color: Color.Transparent, offsetX: 0, offsetY: 0 })
            .animation({ duration: 200, curve: Curve.EaseOut })
            .accessibilityText('立即登录按钮')
            .accessibilityDescription('点击后使用当前填写的用户名和密码进行登录,请确认信息填写正确')
            .accessibilityLevel('yes')
            .onAccessibilityFocus((isFocus: boolean) => {
              this.loginBtnFocused = isFocus
              this.appendLog(`[焦点] 登录按钮 isFocus=${isFocus}`)
            })
            .onAccessibilityActionIntercept((action: AccessibilityAction) => {
              if (action === AccessibilityAction.ACCESSIBILITY_CLICK) {
                this.interceptCount++
                this.appendLog(`🛑 [拦截] 登录按钮无障碍点击 ×${this.interceptCount}`)

                // 无障碍模式下弹出确认框
                AlertDialog.show({
                  title: '♿ 确认登录',
                  message: `账号:${this.username || '(未填写)'}\n是否确认登录?`,
                  primaryButton: {
                    value: '确认登录',
                    fontColor: '#1B5E20',
                    action: () => {
                      this.loginCount++
                      this.appendLog(`✅ 无障碍确认登录 ×${this.loginCount}`)
                    }
                  },
                  secondaryButton: {
                    value: '返回检查',
                    action: () => {
                      this.appendLog('○ 返回检查表单')
                    }
                  }
                })
                return AccessibilityActionInterceptResult.ACTION_INTERCEPT
              }
              return AccessibilityActionInterceptResult.ACTION_CONTINUE
            })
            .onClick(() => {
              this.loginCount++
              this.appendLog(`🟢 [普通点击] 登录 ×${this.loginCount},账号=${this.username || '空'}`)
            })

          // 统计信息
          Row({ space: 24 }) {
            Column({ space: 3 }) {
              Text(`${this.loginCount}`).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#1B5E20')
              Text('登录次数').fontSize(10).fontColor('#9E9E9E')
            }
            Column().width(1).height(36).backgroundColor('#E0E0E0')
            Column({ space: 3 }) {
              Text(`${this.interceptCount}`).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#D32F2F')
              Text('无障碍拦截').fontSize(10).fontColor('#9E9E9E')
            }
          }
          .width('90%').justifyContent(FlexAlign.SpaceEvenly).padding({ top: 12, bottom: 12 })
          .backgroundColor('#F9F9F9').borderRadius(12)
          .border({ width: 1, color: '#E0E0E0', style: BorderStyle.Solid })

          // 事件日志
          Column({ space: 4 }) {
            Text('📋 事件日志(最新在顶)').fontSize(11).fontColor('#9E9E9E').width('100%')
            ForEach(this.log, (item: string, idx: number) => {
              Text(item).fontSize(10).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([
              ['用户名/密码输入框', 'accessibilityText + Description + onAccessibilityFocus'],
              ['记住我 Toggle', 'accessibilityGroup 分组 + 动态 accessibilityText'],
              ['登录按钮', 'onAccessibilityFocus 高亮 + onAccessibilityActionIntercept 确认'],
              ['装饰性元素', 'accessibilityLevel("no") 对无障碍服务隐藏'],
            ], (row: string[]) => {
              Row({ space: 8 }) {
                Text('•').fontSize(12).fontColor('#2E7D32').width(12)
                Column({ space: 2 }) {
                  Text(row[0]).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#333333').width('100%')
                  Text(row[1]).fontSize(10).fontColor('#888888').width('100%')
                }.layoutWeight(1).alignItems(HorizontalAlign.Start)
              }.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: 40 })
      }
      .layoutWeight(1).width('100%')
    }
    .width('100%').height('100%').backgroundColor(Color.White)
  }
}


总结

  1. 焦点监听onAccessibilityFocus(isFocus) 在组件获焦/失焦时触发,常用于高亮边框、显示辅助提示、朗读状态更新,让低视力用户清楚感知当前焦点位置
  2. 动作拦截onAccessibilityActionIntercept 在无障碍点击前触发,三种返回值各司其职:ACTION_INTERCEPT(二次确认)、ACTION_CONTINUE(埋点+正常执行)、ACTION_RISE(向父传递)
  3. 语义属性完整配置accessibilityText > accessibilityDescription > accessibilityLevel 三属性搭配,让每个交互组件都能被屏幕朗读器准确识别和播报
  4. 分组减负accessibilityGroup 将相关子组件合并为一个聚焦单元,减少朗读器焦点跳转次数,提升使用流畅度
  5. Canvas 虚拟节点accessibilityVirtualNode 为自绘图表提供语义化节点,弥补自绘组件无障碍信息缺失
  6. 装饰性元素隐藏accessibilityLevel('no') 隐藏纯装饰性图标/分割线,避免占用朗读器焦点,降低用户认知负担

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

相关推荐
是稻香啊4 小时前
HarmonyOS6 ArkUI 组件区域变化事件(onAreaChange)全面解析与实战演示
harmonyos6
是稻香啊10 小时前
HarmonyOS6 组件显隐事件(onAppear / onDisAppear / onAttach / onDetach)
harmonyos6
是稻香啊12 小时前
HarmonyOS6 ArkUI 组件尺寸变化事件(onSizeChange)全面解析与实战演示
harmonyos6
ITUnicorn21 天前
【HarmonyOS 6】进度组件实战:打造精美的数据可视化
华为·harmonyos·arkts·鸿蒙·harmonyos6
ITUnicorn25 天前
【HarmonyOS 6】数据可视化:实现热力图时间块展示
华为·harmonyos·arkts·鸿蒙·harmonyos6
ITUnicorn1 个月前
【HarmonyOS 6】HarmonyOS 自定义时间选择器实现
华为·harmonyos·arkts·鸿蒙·harmonyos6
ITUnicorn1 个月前
【HarmonyOS6】ArkTS 自定义组件封装实战:动画水杯组件
华为·harmonyos·arkts·鸿蒙·harmonyos6
ITUnicorn1 个月前
【HarmonyOS6】从零实现随机数生成器
华为·harmonyos·arkts·鸿蒙·harmonyos6
ITUnicorn1 个月前
【HarmonyOS6】从零实现自定义计时器:掌握TextTimer组件与计时控制
华为·harmonyos·arkts·鸿蒙·harmonyos6