RN 无障碍探索与实践

1,527 阅读10分钟

无障碍

无障碍也可以叫可访问性(Accessibility),是指在任何场景下对尽可能多的人而言都能便捷、顺畅、无障碍的获取信息。无论有怎样身体条件、处于什么样的现实环境,比如有视觉障碍的人,坐轮椅的人,抱着婴儿的成人等,每一个人都应该拥有相同的机会能够获取信息。而对于想要创建高质量 APP 和 网站的开发人员和组织来说,无障碍是必不可少的,这就不会让一些人在使用他们的产品和服务的时候被排除在外。

前期无障碍调研

跨端框架基于 RN 的基本语法构建,所以此次无障碍功能方案主要以 RN 官方提供的无障碍方案为标准进行适配。在官方 react-native@0.62 版本上学习文档并结合一些实际应用场景展开阐述。

主要问题

1. 层级穿透问题

  • 无障碍模式下显示弹框,下层容器无障碍元素仍会读取,无法准确引导信息选择对应的元素

2. 组件可读性及读取顺序问题

  • 无障碍元素的设置并给出对应规范的提示信息
  • 无障碍元素的设置及布局可能会导致元素读取顺序不一致
  • Touchable 系列组件默认的点击引导信息(点击两次即可激活)
  • Touchable 系列组件子元素不存在 文本元素 且未设置 labelhint 等,无障碍并不能给出默认的点击引导信息

3. 动态信息反馈问题

  • Toast 提示应适配友好的语音提示
  • 动态信息缺少实例(倒计时、面部识别文字等)

4. UI 交互问题(焦点聚焦)

  • 轮播组件等一些交互性的组件未动态聚焦到 active 元素上
  • 模态框显示时,未能聚焦模态框内容(蒙层),且未提示当前弹层的功能

5. 验证码问题

  • 图片验证码
  • 拼图验证码

无障碍适配

针对 RN 无障碍适配场景主要问题并结合 RN 官方文档方案给出相应的解决实现方案

实现方案

PS:下面所提及的属性或方法可查阅 官网 了解更多 , 这里就不做过多描述了。

1. 解决层级穿透

目前,我们采用让读屏应用忽略下层不需要识别的元素信息方式进行处理。

以模态框为例:

依据模态框显隐来判断是否忽略下层不需要识别的元素。于此同时,显示模态框时,设置焦点到模态框内某元素(聚焦问题见第 5 点)

  • importantForAccessibility (Android)
// view 包裹的所有元素将被无障碍忽略
<View importantForAccessibility="no-hide-descendants"></View> 
  • accessibilityElementsHidden (IOS)
// view 包裹的所有元素将被无障碍忽略
<View accessibilityElementsHidden={true}></View> 
  • accessibilityViewIsModal (IOS)

<View id='view1' accessibilityViewIsModal={true}></View> 
// view2(view1 兄弟元素)将被无障碍忽略
<View id='view2'></View> 

2. 组件可读性及朗读顺序

通过合理的设置无障碍元素及调整元素相应布局可以更好的适应使用场景。与此同时,合理的提示信息有助于提升使用过程中元素的可感知性、可操作性、可理解性,达到更好的用户体验。

主要涉及以下对应属性:

  • accessible
  • accessibilityLabel
  • accessibilityHint
  • accessibilityRole
  • accessibilityState

......

具体属性使用及含义可查看 RN 无障碍功能RN 官网

3. 动态信息反馈

在执行一些操作后给出相应的提示反馈信息,如:Toastloading

以 Loading 为例

Loading 显示的时候给出 加载中 提示,隐藏的时候给出 完成 提示,这里只是 🌰 描述,具体使用还是需要结合应用场景。

  • announceForAccessibility()
import { AccessibilityInfo } from 'react-native';

// 发送相应文本信息给读屏应用朗读
AccessibilityInfo.announceForAccessibility('我是读屏应用文本');
  • 第三方库 react-native-tts

见下面说明 拓展- react-native-tts

  • accessibilityLabel、accessibilityHint

可以动态设置 LabelHint 值,不过只能在 焦点未离开该元素 的前提下

问题场景 - 场景二

  • accessibilityLiveRegion(Android)
const [count, setCount] = useState(0);

<TouchableOpacity onPress={() => setCount(count + 1)}>
	<Text>Click me</Text>
</TouchableOpacity>

// polite: 辅助服务应该提醒用户当前视图的变化
<Text accessibilityLiveRegion="polite">
  Clicked {count} times
</Text>

4. UI 交互(焦点聚焦)

在一些交互过程中可能需要动态的处理无障碍元素的聚焦点。这里可以通过 RN 官方提供的聚焦方法进行处理。

使用时注意:

  1. 聚焦方法参数为 number 类型, 需要传递元素对应的 tag 标识
  2. Android 上设置聚焦可能需要调用多次聚焦方法才会生效,相关 issue 说明
  • setAccessibilityFocus()
import { findNodeHandle, AccessibilityInfo } from 'react-native';

// 获取元素并将无障碍焦点聚集到该元素上
方法一:
const elementRef = useRef(null);

// reactTag 是一个数值,类似于当前元素对应的一个 tag 标识
const reactTag = findNodeHandle(elementRef.current);
if (reactTag) {
  // 将无障碍焦点聚焦到 reactTag 元素上
   AccessibilityInfo.setAccessibilityFocus(reactTag);
}

方法二:
const onLayout = (e) => {
  AccessibilityInfo.setAccessibilityFocus(e?.currentTarget);
}

5. 验证码

对于盲人而言,采用图片验证码或滑动拼图来进行身份识别,无疑是很不科学的;可以使用其它验证方式进行处理达到更好的体验,如下面的短信验证码、reCAPTCHA。

  • 短信验证码
    • 短信验证至少可以通过读取到短信提示验证码内容
  • reCAPTCHA(播放按钮语音播放验证码内容)
    • reCAPTCHA 无疑是更好的选择,它可以通过语音到形式来进行身份的辨别

reCAPTCHA l 视频解读

无障碍实践

适配与规范

组件封装

构建 Accessible 无障碍组件,方便后面兼容处理不同端之间无障碍 API 的差异

  • 统一组件无障碍配置属性
  • 无障碍添加适配过程中兼容其他功能(如:中英文国际化)

示例:

import { AccessibilityProps } from 'react-native';

interface AccessibilityInterface extends AccessibilityProps {
  [propsName: string]?: any;
}

基础元素

针对不同元素应给予怎样的播报提示信息,这个其实也是很有必要规范一下的。

  • accessibilityLabel 描述元素语义内容(标签、类型等)
  • accessibilityHint 描述元素的解释文本提示(操作、功能等)
  • accessibilityRole 描述元素角色并给出角色自带的语音提示

......

组合元素

无障碍组合元素是否被读屏应用视为统一整体,这个在 AndroidIOS 上差别还是比较大的。

  • Android:父元素设置为无障碍元素,内部子元素可以设置为无障碍元素【直接子文本(Text)元素除外】

  • IOS:父元素设置为无障碍元素,内部子元素设置为无障碍元素无效

示例:

// 聚焦 view  读取 one、two
<View id='view' accessible>
  <Text>text one</Text>
  <Text>text two</Text>
</View>

// Android:聚焦 view,读取 two;再聚焦 view1,读取 one
// IOS:聚焦 view  读取 one、two
<View id='view' accessible>
  <View id='view1' accessible>
    <Text>one</Text>
  </View>
  <Text>two</Text>
</View>

// Android:聚焦 view1,读取 one;再聚焦 view2,读取 two
// IOS:聚焦 view  读取 one、two
<View id='view' accessible>
  <View id='view1' accessible>
    <Text>one</Text>
  </View>
  <View id='view2' accessible>
    <Text>two</Text>
  </View>
</View>>

角色/状态元素

为无障碍元素添加对应的角色和状态,可在一定程度上提升用户对元素的可感知性和可理解性。

  • accessibilityRole 描述元素角色,在 LabelHint 后面播报角色默认提示信息
  • accessibilityState 描述元素状态,动态更改状态值,并给出对应的提示信息

示例:

// checkbox 复选框元素
<View
	accessible
  accessibilityRole='checkbox'
  accessibilityState={{ disabled, checked }}
/>

// adjustable 可调整特性元素
<View
  accessible
  accessibilityRole='adjustable'
  accessibilityValue={{ min, max, now: highValue }} // 百分之 now / (max - min) 语音播报
  accessibilityActions={[ // 读屏应用执行何种操作触发相应事件
    { name: 'increment', label: 'increment' },
    { name: 'decrement', label: 'decrement' },
  ]}
  onAccessibilityAction={(event) => {
    switch (event.nativeEvent.actionName) {
      case 'increment':
      	handleIncrementValue(); // 加值
      break;
      case 'decrement':
      	handleDecrementValue(); // 减值
      break;
    }
  }}
/>

PS:具体详情可查看 accessibilityRoleaccessibilityActionsaccessibilityValue 等相关属性描述,数值变化也可以调通过announceForAccessibility 事件进行动态语音播报 [实现方案 - 动态信息反馈](#3. 动态信息反馈)

更多无障碍属性方法描述

平台差异

  • 组合元素
    • Android: 无障碍元素内部的无障碍元素可以读取
    • IOS: 无障碍元素内部的无障碍元素不可以读取,并且如果调用方法聚焦内部无障碍元素,可能会导致左右滑动无法切换元素焦点

适配与规范 - 组合元素

  • Touchable 系列组件
    • Android:设置 accessible={false} 无效,仍为无障碍元素
      • onPress 存在时, 设置 accessible={false} 无效
      • onPress 不存在时, 设置 accessible={false} 有效,内部子元素可以设置为无障碍元素【直接子文本(Text)元素除外】
    • IOS:设置 accessible={false} 有效,不为无障碍元素,内部子元素可以设置为无障碍元素

问题场景

场景一

Touch 系列组件自身或内部子元素 View 设置 定位样式 时,元素无法聚焦(android),该场景有点特殊,一般情况下不会复现。

// 场景1: 挂载在 Portal 上,需设置 accessibilityLabel 属性才会聚焦
<Portal.Host>
  <Portal>
    <TouchableWithoutFeedback onPress={() => {}}>
      <View
        accessibilityLabel='设置了我才会聚焦哦'
        style={{  position: 'absolute', top: 200, zIndex: 10, height: 20, backgroundColor: 'rgba(0,0,0,0.8)' }}
       >
        <Text style={{ color: '#fff' }}>白色文本</Text>
      </View>
    </TouchableWithoutFeedback>
  </Portal>
</Portal.Host>

// 场景1: 未挂载在 Portal 上,即使设置了 accessibilityLabel 属性也不会聚焦
<TouchableOpacity
  accessibilityLabel='设置了我也不会聚焦哦'
  onPress={() => {}}
  style={{  position: 'absolute', top: 200, zIndex: 10, height: 20, backgroundColor: 'rgba(0,0,0,0.8)' }}
>
  <Text style={{ color: '#fff' }}>白色文本</Text>
</TouchableOpacity>

场景二

聚焦元素动态改变 LabelHint ,在状态更新后会再次读取的 LabelHint 信息,前提是焦点必须一直保持在该元素上。

// 1. Text 的 Label 会动态提示。 未设置 Label,单独元素内部文本更新不会再次播报
const [count, setCount] = useState(0);

<Text
  onPress={() => setCount(count + 1}
  accessibilityLabel={`${count}`}
>
  {count}
</Text>

//2. checkbox 改变时除了提示 accessibilityState 对应信息外,还提示 Label 和 Hint 对应变化信息(Hint 在动态变化)
<Checkbox
	accessible
  accessibilityLabel='checkbox' // checkbox
  accessibilityHint={checked ? 'test1': 'test2'} // 首次根据不同 checked 状态提示对应值
  accessibilityRole='checkbox' // 首次聚焦时提示复选框
  accessibilityState={{ checked }} // true已选中false未选中
/>

拓展

是否开启无障碍服务

  • isScreenReaderEnabled()
// 读屏应用当前是否开启
AccessibilityInfo.isScreenReaderEnabled();
  • addEventListener()
// 监听读屏应用的状态变化
import React, { useState } from 'react';
import { AccessibilityInfo } from 'react-native';

const [accessibilityEnabled, setAccessibilityEnabled] = useState(false); // 读屏设备无障碍是否开启,默认 false

useEffect(() => {
  AccessibilityInfo.fetch().done((enabled) => {
    setAccessibilityEnabled(enabled);
  });
}, [])

useEffect(() => {
  // 监听读屏应用状态改变
	AccessibilityInfo.addEventListener('change', handleAccessibilityChange);
  
  return () => {
    // 组件销毁前,remove 监听事件
		AccessibilityInfo.removeEventListener('change', handleAccessibilityChange);
  }
})

// 监听事件
const handleAccessibilityChange = (enabled) => {
	setAccessibilityEnabled(enabled);
}

react-native-tts

react-native-tts 文档

安装

// 安装依赖包
npm install --save react-native-tts
// rn link
react-native link react-native-tts

Android 手动 link 配置

  1. android/settings.gradle 文件添加
include ':react-native-tts'
project(':react-native-tts').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-tts/android')
  1. android/app/build.gradle 文件添加
dependencies {
    ...
    implementation project(':react-native-tts') // 添加
    ...
}
  1. android/app/src/main/java/com/rncomponents/MainApplication.java 文件添加(安装步骤 link 后貌似该文件内容没有自动添加)
import net.no_mad.tts.TextToSpeechPackage; // 添加

@Override
protected List<ReactPackage> getPackages() {
  return Arrays.<ReactPackage>asList(
    new MainReactPackage(),
    new TextToSpeechPackage(), // 添加
    ...
  );
}

注意:

  • Tts.speak() 不管是否开启无障碍功能都会播报提示语音文字(需与上面的是否已开启无障碍服务功能结合使用)
  • AccessibilityInfo.announceForAccessibility()Tts.speak() 同时设置前者的优先级更高

总结

虽然本次无障碍适配是在跨端组件基础上基于 RN 探索实践的,但在适配过程中我们也对应用开发过程可能遇到的绝大部分场景问题进行了探讨,并给出了相应的解决方案。我们在整个实践过程中,通过对无障碍进行组件封装、探索场景解决方案、定制适配开发规范,这无疑减少了适配开发的成本。而随着科技发展,人们对互联网的依赖性日益增强,国家以及各级政府部门的重视,各大互联网公司的逐步投入,相信 信息无障碍已经成为必然趋势。让我们一起行动起来!相信明天的世界将更美好!

参考链接
RN 无障碍功能
无障碍适配在京东到家RN中的实践总结
RN 官网地址
信息技术互联网内容无障碍可访问性技术要求与测试方法
W3C Web Accessibility Inititive
【译】什么是无障碍?为什它对于用户体验很重要?
关于无障碍可访问性你需要了解