Skip to content

第50天 项目 10 第二部分

今天,在开始为我们的应用构建用户界面之前,我们将再介绍两种技术。

尽管今天内容的基础知识你可能已经很熟悉了,但正如你将看到的,其中仍有新内容值得学习。随着我们不断突破 SwiftUI 的界限,这种情况会变得尤为常见——当应用比较简单时,一切都很容易上手,但当我们着手开发更大型的应用时,你会发现我们需要花更多时间来完善细节。

不过这没关系。正如美国轮胎大亨哈维·费尔斯通曾经说过的:“成功是细节的总和。” 希望你能以苹果的 iOS 应用为灵感:它们的用户界面通常并不复杂,但苹果团队在细节打磨上投入了大量精力,因此整体使用体验非常出色。

当用户在价值 1000 美元的 iPhone 上启动你的应用时,应用会占据整个屏幕。为了用户,也为了你自己,你有责任尽最大努力确保应用运行流畅。既然苹果能做到,我们也可以!

今天你需要学习三个主题,分别是触觉反馈效果、对 @Observable 类进行编码等内容。

  • 为 @Observable 类添加 Codable 一致性
  • 添加触觉反馈效果
  • 获取基本订单详情

为 @Observable 类添加 Codable 一致性

作者:Paul Hudson 2024 年 5 月 15 日

如果某个类型的所有属性都已遵循 Codable 协议,那么该类型本身无需额外编写代码就能遵循 Codable 协议——Swift 会自动生成所需的代码,用于对该类型进行归档和反归档操作。然而,当处理使用 @Observable 宏的类时,情况会稍微复杂一些,这是因为 Swift 会对我们的代码进行重写。

要实际了解这个问题,我们可以创建一个简单的可观察类,该类包含一个名为 name 的属性,代码如下:

swift
@Observable
class User: Codable {
    var name = "Taylor"
}

接下来,我们可以编写一段 SwiftUI 代码,当按钮被点击时对该类的实例进行编码,并打印出编码后的文本:

swift
struct ContentView: View {
    var body: some View {
        Button("Encode Taylor", action: encodeTaylor)
    }

    func encodeTaylor() {
        let data = try! JSONEncoder().encode(User())
        let str = String(decoding: data, as: UTF8.self)
        print(str)
    }
}

你会看到出乎意料的结果:{"_name":"Taylor","_$observationRegistrar":{}}。我们的 name 属性变成了 _name,JSON 中还多了一个观察注册器(observation registrar)实例。

要记住,@Observable 宏会在后台对我们的类进行重写,以便 SwiftUI 能够监控该类,而在这里,这种重写的痕迹暴露了出来——我们能清楚地看到重写的结果,这可能会引发各种问题。例如,如果你想向服务器发送一个 name 值,服务器可能完全不知道该如何处理 _name

要解决这个问题,我们需要明确告诉 Swift 应该如何对数据进行编码和解码。具体做法是在类内部嵌套一个名为 CodingKeys 的枚举,该枚举需要具有 String 类型的原始值,并且要遵循 CodingKey 协议。是的,这听起来有点容易混淆——枚举名为 CodingKeys,而协议名为 CodingKey,但两者的名称确实不能混淆。

在这个枚举中,你需要为每个想要保存的属性定义一个 case(枚举值),并通过原始值指定该属性在编码后的名称。在我们的例子中,这意味着要将 _name(即 name 属性的底层存储变量)指定为编码后的字符串 name(不带下划线):

swift
@Observable
class User: Codable {
    enum CodingKeys: String, CodingKey {
        case _name = "name"
    }

    var name = "Taylor"
}

这样就完成了!如果你再次运行代码,会发现 name 属性的名称显示正确了,而且 JSON 中也不再包含观察注册器——编码结果简洁了很多。

这种编码键映射是双向生效的:当 Codable 从 JSON 中读取到 name 时,会自动将其存储到 _name 属性中。

添加触觉反馈效果

作者:Paul Hudson 2023 年 11 月 11 日

SwiftUI 内置了对简单触觉反馈效果的支持,这些效果借助苹果的 Taptic Engine(触觉引擎)让手机以各种方式振动。在 iOS 中,我们有两种实现方式:简单方式和完整方式——我会把两种方式都展示给你,让你了解所有可能性,但公平地说,除非有非常特殊的需求,否则你可能更倾向于使用简单方式!

重要提示: 这些触觉反馈效果仅在实体 iPhone 上有效——Mac 和 iPad 等其他设备无法实现振动效果。

我们先从简单方式开始。与弹出层(sheets)和警告框(alerts)类似,我们只需告诉 SwiftUI 何时触发触觉效果,剩下的工作就由它来完成。

首先,我们可以编写一个简单的视图,每当按钮被点击时,就将计数器加 1:

swift
struct ContentView: View {
    @State private var counter = 0

    var body: some View {
        Button("Tap Count: \(counter)") {
            counter += 1
        }
    }
}

这都是之前学过的代码,现在我们来增加一些趣味性:为按钮添加触觉反馈效果,使其在每次被点击时触发。给按钮添加以下修饰符:

swift
.sensoryFeedback(.increase, trigger: counter)

在真实设备上运行这段代码,每次点击按钮时,你应该能感受到轻微的触觉振动。

.increase 是内置的触觉反馈变体之一,最适合在增加计数器等值时使用。还有很多其他变体可供选择,例如 .success、.warning、.error、.start、.stop 等。

每种反馈变体都有不同的触感,虽然你可能会想逐个尝试并挑选自己最喜欢的,但请务必考虑视障用户的感受——他们依赖触觉反馈来获取信息。如果你的应用出现错误,却因为个人喜欢而播放“成功”的触觉反馈,可能会给他们造成困惑。

如果你想对触觉效果有更多控制,还有另一种选择:.impact()。它有两种变体:一种是指定物体的柔韧性(flexibility)和效果强度(intensity),另一种是指定重量(weight)和强度(intensity)。

例如,我们可以请求一个中等强度、两个柔软物体之间的碰撞效果:

swift
.sensoryFeedback(.impact(flexibility: .soft, intensity: 0.5), trigger: counter)

或者,我们可以指定一个高强度、两个重物之间的碰撞效果:

swift
.sensoryFeedback(.impact(weight: .heavy, intensity: 1), trigger: counter)

对于更高级的触觉效果,苹果为我们提供了一个完整的框架——Core Haptics。这个框架仿佛是苹果团队倾注心血打造的作品,我认为它是 iOS 13 中真正的“隐藏宝藏”之一——当然,我在看到发布说明后就立刻开始研究它了!

Core Haptics 允许我们通过组合轻触(taps)、持续振动(continuous vibrations)、参数曲线(parameter curves)等元素,创建高度可定制的触觉效果。在这里,我不想深入过多细节,因为这有点偏离主题,但我至少会给你一个示例,让你可以亲自尝试。

首先,在 ContentView.swift 文件的顶部添加以下导入语句:

swift
import CoreHaptics

接下来,我们需要将 CHHapticEngine 的实例作为属性创建——这是实际负责生成振动的对象,因此我们需要在想要使用触觉效果之前提前创建它。

因此,在 ContentView 中添加以下属性:

swift
@State private var engine: CHHapticEngine?

我们会在主视图出现时立即创建这个引擎实例。创建引擎时,你可以附加处理程序,以便在引擎停止(例如应用进入后台时)时帮助恢复其活动,但在这里我们会保持简单:如果当前设备支持触觉反馈,我们就启动引擎;如果启动失败,则打印错误信息。

在 ContentView 中添加以下方法:

swift
func prepareHaptics() {
    guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }

    do {
        engine = try CHHapticEngine()
        try engine?.start()
    } catch {
        print("创建引擎时出错:\(error.localizedDescription)")
    }
}

现在到了有趣的部分:我们可以配置控制触觉强度(.hapticIntensity)和“尖锐度”(.hapticSharpness)的参数,然后将这些参数整合到一个带有相对时间偏移的触觉事件中。“尖锐度”这个术语听起来有点奇怪,但只要你亲自尝试一下就会明白——尖锐度值为 0 时,触感确实比值为 1 时更“沉闷”。至于相对时间,它允许我们在单个序列中创建多个触觉事件。

现在在 ContentView 中添加以下方法:

swift
func complexSuccess() {
    // 确保设备支持触觉反馈
    guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
    var events = [CHHapticEvent]()

    // 创建一个高强度、高尖锐度的轻触效果
    let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: 1)
    let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: 1)
    let event = CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: 0)
    events.append(event)

    // 将这些事件转换为一个模式,并立即播放
    do {
        let pattern = try CHHapticPattern(events: events, parameters: [])
        let player = try engine?.makePlayer(with: pattern)
        try player?.start(atTime: 0)
    } catch {
        print("播放模式失败:\(error.localizedDescription)。")
    }
}

要试用我们的自定义触觉效果,请将 ContentView 的 body 属性修改为以下内容:

swift
Button("Tap Me", action: complexSuccess)
    .onAppear(perform: prepareHaptics)

添加 onAppear() 可以确保触觉系统启动,从而使点击手势能正常触发触觉效果。

如果你想进一步尝试触觉效果,可以将 let intensity、let sharpness 和 let event 这几行代码替换为任何你想要的触觉配置。例如,如果你用以下代码替换这三行,将会得到一系列强度和尖锐度先增加后减少的轻触效果:

swift
for i in stride(from: 0, to: 1, by: 0.1) {
    let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(i))
    let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(i))
    let event = CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: i)
    events.append(event)
}

for i in stride(from: 0, to: 1, by: 0.1) {
    let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(1 - i))
    let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(1 - i))
    let event = CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: 1 + i)
    events.append(event)
}

Core Haptics 非常适合用来尝试各种效果,但由于实现起来需要更多工作量,因此我认为你可能会尽可能优先使用内置的触觉效果!

到这里,本项目的概述就结束了,请将 ContentView.swift 恢复到原始状态,以便我们开始构建主项目。

获取基本订单详情

作者:Paul Hudson 2023 年 11 月 11 日

本项目的第一步是创建一个订单界面,用于获取订单的基本详情:用户想要多少个纸杯蛋糕、想要哪种类型的纸杯蛋糕,以及是否有特殊定制要求。

在开始设计用户界面之前,我们需要先定义数据模型。之前,我们通过混合使用结构体(structs)和类(classes)来达到理想的效果,但这次我们将采用一种不同的方案:创建一个单独的类来存储所有数据,并在各个界面之间传递这个类的实例。这意味着应用中的所有界面都共享同一组数据,你很快就会发现这种方式非常有效。

目前,这个类不需要太多属性:

  • 蛋糕类型,以及一个包含所有可能选项的静态数组。
  • 用户想要订购的蛋糕数量。
  • 用户是否有特殊要求(这将决定在用户界面中是否显示额外选项)。
  • 用户是否希望在蛋糕上多加糖霜。
  • 用户是否希望在蛋糕上添加糖屑。

这些属性在发生变化时都需要更新用户界面,这意味着我们需要确保该类使用 @Observable 宏。

因此,请创建一个新的 Swift 文件,命名为 Order.swift,将文件中的 Foundation 导入语句改为 SwiftUI 导入语句,并添加以下代码:

swift
@Observable
class Order {
    static let types = ["Vanilla(香草味)", "Strawberry(草莓味)", "Chocolate(巧克力味)", "Rainbow(彩虹味)"]

    var type = 0
    var quantity = 3

    var specialRequestEnabled = false
    var extraFrosting = false
    var addSprinkles = false
}

现在,我们可以在 ContentView 中创建该类的一个实例,方法是添加以下属性:

swift
@State private var order = Order()

这是唯一会创建订单实例的地方——应用中的其他所有界面都会接收这个属性,因此它们都操作同一组数据。

我们将分三个部分构建这个界面的用户界面,首先是纸杯蛋糕的类型和数量选择。第一部分将包含一个选择器(picker),让用户从香草味、草莓味、巧克力味和彩虹味中选择蛋糕类型;然后是一个数值选择器(stepper),范围从 3 到 20,用于选择蛋糕数量。所有这些元素都将包裹在一个表单(form)中,而表单又会放在一个导航栈(navigation stack)中,以便我们设置标题。

这里有一个小问题:我们的纸杯蛋糕类型列表是一个字符串数组,但我们用整数来存储用户的选择——如何将两者匹配起来呢?一个简单的解决方案是使用数组的 indices 属性,该属性会返回每个元素的位置,我们可以将这个位置用作数组的索引。对于可变数组来说,这种方法并不可取,因为数组的顺序可能随时改变,但在我们的案例中,数组的顺序永远不会改变,因此这种方法是安全的。

现在,将以下代码添加到 ContentView 的 body 中:

swift
NavigationStack {
    Form {
        Section {
            Picker("选择蛋糕类型", selection: $order.type) {
                ForEach(Order.types.indices, id: \.self) {
                    Text(Order.types[$0])
                }
            }

            Stepper("蛋糕数量:\(order.quantity)", value: $order.quantity, in: 3...20)
        }
    }
    .navigationTitle("纸杯蛋糕小店")
}

表单的第二部分将包含三个切换开关(toggle),分别与 specialRequestEnabled、extraFrosting 和 addSprinkles 绑定。但是,第二个和第三个开关只有在第一个开关开启时才显示,因此我们会将它们包裹在一个条件语句中。

现在添加第二部分:

swift
Section {
    Toggle("是否有特殊要求?", isOn: $order.specialRequestEnabled)

    if order.specialRequestEnabled {
        Toggle("多加糖霜", isOn: $order.extraFrosting)

        Toggle("添加糖屑", isOn: $order.addSprinkles)
    }
}

可以运行应用并尝试操作一下。

不过,这里存在一个我们自己造成的漏洞:如果我们开启“特殊要求”,然后开启“多加糖霜”和/或“添加糖屑”,之后再关闭“特殊要求”,之前选择的特殊要求选项仍然会保持激活状态。这意味着如果我们再次开启“特殊要求”,之前的特殊要求选项依然会生效。

如果代码的每一层都能考虑到这个问题,解决起来并不难——例如,应用、服务器、数据库等都被编程为在 specialRequestEnabled 设为 false 时忽略 extraFrosting 和 addSprinkles 的值。但是,一个更好(也更安全)的方法是:当 specialRequestEnabled 设为 false 时,确保 extraFrosting 和 addSprinkles 都重置为 false。

我们可以通过为 specialRequestEnabled 添加 didSet 属性观察器来实现这一点。现在添加以下代码:

swift
var specialRequestEnabled = false {
    didSet {
        if specialRequestEnabled == false {
            extraFrosting = false
            addSprinkles = false
        }
    }
}

第三部分是最简单的,因为它只是一个指向 next 界面的导航链接(NavigationLink)。目前我们还没有第二个界面,但我们可以快速创建一个:新建一个 SwiftUI 视图,命名为“AddressView”,并为其添加一个 order 属性,代码如下:

swift
struct AddressView: View {
    var order: Order

    var body: some View {
        Text("Hello World(你好,世界)")
    }
}

#Preview {
    AddressView(order: Order())
}

我们很快会让这个界面变得更实用,但目前,我们可以回到 ContentView.swift 中,为表单添加最后一部分。这部分将创建一个 NavigationLink,指向 AddressView,并传入当前的订单对象。

现在请添加最后一部分:

swift
Section {
    NavigationLink("配送详情") {
        AddressView(order: order)
    }
}

至此,我们的第一个界面就完成了。在继续下一步之前,最后尝试运行一次应用——你应该能够选择蛋糕类型、设置数量,并正常切换所有开关。

本站使用 VitePress 制作