VoiceOver

iOS 拥有各种辅助技术,但核心是 VoiceOver —— 苹果的屏幕阅读器系统。这显然主要有利于盲人和视障用户,但它的作用远不止于此:VoiceOver 会告诉你系统如何解释你的 UI,包括可见的内容和排序方式。

VoiceOver 用户非常擅长在用户界面中导航,而且他们还经常将阅读速度设置得非常快。在设计 UI 时,考虑到这两点非常重要:这些用户不仅仅是出于好奇而尝试 VoiceOver,而是依靠它来访问您的应用程序的 VoiceOver 高级用户。因此,我们必须确保 UI 消除尽可能多的混乱,以便用户可以快速浏览,而不必听 VoiceOver 阅读无用的描述。

如果您尚未在 iOS 设备上的“设置”应用中启用 VoiceOver,请立即执行以下操作:“设置” > “辅助功能” > “VoiceOver” 将其打开。或者您可以随时激活 Siri 并要求启用或禁用 VoiceOver。VoiceOver 开关的正下方是有关如何使用它的说明。


SwiftUI 试图提供开箱即用的可用性功能,例如当给定一个 Image 图像时,它会自动使用图像的“文件名”作为 VoiceOver 要读出的文本。但如果想要自定义 VoiceOver 读出的内容,可以通过两个修饰符:.accessibilityLabel().accessibilityHint()

accessibilityLabel 辅助标签

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
    }

accessibilityAddTraits 添加特征

当 Image 视图,加上了 onTapGesture 修饰符后,它仍然会被程序识别为图像,于是在 VoiceOver 读出的最后,会读出“图像”一词。但实际上由于我们在它上面附加了点击手势,所以它实际上是一个按钮。这时我们可以使用修饰符 .accessibilityAddTraits() 来解决正确识别的问题。我们可以向 VoiceOver 提供一些额外的幕后信息来描述该视图的工作原理。

//例如以上例子,可以添加 accessibilityAddTraits 修饰符,来告诉程序该图像也是一个按钮:
Image(pictures[selectedPicture])
		.resizable()
    .scaledToFit()
    .onTapGesture {
		    selectedPicture = Int.random(in: 0...3)
    }
    .accessibilityLabel(labels[selectedPicture])
		.accessibilityAddTraits(.isButton)

accessibilityRemoveTraits 删除特征

如果你愿意,你也可以删除具体的特征,虽然实际上体验不出什么区别:

//比如,当图像被用作按钮时,我们不希望辅助系统读出图像特征,那就可以移除
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)

decorative 标记图像为不重要

例如,可以使用 Image(decorative:) 告诉 SwiftUI 特定图像只是为了让 UI 看起来更好。无论是简单的要点还是应用程序吉祥物角色跑来跑去的动画,它实际上并没有传达任何信息,因此 Image(decorative:) 告诉 SwiftUI 它应该被 VoiceOver 忽略。

Image(decorative: "character")

accessibilityHidden 从辅助系统中隐藏

如果您想更进一步,可以使用 .accessibilityHidden() 修饰符,这使得任何视图对于辅助功能系统完全不可见。

使用该修改器,无论图像具有什么特征,它都对 VoiceOver 不可见。显然,只有当相关视图确实没有添加任何内容时,您才应该使用此选项 - 如果您将视图放置在屏幕外,因此当前用户看不到它,则您也应该将其标记为 VoiceOver 无法访问。

Image(decorative: "character")
    .accessibilityHidden(true)

accessibilityElement

.combine 将子视图归组

使用 .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 让子视图不可见

VStack {
    Text("Your score is")
    Text("1000")
        .font(.title)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Your score is 1000")

<aside> 💡 使用 .combine 会在两段文本之间添加暂停,而使用 .ignore 和自定义标签可以一次读取所有文本,会更加自然。

</aside>


accessibilityInputLabels 处理语音输入

确保应用能够与 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 属性。

当你在模拟器中进行测试以下特性时,需要打开模拟设备内的 “设置应用”,并选择 “辅助功能” > “显示文本大小” > “区分无颜色”等。

VoiceOver 是否启用:accessibilityVoiceOverEnabled

@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)
        }
    }
}

区分无颜色 accessibilityDifferentiateWithoutColor

例如,一个辅助选项是 —— “无需颜色即可区分”,这对于十二分之一的色盲男性很有帮助。启用此设置后,应用程序应尝试使用形状、图标和纹理而不是颜色来使其 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>


减少动画 Reduce Motion

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)
    }
}

减少透明度 Reduce Transparency

启用该选项后,应用程序应该减少设计中使用的模糊和半透明量,以确保一切都清晰。

//例如,此代码在启用“减少透明度”时使用纯黑色背景,否则使用 50% 透明度:
struct ContentView: View {

    @Environment(\\.accessibilityReduceTransparency) var reduceTransparency

    var body: some View {
        Text("Hello, World!")
            .padding()
            .background(reduceTransparency ? .black : .black.opacity(0.5))
    }
}