Skip to content

第74天 项目 15 第一部分

今天我们有一个新的技术项目,这次的重点可能是你从未考虑过的领域:无障碍功能。无障碍功能衡量的是我们的应用能被有不同使用需求的人群顺利使用的程度——这些人可能需要更大的文字、可能需要我们避免使用某些颜色以确保他们能清晰视物、可能需要我们的用户界面(UI)能被朗读出来,等等。

太多开发者完全忽视了无障碍功能的重要性,这导致许多应用对大量人群而言完全不可用。托尼·法德尔(苹果公司iPod的创造者)曾说过:“定义你的不仅是你做了什么,还有你没做什么。”

诚然,你可以跳过无障碍功能开发,90%的人可能都不会注意到,但你想因此被定义吗?我猜答案是否定的。

今天你需要学习四个主题,从中你将了解无障碍标签、值、提示等内容。

  • 无障碍功能:简介
  • 用实用标签标识视图
  • 隐藏和分组无障碍数据
  • 读取控件的值

完成学习后,试着在手机上开启“语音朗读”(VoiceOver)功能使用其他应用——你能发现哪些应用需要改进吗?

无障碍功能:简介

作者:Paul Hudson 2024年1月13日

让应用具备无障碍功能,意味着采取措施确保所有人都能完全使用你的应用,无论他们有何种个人需求。例如,如果用户是视障人士,你的应用应能与系统的“语音朗读”功能良好配合,确保用户界面能顺畅地被朗读出来。

SwiftUI为我们免费提供了大量功能,因为它的布局系统(如VStack和HStack)自然地形成了视图流。但它并非完美无缺,只要你能添加一些额外信息来辅助iOS无障碍系统,就可能带来很大帮助。

通常,测试应用的最佳方式是开启“语音朗读”支持,并在真实设备上运行应用——如果你的应用在“语音朗读”模式下表现出色,那很可能你已经远超大多数iOS应用的平均水平了。

总之,在这个技术项目中,我们将学习一些无障碍功能开发技巧,然后回顾之前做过的一些项目,看看如何对它们进行升级。

现在,请使用“App”模板创建一个新的iOS应用,命名为AccessibilitySandbox。你需要在真实设备上运行这个项目,这样才能实际开启“语音朗读”功能。

鸣谢: 感谢罗宾·基普协助编写本章——他来信提供了关于无障碍功能开发的详细建议,还分享了很多实例,让我了解到这些功能对他个人使用的影响。

用实用标签标识视图

作者:Paul Hudson 2024年1月13日

在本项目的文件中,我放置了四张从Unsplash下载的图片。Unsplash的文件名由图片ID和摄影师姓名组成,因此当你将这些图片拖入资源目录时,会看到类似“ales-krivec-15949”这样的文件名。这本身不是问题,事实上,这种命名方式有助于记住资源的来源。但对于屏幕阅读器来说,这就成了一个问题。

要开始使用“语音朗读”功能,我们先创建一个简单的视图,让它随机切换资源目录中的四张图片。将ContentView结构体修改为以下代码:

swift
struct ContentView: View {
    let pictures = [
        "ales-krivec-15949",
        "galina-n-189483",
        "kevin-horstmann-141705",
        "nicolas-tissot-335096"
    ]

    @State private var selectedPicture = Int.random(in: 0...3)

    var body: some View {
        Image(pictures[selectedPicture])
            .resizable()
            .scaledToFit()
            .onTapGesture {
                selectedPicture = Int.random(in: 0...3)
            }
    }
}

这段代码并不复杂,但它已经暴露出两个严重的问题。

如果你尚未在iOS设备的“设置”应用中开启“语音朗读”功能,请现在开启:进入“设置”>“无障碍”>“语音朗读”,然后打开开关。此外,你也可以随时唤醒Siri,让它开启或关闭“语音朗读”功能。

重要提示: “语音朗读”开关正下方有使用说明。你平时习惯的点击和滑动操作在该模式下会有所不同,请务必阅读这些说明!

现在在设备上启动我们的应用,尝试点击一次图片以激活它。仔细听“语音朗读”的内容,你会发现两个问题:

  1. 听到“凯文·霍斯特曼 1 4 1 7 0 5”这样的朗读内容不仅对用户毫无帮助(因为它完全没有描述图片内容),还会造成困惑——一长串数字只会带来负面影响。
  2. 朗读完上述内容后,“语音朗读”还会说“图片”。这确实是事实,它的确是一张图片,但我们添加了onTapGesture()修饰符后,它实际上还起到了按钮的作用。

第一个问题是SwiftUI默认行为带来的副作用:当遇到图片时,它会自动将图片的文件名作为朗读文本。

我们可以通过添加两个修饰符来控制“语音朗读”对特定视图的朗读内容:.accessibilityLabel()和.accessibilityHint()。这两个修饰符都可以接收任意文本,但用途不同:

  • 标签(label) 会立即被朗读,应使用简洁明了的文本。如果某个视图的功能是删除用户数据中的某项内容,标签可以是“删除”。
  • 提示(hint) 会在短暂延迟后被朗读,用于提供更多关于视图用途的细节。例如,提示可以是“从收件箱中删除一封邮件”。

标签正是解决第一个问题的关键,因为它能让我们在保留图片原有文件名的同时,让“语音朗读”输出对用户有帮助的内容。

首先,在ContentView中添加第二个数组,用于存储图片描述:

swift
let labels = [
    "郁金香",
    "冻结的树芽",
    "向日葵",
    "烟花",
]

然后给图片添加以下修饰符:

swift
.accessibilityLabel(labels[selectedPicture])

这样一来,无论显示哪张图片,“语音朗读”都能读出正确的标签。当然,如果你的图片不会随机变化,也可以直接在修饰符中输入标签文本。

第二个问题是图片被识别为“图片”。这虽然是事实,但并无实际帮助,因为我们添加了点击手势后,它实际上是一个按钮。

我们可以使用另一个修饰符.accessibilityAddTraits()来解决第二个问题。这个修饰符能向“语音朗读”提供额外的后台信息,描述视图的功能。在我们的案例中,只需添加以下修饰符,就能告诉“语音朗读”这张图片同时也是一个按钮:

swift
.accessibilityAddTraits(.isButton)

如果你愿意,也可以移除“图片”这一特性,因为它的作用不大:

swift
.accessibilityRemoveTraits(.isImage)

完成这些修改后,我们的用户界面会好用很多:“语音朗读”现在能读出图片内容的有效描述,同时也会让用户知道这张图片还可作为按钮使用。

话虽如此,如果我们一开始就使用常规按钮,而不是给图片添加点击手势,那么添加和移除特性的操作根本就不需要。这就是为什么在可能的情况下,优先使用按钮而非onTapGesture()是更好的选择。对于本案例,优化后的代码如下:

swift
Button {
    selectedPicture = Int.random(in: 0...3)
} label: {
    Image(pictures[selectedPicture])
        .resizable()
        .scaledToFit()
}
.accessibilityLabel(labels[selectedPicture])

隐藏和分组无障碍数据

作者:Paul Hudson 2024年4月4日

只要你和经常使用“语音朗读”的用户相处几分钟,就会很快发现两件事:他们非常擅长在用户界面中导航,而且他们通常会把朗读速度调得非常快——比你我正常使用的速度快得多。

在设计用户界面时,必须考虑到这两点:这些用户使用“语音朗读”并非出于好奇,而是依赖它来使用你的应用。因此,我们要确保用户界面尽可能减少冗余信息,让用户能快速导航,不必听“语音朗读”读出无用的描述。

除了设置标签和提示,我们还有多种方法可以控制“语音朗读”的内容。这里我重点介绍三种:

  • 将图片标记为对“语音朗读”不重要的内容。
  • 让视图对无障碍系统不可见。
  • 将多个视图分组为一个整体。

这些修改都很简单,但能带来显著的体验提升。

例如,对于只是为了美化界面而存在的图片,我们可以使用Image(decorative:)来告诉SwiftUI。无论是简单的项目符号,还是应用吉祥物奔跑的动画,只要它们不传递任何实际信息,使用Image(decorative:)就能让SwiftUI知道“语音朗读”应忽略这些内容。

使用方式如下:

swift
Image(decorative: "character")

对于所有视图(无论是否为图片),我们都可以使用.accessibilityHidden()修饰符实现同样的效果,该修饰符能让任意视图对无障碍系统完全不可见:

swift
Image(.character)
    .accessibilityHidden(true)

显然,只有当视图确实没有任何实际作用时,才应使用这个修饰符。例如,如果某个视图被放置在屏幕外,当前用户无法看到,那么也应该让它对“语音朗读”不可见。

第三种让内容对“语音朗读”不可见的方法是“分组”,它能让我们控制系统如何朗读多个相关联的视图。以以下布局为例:

swift
VStack {
    Text("你的分数是")
    Text("1000")
        .font(.title)
}

“语音朗读”会将其视为两个不相关的文本视图,因此用户选中不同文本时,会分别听到“你的分数是”或“1000”。这两种朗读结果都没有实际意义,这时候就需要用到.accessibilityElement(children:)修饰符了:我们可以将它应用到父视图上,让系统将子视图合并成一个无障碍元素。

例如,以下代码会让两个文本视图的内容被一起朗读,中间会有短暂的停顿:

swift
VStack {
    Text("你的分数是")
    Text("1000")
        .font(.title)
}
.accessibilityElement(children: .combine)

这种方式在子视图包含独立信息时效果很好,但在我们的案例中,两个子视图的内容本就应该被当作一个整体朗读。因此,更好的解决方案是使用.accessibilityElement(children: .ignore),让子视图对“语音朗读”不可见,然后给父视图设置一个自定义标签,代码如下:

swift
VStack {
    Text("你的分数是")
    Text("1000")
        .font(.title)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("你的分数是1000")

值得尝试这两种方式,感受它们的实际差异。使用.combine时,两个文本内容之间会有停顿,因为它们原本并非为连贯朗读设计;而使用.ignore并设置自定义标签后,文本会被连贯朗读,更符合自然表达习惯。

提示: children参数的默认值是.ignore,因此.accessibilityElement(children: .ignore)也可以简化为.accessibilityElement()。

读取控件的值

作者:Paul Hudson 2024年1月13日

默认情况下,SwiftUI会为其用户界面控件提供“语音朗读”支持,虽然这些默认支持通常效果不错,但有时并不符合你的具体需求。在这种情况下,我们可以使用accessibilityValue()修饰符将控件的“值”与“标签”分开,也可以使用accessibilityAdjustableAction()来定义自定义滑动操作。

例如,你可能会创建一个视图,显示某种由多个按钮控制的输入内容,类似一个自定义的步进器(stepper):

swift
struct ContentView: View {
    @State private var value = 10

    var body: some View {
        VStack {
            Text("数值:\(value)")

            Button("增加") {
                value += 1
            }

            Button("减少") {
                value -= 1
            }
        }
    }
}

这个视图在点击交互时可能完全符合你的预期,但在“语音朗读”模式下体验并不好——用户每次点击按钮,只能听到“增加”或“减少”的朗读内容。

要解决这个问题,我们可以给iOS提供专门的调整操作说明:通过accessibilityElement()和accessibilityLabel()将VStack分组,然后添加accessibilityValue()和accessibilityAdjustableAction()修饰符,以响应滑动操作并执行自定义代码。

可调整操作(adjustable action)会接收用户的滑动方向,我们可以根据方向执行相应操作。但有一点需要注意:我们可以处理“增加”和“减少”两种滑动方向,但还需要一个特殊的默认情况来处理未来可能出现的未知值——苹果保留了在未来添加其他调整类型的权利。

具体代码如下:

swift
VStack {
    Text("数值:\(value)")

    Button("增加") {
        value += 1
    }

    Button("减少") {
        value -= 1
    }
}
.accessibilityElement()
.accessibilityLabel("数值")
.accessibilityValue(String(value))    
.accessibilityAdjustableAction { direction in
    switch direction {
    case .increment:
        value += 1
    case .decrement:
        value -= 1
    default:
        print("未处理的方向。")
    }
}

这样,用户选中整个VStack时,会听到“数值:10”的朗读内容;之后他们可以上下滑动来调整数值,此时“语音朗读”只会读出数值本身——这种操作方式更加自然。

本站使用 VitePress 制作