资讯动态

iOS无障碍适配实战:从VoiceOver基础到复杂自定义控件

发布时间:2026/10/19 16:04:16 来源:尧图企业网站定制
1. 为什么无障碍适配值得每个iOS开发者认真对待很多iOS开发者第一次接触Accessibility无障碍这个概念是在App Store审核被拒的时候——审核团队反馈说VoiceOver用户无法完成某个核心流程。这时候才手忙脚乱地去查文档、加label然后发现整个页面的无障碍逻辑几乎是从零开始补。我自己第一次遇到这个问题是在一个内容列表页上VoiceOver读出来的内容完全乱套焦点顺序跟视觉顺序对不上用户根本没法正常浏览。Accessibility在iOS开发里不是一个可选项它是App质量的一部分。全球有超过十亿人存在不同程度的残障其中视觉障碍、运动障碍、听觉障碍用户都在使用iPhone和iPad。iOS系统内置了VoiceOver屏幕朗读、Voice Control语音控制、Switch Control开关控制、AssistiveTouch辅助触控等一整套辅助功能。如果你的App没有做适配这些用户打开你的App就是一片混乱——按钮读不出来、图片没有描述、焦点乱跳、自定义手势完全不可用。Swift 5在这个领域带来的变化不算翻天覆地但几个关键特性让无障碍代码写起来更舒服了。dynamicMemberLookup让DSL风格的属性配置更自然Result类型让错误处理更清晰而unknown default在枚举的switch中强制你考虑未来可能新增的case——这在处理系统辅助功能通知时特别有用。更重要的是Swift 5的ABI稳定意味着你可以更放心地使用系统框架提供的无障碍API不用担心运行时兼容性问题。这篇文章面向的是已经会写iOS App、但对Accessibility还比较陌生的开发者。我会从最基础的概念讲起一直讲到复杂自定义控件的无障碍适配。每一段代码你都可以直接复制到项目里跑每一个坑我都踩过。读完你至少能做到知道什么时候该加什么无障碍属性、怎么测试、怎么排查常见问题。注意Accessibility的适配不是一次性工作它应该像写单元测试一样融入日常开发流程。每写一个新页面顺手把label和trait加上比事后补要轻松十倍。2. 先把这几个核心概念吃透后面才不会晕2.1 Accessibility ElementVoiceOver眼里的“可交互单元”VoiceOver不是像人眼那样“看”屏幕的。它把界面拆解成一个个Accessibility Element无障碍元素然后按顺序朗读这些元素。一个UIView默认不一定是无障碍元素但UILabel、UIButton、UIImageView有accessibilityLabel时这些系统控件通常会自动成为无障碍元素。关键点在于不是所有视图都需要成为无障碍元素。比如一个纯装饰性的分割线、一个背景渐变层它们不应该出现在VoiceOver的焦点序列里。你需要做的是把有意义的内容暴露出去把无意义的装饰隐藏掉。// 隐藏装饰性视图 decorationView.isAccessibilityElement false // 让一个容器视图成为无障碍元素 containerView.isAccessibilityElement true containerView.accessibilityLabel 用户信息卡片这里有个容易搞混的地方isAccessibilityElement true的视图它的子视图默认就不会被VoiceOver单独聚焦了。这跟accessibilityElementsHidden不一样后者是隐藏整个子树但不影响自身。2.2 Accessibility Label / Value / Hint / Trait四件套各管什么这四个属性是Accessibility适配的核心工具但很多人分不清它们各自的职责。我用一个生活化的类比来解释Label这是什么比如“提交按钮”、“用户头像”。Value它的当前值是什么比如滑块当前是“50%”开关当前是“开启”。Hint对它操作会发生什么比如“双击提交表单”、“向上滑动增加音量”。Trait它是什么类型比如按钮、标题、图片、选中的标签页。submitButton.accessibilityLabel 提交 submitButton.accessibilityHint 双击提交表单 submitButton.accessibilityTraits .button volumeSlider.accessibilityLabel 音量 volumeSlider.accessibilityValue \(Int(volume))% volumeSlider.accessibilityTraits .adjustableTrait特别重要因为它决定了VoiceOver如何与元素交互。.adjustable的滑块可以用上下滑动来调整值.button会提示用户双击激活.header会让VoiceOver在快速导航时把该元素作为标题跳转点。2.3 Accessibility Container自定义视图的焦点管理当你写了一个完全自定义的控件比如一个用UICollectionView实现的标签选择器或者一个用CAShapeLayer画的图表系统不知道该怎么处理它的无障碍焦点。这时候你需要实现UIAccessibilityContainer协议手动管理子元素的焦点顺序。class CustomChartView: UIView, UIAccessibilityContainer { private var accessibilityElementsArray: [Any] [] override var isAccessibilityElement: Bool { get { return false } set { } } func accessibilityElementCount() - Int { return accessibilityElementsArray.count } func accessibilityElement(at index: Int) - Any? { guard index accessibilityElementsArray.count else { return nil } return accessibilityElementsArray[index] } func index(ofAccessibilityElement element: Any) - Int { return accessibilityElementsArray.firstIndex(where: { $0 as AnyObject element as AnyObject }) ?? NSNotFound } }这个协议在Swift 5里的写法跟Objective-C时代略有不同主要是NSNotFound的处理和可选值的返回方式。实际项目中更常见的做法是直接用UIAccessibilityElement类来创建虚拟元素然后塞进数组里。2.4 通知机制界面变化时怎么告诉VoiceOver当界面发生动态变化时——比如加载完成、弹窗出现、列表刷新——你需要主动发通知给VoiceOver否则它可能还在读旧内容。// 页面布局完成 UIAccessibility.post(notification: .screenChanged, argument: titleLabel) // 局部刷新 UIAccessibility.post(notification: .layoutChanged, argument: nil) // 弹窗出现 UIAccessibility.post(notification: .announcement, argument: 加载完成).screenChanged会强制VoiceOver重新扫描整个屏幕.layoutChanged只更新布局变化的部分。.announcement则是直接朗读一段文字不改变焦点。用错了通知类型会导致焦点乱跳或者朗读中断这个后面会详细讲。3. 从零开始给一个真实页面加上完整的无障碍支持3.1 场景设定一个商品详情页假设我们有一个电商App的商品详情页结构大概是顶部商品图片轮播、商品标题、价格、规格选择按钮、加入购物车按钮、用户评价列表。这个页面在没做适配之前VoiceOver用户会遇到什么问题图片轮播VoiceOver会逐个读图片的文件名或者什么都不读价格如果价格是用多个Label拼出来的会读成“¥”“1”“2”“9”“9”这样规格按钮如果用的是自定义View可能完全无法聚焦评价列表如果Cell里嵌套了复杂布局焦点顺序可能完全错乱3.2 图片轮播的适配UIPageViewController UIAccessibility轮播图通常用UIPageViewController或者UICollectionView实现。核心问题是VoiceOver用户怎么知道当前是第几张图、怎么切换class ProductImageCell: UICollectionViewCell { IBOutlet weak var imageView: UIImageView! func configure(with image: UIImage, index: Int, total: Int) { imageView.image image isAccessibilityElement true accessibilityLabel 商品图片第\(index 1)张共\(total)张 accessibilityTraits .image accessibilityHint 左右滑动切换图片 } }对于轮播容器本身你需要设置accessibilityContainerType为.semanticGroupiOS 13这样VoiceOver会把整个轮播当作一个组来处理而不是逐个聚焦每张图片。if #available(iOS 13.0, *) { carouselView.accessibilityContainerType .semanticGroup }实测下来.semanticGroup在iOS 13和14上的行为略有差异。iOS 13上它会把子元素合并成一个焦点iOS 14上则允许用户进入组内逐个浏览。如果你的App要支持iOS 13建议在组级别加一个accessibilityLabel说明这是图片轮播然后让子图片各自可聚焦。3.3 价格拼接的坑如何让VoiceOver读出一个完整的金额很多设计师喜欢把价格拆成“¥”、“129”、“99”三个Label视觉上确实好看但VoiceOver会读成“人民币符号一百二十九九十九”完全不知所云。解决方案有两种方案一合并成一个Label。用NSAttributedString在一个Label里实现不同字号和颜色。let priceString NSMutableAttributedString(string: ¥, attributes: [ .font: UIFont.systemFont(ofSize: 14) ]) priceString.append(NSAttributedString(string: 129, attributes: [ .font: UIFont.boldSystemFont(ofSize: 24) ])) priceString.append(NSAttributedString(string: .99, attributes: [ .font: UIFont.systemFont(ofSize: 16) ])) priceLabel.attributedText priceString priceLabel.accessibilityLabel 价格129.99元方案二保留多个Label但把父容器设为无障碍元素。priceContainer.isAccessibilityElement true priceContainer.accessibilityLabel 价格129.99元 priceContainer.accessibilityTraits .staticText方案二更灵活但要注意把子Label的isAccessibilityElement设为false否则VoiceOver还是会读到它们。3.4 规格选择按钮自定义控件的Trait与状态管理规格按钮通常是这样的一个圆角矩形选中时边框变色未选中时灰色。用UIButton可以轻松实现但如果你用的是自定义View就需要手动管理Trait。class SpecButton: UIView { var isSelected: Bool false { didSet { updateAccessibility() } } private func updateAccessibility() { isAccessibilityElement true accessibilityLabel titleLabel.text accessibilityTraits isSelected ? [.button, .selected] : .button accessibilityHint isSelected ? 已选中双击取消 : 双击选中 } override func accessibilityActivate() - Bool { isSelected.toggle() sendActions(for: .touchUpInside) return true } }.selected这个Trait会让VoiceOver在朗读时加上“已选中”的提示。accessibilityActivate()是双击激活的回调必须返回true表示已处理。注意如果你重写了accessibilityActivate()但没有调用sendActions业务逻辑可能不会触发。我见过有开发者只改了UI状态但忘了发事件结果VoiceOver用户双击后界面变了但数据没更新。3.5 评价列表UITableViewCell的焦点顺序优化评价列表的每个Cell通常包含头像、用户名、评分星星、评价内容、时间。默认情况下VoiceOver会按子视图的添加顺序逐个聚焦这往往跟视觉顺序不一致。class ReviewCell: UITableViewCell { IBOutlet weak var avatarImageView: UIImageView! IBOutlet weak var usernameLabel: UILabel! IBOutlet weak var ratingView: StarRatingView! IBOutlet weak var contentLabel: UILabel! IBOutlet weak var timeLabel: UILabel! override func awakeFromNib() { super.awakeFromNib() setupAccessibility() } private func setupAccessibility() { // 头像不单独聚焦作为装饰 avatarImageView.isAccessibilityElement false // 用户名 usernameLabel.isAccessibilityElement true usernameLabel.accessibilityTraits .header // 评分 ratingView.isAccessibilityElement true ratingView.accessibilityLabel 评分 // 内容 contentLabel.isAccessibilityElement true // 时间 timeLabel.isAccessibilityElement true // 设置焦点顺序 accessibilityElements [usernameLabel, ratingView, contentLabel, timeLabel] } }accessibilityElements数组的顺序就是VoiceOver的聚焦顺序。这个属性在Swift 5里是[Any]?类型你可以放任何NSObject子类。3.6 加入购物车按钮动态状态与反馈加入购物车按钮在点击后通常会变成“已加入”或者显示一个加载状态。这时候需要更新无障碍信息并发送通知。IBAction func addToCartTapped(_ sender: UIButton) { sender.isEnabled false sender.accessibilityLabel 正在加入购物车 CartService.add(product: product) { [weak self] result in DispatchQueue.main.async { sender.isEnabled true switch result { case .success: sender.accessibilityLabel 已加入购物车 UIAccessibility.post(notification: .announcement, argument: 已成功加入购物车) case .failure(let error): sender.accessibilityLabel 加入购物车失败 UIAccessibility.post(notification: .announcement, argument: 加入失败\(error.localizedDescription)) } } } }这里用.announcement而不是.layoutChanged因为按钮本身没有移动或消失只是状态变了。.announcement会直接朗读提示不会打断用户当前的焦点。4. 进阶实战复杂自定义控件的无障碍适配4.1 用UIAccessibilityElement构建虚拟焦点有些控件在视觉上是一个整体但逻辑上包含多个可交互区域。比如一个用UIBezierPath画的饼图每个扇区都可以点击查看详情。这种场景下你不能把每个扇区做成UIView但可以用UIAccessibilityElement创建虚拟焦点。class PieChartView: UIView { private var slices: [PieSlice] [] private var accessibilityElementsArray: [UIAccessibilityElement] [] func reloadData(slices: [PieSlice]) { self.slices slices rebuildAccessibilityElements() } private func rebuildAccessibilityElements() { accessibilityElementsArray slices.enumerated().map { index, slice in let element UIAccessibilityElement(accessibilityContainer: self) element.accessibilityLabel slice.title element.accessibilityValue \(Int(slice.percentage * 100))% element.accessibilityTraits .button element.accessibilityHint 双击查看详情 // 计算扇区的屏幕坐标 let frame frameForSlice(at: index) element.accessibilityFrameInContainerSpace frame return element } } override func accessibilityElementCount() - Int { return accessibilityElementsArray.count } override func accessibilityElement(at index: Int) - Any? { guard index accessibilityElementsArray.count else { return nil } return accessibilityElementsArray[index] } override func index(ofAccessibilityElement element: Any) - Int { return accessibilityElementsArray.firstIndex(where: { $0 element as? UIAccessibilityElement }) ?? NSNotFound } }accessibilityFrameInContainerSpace是iOS 11引入的它让你用容器坐标系的frame来定位虚拟元素。在这之前你需要用accessibilityFrame配合UIAccessibility.convertToScreenCoordinates来转换麻烦很多。4.2 手势冲突当VoiceOver遇上自定义手势VoiceOver开启后很多自定义手势会失效。比如你给一个View加了UIPanGestureRecognizer来实现拖拽排序VoiceOver用户用三指滑动来滚动页面根本触发不了你的手势。解决方案是使用UIAccessibilityCustomAction。它会在VoiceOver的转子菜单里添加一个自定义操作用户可以通过上下滑动选择“排序”操作然后双击激活。class SortableItemView: UIView { override init(frame: CGRect) { super.init(frame: frame) setupCustomActions() } private func setupCustomActions() { let moveUpAction UIAccessibilityCustomAction( name: 上移, target: self, selector: #selector(moveUp) ) let moveDownAction UIAccessibilityCustomAction( name: 下移, target: self, selector: #selector(moveDown) ) accessibilityCustomActions [moveUpAction, moveDownAction] } objc private func moveUp() - Bool { // 执行上移逻辑 return true } objc private func moveDown() - Bool { // 执行下移逻辑 return true } }UIAccessibilityCustomAction的selector必须返回Bool表示操作是否成功。如果返回falseVoiceOver会播放一个错误提示音。4.3 模态弹窗的焦点陷阱模态弹窗出现时VoiceOver的焦点应该被限制在弹窗内部不能跑到后面的页面上。这个行为叫“焦点陷阱”Focus Trap。class ModalViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // 保存之前的焦点 previousFocusedElement UIAccessibility.focusedElement(using: .voiceOver) // 把焦点移到弹窗标题 UIAccessibility.post(notification: .screenChanged, argument: titleLabel) // 设置焦点陷阱 view.accessibilityViewIsModal true } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // 恢复之前的焦点 UIAccessibility.post(notification: .screenChanged, argument: previousFocusedElement) } }accessibilityViewIsModal true会让VoiceOver忽略该View同级及以下的所有元素。注意是同级不是子级。所以如果你的弹窗是加在UIWindow上的需要把window.accessibilityViewIsModal设为true。4.4 动态字体与无障碍的配合Dynamic Type动态字体和VoiceOver是两套独立的辅助功能但它们经常一起使用。低视力用户可能同时开启大字体和VoiceOver。// 监听字体变化 NotificationCenter.default.addObserver( self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil ) objc private func contentSizeCategoryDidChange() { // 更新布局 tableView.reloadData() // 通知VoiceOver布局变化 UIAccessibility.post(notification: .layoutChanged, argument: nil) }在UITableViewCell里如果字体变大导致内容被截断VoiceOver读出来的内容也会不完整。所以numberOfLines 0和preferredMaxLayoutWidth这些设置要配合好。5. 测试与排查怎么知道你的适配到底行不行5.1 Accessibility Inspector最直接的调试工具Xcode自带的Accessibility Inspector是排查无障碍问题的首选工具。打开方式Xcode → Open Developer Tool → Accessibility Inspector。它的核心功能Inspection鼠标悬停在模拟器或真机界面上直接显示该元素的label、value、trait、frameAudit自动扫描当前页面列出所有潜在的无障碍问题比如缺少label的按钮、对比度不足的文字Settings可以模拟不同的字体大小、颜色滤镜、减少动画等辅助功能设置我通常的工作流是写完一个页面 → 打开Audit → 修复所有红色警告 → 手动用VoiceOver过一遍核心流程。5.2 VoiceOver真机测试的完整流程模拟器上的VoiceOver跟真机有差异特别是手势操作和语音反馈的延迟。真机测试是必须的。开启VoiceOver设置 → 辅助功能 → 旁白 → 打开。或者用辅助功能快捷键连按三次侧边按钮。核心手势单指滑动移动到下一个/上一个元素双击激活当前元素三指滑动滚动页面四指点击上半屏/下半屏选择第一个/最后一个元素转子两指旋转切换导航模式按标题、按链接、按表单等测试时重点关注焦点顺序是否跟视觉顺序一致每个可交互元素是否都有清晰的label和hint动态内容变化后焦点是否合理模态弹窗是否限制了焦点范围自定义手势是否有替代的无障碍操作5.3 常见问题排查表问题现象可能原因排查方法解决方案VoiceOver读不出某个按钮isAccessibilityElement为false用Accessibility Inspector检查设为true并添加label焦点顺序错乱子视图添加顺序与视觉顺序不一致逐个聚焦观察顺序设置accessibilityElements数组图片没有描述accessibilityLabel为空Inspector显示label为空添加有意义的描述弹窗后焦点跑到背景未设置accessibilityViewIsModal弹窗后滑动焦点设置accessibilityViewIsModal true自定义手势无法使用VoiceOver拦截了手势开启VoiceOver后测试添加UIAccessibilityCustomAction动态内容更新后朗读旧内容未发送通知观察更新后焦点发送.layoutChanged或.announcement价格读成多个数字多个Label拼接Inspector查看元素数量合并Label或设置容器元素5.4 自动化测试把无障碍检查加入CIXCTest提供了无障碍相关的API可以在UI测试中检查元素的无障碍属性。func testAccessibilityLabels() { let app XCUIApplication() app.launch() let submitButton app.buttons[submitButton] XCTAssertTrue(submitButton.exists) XCTAssertEqual(submitButton.label, 提交) XCTAssertTrue(submitButton.isEnabled) // 检查图片是否有label let productImage app.images[productImage] XCTAssertFalse(productImage.label.isEmpty) }更进一步的你可以用XCUIElement的accessibilityTraits属性来验证Trait是否正确。不过XCTest对无障碍的支持有限复杂的焦点顺序和自定义操作还是需要手动测试。6. 几个让我踩过坑的细节6.1 accessibilityLabel不要写“按钮”两个字VoiceOver会自动根据Trait朗读“按钮”、“图片”、“文本字段”这些类型。如果你在label里写了“提交按钮”VoiceOver会读成“提交按钮按钮”重复且冗余。正确做法label只写内容Trait负责类型。// 错误 button.accessibilityLabel 提交按钮 // 正确 button.accessibilityLabel 提交 button.accessibilityTraits .button6.2 隐藏元素的正确姿势isHidden true和alpha 0都会让元素从无障碍树中消失但isAccessibilityElement false只是让元素本身不被聚焦子元素仍然可能被聚焦。如果你想让整个子树都从无障碍树中消失用accessibilityElementsHidden true。// 只隐藏自己 view.isAccessibilityElement false // 隐藏自己和所有子元素 view.accessibilityElementsHidden true // 隐藏自己但保留子元素 view.isAccessibilityElement false view.accessibilityElementsHidden false6.3 通知发送的时机很关键UIAccessibility.post(notification:argument:)如果在viewDidLoad里调用可能不会生效因为此时视图还没有出现在屏幕上。最佳时机是viewDidAppear或者布局完成后的回调里。另外.screenChanged的argument参数应该传一个可以聚焦的元素而不是nil。传nil会导致VoiceOver把焦点重置到屏幕左上角。override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) UIAccessibility.post(notification: .screenChanged, argument: firstResponderElement) }6.4 自定义字体对VoiceOver的影响如果你用了自定义字体VoiceOver的朗读不受影响但Dynamic Type的缩放会失效。因为自定义字体不会自动响应UIContentSizeCategory的变化。解决方案是用UIFontMetrics来缩放自定义字体let customFont UIFont(name: CustomFont, size: 17)! let scaledFont UIFontMetrics(forTextStyle: .body).scaledFont(for: customFont) label.font scaledFont label.adjustsFontForContentSizeCategory true6.5 SwiftUI与UIKit的无障碍差异虽然这篇文章主要讲UIKit但如果你在用SwiftUI几个关键差异需要知道SwiftUI的.accessibilityLabel()是View Modifier不是属性.accessibilityElement(children: .combine)可以合并子元素.accessibilityAddTraits()添加TraitSwiftUI的焦点管理用AccessibilityFocusState// SwiftUI示例 VStack { Text(商品名称) Text(¥129.99) } .accessibilityElement(children: .combine) .accessibilityLabel(商品名称价格129.99元)SwiftUI在无障碍方面做了很多自动化的工作但复杂场景下还是需要手动干预。如果你的项目是UIKit和SwiftUI混用注意两套系统的无障碍树是打通的但焦点顺序可能需要额外调整。7. 把无障碍适配变成开发习惯我现在的做法是每创建一个新的ViewController先花五分钟把基本的无障碍属性加上。具体清单所有UILabel检查是否需要accessibilityLabel如果文本本身已经清晰通常不需要额外设置所有UIButton检查label是否明确所有UIImageView检查是否有描述装饰性图片设为isAccessibilityElement false自定义控件检查Trait和accessibilityActivate()列表页面检查accessibilityElements顺序模态弹窗检查accessibilityViewIsModal动态内容检查通知发送这七个检查点花不了多少时间但能避免90%的常见问题。剩下的10%——复杂自定义控件、手势冲突、焦点陷阱——需要根据具体情况单独处理。最后分享一个我常用的调试技巧在开发阶段给所有需要无障碍适配的视图加一个红色边框这样一眼就能看出哪些元素被遗漏了。#if DEBUG extension UIView { func debugAccessibility() { if isAccessibilityElement { layer.borderColor UIColor.red.cgColor layer.borderWidth 1 } } } #endif在viewDidAppear里遍历所有子视图调用这个方法就能快速定位问题。上线前记得把这段代码去掉或者用#if DEBUG包起来。Accessibility适配这件事做一次可能觉得麻烦但形成习惯之后就是顺手的事。而且它带来的收益是实实在在的——你的App能被更多人使用App Store审核少一个被拒的理由代码质量也更高。我见过太多团队在发版前一周才开始补无障碍结果手忙脚乱还漏掉关键流程。不如从现在开始每写一个页面就顺手做了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑