夜雨聆风学习资料网

ARTICLE · 1035473

iOS无障碍优化之解决弹窗焦点穿透

iOS无障碍优化之解决弹窗焦点穿透

今天来和大家聊一个在iOS无障碍开发中特别容易被忽视的问题,如何屏蔽模态弹窗以外的底层焦点

这个问题说起来简单,但真正踩过坑的人都知道,它背后牵扯到无障碍树、模态语义、焦点管理一连串东西。本文就用一个实际案例,带你深入理解模态弹窗的可访问性焦点管理设计。

一、VoiceOver是什么

VoiceOver是苹果系统内置的屏幕朗读软件,帮助视障人士操作手机、电脑,Apple 的所有设备都有该功能,包括iPhoneiPadMacApple TV上都有。视障用户开启它之后,手机的操作方式会完全改变:

·单指在屏幕上移动,VoiceOver会朗读手指当前摸到的元素是什么;

·单指左右滑动,在可聚焦的元素之间切换焦点;

·找到目标后,单指双击屏幕任意位置来激活;

·三指上下滑动滚动屏幕,左右滑动翻页;

·双指左右扫动执行返回手势等。

对于视障用户来说,屏幕上的一切信息,按钮叫什么、列表有几项、弹窗标题是什么等,都依赖应用是否正确构建和提供无障碍信息。我们开发时有没有给控件设置合适的accessibilityLabel,是否设置了控件的role有没有把该隐藏的元素隐藏掉,有没有把该标记为模态的容器标记上等,直接决定了他们能不能用。

一次,我在一个购物App上做无障碍测试,发现了一个很典型的问题。

二、典型场景:购物软件的搜索界面,筛选弹窗打开后,依然能浏览到被遮罩的底层商品元素

那是一个电商App的商品列表页。页面顶部有一排筛选栏,其中有一个“筛选”按钮。点击之后会弹出一个自定义的筛选面板,里面有价格区间、品类选项,底部是“确定”和“取消”。

页面层级大致是这样的:

SearchResultViewController.view├── FilterBar(顶部筛选栏,含“筛选”按钮)├── ProductCollectionView(商品列表)└── FilterPanel(筛选弹窗容器)

打开VoiceOver,单指左右滑动找到“筛选”按钮,双击激活,筛选面板弹出来了。视觉上,面板覆盖了商品列表,背景还加了半透明遮罩。

然后用手指去触摸筛选面板以外的遮罩区域,VoiceOver焦点穿透了模态对话框,居然把商品名称、价格都读了出来。左右滑动,焦点可以在筛选面板里的选项和底层商品列表之间来回跑。

这就是典型的底层焦点问题,也叫焦点穿透。

一句话定义:当一个模态视图覆盖在当前页面之上时,辅助技术仍然能够聚焦并朗读被覆盖的底层元素。

三、为什么视觉覆盖了,VoiceOver还能聚焦浏览?

这里需要理解一个关键区别:视觉遮挡和无障碍遮挡是两回事。

iOS的无障碍浏览基于视图层级和可访问性树结构来枚举可聚焦元素,而不是基于视觉遮挡关系。弹窗盖住了商品列表,但从UIKit的视角看,商品列表的cell仍然是可见的、可聚焦的。VoiceOver无法判断视觉被覆盖住了。

开发者不显式声明模态边界,VoiceOver就不知道哪些内容应该被排除。

那什么是模态弹窗?从交互上说,模态弹窗是一种覆盖在当前页面之上、要求用户先完成或关闭弹窗才能继续操作底层内容的模式。视觉上通常有遮罩、阴影、背景变暗。但无障碍层面的模态需要额外用代码告诉辅助技术:“当前上下文已经切换了,辅助软件应该只关注弹窗内部。”

如果没告知辅助软件,焦点就会跨越到模态外部。

四、焦点穿透会造成什么问题?

表面上看只是多读了一些不该读的东西,但实际体验上的问题非常严重:

·信息结构混乱:用户打开筛选弹窗,左右滑动想切换选项,结果滑到了弹窗背后的商品名称。用户会困惑:我现在到底在弹窗里,还是在商品列表里?

·导航迷失:焦点可以在弹窗和底层内容之间自由游走,用户没有任何“我在模态中”的感知。对全盲用户来说,这就像在黑暗中摸到了一个不该存在的房间入口。

在无障碍国标GB/T 37668-2019《信息技术互联网内容无障碍可访问性技术要求与测试方法》中,3.3.1.1“功能性组件访问”也明确规定:在页面局部更新后不可见的组件应不可访问;新出现的可见非装饰性组件应能被用户代理正常访问。弹窗打开后,底层商品变成了“不可见的组件”,就应该从无障碍树中隐藏;弹窗本身是“新出现的可见非装饰性组件”,应该能被正常访问。

五、如何解决?

主要分为两步:划定无障碍模态边界,然后通知辅助技术软件把焦点移进去

第一步:告诉VoiceOver“这是一个模态对话框”

filterPanel.accessibilityViewIsModal = YES;

accessibilityViewIsModalUIKit提供的一个布尔属性。设置为YES后,辅助技术会忽略该视图的所有兄弟视图及其子树,把导航范围只限制在这个视图内部。

这个属性要设在弹窗的根容器视图上,而不是弹窗内部的某个子视图上。Apple官方文档特别强调:“此属性只能在包含模态内容的视图上设置,而不能在模态视图中包含的元素上设置。”

如果层级实在没法调整,可以补充使用accessibilityElementsHidden = YES临时把底层内容从无障碍树中隐藏,关闭时再恢复,但强烈不建议这么做,优先考虑调整结构。

第二步:通知辅助软件把焦点移进弹窗

只设accessibilityViewIsModal = YES还不够。它解决的是“底层不该被访问”的问题,但解决不了“焦点还在原来的按钮上”的问题。用户激活“筛选”按钮后,VoiceOver焦点还停在那个按钮上,不知道弹窗已经出现了。

这时候需要发送一个通知:

UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification,filterPanel.accessibilityElements.firstObject);

UIAccessibilityScreenChangedNotification用于通知系统“屏幕内容发生了重大变化”。根据 iOS 文档描述它适用的场景是“当一个新的视图出现并占据屏幕主要部分时”。通知的第二个参数指定了焦点应该移动到的目标元素。如果传nilVoiceOver会聚焦到页面上的第一个可聚焦元素。

这就解决了焦点没有自动聚焦到弹窗内部的问题。用户打开弹窗后,VoiceOver会立即朗读传入的那个元素。

六、几个容易踩的坑

Toast不能设成模态。accessibilityViewIsModal = YES的语义是“当前上下文被阻塞,用户必须先处理这个视图才能继续”。Toast是非阻塞式提示,用户看到提示的同时仍然可以操作页面其他内容。如果给Toast设了模态,VoiceOver用户会被困在Toast里,体验反而更差。此前,react-native-screens库就因为这个原因专门回退过一个改动。

关闭时忘记设回NO如果不把accessibilityViewIsModal设回NO,弹窗虽然移除了,但底层内容可能仍然被VoiceOver忽略。

通知一定要在主线程发,且要在布局完成之后。viewDidLoad里发通知是无效的,因为视图还没上屏。建议在layoutIfNeeded之后,或者等动画完成的回调里发送。

焦点目标不要传nil虽然 Apple 文档表示,传nil会聚焦到第一个可聚焦元素,但“第一个可聚焦元素”是系统按层级和元素位置算出来的,不一定是弹窗中的真正想要用户操作的第一个元素。最好显式传一个确定的目标,比如弹窗标题或第一个可操作元素。

七、总结

底层焦点这个问题,说到底就是开发者没有把“视觉模态”和“无障碍模态”在语义上对齐。视觉上弹窗盖住了底层内容,但无障碍树里底层元素还在,VoiceOver就会继续读。

无障碍开发就是这样,API看起来简单,坑都在细节里。希望这篇文章能帮到正在做iOS无障碍优化的伙伴们。如果大家遇到类似问题,欢迎一起交流讨论。

参考资料

  1. Apple Developer Documentation: accessibilityViewIsModal
  2. Apple Developer Documentation: UIAccessibilityScreenChangedNotification

作者

张赐荣,视障者,信息无障碍软件优化解决方案资深研发工程师。

深耕Web/PC/移动端可访问性工作多年,对跨平台无障碍解决方案拥有独特的理论研究和丰富的实战经验。

精通视障软件交互设计,致力于改善、提升产品可及性体验。

相关学习资料