ARTICLE · 1035473
iOS无障碍优化之解决弹窗焦点穿透
iOS无障碍优化之解决弹窗焦点穿透
今天来和大家聊一个在iOS无障碍开发中特别容易被忽视的问题,如何屏蔽模态弹窗以外的底层焦点。
这个问题说起来简单,但真正踩过坑的人都知道,它背后牵扯到无障碍树、模态语义、焦点管理一连串东西。本文就用一个实际案例,带你深入理解模态弹窗的可访问性焦点管理设计。
一、VoiceOver是什么
VoiceOver是苹果系统内置的屏幕朗读软件,帮助视障人士操作手机、电脑,Apple 的所有设备都有该功能,包括iPhone、iPad、Mac、Apple 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;
accessibilityViewIsModal是UIKit提供的一个布尔属性。设置为YES后,辅助技术会忽略该视图的所有兄弟视图及其子树,把导航范围只限制在这个视图内部。
这个属性要设在弹窗的根容器视图上,而不是弹窗内部的某个子视图上。Apple官方文档特别强调:“此属性只能在包含模态内容的视图上设置,而不能在模态视图中包含的元素上设置。”
如果层级实在没法调整,可以补充使用accessibilityElementsHidden = YES临时把底层内容从无障碍树中隐藏,关闭时再恢复,但强烈不建议这么做,优先考虑调整结构。
第二步:通知辅助软件把焦点移进弹窗
只设accessibilityViewIsModal = YES还不够。它解决的是“底层不该被访问”的问题,但解决不了“焦点还在原来的按钮上”的问题。用户激活“筛选”按钮后,VoiceOver焦点还停在那个按钮上,不知道弹窗已经出现了。
这时候需要发送一个通知:
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification,filterPanel.accessibilityElements.firstObject);
UIAccessibilityScreenChangedNotification用于通知系统“屏幕内容发生了重大变化”。根据 iOS 文档描述它适用的场景是“当一个新的视图出现并占据屏幕主要部分时”。通知的第二个参数指定了焦点应该移动到的目标元素。如果传nil,VoiceOver会聚焦到页面上的第一个可聚焦元素。
这就解决了焦点没有自动聚焦到弹窗内部的问题。用户打开弹窗后,VoiceOver会立即朗读传入的那个元素。
六、几个容易踩的坑
Toast不能设成模态。accessibilityViewIsModal = YES的语义是“当前上下文被阻塞,用户必须先处理这个视图才能继续”。Toast是非阻塞式提示,用户看到提示的同时仍然可以操作页面其他内容。如果给Toast设了模态,VoiceOver用户会被困在Toast里,体验反而更差。此前,react-native-screens库就因为这个原因专门回退过一个改动。
关闭时忘记设回NO。如果不把accessibilityViewIsModal设回NO,弹窗虽然移除了,但底层内容可能仍然被VoiceOver忽略。
通知一定要在主线程发,且要在布局完成之后。在viewDidLoad里发通知是无效的,因为视图还没上屏。建议在layoutIfNeeded之后,或者等动画完成的回调里发送。
焦点目标不要传nil。虽然 Apple 文档表示,传nil会聚焦到第一个可聚焦元素,但“第一个可聚焦元素”是系统按层级和元素位置算出来的,不一定是弹窗中的真正想要用户操作的第一个元素。最好显式传一个确定的目标,比如弹窗标题或第一个可操作元素。
七、总结
底层焦点这个问题,说到底就是开发者没有把“视觉模态”和“无障碍模态”在语义上对齐。视觉上弹窗盖住了底层内容,但无障碍树里底层元素还在,VoiceOver就会继续读。
无障碍开发就是这样,API看起来简单,坑都在细节里。希望这篇文章能帮到正在做iOS无障碍优化的伙伴们。如果大家遇到类似问题,欢迎一起交流讨论。
参考资料
- Apple Developer Documentation: accessibilityViewIsModal
- Apple Developer Documentation: UIAccessibilityScreenChangedNotification
作者
张赐荣,视障者,信息无障碍软件优化解决方案资深研发工程师。
深耕Web/PC/移动端可访问性工作多年,对跨平台无障碍解决方案拥有独特的理论研究和丰富的实战经验。
精通视障软件交互设计,致力于改善、提升产品可及性体验。