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') 隐藏纯装饰性图标/分割线,避免占用朗读器焦点,降低用户认知负担

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

相关推荐
全栈若城1 天前
HarmonyOS6 半年磨一剑 - RcInput 组件清空、密码切换与图标交互机制
架构·交互·harmonyos6·三方库开发实战·rchoui·三方库开发
全栈若城6 天前
HarmonyOS 6 实战:Component3D 与 SURFACE 渲染模式深度解析
3d·架构·harmonyos6
全栈若城6 天前
HarmonyOS 6 实战:使用 ArkGraphics3D 加载 GLB 模型与 Scene 初始化全流程
3d·华为·架构·harmonyos·harmonyos6
是稻香啊14 天前
HarmonyOS6 ArkTS Popup 气泡组件指南
harmonyos6
是稻香啊14 天前
HarmonyOS6 触摸目标 touch-target 属性使用指南
harmonyos6
是稻香啊15 天前
HarmonyOS6 foregroundBlurStyle 通用属性使用指南
harmonyos6
是稻香啊15 天前
HarmonyOS6 clickEffect 通用属性使用指南
harmonyos6
是稻香啊15 天前
HarmonyOS6 filter 通用属性使用指南
harmonyos6
是稻香啊21 天前
HarmonyOS6 ArkUI 无障碍悬停事件(onAccessibilityHover)全面解析与实战演示
华为·harmonyos·harmonyos6
是稻香啊22 天前
HarmonyOS6 背景设置:background 基础属性全解析
harmonyos6