在今天的话题中,我鼓励你停下来并继续进行设计。我敢肯定,有些人会跳过这一想赶快走到尽头的事情,但我希望你不要。正如宇航员约翰·格伦(John Glenn)所说:“我认为,比其他任何人都更有力量的宇航员的素质都是好奇心–他们必须到达一个从未有过的地方。”

使用ScrollView和GeometryReader显示任务详细信息

当用户从我们的主列表中选择一个阿波罗任务时,我们希望显示有关该任务的信息:其图像,任务徽章以及机组人员中的所有宇航员及其角色。前两个并不太难,但是第二个需要更多的工作,因为我们需要在两个JSON文件中将乘员ID与乘员详细信息进行匹配。

让我们从简单开始并逐步进行:创建一个名为MissionView.swift的新SwiftUI视图。最初,它仅具有一个mission属性,以便我们可以显示任务徽章和说明,但是不久之后我们将对其添加更多内容。

就布局而言,此东西需要滚动,VStack并带有可调整大小的任务徽章图像,然后是文本视图,然后是分隔符,以便所有内容都可以推送到屏幕顶部。我们将使用GeometryReader该图像来设置任务图像的最大宽度,尽管通过反复试验,我发现任务徽章在非全角时效果最佳-避免介于50%和75%之间的宽度看起来更好在屏幕上变得越来越大。

现在将此代码放入MissionView.swift中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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)
}
}

你是否注意到间隔是由创建的minLength: 25?这不是我们以前使用过的东西,但是它可以确保垫片的最小高度至少为25点。这在滚动视图内部很有用,因为总的可用高度是灵活的:间隔符通常会占用所有可用的剩余空间,但是在滚动视图内部没有意义。

使用可以达到相同的结果Spacer().frame(minHeight: 25),但是使用Spacer(minLength: 25)的优势在于,如果你更改堆栈方向(例如,从a VStack转到a)HStack,则它实际上变为Spacer().frame(minWidth: 25)。

无论如何,有了新视图后,代码将不再生成,这完全是因为它下面的预览结构–该东西需要Mission传入一个对象,因此它需要渲染。幸运的是,我们的Bundle扩展程序也可以在这里找到:

1
2
3
4
5
6
7
struct MissionView_Previews: PreviewProvider {
static let missions: [Mission] = Bundle.main.decode("missions.json")

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

如果你在预览中查看,将会发现这是一个不错的开始,但是下一部分比较棘手:我们要在说明下方显示参与任务的宇航员列表。让我们接下来解决……

使用first(where :)合并可编码结构

在我们的任务描述下方,我们想显示每个机组人员的图片,姓名和角色,这说起来容易做起来难。

这里的复杂性在于,我们的JSON分两部分提供:missions.json和astronauts.json。这消除了数据的重复,因为一些宇航员参加了多次任务,但这也意味着我们需要编写一些代码以将我们的数据连接在一起,例如将“ armstrong”解析为“ Neil A. Armstrong”。你会看到,一方面,我们执行的任务知道机组人员“阿姆斯特朗”扮演的角色是“指挥官”,但不知道谁是“阿姆斯特朗”,而另一方面,我们的任务是“尼尔·阿姆斯特朗”及其描述他,但不知道他是阿波罗11号的指挥官。

因此,我们需要做的是使我们MissionView接受已获得的任务以及我们的整个宇航员阵容,然后弄清楚哪些宇航员实际上参与了发射。因为此合并数据只是临时的,所以我们可以使用元组而不是结构,但是老实说并没有太大的区别,因此我们将在这里使用新的结构。

MissionView现在在内部添加此嵌套结构:

1
2
3
4
struct CrewMember {
let role: String
let astronaut: Astronaut
}

现在到了棘手的部分:我们需要向其中添加一个属性,以MissionView存储CrewMember对象数组–这些是完全解析的角色/宇航员配对。首先,这就像添加另一个属性一样简单:

1
let astronauts: [CrewMember]

但是,我们如何设置该属性?好吧,想一想:如果我们将这一观点传递给它的任务和所有宇航员,我们就可以对任务组进行遍历,然后让每位宇航员对我们所有的宇航员进行遍历,以找到具有匹配ID的宇航员。当我们找到一个角色时,我们可以将其及其角色转换为一个CrewMember对象,但是如果不这样做,则意味着我们以某种方式使用了无效或未知名称的乘务员角色。

Swift为我们提供了一种称为的数组方法first(where:),该方法确实可以帮助完成此过程。我们可以给它一个谓词(条件的花哨词),它会发回与该谓词匹配的第一个数组元素,nil如果没有则返回。在我们的案例中,我们可以这样说:“给我第一名阿姆斯特朗ID的宇航员。”

让我们使用的自定义初始化程序将所有内容输入代码中MissionView。就像我说的那样,这将接受它与所有宇航员一起执行的任务,其工作是将任务存储起来,然后找出一批有决心的宇航员。

这是代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
}

输入该代码后,我们的预览结构将再次停止工作,因为它需要更多信息。因此,在decode()此处添加第二个呼叫,以便加载所有宇航员,然后再将其传递给:

1
2
3
4
5
6
7
8
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组合ContentView,除了现在我们需要在结尾处使用一个空格HStack将视图向左推–之前我们是免费获得的,因为我们位于List,但不是现在的情况。我们还将使用太空舱夹子的形状和覆盖层为宇航员的图片添加一些额外的样式,以使其看起来更好。

之前添加以下代码Spacer(minLength: 25)中MissionView:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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)
}

你应该在预览中看到它看起来不错,但是要在模拟器中看到它,我们需要修改
NavigationLinkin ContentView–它会立即推送到Text("Detail View"),但请替换为:

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

现在继续在模拟器中运行该应用程序-它开始变得有用!

在继续之前,请尝试花费一些时间来定制宇航员的显示方式–我使用了胶囊夹的形状和覆盖层,但是你可以尝试使用圆形或圆形的矩形,可以使用不同的字体或更大的图像,甚至可以添加标记任务指挥官是谁的某种方式。

解决buttonStyle()和layoutPriority()的问题

为了完成该程序,我们将制作第三个也是最后一个视图,以显示宇航员的详细信息,这可以通过在任务视图中点击一位宇航员来实现。这大多数情况下只是你的习惯,但我确实想强调一个有趣的怪癖,以及如何使用名为的新修饰符解决它layoutPriority()。

首先创建一个名为的新SwiftUI视图AstronautView。这将有一个单独的Astronaut属性,因此它知道要显示什么,那么它将把那出使用类似GeometryReader/ ScrollView/ VStack组合,因为我们曾在MissionView。输入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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)
}
}

我们再次需要更新预览,以便它使用一些数据创建其视图:

1
2
3
4
5
6
7
struct AstronautView_Previews: PreviewProvider {
static let astronauts: [Astronaut] = Bundle.main.decode("astronauts.json")

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

现在我们可以通过MissionView使用另一个演示它NavigationLink。这需要放在里面,ForEach以便包装现有的HStack:

1
2
3
4
5
6
NavigationLink(destination: AstronautView(astronaut: crewMember.astronaut)) {
HStack {
// current code
}
.padding(.horizontal)
}

立即运行该应用程序,并进行全面尝试-你应该至少看到一个错误,或者取决于SwiftUI,可能会看到两个错误。

第一个错误非常明显:在任务视图中,我们所有的宇航员图片均显示为纯蓝色胶囊,而不是其图片。你可能还会注意到,每个人的名字都用相同的蓝色阴影书写,这可能为你提供了线索-现在,这是一个导航链接,SwiftUI通过将视图涂成蓝色来使整个外观看起来很活跃。

为了解决这个问题,我们需要让SwiftUI将导航链接的内容呈现为一个普通按钮,这意味着它将不会对图像或文本应用颜色。因此,添加此作为改性剂的宇航员NavigationLink在MissionView:

1
.buttonStyle(PlainButtonStyle())

至于第二个错误,可能你甚至根本没有看到它-在我看来这是SwiftUI本身的一个错误,因此它可能会在将来的版本中修复,或者可能仅影响特定设备配置。因此,如果在使用与我相同的iPhone模拟器时不存在此错误,则可能已解决!

漏洞是这样的:如果你选择某些宇航员,例如阿波罗1号的爱德华·怀特二世,你可能会看到其说明文字被裁剪到底部。因此,你只看到了一些文本,而不是看到所有文本,后面是省略号。而且,如果你仔细查看图像的顶部,你会发现它不再直接靠在顶部的导航栏上。

我们看到的是SwiftUI的布局算法很难得出正确的结论。在我看来,这是一个SwiftUI错误,到你自己尝试时,它甚至可能不存在。但是它就在这里,因此我将向你展示如何使用layoutPriority()修饰符修复它。

布局优先级使我们可以控制在有限的空间中缩小视图或在足够的空间中扩展视图的容易程度。默认情况下,所有视图的布局优先级均为0,这意味着它们各自具有同等的增长或收缩机会。我们将给宇航员说明一个布局优先级1,该优先级高于图片的0,这意味着它将自动占用所有可用空间。

为此,只需在中添加layoutPriority(1)描述文本视图AstronautView,如下所示:

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

解决了这两个错误之后,我们的程序就完成了–上一次运行它,然后尝试一下!

我的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import SwiftUI

struct MissionView: View {
let mission: Mission

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

let astronauts: [CrewMember]

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

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

Spacer(minLength: 25)


ForEach(self.astronauts, id: \.role){ astMember in
NavigationLink(destination: AstronautView(astronaut: astMember.astronaut)) {
HStack {
Image(astMember.astronaut.id)
.resizable()
.scaledToFit()
.frame(width: 83, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.leading)

VStack(alignment: .leading) {
Text(astMember.astronaut.name)
.font(.headline)
.padding(.bottom, 6)

Text(astMember.astronaut.id)
.font(.footnote)
.foregroundColor(.gray)
}

Spacer()
}
}.buttonStyle(PlainButtonStyle())
}
}
}
.navigationBarTitle(Text(self.mission.displayName), displayMode: .inline)
}

}

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

self.astronauts = matches
}
}


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

AstronautView.swift

参考资料

查看下一天的SwiftUI学习笔记

关于100days英文课程