Skip to content

第90天 项目 17 第五部分

今天,我们将通过添加一些最终功能并修复大量漏洞来完成我们的程序。是的,我们的程序存在漏洞,接下来我会带大家了解其中的一些漏洞,并展示如何修复它们。

在学习编程时,在代码中发现漏洞可能会让人感到沮丧,因为这感觉像是自己搞砸了。但正如荷兰传奇的荷兰计算机科学家Edsger Dijkstra曾说过:“如果调试是消除漏洞的过程,那么编程就一定是引入漏洞的过程。”

换句话说,在开发软件的过程中,修复漏洞是家常便饭,因为我们并非完美无缺。你对制造漏洞、发现漏洞以及修复漏洞越熟悉,你就能成为更优秀的开发者。

今天你需要完成两个主题的内容,在这些内容中,你将修复应用中的许多漏洞,然后添加一个用于编辑卡片的新屏幕。

  • 修复漏洞
  • 添加和删除卡片

又一个大型应用程序完成了——一定要和其他人分享你的进展!

修复漏洞

作者:Paul Hudson 2024年2月21日

到目前为止,我们的SwiftUI应用看起来还不错:我们有一堆可以拖动来控制应用的卡片,还支持一些辅助功能。但与此同时,它也存在不少影响使用的小毛病——有些比较严重,有些相对轻微,但都值得我们去解决。

首先,当卡片不在最顶层时,用户仍然可以拖动它们。这会让用户感到困惑,因为他们能拖动一张实际上看不到的卡片,这种情况是绝不应该出现的。

为了修复这个问题,我们将使用allowsHitTesting(),这样只有最后一张卡片——也就是最顶层的那张——才能被拖动。在ContentView中找到stacked()修饰符,并在其正下方添加以下代码:

swift
.allowsHitTesting(index == cards.count - 1)

其次,当使用“旁白”(VoiceOver)功能时,我们的用户界面会变得有些混乱。如果你在开启了“旁白”的真实设备上启动应用,会发现点击背景图片时,设备会读出“背景,图片”,这毫无意义。更糟糕的是:轻轻向右滑动,“旁白”会逐个读取所有辅助功能元素——即使是那些不可见的卡片上的文本,它也会读出来。

要解决背景图片的问题,我们应该将其设置为装饰性图片,这样它就不会作为辅助功能布局的一部分被读出来。将背景图片的代码修改为如下所示:

swift
Image(decorative: "background")

要解决卡片的问题,我们需要使用accessibilityHidden()修饰符,其条件与我们刚才添加的allowsHitTesting()修饰符类似。在这种情况下,所有索引小于最顶层卡片索引的卡片都应该对辅助功能系统隐藏,因为辅助功能系统对这些卡片无法发挥任何有用的作用。因此,在allowsHitTesting()修饰符的正下方添加以下代码:

swift
.accessibilityHidden(index < cards.count - 1)

我们的应用还存在第三个辅助功能方面的问题,这直接是由使用手势来控制操作导致的。诚然,大多数时候手势使用起来很有趣,但对于有特定辅助功能需求的用户来说,使用手势可能会非常困难。

在这个应用中,手势引发了多个问题:“旁白”用户无法清楚地知道应该如何控制应用:

  1. 我们没有提示用户这些卡片是可以点击的按钮。
  2. 当答案显示出来时,没有声音提示告知用户答案是什么。
  3. 用户无法通过左右滑动来切换卡片。

修复这些问题不需要花费太多功夫,但带来的回报是我们的应用能让更多人更方便地使用。

首先,我们需要明确告知用户这些卡片是可点击的按钮。只需在CardView中的ZStack上,在其opacity()修饰符之后添加accessibilityAddTraits()并设置为.isButton即可,代码如下:

swift
.accessibilityAddTraits(.isButton)

现在,系统会读出“《神秘博士》中第13任博士的扮演者是谁?按钮”——这对用户来说是一个重要提示,让他们知道这张卡片是可以点击的。

其次,我们需要帮助系统不仅能读取卡片上的问题,还能读取答案。目前,用户虽然也能听到答案,但只有在屏幕上滑动时才有可能听到——这显然不够直观。因此,为了修复这个问题,我们将检测用户设备上是否启用了辅助功能,如果启用了,就自动在显示提示(问题)和显示答案之间切换。也就是说,我们不会将答案显示在提示下方,而是替换提示,只显示答案,这样“旁白”就会立即读出答案。

SwiftUI提供了一个特定的环境属性,用于告知我们“旁白”是否正在运行,该属性名为accessibilityVoiceOverEnabled。因此,在CardView中添加以下新属性:

swift
@Environment(\.accessibilityVoiceOverEnabled) var accessibilityVoiceOverEnabled

目前,我们用于显示提示和答案的代码如下:

swift
VStack {
    Text(card.prompt)
        .font(.largeTitle)
        .foregroundStyle(.black)

    if isShowingAnswer {
        Text(card.answer)
            .font(.title)
            .foregroundStyle(.secondary)
    }
}

我们要对这段代码进行修改,让提示和答案在一个文本视图中显示,并通过accessibilityEnabled来决定显示哪种布局。将代码修改为如下形式:

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

如果你在开启“旁白”的情况下试用,会发现体验好很多——双击卡片后,答案会立即被读出来。

第三,我们需要让用户更容易将卡片标记为“正确”或“错误”,因为目前我们使用的图片完全无法满足需求。这些图片不仅会阻止用户通过点击手势与应用交互,而且“旁白”还会读出它们的SF Symbols名称(如“对勾,圆形,图片”),而不是有用的信息。

为了修复这个问题,我们需要用实际能移除卡片的按钮来替换这些图片。不过,无论用户选择“正确”还是“错误”,我们暂时不做其他区分——我总得给你们留一些挑战任务!——但至少我们可以从卡片堆中移除最顶层的卡片。同时,我们要为按钮添加辅助功能标签(label)和提示(hint),让用户更清楚这些按钮的作用。

因此,将当前包含这些图片的HStack替换为以下新代码:

swift
HStack {
    Button {
        withAnimation {
            removeCard(at: cards.count - 1)
        }
    } label: {
        Image(systemName: "xmark.circle")
            .padding()
            .background(.black.opacity(0.7))
            .clipShape(.circle)
    }
    .accessibilityLabel("错误")
    .accessibilityHint("将你的答案标记为错误。")

    Spacer()

    Button {
        withAnimation {
            removeCard(at: cards.count - 1)
        }
    } label: {
        Image(systemName: "checkmark.circle")
            .padding()
            .background(.black.opacity(0.7))
            .clipShape(.circle)
    }
    .accessibilityLabel("正确")
    .accessibilityHint("将你的答案标记为正确。")
}

由于即使最后一张卡片已经被移除,这些按钮仍然会显示在屏幕上,所以我们需要在removeCard(at:)方法的开头添加一个guard检查,以确保我们不会尝试移除不存在的卡片。因此,在该方法的开头添加以下代码:

swift
guard index >= 0 else { return }

最后,我们可以在启用accessibilityDifferentiateWithoutColor(无障碍模式下的“无颜色区分”)或“旁白”功能时,让这些按钮显示出来。这意味着需要在ContentView中再添加一个accessibilityVoiceOverEnabled属性:

swift
@Environment(\.accessibilityVoiceOverEnabled) var accessibilityVoiceOverEnabled

然后将if accessibilityDifferentiateWithoutColor {这个条件修改为:

swift
if accessibilityDifferentiateWithoutColor || accessibilityVoiceOverEnabled {

通过这些辅助功能方面的修改,我们的应用能让更多人更便捷地使用了——做得好!

在完成之前,我还想添加一个小小的额外修改。目前,如果你拖动一张卡片后稍微松开手,卡片会回到偏移量为零的位置,这会让它突然跳回屏幕中央。如果我们给卡片添加一个弹簧动画,它就会滑回中央,这样能更清晰地让用户知道发生了什么。

要实现这个效果,在CardView中的ZStack的末尾,也就是onTapGesture()之后,添加一个animation()修饰符:

swift
.animation(.bouncy, value: offset)

这样好多了!

提示: 如果你仔细观察,可能会发现当你将卡片向右拖动一点然后松开时,卡片会闪一下红色。后续我们会对此进行详细说明!

添加和删除卡片

作者:Paul Hudson 2024年2月21日

到目前为止,我们所做的所有工作都是基于一组固定的示例卡片,但显然,只有当用户能够自定义他们看到的卡片列表时,这个应用才真正有用。这意味着我们需要添加一个新视图,用于列出所有现有的卡片,并允许用户添加新卡片,这些功能都是你以前接触过的。不过,这次有一个有趣的小问题需要用新方法来解决,所以这个部分值得我们仔细研究。

首先,我们需要一个状态来控制编辑屏幕是否可见。因此,在ContentView中添加以下代码:

swift
@State private var showingEditScreen = false

接下来,我们需要添加一个按钮,点击该按钮时切换这个布尔值的状态。找到if differentiateWithoutColor || accessibilityEnabled这个条件,在其前面添加以下代码:

swift
VStack {
    HStack {
        Spacer()

        Button {
            showingEditScreen = true
        } label: {
            Image(systemName: "plus.circle")
                .padding()
                .background(.black.opacity(0.7))
                .clipShape(.circle)
        }
    }

    Spacer()
}
.foregroundStyle(.white)
.font(.largeTitle)
.padding()

我们要设计一个新的EditCards视图,用于将Card数组编码和解码到UserDefaults中。但在此之前,我希望你让Card结构体遵循Codable协议,代码如下:

swift
struct Card: Codable {

现在创建一个新的SwiftUI视图,命名为“EditCards”。这个视图需要实现以下功能:

  1. 拥有自己的Card数组。
  2. 被包裹在NavigationStack中,以便我们能添加一个“完成”按钮来关闭该视图。
  3. 用列表显示所有现有的卡片。
  4. 支持滑动删除卡片。
  5. 在列表顶部添加一个区域,供用户添加新卡片。
  6. 提供从UserDefaults加载数据和向其中保存数据的方法。

我们之前实际上已经学习过所有这些代码的相关知识了,所以这里我就不再逐一解释了。希望你能停下来,好好感受一下自己已经取得了多么大的进步!

EditCards结构体的模板代码替换为以下内容:

swift
struct EditCards: View {
    @Environment(\.dismiss) var dismiss
    @State private var cards = [Card]()
    @State private var newPrompt = ""
    @State private var newAnswer = ""

    var body: some View {
        NavigationStack {
            List {
                Section("添加新卡片") {
                    TextField("提示(问题)", text: $newPrompt)
                    TextField("答案", text: $newAnswer)
                    Button("添加卡片", action: addCard)
                }

                Section {
                    ForEach(0..<cards.count, id: \.self) { index in
                        VStack(alignment: .leading) {
                            Text(cards[index].prompt)
                                .font(.headline)
                            Text(cards[index].answer)
                                .foregroundStyle(.secondary)
                        }
                    }
                    .onDelete(perform: removeCards)
                }
            }
            .navigationTitle("编辑卡片")
            .toolbar {
                Button("完成", action: done)
            }
            .onAppear(perform: loadData)
        }
    }

    func done() {
        dismiss()
    }

    func loadData() {
        if let data = UserDefaults.standard.data(forKey: "Cards") {
            if let decoded = try? JSONDecoder().decode([Card].self, from: data) {
                cards = decoded
            }
        }
    }

    func saveData() {
        if let data = try? JSONEncoder().encode(cards) {
            UserDefaults.standard.set(data, forKey: "Cards")
        }
    }

    func addCard() {
        let trimmedPrompt = newPrompt.trimmingCharacters(in: .whitespaces)
        let trimmedAnswer = newAnswer.trimmingCharacters(in: .whitespaces)
        guard trimmedPrompt.isEmpty == false && trimmedAnswer.isEmpty == false else { return }

        let card = Card(prompt: trimmedPrompt, answer: trimmedAnswer)
        cards.insert(card, at: 0)
        saveData()
    }

    func removeCards(at offsets: IndexSet) {
        cards.remove(atOffsets: offsets)
        saveData()
    }
}

EditCards的大部分代码已经完成了,但在使用它之前,我们还需要在ContentView中添加更多代码,以便在需要时显示这个工作表(sheet),并在工作表关闭时调用resetCards()方法。

我们之前已经使用过工作表了,但这里我想向你展示另一种实现相同效果的方法:你可以给工作表附加一个函数,当工作表关闭时,这个函数会自动执行。如果需要从工作表中传递数据回来,这种方法可能不太有用,但在我们的场景中,我们只需要调用resetCards(),所以这种方法非常合适。

ContentView中最外层的ZStack末尾添加以下sheet()修饰符:

swift
.sheet(isPresented: $showingEditScreen, onDismiss: resetCards) {
    EditCards()
}

这样是可行的,但既然你在SwiftUI方面的经验越来越丰富,我想再给你展示一种能达到相同效果的替代方式。

当我们使用sheet()修饰符时,需要给SwiftUI传递一个函数,该函数返回要在工作表中显示的视图。在上面的代码中,我们传递的是一个包含EditCards()的闭包——这个闭包会创建并返回一个新视图,这正是工作表所需要的。

当我们编写EditCards()时,其实是在利用语法糖——我们将视图结构体当作函数来使用,因为Swift会默默地将其视为对视图初始化器的调用。所以,实际上我们写的等同于EditCards.init(),只是写法更简洁而已。

这一点很重要,因为我们不需要创建一个调用EditCards初始化器的闭包,而是可以直接将EditCards的初始化器传递给工作表,代码如下:

swift
.sheet(isPresented: $showingEditScreen, onDismiss: resetCards, content: EditCards.init)

这句话的意思是:“当你需要获取工作表的内容视图时,调用EditCards的初始化器,它会返回你需要使用的视图。”

重要提示: 这种方法仅在EditCards拥有无参数初始化器的情况下有效。如果你需要传递特定的值,就必须使用基于闭包的方法。

此外,除了在工作表关闭时调用resetCards(),我们还希望在视图首次出现时也调用它。因此,在之前添加的修饰符下方再添加以下修饰符:

swift
.onAppear(perform: resetCards)

这样一来,当视图首次显示时,会调用resetCards();当用户关闭EditCards后视图再次显示时,也会调用resetCards()。这意味着我们可以删除示例卡片数据,转而使用一个空数组,该数组会在运行时被填充数据。

因此,将ContentView中的cards属性修改为:

swift
@State private var cards = [Card]()

要完成ContentView的相关设置,我们还需要让它在需要时加载cards属性的数据。我们可以使用刚才在EditCard中添加的相同代码,因此现在在ContentView中添加以下方法:

swift
func loadData() {
    if let data = UserDefaults.standard.data(forKey: "Cards") {
        if let decoded = try? JSONDecoder().decode([Card].self, from: data) {
            cards = decoded
        }
    }
}

现在,我们可以在resetCards()中添加对loadData()的调用,这样当应用启动或用户编辑卡片后,cards属性就会被重新填充所有已保存的卡片:

swift
func resetCards() {
    timeRemaining = 100
    isActive = true
    loadData()        
}

现在可以运行应用了。我们已经删除了默认的示例卡片,所以你需要点击“+”图标来添加一些自己的卡片。

完成这最后一项修改后,我们的应用就全部完成了——做得好!

本站使用 VitePress 制作