第39天 项目 8 第一部分
2019年我最初编写这门课程时,Apple TV+才刚刚推出。当时,所有人都在谈论的剧集是《为全人类》(For All Mankind),这部剧以戏剧化的方式呈现了1969年登月事件的另一种历史可能性。因此,我围绕这个主题设计了今天的新项目,详细介绍美国国家航空航天局(NASA)阿波罗太空计划背后的一些历史。
我还觉得,今天的引言如果来自首位登上月球的人——尼尔·阿姆斯特朗(Neil Armstrong),会非常贴切。2000年,他曾说过:“科学关乎‘是什么’,工程学关乎‘可能是什么’。”我不知道你们怎么想,但我觉得这句话极具启发意义:每次我们创建一个新的Xcode项目时,都有一块空白的画布可以利用,而这块画布能变成我们想要的任何东西。
今天我们要学习构建“月球探索”(Moonshot)应用所需的技术,但和我们所学的所有技术一样,这些技术将成为你知识体系的一部分,在未来的岁月里,你可以随意组合、重新组合它们,为你所用。
今天你需要学习五个主题,在这些主题中,你将了解容器相对框架、ScrollView、NavigationLink等内容。
- 月球探索:简介
- 调整图像大小以适应可用空间
- ScrollView如何帮助我们处理可滚动数据
- 使用NavigationLink将新视图推送到视图栈
- 处理层级化的Codable数据
- 如何在可滚动网格中布局视图
别忘了在某个地方分享你的进度——保持责任感!(完成后,坐下来看看《为全人类》吧。)
月球探索:简介
作者:Paul Hudson 2021年10月15日
在这个项目中,我们将构建一个应用,让用户了解NASA阿波罗太空计划中的任务和宇航员。你将获得更多使用Codable的实践机会,但更重要的是,你还将学习滚动视图、导航以及更有趣的布局方式。
当然,你还会练习使用List、Text等组件,但同时也会开始解决SwiftUI中的一些重要问题——如何让图像正确适配其所在空间?如何使用计算属性简化代码?如何将小型视图组合成大型视图,以保持项目的条理性?
和往常一样,有很多工作要做,那我们就开始吧:使用App模板创建一个新的iOS应用,将其命名为“Moonshot”。我们将用这个应用来完成项目,不过首先,让我们仔细看看你需要熟悉的新技术……
调整图像大小以适应可用空间
作者:Paul Hudson 2023年10月31日
在SwiftUI中创建Image视图时,视图会根据其内容的尺寸自动调整自身大小。因此,如果图片的尺寸是1000×500,那么Image视图的尺寸也会是1000×500。有时候这正是我们想要的效果,但大多数情况下,我们希望以更小的尺寸显示图像。接下来我会向你展示如何实现这一点,同时还会介绍如何利用相对框架,让图像适配用户屏幕宽度的一定比例。
首先,向你的项目中添加一张图像,图像内容无关紧要,只要宽度超过屏幕宽度即可。我将我的图像命名为“Example”,但显然你在下面的代码中应该替换成你自己的图像名称。
现在,我们在屏幕上绘制这张图像:
struct ContentView: View {
var body: some View {
Image("Example")
}
}提示:当你使用像这样的固定图像名称时,Xcode会为所有图像生成常量名称,你可以用这些常量名称代替字符串。在这种情况下,这意味着你可以写成Image(.example),这比使用字符串安全得多!
即使在预览中,你也能看到图像对于可用空间来说太大了。图像和其他视图一样,都有frame()修饰符,所以你可能会尝试像下面这样缩小图像:
Image(.example)
.frame(width: 300, height: 300)然而,这种方法行不通——图像仍然会以原始尺寸显示。如果你想知道原因,可以将Xcode的预览模式从“实时”(Live)切换到“可选择”(Selectable)——在Xcode预览窗口的左下角有三个按钮,点击带有鼠标光标的那个。
重要提示:切换到可选择模式后,预览将不再实时运行,因此在重新选择实时模式之前,你无法与视图进行交互。
启用可选择模式后,仔细观察预览窗口:你会发现图像仍是原始尺寸,但中间会出现一个300×300的方框。虽然Image视图的框架已正确设置,但图像的内容仍以原始尺寸显示。
尝试将图像代码修改为以下内容:
Image(.example)
.frame(width: 300, height: 300)
.clipped()现在你就能更清楚地看到效果了:我们的Image视图确实是300×300的尺寸,但这并不是我们真正想要的结果。
如果你希望图像内容也能调整大小,就需要使用resizable()修饰符,如下所示:
Image(.example)
.resizable()
.frame(width: 300, height: 300)这样情况有所改善,但效果仍不够理想。没错,图像现在确实能正确调整大小了,但看起来可能会被挤压变形。我的图像不是正方形的,所以当它被调整成正方形尺寸时,就会出现失真。
要解决这个问题,我们需要让图像按比例调整大小,这可以通过scaledToFit()和scaledToFill()修饰符来实现。scaledToFit()会确保整个图像都能适配容器,即使这意味着视图的某些部分会留白;而scaledToFill()则会确保视图没有留白,即使这意味着图像的某些部分会超出容器范围。
你可以尝试这两种修饰符,亲自看看它们的区别。下面是应用.fit模式的代码:
Image(.example)
.resizable()
.scaledToFit()
.frame(width: 300, height: 300)下面是应用scaledToFill()的代码:
Image(.example)
.resizable()
.scaledToFill()
.frame(width: 300, height: 300)如果我们想要固定尺寸的图像,以上方法非常有效,但很多时候,我们希望图像能自动缩放,在一个或两个维度上填满更多屏幕空间。也就是说,我们真正想实现的不是硬编码一个300的宽度,而是“让这张图像填满屏幕宽度的80%”。
SwiftUI提供了专门的containerRelativeFrame()修饰符,无需强制设置特定框架,就能实现我们想要的效果。这里的“容器”可能是整个屏幕,也可能只是当前视图的直接父视图所占据的屏幕区域——比如,我们的图像可能显示在一个包含其他视图的VStack中。
我们将在项目18中更详细地介绍容器相对框架,不过现在,我们只用它来完成一项任务:确保图像填满屏幕可用宽度的80%。
例如,我们可以创建一个占屏幕宽度80%的图像:
Image(.example)
.resizable()
.scaledToFit()
.containerRelativeFrame(.horizontal) { size, axis in
size * 0.8
}我们来拆解一下这段代码:
- 我们希望给这个图像设置一个框架,该框架相对于其父视图的水平尺寸。我们没有指定垂直尺寸,稍后会对此进行说明。
- 随后SwiftUI会执行一个闭包,在闭包中会给我们提供一个尺寸(size)和一个轴(axis)。对我们而言,轴是
.horizontal,因为我们使用的是水平方向的相对框架,但当你同时创建水平和垂直方向的相对尺寸时,轴的作用会更加重要。size值代表容器的尺寸,对于这个图像来说,容器就是整个屏幕。 - 我们需要返回当前轴方向上想要的尺寸,因此这里返回的是容器宽度的80%。
同样,我们不需要在这里指定高度。这是因为我们已经给了SwiftUI足够的信息,它可以自动计算出高度:它知道图像的原始宽度、目标宽度,也知道内容模式,因此能够理解图像的目标高度与目标宽度之间的比例关系。
ScrollView如何帮助我们处理可滚动数据
作者:Paul Hudson 2023年10月31日
你已经了解了List和Form如何帮助我们创建可滚动的数据表格,但当我们想要滚动任意数据(即一些我们手动创建的视图)时,就需要用到SwiftUI的ScrollView了。
滚动视图可以水平滚动、垂直滚动,也可以同时在两个方向上滚动,你还可以控制系统是否应该在滚动视图旁边显示滚动指示器——这些小小的滚动条能让用户了解内容的整体大小。当我们将视图放置在滚动视图中时,滚动视图会自动计算内容的尺寸,以便用户能从一端滚动到另一端。
例如,我们可以创建一个包含100个文本视图的滚动列表,代码如下:
ScrollView {
VStack(spacing: 10) {
ForEach(0..<100) {
Text("Item \($0)")
.font(.title)
}
}
}如果你在模拟器中运行这段代码,就会发现可以自由拖动滚动视图;当你滚动到最底部时,还会发现ScrollView对待安全区域的方式与List和Form相同——它们的内容会延伸到主屏幕指示器(home indicator)下方,但会添加额外的内边距,以确保最后的视图能完全显示出来。
你可能还会注意到,必须直接点击屏幕中央才能进行交互,这有点麻烦——更常见的做法是让整个区域都可以滚动。要实现这种效果,我们应该让VStack占据更多空间,同时保持默认的居中对齐,代码如下:
ScrollView {
VStack(spacing: 10) {
ForEach(0..<100) {
Text("Item \($0)")
.font(.title)
}
}
.frame(maxWidth: .infinity)
}现在,你可以点击并拖动屏幕上的任意位置来滚动,这对用户来说友好得多。
这一切看起来都非常简单,但有一个重要的问题需要你注意:当我们将视图添加到滚动视图中时,这些视图会立即创建。为了演示这一点,我们可以创建一个简单的包装视图,包裹普通的文本视图,代码如下:
struct CustomText: View {
let text: String
var body: some View {
Text(text)
}
init(_ text: String) {
print("Creating a new CustomText")
self.text = text
}
}现在,我们可以在ForEach中使用这个自定义视图:
ForEach(0..<100) {
CustomText("Item \($0)")
.font(.title)
}运行结果看起来和之前一样,但现在当你运行应用时,会在Xcode的日志中看到“Creating a new CustomText”打印了100次——SwiftUI不会等到你向下滚动查看视图时才创建它们,而是会立即创建所有视图。
如果你想避免这种情况,可以使用VStack和HStack的替代方案,即LazyVStack和LazyHStack。它们的使用方式与普通的栈视图完全相同,但会按需加载内容——只有当视图实际显示时才会创建,从而最大限度地减少系统资源的占用。
因此,在这种情况下,我们可以将VStack替换为LazyVStack,代码如下:
LazyVStack(spacing: 10) {
ForEach(0..<100) {
CustomText("Item \($0)")
.font(.title)
}
}
.frame(maxWidth: .infinity)实际上,只需在“VStack”前面加上“Lazy”,就能让代码运行得更高效——现在只有当确实需要时,才会创建CustomText结构体。
虽然使用普通栈视图和惰性栈视图的代码相同,但它们在布局上有一个重要区别:惰性栈视图在布局中总是会占据尽可能多的空间,而普通栈视图只占据所需的空间。这样设计是有意为之的,因为如果加载了一个需要更多空间的新视图,惰性栈视图就无需调整自身尺寸。
最后还有一点:创建水平滚动视图时,可以在初始化ScrollView时传入.horizontal参数。完成这一步后,要确保创建的是水平栈视图或惰性水平栈视图,这样内容的布局才会符合你的预期:
ScrollView(.horizontal) {
LazyHStack(spacing: 10) {
ForEach(0..<100) {
CustomText("Item \($0)")
.font(.title)
}
}
}使用NavigationLink将新视图推送到视图栈
作者:Paul Hudson 2023年10月31日
SwiftUI的NavigationStack会在视图顶部显示一个导航栏,除此之外,它还有一个功能:能将视图推送到视图栈中。事实上,这是iOS导航最基础的形式之一——在“设置”应用中点击“无线局域网”或“通用”,或者在“信息”应用中点击某人的名字,都能看到这种导航方式。
这种视图栈系统与我们之前使用的模态视图(sheet)有很大不同。诚然,两者都能显示新视图,但它们的呈现方式不同,这会影响用户对视图关系的理解。
我们先来看一段代码,亲身体验一下——我们可以在导航栈中显示一个简单的文本视图,代码如下:
struct ContentView: View {
var body: some View {
NavigationStack {
Text("Tap Me")
.navigationTitle("SwiftUI")
}
}
}这个文本视图只是静态文本,尽管它的内容是“Tap Me”(点击我),但它并不是一个带有任何交互动作的按钮。接下来,我们要实现的功能是:当用户点击这个文本视图时,向他们展示一个新视图。要实现这一点,需要使用NavigationLink:给它指定一个目标视图和一个可点击的内容,剩下的工作就由它来完成。
SwiftUI最让我喜欢的一点是,我们可以将NavigationLink与任何类型的目标视图一起使用。没错,我们可以设计一个自定义视图来推送,但也可以直接推送一个文本视图。
为了尝试这个功能,将你的视图修改为以下代码:
NavigationStack {
NavigationLink("Tap Me") {
Text("Detail View")
}
.navigationTitle("SwiftUI")
}现在运行代码,看看效果。你会发现“Tap Me”现在看起来像一个按钮,点击它会从右侧滑入一个新视图,显示“Detail View”(详情视图)。更棒的是,你会看到“SwiftUI”这个标题会动画过渡为一个返回按钮,你可以点击这个按钮,或者从屏幕左边缘向右滑动,返回到上一个视图。
如果你不希望用简单的文本视图作为标签,也可以给NavigationLink使用两个尾随闭包。例如,我们可以用多个文本视图和一张图像来组成一个标签:
NavigationStack {
NavigationLink {
Text("Detail View")
} label: {
VStack {
Text("This is the label")
Text("So is this")
Image(systemName: "face.smiling")
}
.font(.largeTitle)
}
}由此可见,sheet()和NavigationLink都能从当前视图跳转到新视图,但它们的实现方式不同,你需要根据具体场景谨慎选择:
NavigationLink适用于展示用户所选内容的详情,就像深入探索某个主题一样。sheet()适用于展示不相关的内容,例如设置界面或撰写窗口。
NavigationLink最常见的使用场景是与列表结合,在这种情况下,SwiftUI会有非常出色的表现。
尝试将你的代码修改为以下内容:
NavigationStack {
List(0..<100) { row in
NavigationLink("Row \(row)") {
Text("Detail \(row)")
}
}
.navigationTitle("SwiftUI")
}现在运行应用,你会看到100个列表行,点击任意一行都会显示对应的详情视图,同时你还会看到每行右侧都有灰色的 disclosure 指示器( disclosure indicator,即向右的箭头图标)。这是iOS的标准设计,用于告知用户点击该行后,会有一个新屏幕从右侧滑入。SwiftUI非常智能,会在这里自动添加这个指示器。如果这些行不是导航链接(比如你注释掉NavigationLink行及其闭合括号),你会发现这些指示器消失了。
处理层级化的Codable数据
作者:Paul Hudson 2023年10月31日
Codable协议让解码扁平数据变得非常简单:如果你要解码某个类型的单个实例,或者该类型的数组、字典,那么一切都会“正常工作”。然而,在这个项目中,我们要解码的JSON数据会稍微复杂一些:数组中会嵌套另一个数组,并且使用不同的数据类型。
如果要解码这种层级化的数据,关键是为每个层级创建单独的类型。只要数据与你定义的层级结构匹配,Codable就能自动完成所有解码工作,无需我们额外操作。
为了演示这一点,在你的内容视图中添加一个按钮:
Button("Decode JSON") {
let input = """
{
"name": "Taylor Swift",
"address": {
"street": "555, Taylor Swift Avenue",
"city": "Nashville"
}
}
"""
// 后续代码将在此处添加
}这段代码在程序中创建了一个JSON字符串。如果你对JSON不太熟悉,最好先看看与之匹配的Swift结构体——你可以将这些结构体直接放在按钮的动作闭包中,也可以放在ContentView结构体之外,位置无关紧要:
struct User: Codable {
let name: String
let address: Address
}
struct Address: Codable {
let street: String
let city: String
}现在你应该能明白这个JSON包含的内容了:一个用户(User)有一个姓名(name,字符串类型)和一个地址(address),而地址(Address)包含一条街道(street,字符串类型)和一个城市(city,字符串类型)。
接下来就是最关键的一步:我们可以将JSON字符串转换为Data类型(Codable正是基于Data类型工作的),然后将其解码为User实例:
let data = Data(input.utf8)
let decoder = JSONDecoder()
if let user = try? decoder.decode(User.self, from: data) {
print(user.address.street)
}如果你运行这个程序并点击按钮,应该会看到地址信息被打印出来——不过需要说明的是,这并不是泰勒·斯威夫特(Taylor Swift)的真实地址!
Codable能处理的层级数量没有限制——重要的是你定义的结构体要与JSON字符串的结构匹配。
如何在可滚动网格中布局视图
作者:Paul Hudson 2023年10月31日
SwiftUI的List视图非常适合展示可滚动的行数据,但有时你也需要列数据——也就是网格形式的信息,并且网格能够自适应更大的屏幕,显示更多数据。
在SwiftUI中,这可以通过两个视图来实现:LazyHGrid用于展示水平方向的数据,LazyVGrid用于展示垂直方向的数据。和惰性栈视图一样,名称中的“lazy”(惰性)意味着SwiftUI会自动延迟加载视图,直到真正需要它们的时候才创建,这样我们就能显示更多数据,而不会占用大量系统资源。
创建网格分为两步。首先,我们需要定义想要的行或列——根据我们要创建的网格类型,只需定义其中一种即可。
例如,如果我们要创建一个垂直滚动的网格,并且希望数据分布在三列中,每列宽度固定为80点,可以在视图中添加以下属性:
let layout = [
GridItem(.fixed(80)),
GridItem(.fixed(80)),
GridItem(.fixed(80))
]定义好布局后,就可以将网格放置在ScrollView中,并在网格中添加任意数量的项目。网格中的每个项目会自动分配到相应的列中,就像列表中的行自动放置在其父视图中一样。
例如,我们可以在三列网格中显示1000个项目,代码如下:
ScrollView {
LazyVGrid(columns: layout) {
ForEach(0..<1000) {
Text("Item \($0)")
}
}
}这种方式在某些场景下适用,但网格最棒的特点是能够适配各种屏幕尺寸。我们可以使用“自适应”(adaptive)尺寸来定义不同的列布局,代码如下:
let layout = [
GridItem(.adaptive(minimum: 80)),
]这行代码告诉SwiftUI,我们希望尽可能多地显示列,只要每列的宽度至少为80点。你也可以指定最大宽度范围,以获得更多控制:
let layout = [
GridItem(.adaptive(minimum: 80, maximum: 120)),
]我通常最依赖这种自适应布局,因为它能让网格最大限度地利用可用的屏幕空间。
最后,我简要向你展示如何创建水平网格。创建过程几乎完全相同,只需让ScrollView支持水平滚动,然后创建LazyHGrid并指定行布局(而非列布局)即可:
ScrollView(.horizontal) {
LazyHGrid(rows: layout) {
ForEach(0..<1000) {
Text("Item \($0)")
}
}
}到这里,这个项目的概述就结束了,请将ContentView.swift重置为初始状态。