Skip to content

第41天 项目 8 第三部分

今天,我们将通过添加另外两个视图以及它们之间的导航来完成 Moonshot 应用,不过从现在开始,你将了解到在 SwiftUI 中创建自定义布局需要做些什么——例如,我们会使用容器相对框架(container relative frames)来精确控制视图大小,以满足我们的需求。

在此过程中,我们还将解决程序员常会遇到的一个问题:当你有两段独立的数据,却需要以某种方式将它们合并时该如何处理。对我们而言,这两段数据分别是宇航员数据和任务数据,但你会发现,这个概念具有很强的可迁移性。

在今天的内容中,我会建议你暂停下来,尝试调整一下设计。我知道有些人可能会跳过这一步,急于想着赶紧完成,但我希望你不要这样做。正如宇航员约翰·格伦所说:“我认为,宇航员身上一种比其他任何品质都更重要的品质就是好奇心——他们必须前往从未有人去过的地方。”

保持好奇心吧——花时间探索自己的技能,最终一定会有回报!

今天你需要完成三个主题的学习,在这些内容中,你将接触容器相对框架、ScrollView 等知识。

  • 使用 ScrollView 和 containerRelativeFrame() 展示任务详情
  • 合并 Codable 结构体
  • 用最后一个视图完成整个应用

又一个完整的应用即将构建完成——一定要分享你的进展,让其他人知道你的学习情况!

使用 ScrollView 和 containerRelativeFrame() 展示任务详情

作者:Paul Hudson 2023年11月1日

当用户从主列表中选中某个阿波罗任务时,我们希望展示该任务的相关信息:任务徽章、任务描述,以及执行该任务的所有宇航员及其职责。其中,前两项实现起来不算不算难,但第三项需要多做些工作,因为我们需要将两个 JSON 文件中的宇航员 ID 与宇航员详细信息进行匹配。

让我们从简单的部分入手,逐步推进:新建一个名为 MissionView.swift 的 SwiftUI 视图。最初,这个视图只需要一个 mission 属性,用于展示任务徽章和描述,不久后我们会为它添加更多内容。

在布局方面,这个视图需要一个可滚动的 VStack,里面包含一个可调整大小的任务徽章图片, followed by a text view. 我们将使用 containerRelativeFrame() 来设置任务图片的宽度——经过多次尝试,我发现任务徽章不宜占满屏显示,宽度设为屏幕宽度的 50% 到 70% 之间看起来效果更好,可以避免它在屏幕上显得过大、不协调。

现在,将以下代码添加到 MissionView.swift 中:

swift
struct MissionView: View {
    let mission: Mission

    var body: some View {
        ScrollView {
            VStack {
                Image(mission.image)
                    .resizable()
                    .scaledToFit()
                    .containerRelativeFrame(.horizontal) { width, axis in
                        width * 0.6
                    }
                    .padding(.top)

                VStack(alignment: .leading) {
                    Text("任务亮点")
                        .font(.title.bold())
                        .padding(.bottom, 5)

                    Text(mission.description)
                }
                .padding(.horizontal)
            }
            .padding(.bottom)
        }
        .navigationTitle(mission.displayName)
        .navigationBarTitleDisplayMode(.inline)
        .background(.darkBackground)
    }
}

在一个 VStack 内部再嵌套一个 VStack,这样我们就可以对视图的特定部分控制对齐方式——主任务图片可以居中显示,而任务详情则可以靠左侧对齐。

无论如何,添加了这个新视图后,代码可能无法正常编译,问题出在下方的预览结构体(previews struct)上——它需要传入一个 Mission Mission 对象才能进行渲染。幸运的是,我们的 Bundle 扩展在这里也可以使用:

swift
#Preview {
    let missions: [Mission] = Bundle.main.decode("missions.json")

    return MissionView(mission: missions[0])
        .preferredColorScheme(.dark)
}

提示: 由于在 ContentView 的 NavigationStack 上设置了深色配色方案,这个视图会自动使用深色模式,但 MissionView 的预览并不知道这一点,所以我们需要手动启用深色模式。

查看预览效果,你会发现这是一个不错的开始,但接下来的部分会更复杂:我们希望在描述下方展示执行该任务的宇航员列表。让我们继续解决这个问题……

合并 Codable 结构体

作者:Paul Hudson 2023年11月1日

在任务描述下方,我们希望展示每位机组人员的照片、姓名和职责,这意味着需要将来自两个不同 JSON 文件的数据进行匹配。

如果你还有印象的话,我们的 JSON 数据分别存储在 missions.json 和 astronauts.json 中。这种方式可以避免数据重复(因为有些宇航员参与过多次任务),但同时也意味着我们需要编写代码来合并这些数据——例如,将“armstrong”关联到“尼尔·A·阿姆斯特朗”。要知道,一方面,任务数据中只知道机组人员“armstrong”的职责是“指令长”,但不知道“armstrong”是谁;另一方面,宇航员数据中包含“尼尔·A·阿姆斯特朗”的信息及其介绍,却不知道他曾是阿波罗 11 号任务的指令长。

因此,我们需要让 MissionView 接收用户点击的任务以及完整的宇航员字典数据,然后确定哪些宇航员参与了该次任务。

现在,在 MissionView 内部添加以下嵌套结构体:

swift
struct CrewMember {
    let role: String
    let astronaut: Astronaut
}

接下来是关键部分:我们需要为 MissionView 添加一个属性,用于存储 CrewMember 对象数组——这些对象是职责与宇航员信息完全匹配后的组合。一开始,添加这个属性很简单:

swift
let crew: [CrewMember]

但之后该如何设置这个属性呢?其实思路很清晰:如果我们给这个视图传入对应的任务和所有宇航员数据,就可以遍历任务的机组人员列表,然后针对每位机组人员,在字典中查找与之匹配的宇航员 id。找到匹配项后,我们就可以将该宇航员的信息与其职责组合成一个 CrewMember 对象;如果找不到匹配项,那就说明我们的机组人员职责数据中存在无效或未知的宇航员 id。

后一种情况本不应该发生。 需要明确的是,如果你在项目中添加的 JSON 数据指向了应用中不存在的数据,那就是一个根本性的错误——这种错误不需要在运行时进行错误处理,因为它从一开始就不应该出现。因此,这是使用 fatalError() 的绝佳场景:如果我们无法通过宇航员 id 找到对应的宇航员信息,就应该立即终止程序,并明确指出问题所在。

让我们把这些逻辑用代码实现,为 MissionView 创建一个自定义初始化器。正如刚才所说,这个初始化器会接收对应的任务和所有宇航员数据,它的作用是存储任务信息,并生成匹配后的宇航员数组。

代码如下:

swift
init(mission: Mission, astronauts: [String: Astronaut]) {
    self.mission = mission

    self.crew = mission.crew.map { member in
        if let astronaut = astronauts[member.name] {
            return CrewMember(role: member.role, astronaut: astronaut)
        } else {
            fatalError("缺失 \(member.name) 对应的宇航员信息")
        }
    }
}

添加完这段代码后,预览结构体又会无法正常工作,因为它需要更多的信息。因此,在预览结构体中再添加一次 decode() 调用,加载所有宇航员数据,然后将其传入:

swift
#Preview {
    let missions: [Mission] = Bundle.main.decode("missions.json")
    let astronauts: [String: Astronaut] = Bundle.main.decode("astronauts.json")

    return MissionView(mission: missions[0], astronauts: astronauts)
        .preferredColorScheme(.dark)
}

现在我们已经获取了所有宇航员数据,可以在任务描述下方使用水平滚动视图来展示这些信息了。我们还会对宇航员照片做一些额外的样式调整,让它们看起来更美观,比如使用胶囊状的裁剪形状和边框叠加效果。

在“VStack(alignment: .leading)”之后添加以下代码:

swift
ScrollView(.horizontal, showsIndicators: false) {
    HStack {
        ForEach(crew, id: \.role) { crewMember in
            NavigationLink {
                Text("宇航员详情")
            } label: {
                HStack {
                    Image(crewMember.astronaut.id)
                        .resizable()
                        .frame(width: 104, height: 72)
                        .clipShape(.capsule)
                        .overlay(
                            Capsule()
                                .strokeBorder(.white, lineWidth: 1)
                        )

                    VStack(alignment: .leading) {
                        Text(crewMember.astronaut.name)
                            .foregroundStyle(.white)
                            .font(.headline)
                        Text(crewMember.role)
                            .foregroundStyle(.white.opacity(0.5))
                    }
                }
                .padding(.horizontal)
            }
        }
    }
}

为什么要把这段代码放在 VStack 之后,而不是放在 VStack 内部呢?因为滚动视图要想充分利用可用的屏幕空间,最好能实现边缘到边缘的滚动效果。如果把它放在 VStack 内部,它会继承 VStack 中其他文本的内边距,这样在滚动时会出现奇怪的效果——机组人员信息会在碰到 VStack 的左边缘时被截断,看起来很不协调。

稍后我们会让这个 NavigationLink 实现更实用的功能,但首先需要修改 ContentView 中的 NavigationLink——目前它会跳转到“Text("详情视图")”,请将其替换为以下代码:

swift
MissionView(mission: mission, astronauts: astronauts)

现在可以在模拟器中运行应用了——它已经开始具备实际用途了!

在继续下一步之前,建议你花几分钟时间自定义宇航员信息的展示样式。我使用了胶囊状的裁剪形状和边框叠加效果,但你也可以尝试圆形或圆角矩形,使用不同的字体或更大的图片,甚至可以添加某种标记来突出显示任务指令长。

在我的项目中,我认为在任务视图中添加一些视觉分隔会很有帮助,这样任务徽章、描述和机组人员信息就能区分得更清晰。

SwiftUI 提供了专门的 Divider 视图,用于在布局中创建视觉分隔线,但它无法自定义——始终只是一条细线条。因此,为了实现更理想的效果,我会绘制一个自定义的分隔线来分割视图。

首先,在“任务亮点”文本的正上方添加以下代码:

swift
Rectangle()
    .frame(height: 2)
    .foregroundStyle(.lightBackground)
    .padding(.vertical)

然后在“mission.description”文本的正下方再添加一段相同的代码。这样看起来就好多了!

为了完善这个视图,我还会在机组人员信息上方添加一个标题,但需要谨慎处理位置。要知道,虽然这个标题与滚动视图相关,但它需要和其他文本保持相同的内边距。因此,最佳的放置位置是在 VStack 内部,紧接在刚才添加的分隔线之后:

swift
Text("机组人员")
    .font(.title.bold())
    .padding(.bottom, 5)

你也不一定要把标题放在这里——如果你愿意,也可以把它放在 VStack 外部,然后为这个文本视图单独设置内边距。但如果选择这种方式,请确保设置的内边距大小与其他元素一致,以保持所有内容对齐整齐。

用最后一个视图完成整个应用

作者:Paul Hudson 2023年11月1日

要完成这个程序,我们需要创建第三个也是最后一个视图,用于展示宇航员的详细信息。用户在任务视图中点击某位宇航员,就会跳转到这个视图。这部分内容对你来说主要是实践,但我希望它也能让你认识到 NavigationStack 的重要性——我们在不断深入获取应用中的信息,而视图的滑入滑出效果能让用户清晰地感知到这种层级关系。

首先,新建一个名为 AstronautView 的 SwiftUI 视图。它需要一个 Astronaut 属性来确定要展示的内容,然后使用与 MissionView 类似的 ScrollView/VStack 组合来布局。为其添加以下代码:

swift
struct AstronautView: View {
    let astronaut: Astronaut

    var body: some View {
        ScrollView {
            VStack {
                Image(astronaut.id)
                    .resizable()
                    .scaledToFit()

                Text(astronaut.description)
                    .padding()
            }
        }
        .background(.darkBackground)
        .navigationTitle(astronaut.name)
        .navigationBarTitleDisplayMode(.inline)
    }
}

同样,我们需要更新预览结构体,让它能使用一些数据来创建视图:

swift
#Preview {
    let astronauts: [String: Astronaut] = Bundle.main.decode("astronauts.json")

    return AstronautView(astronaut: astronauts["aldrin"]!)
        .preferredColorScheme(.dark)
}

现在,我们可以从 MissionView 中的 NavigationLink 跳转到这个视图了。目前,这个 NavigationLink 指向的是“Text("宇航员详情")”,我们可以将其更新为指向新创建的 AstronautView:

swift
AstronautView(astronaut: crewMember.astronaut)

这很简单,对吧?但如果你现在运行应用,会发现用户界面的交互变得非常自然——我们从最宽泛的信息层面(展示所有任务)开始,点击进入某个具体任务,再点击进入某位具体宇航员的详情页。iOS 会自动处理新视图的动画效果,同时提供返回按钮和滑动返回功能,帮助用户回到之前的视图。

本站使用 VitePress 制作