iOS 拥有各种辅助技术,但核心是 VoiceOver —— 苹果的屏幕阅读器系统。这显然主要有利于盲人和视障用户,但它的作用远不止于此:VoiceOver 会告诉你系统如何解释你的 UI,包括可见的内容和排序方式。
VoiceOver 用户非常擅长在用户界面中导航,而且他们还经常将阅读速度设置得非常快。在设计 UI 时,考虑到这两点非常重要:这些用户不仅仅是出于好奇而尝试 VoiceOver,而是依靠它来访问您的应用程序的 VoiceOver 高级用户。因此,我们必须确保 UI 消除尽可能多的混乱,以便用户可以快速浏览,而不必听 VoiceOver 阅读无用的描述。
如果您尚未在 iOS 设备上的“设置”应用中启用 VoiceOver,请立即执行以下操作:“设置” > “辅助功能” > “VoiceOver” 将其打开。或者您可以随时激活 Siri 并要求启用或禁用 VoiceOver。VoiceOver 开关的正下方是有关如何使用它的说明。
SwiftUI 试图提供开箱即用的可用性功能,例如当给定一个 Image 图像时,它会自动使用图像的“文件名”作为 VoiceOver 要读出的文本。但如果想要自定义 VoiceOver 读出的内容,可以通过两个修饰符:.accessibilityLabel()
和 .accessibilityHint()
.accessibilityLabel(...)
接受想要读出的任何内容的文本.accessibilityLabel(...)
会被立即阅读,它应该是简短文字。例如是要从用户数据中删除某个项目,则显示“删除”即可.accessibilityHint(...)
同样接受想要读出的任何内容的文本.accessibilityHint(...)
会在 ”短暂延迟” 后读出,它应该提供视图详细信息。例如它可能会写“从收件箱中删除邮件”之类Button("Add", systemName: "person.crop.circle") {
print("Adding friend…)
}
.accessibilityLabel("Add to group")
.accessibilityHint("Add Jess to the Bridgerton Fans chat group.")
//如果不想iOS读出一些特殊字符,可以用字符串的替换方法先处理一遍。例如把.换成空格停顿
.accessibilityLabel(crewMember.astronaut.name.replacingOccurrences(of: ".", with: ""))
面向 iOS 18 及更高版本,accessibilityLabel()
有另一个变体非常有用。
我们可以为它提供一个闭包来自定义 SwiftUI 的默认标签,从而允许我们添加现有的描述。例如,下面的代码将读取 SwiftUI 的默认文本标签,但在其前面添加单词“警告”以反映文本为粗体和红色的事实:
Text("This is an important message")
.fontWeight(.bold)
.foregroundStyle(.red)
.accessibilityLabel { label in
Text("Warning:")
label
}
当 Image 视图,加上了 onTapGesture 修饰符后,它仍然会被程序识别为图像,于是在 VoiceOver 读出的最后,会读出“图像”一词。但实际上由于我们在它上面附加了点击手势,所以它实际上是一个按钮。这时我们可以使用修饰符 .accessibilityAddTraits()
来解决正确识别的问题。我们可以向 VoiceOver 提供一些额外的幕后信息来描述该视图的工作原理。
//例如以上例子,可以添加 accessibilityAddTraits 修饰符,来告诉程序该图像也是一个按钮:
Image(pictures[selectedPicture])
.resizable()
.scaledToFit()
.onTapGesture {
selectedPicture = Int.random(in: 0...3)
}
.accessibilityLabel(labels[selectedPicture])
.accessibilityAddTraits(.isButton)
如果你愿意,你也可以删除具体的特征,虽然实际上体验不出什么区别:
//比如,当图像被用作按钮时,我们不希望辅助系统读出图像特征,那就可以移除
Image(...)
.accessibilityRemoveTraits(.isImage)
虽然上面的做法可以让 Image 实现类似按钮的可用性。但如果我们只使用常规按钮而不是带有点击手势的图像,则根本不需要自行添加和删除特征。所以我们应该尽可能使用按钮而不是 onTapGesture()
,那样更符合语义:
//这意味着代码修改如下:
Button {
selectedPicture = Int.random(in: 0...3)
} label: {
Image(pictures[selectedPicture])
.resizable()
.scaledToFit()
}
.accessibilityLabel(labels[selectedPicture])
除了设置标签和提示之外,还有多种方法可以控制 VoiceOver 读出的内容:
大多数文本都可以作为单词阅读,但某些特殊文本(例如密码、股票代码和某些数字)必须通过 VoiceOver 逐个字母地阅读才能发挥作用。在 SwiftUI 中,可以使用 speechSpellsOutCharacters()
修饰符启用此功能,其用法如下:
Text("abCayer-muQai")
.speechSpellsOutCharacters()
此修饰符返回另一个 Text
实例,这意味着您可以根据需要将其链接到其他仅 Text
修饰符。当将辅助功能元素分组在一起时,它的效果特别好,因此它们被视为一个整体,如下所示:
VStack {
Text("Your password is")
Text("abCayer-muQai")
.font(.title)
.speechSpellsOutCharacters()
}
.accessibilityElement(children: .combine)
例如,可以使用 Image(decorative:)
告诉 SwiftUI 特定图像只是为了让 UI 看起来更好。无论是简单的要点还是应用程序吉祥物角色跑来跑去的动画,它实际上并没有传达任何信息,因此 Image(decorative:)
告诉 SwiftUI 它应该被 VoiceOver 忽略。
Image(decorative: "character")
如果您想更进一步,可以使用 .accessibilityHidden()
修饰符,这使得任何视图对于辅助功能系统完全不可见。
使用该修改器,无论图像具有什么特征,它都对 VoiceOver 不可见。显然,只有当相关视图确实没有添加任何内容时,您才应该使用此选项 - 如果您将视图放置在屏幕外,因此当前用户看不到它,则您也应该将其标记为 VoiceOver 无法访问。
Image(decorative: "character")
.accessibilityHidden(true)
使用 .accessibilityElement(children: .combine)
可以将子视图归成一组,让他们一起被读出。
// 参考以下布局: VoiceOver 会将其视为两个不相关的文本视图,因此根据用户的选择,它会显示“您的分数是”或“1000”;
// 这两者都没有帮助,这就是 .accessibilityElement(children:) 修饰符的用武之地;
// 我们可以将其应用于父视图,并要求它将子视图组合成单个可访问性元素;
VStack {
Text("Your score is")
Text("1000")
.font(.title)
}
//例如,这将导致两个文本视图一起阅读,并在它们之间短暂停顿:
VStack {
Text("Your score is")
Text("1000")
.font(.title)
}
.accessibilityElement(children: .combine)
.ignore
是 children
的默认参数,因此只需输入 .accessibilityElement()
即可获得相同的结果。.accessibilityElement(children: .ignore)
让子视图对 VoiceOver 不可见。然后再单独向父视图通过 accessibilityLabel
提供自定义标签,如下所示:VStack {
Text("Your score is")
Text("1000")
.font(.title)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Your score is 1000")
<aside>
💡 使用 .combine
会在两段文本之间添加暂停,而使用 .ignore
和自定义标签可以一次读取所有文本,会更加自然。
</aside>
确保应用能够与 Voiceover 良好配合后,下一步就是确保它也能处理语音输入 ,该技术允许用户通过对应用说话来控制您的应用。语音输入允许用户通过名称或数字激活控件,名称会根据您所呈现的内容自动生成。这是一个简单的例子:
Button("Tap Me") {
print("Button tapped")
}
//因为屏幕上有“Tap Me”,所以可以通过说“Press Tap Me”来激活它。
//这很简洁,但事情往往更复杂。例如,假设您有带有不同总统姓名的按钮,如下所示:
Button("John Fitzgerald Kennedy") {
print("Button tapped")
}
//如果能识别出“Tap Kennedy”甚至“Tap JFK”岂不是很棒吗?认出这三个人怎么样?
//这是 SwiftUI 需要我们使用 accessibilityInputLabels() 修饰符提供一些额外帮助的地方。它接受可以附加到我们的按钮的字符串数组,因此用户可以通过多种方式触发它。因此,要使用三个不同的短语触发按钮,我们将使用以下命令:
Button("John Fitzgerald Kennedy") {
print("Button tapped")
}
.accessibilityInputLabels(["John Fitzgerald Kennedy", "Kennedy", "JFK"])
//目标是帮助用户使用他们认为自然的任何方式激活您的控件 - 您可以提供任意数量的字符串,iOS 将侦听所有字符串。
默认情况下,SwiftUI 为其用户界面控件提供 VoiceOver 读出,尽管这些通常很好,但有时它们并不适合您的需要。在这些情况下,我们可以使用 accessibilityValue()
修饰符将控件的值与其标签分开,也可以使用 accessibilityAdjustableAction()
指定自定义滑动操作。
//例如构建一个视图,显示由各种按钮控制的某种输入,例如自定义步进器:
//这可能是您想要的点击交互方式,但对于 VoiceOver 来说不是很好的体验,因为所有用户每次点击其中一个按钮时都会听到“递增”或“递减”
struct ContentView: View {
@State private var value = 10
var body: some View {
VStack {
Text("Value: \\(value)")
Button("Increment") {
value += 1
}
Button("Decrement") {
value -= 1
}
}
}
}
为了解决这个问题,可以为 iOS 提供如何处理调整的具体说明,方法是使用 accessibilityElement()
和 accessibilityLabel()
将 VStack
组合在一起,然后添加 accessibilityValue()
和 accessibilityAdjustableAction()
修饰符,用于使用自定义代码响应滑动。
可调整的操作为我们提供了用户滑动的方向,我们可以根据需要做出响应。有一个附带条件:是的,我们可以在递增和递减滑动之间进行选择,但我们还需要一种特殊的默认情况来处理未知的未来值 - Apple 保留在未来添加其他类型调整的权利。
@State private var value = 10
VStack {
Text("Value: \\(value)")
Button("Increment") {
value += 1
}
Button("Decrement") {
value -= 1
}
}
.accessibilityElement()
.accessibilityLabel("Value")
.accessibilityValue(String(value))
.accessibilityAdjustableAction {
direction in
switch direction {
case .increment:
value += 1
case .decrement:
value -= 1
default:
print("Not handled.")
}
}
//这让用户可以选择整个 VStack 来读出“Value: 10”,然后他们可以向上或向下滑动来操纵该值并只读出数字,这是一种更自然的方式。
相比上面的可访问性标签、提示、特征、组等,以下这些设置有所不同,因为它们是通过环境变量 @Environment
提供的。这意味着 SwiftUI 会自动监视它们的更改,并在其中之一发生更改时重新调用我们的 body
属性。
当你在模拟器中进行测试以下特性时,需要打开模拟设备内的 “设置应用”,并选择 “辅助功能” > “显示文本大小” > “区分无颜色”等。
@Environment(\\.accessibilityVoiceOverEnabled) var accessibilityVoiceOverEnabled
//使用时
VStack {
if accessibilityVoiceOverEnabled {
Text(isShowingAnswer ? card.answer : card.prompt)
.font(.largeTitle)
.foregroundStyle(.black)
} else {
Text(card.prompt)
.font(.largeTitle)
.foregroundStyle(.black)
if isShowingAnswer {
Text(card.answer)
.font(.title)
.foregroundStyle(.secondary)
}
}
}
例如,一个辅助选项是 —— “无需颜色即可区分”,这对于十二分之一的色盲男性很有帮助。启用此设置后,应用程序应尝试使用形状、图标和纹理而不是颜色来使其 UI 更清晰。要使用它,只需添加一个环境属性,如下所示:
//例如,在下面的代码中,我们使用简单的绿色背景作为常规布局:
struct ContentView: View {
//differentiateWithoutColor 要么是正确的,要么是错误的,是布尔值
@Environment(\\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor
var body: some View {
HStack {
//当启用“无颜色区分”时,我们添加复选标记
if differentiateWithoutColor {
Image(systemName: "checkmark.circle")
}
Text("Success")
}
//当启用“无颜色区分”时,我们使用黑色背景
.background(differentiateWithoutColor ? .black : .green)
}
}
<aside> 💡 您可以在模拟器中进行测试,方法是转到模拟器的 “设置Setting” 应用程序,并选择“辅助功能”>“显示和文本大小”>“区分无颜色”。
</aside>
withAnimation()
和 animation
的使用struct ContentView: View {
// 此设置作为环境布尔值向我们公开,因此应该先将其属性添加到视图中 (是布尔值)
@Environment(\\.accessibilityReduceMotion) var reduceMotion
@State private var scale = 1.0
var body: some View {
Button("Hello, World!") {
if reduceMotion {
//如果减少了运动,就不需要加 withAnimation 动画
scale *= 1.5
} else {
//如果正常,就加 withAnimation 动画
withAnimation {
scale *= 1.5
}
}
}
.scaleEffect(scale)
// 在隐式动画中使用方法
.animation(reduceMotion ? nil : .spring(response: 1, dampingFraction: 0.1), value: someValue)
}
}
以上写法很烦人,需要每个地方单独处理。
另外一种方法是可以在 withAnimation()
周围添加一个直接使用 UIKit 的 UIAccessibility
数据的小包装函数,从而允许我们自动绕过动画:
//添加以下方法:
func withOptionalAnimation<Result>(_ animation: Animation? = .default, _ body: () throws -> Result) rethrows -> Result {
if UIAccessibility.isReduceMotionEnabled {
return try body()
} else {
return try withAnimation(animation, body)
}
}
//因此,当“Reduce Motion Enabled”为 true 时,传入的闭包代码将立即执行,否则将使用 withAnimation() 传递。整个 throws / rethrows 是更高级的 Swift,但它是 withAnimation() 函数签名的直接副本,因此两者可以互换使用。
//使用时:使用这种方法,就不需要每次都重复动画代码了
struct ContentView: View {
@State private var scale = 1.0
var body: some View {
Button("Hello, World!") {
withOptionalAnimation {
scale *= 1.5
}
}
.scaleEffect(scale)
}
}
启用该选项后,应用程序应该减少设计中使用的模糊和半透明量,以确保一切都清晰。
//例如,此代码在启用“减少透明度”时使用纯黑色背景,否则使用 50% 透明度:
struct ContentView: View {
@Environment(\\.accessibilityReduceTransparency) var reduceTransparency
var body: some View {
Text("Hello, World!")
.padding()
.background(reduceTransparency ? .black : .black.opacity(0.5))
}
}