[SwiftUI 100 天] 登月计划 - part4 GeometryReader

236 阅读10分钟
译自 Showing mission details with ScrollView and GeometryReader
更多内容,欢迎关注公众号 「Swift花园」
喜欢文章?不如来个 🔺💛➕三连?关注专栏,关注我 🚀🚀🚀

用 ScrollView 和 GeometryReader 显示任务细节

当用户从我们的主列表中选择一项 Apollo 任务时 ,我们希望能显示有关任务的信息:它的图片,任务徽章,机组中所有的宇航员以及他们在机组中承担的角色。前两个不难,后面两项需要我们通过两个 JSON 文件匹配机组的 ID 和机组细节。

让我们先从简单的开始:创建一个新的 SwiftUI 视图,文件名叫 MissionView.swift 。一开始它只会有一个 mission 属性,以便我们显示徽章和描述,不过很快我们会添加更多内容。

在布局方面,我们需要一个可滚动的 VStack ,里面是一个可调整大小的图像,显示任务徽章,然后是一个文本视图,一个 Spacer (以便所有的视图都被推动屏幕顶部)。我们将用 GeometryReader 来设置任务图像的最大宽度。经过尝试,我发现任务徽章在它没有占据全屏宽度时效果更好 —— 大概在 50% 到 75% 之间看起来更好,以避免在屏幕上太大而看起来很古怪。

把下面的代码放进 MissionView.swift :

struct MissionView: View {
    let mission: Mission

    var body: some View {
        GeometryReader { geometry in
            ScrollView(.vertical) {
                VStack {
                    Image(self.mission.image)
                        .resizable()
                        .scaledToFit()
                        .frame(maxWidth: geometry.size.width * 0.7)
                        .padding(.top)

                    Text(self.mission.description)
                        .padding()

                    Spacer(minLength: 25)
                }
            }
        }
        .navigationBarTitle(Text(mission.displayName), displayMode: .inline)
    }
}

你是否注意到 spacer 是以 minLength: 25的方式创建的?这种方式我们之前没有用过,它是用来确保 spacer 至少有 25 个点的最小高度。在滚动视图中这很有帮助,因为总的可用高度是灵活的:而一个 spacer 通常是占据所有剩余的空间,但剩余的空间在一个滚动视图中是没有意义的。

我们可以用 Spacer().frame(minHeight: 25)实现相同的效果,但用 Spacer(minLength: 25) 有一个好处是即便你改变了栈的方向 —— 假如你从 VStack 改成 HStack—— 那么它等价于 Spacer().frame(minWidth: 25)

言归正传,添加完新视图我们的代码又不能编译了,因为下面的预览结构体 —— 它需要传入一个 Mission对象。幸运的是,我们的 Bundle 扩展在这里也是可用的:

struct MissionView_Previews: PreviewProvider {
    static let missions: [Mission] = Bundle.main.decode("missions.json")

    static var previews: some View {
        MissionView(mission: missions[0])
    }
}

看到预览,这是一个好的开始,但接下来才是难点:我们想在任务的描述下方显示参与任务的宇航员列表。让我们在下一节中拆解...

译自 Merging Codable structs using first(where:)

用 first(where:) 合并 Codable 结构体

在我们的任务描述下面,我们希望以图片,名字和角色来展示每位机组人员,这个需求说起来容易做起来难。

这里的复杂性在于我们的 JSON 是通过两部分提供的:missions.json 和 astronauts.json 。 分离本身消除了数据重复的情况,因为某些宇航员参与了多个任务,但也意味着我们需要编写一些代码把两部分数据结合起来 —— 举个例子,从 “armstrong” 到 “Neil A. Armstrong”。你看,一边我们拥有任务,知道机组成员 “armstrong” 的角色是 “Commander”,但是不知道 “armstrong” 是谁,另一边我们有 “Neil A. Armstrong” 和他的描述,但是不知道他在 Apollo 11 任务中担任 commander 。

所以,我们需要做的是让 MissionView 接收被点击的任务,同时伴随完整的宇航员数组,然后从中找出哪些宇航员实际参与了发射。 因为这里的合并数据只作临时用途,我们可以用元组而不是结构体,不过老实说没有太大区别,所以我们这里还是用一个新的结构体。

把这个嵌套的结构体放进 MissionView

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

接下来是技巧部分:我们需要添加一个属性到 MissionView ,以存储 CrewMember 对象的数组,这些是完全解析过的角色/宇航员对。从最简单的部分开始,添加这个属性:

let astronauts: [CrewMember]

那么要怎么设置这个属性呢?认真思考:假设视图被传入任务和所有宇航员,我们可以遍历任务的机组人员,对每位机组成员遍历所有宇航员找到 ID 匹配的人。当我们发现某个匹配的人,我们把这个人的机组角色和宇航员组装成一个 CrewMember 对象,但如果还有没匹配到的,意味着机组里有某个角色的名字是非法的,或者未知的。

Swift 给我们提供了一个数组的方法,叫 first(where:) ,它对上面设想的过程真是太有帮助了。我们需要给它一个谓语(条件的花哨叫法),它会返回第一个匹配谓语的数组元素,如果没找到则返回 nil 。在我们的案例中,我们可以说 ”给我第一个 ID 是 armstrong 的宇航员” 。

让我们把上面的想法放进代码,用在 MissionView的自定义构造器。就像前面说过的,这个构造器会接收它要呈现的任务以及所有的宇航员,而它的工作是存储这个任务,然后解析出相关的宇航员数组。

下面是代码:

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

    var matches = [CrewMember]()

    for member in mission.crew {
        if let match = astronauts.first(where: { $0.id == member.name }) {
            matches.append(CrewMember(role: member.role, astronaut: match))
        } else {
            fatalError("Missing \(member)")
        }
    }

    self.astronauts = matches
}

上面的代码写完,我们的预览结构体就会停止工作。因为 MissionView 的构造器变了。我们要再加载全部的宇航员数据,然后传给 MissionView:

struct MissionView_Previews: PreviewProvider {
    static let missions: [Mission] = Bundle.main.decode("missions.json")
    static let astronauts: [Astronaut] = Bundle.main.decode("astronauts.json")

    static var previews: some View {
        MissionView(mission: missions[0], astronauts: astronauts)
    }
}

现在我们已经拥有完整的机组宇航员数据,我们可以用ForEach在任务描述下面直接显示他们。这里又将用到我们曾经用过的 HStack/VStack 组合,另外要在 HStack 末端要用到一个 Spacer ,以便把视图推向左边 —— 之前我们是免费获得这个效果的,因为我们在List里面,但是现在情况不一样了。我们还要额外添加一些样式,以便宇航员的图片看起来效果更好,用到一个胶囊形状的裁切以及 overlay 。

把下面的代码添加到 Spacer(minLength: 25) 前面:

ForEach(self.astronauts, id: \.role) { crewMember in
    HStack {
        Image(crewMember.astronaut.id)
            .resizable()
            .frame(width: 83, height: 60)
            .clipShape(Capsule())
            .overlay(Capsule().stroke(Color.primary, lineWidth: 1))

        VStack(alignment: .leading) {
            Text(crewMember.astronaut.name)
                .font(.headline)
            Text(crewMember.role)
                .foregroundColor(.secondary)
        }

        Spacer()
    }
    .padding(.horizontal)
}

你应该能在预览中看到新的效果已经很不错了,不过为了在模拟器里看到,我们还需要修改 ContentView 里的 NavigationLink —— 它目前是调转到 Text("Detail View") ,把它替换成下面这样:

NavigationLink(destination: MissionView(mission: mission, astronauts: self.astronauts)) {

现在可以在模拟器里运行 app —— 它开始变得有用了!

在你继续之前,尝试花几分钟定制一下宇航员信息的显示方式 —— 我用了一个胶囊裁切和 overlay ,但你可以尝试圆形和圆角矩形,还可以用不同的字号和更大的图片,甚至某些能凸显谁是指挥官的形式。

译自 Fixing problems with buttonStyle() and layoutPriority()

用 buttonStyle() 和 layoutPriority() 修复问题

为了完成这个程序,我们将要创建第三个同时也是最后一个视图,显示宇航员的细节信息。这个视图会通过点击任务视图中宇航员列表里的项目来触发。这个对你来说只是练习,因为用到的技术点此前你都已经学习过了,不过我要强调一个有趣的奇淫巧技,它会用到一个新的 modifier ,叫 layoutPriority()

创建一个新的 SwiftUI 视图,取名叫 AstronautView,这个视图只有一个Astronaut属性,所以你知道该显示什么。我们会用到一组似曾相识的 GeometryReader/ScrollView/ VStack组合。代码如下:

struct AstronautView: View {
    let astronaut: Astronaut

    var body: some View {
        GeometryReader { geometry in
            ScrollView(.vertical) {
                VStack {
                    Image(self.astronaut.id)
                        .resizable()
                        .scaledToFit()
                        .frame(width: geometry.size.width)

                    Text(self.astronaut.description)
                        .padding()
                }
            }
        }
        .navigationBarTitle(Text(astronaut.name), displayMode: .inline)
    }
}

再一次,我们要更新预览代码以便它能利用数据构建视图:

struct AstronautView_Previews: PreviewProvider {
    static let astronauts: [Astronaut] = Bundle.main.decode("astronauts.json")

    static var previews: some View {
        AstronautView(astronaut: astronauts[0])
    }
}

现在,我们需要回到 MissionView中来导航到宇航员视图。这里要到 ForEach 里把之前的HStack用 NavigationLink 包装起来:

NavigationLink(destination: AstronautView(astronaut: crewMember.astronaut)) {
    HStack {
        // current code
    }
    .padding(.horizontal)
}

运行 app ,尝试走一下流程 —— 你应该至少会发现一个 bug ,也许是两个,这取决于 SwiftUI 。

第一个 bug 很显眼:在任务视图里,所有的宇航员的图片都被显示成蓝色的胶囊,而不是他们的照片。你可能还会留意到每个人的名字也上了一层蓝色,这可能给到你提示发生了什么 —— 现在这是一个导航链接, SwiftUI 正通过给视图添加蓝色尝试让整个东西看起来是活动的。

为了修正这个问题,我们需要告诉 SwiftUI 把导航链接当做普通的按钮来渲染,这样它就不会给图片或者文本上色了。把下面这个 modifier 添加给宇航员的 NavigationLink

.buttonStyle(PlainButtonStyle())

至于第二个 bug ,有可能你甚至没有注意到它 —— 对我来说,它看起来像是 SwiftUI 自身的一个 bug ,要么会在未来的发布中修复,要么可能只影响某些特定的设备配置。所以,如果这个 bug 在你使用和我相同 iPhone 模拟器的时候没有出现,那说明它可能已经被解决了。

这个 bug 是这样的:如果你选择某位宇航员,例如来自 Apollo 1 的 Edward H. White II ,你可能会发现他们的描述文本在底部在裁掉了一部分。所以,你看不到完整的文本,只能看到部分,后面跟着一个省略号。并且你你仔细看图片的顶部,你会注意它并没有居顶紧挨着导航栏的下沿。

我们看到的情况是 SwiftUI 的布局算法在搞明白应该如何布局我们的内容时步履维艰。在我看来这是 SwiftUI 的一个 bug ,很可能你自己尝试的时候都它甚至都不存在。不过既然它出现,我将向你演示我们如何利用layoutPriority() modifier 修复它。

布局优先级可以让我们控制视图在空间有限时如何收缩,或者在空间充裕时如何扩展。 所有的视图的默认布局优先级都是 0 ,也就是说他们有均等的机会扩展或者收缩。我们要给宇航员的描述文本 1 的布局优先级,这比图片的 0 要高,也就说,它会自动占据所有可用的空间。

为了实现这一点,只要把 layoutPriority(1) 添加到AstronautView的宇航员描述文本视图上,像这样:

Text(self.astronaut.description)
    .padding()
    .layoutPriority(1)

修复完这两个 bug 我们的项目就算完工了 —— 最后运行代码并尝试一下吧!


我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~