第21天 项目2 第二部分
人们在学习编程时经常抱怨的一件事是:他们确实想忙着开发自己构思的 “大型应用创意”,但却不得不跟着教程去做各种各样完全不相关的应用。
我理解这可能很让人恼火,但请相信我:你学到的所有知识都不会白费。诚然,你可能永远不会去做一个国旗猜谜游戏,但你在这里学到的概念 —— 构建布局、跟踪状态、随机化数组等等 —— 会在未来多年里一直对你有用。
奥普拉・温弗瑞(Oprah Winfrey)曾经说过:“先做你必须做的事,直到你能做你想做的事。” 到这门 100 天课程结束时,我希望你能完全做到自己想做的事,但在此期间,请坚持下去 —— 你正在这里学习关键技能!
今天你需要学习三个主题,在这些主题中,你将运用关于 VStack、LinearGradient、弹窗(alert)等的知识。
- 堆叠按钮
- 用弹窗显示玩家得分
- 设置国旗样式
- 升级我们的设计
不得不承认:用 SwiftUI 开发应用真的很快,不是吗?一旦你熟悉了所使用的工具,就能在 15 分钟内完成一个完整的游戏,然后就像我们之前做的那样,尝试调整设计,直到找到自己喜欢的样式。
堆叠按钮
作者:Paul Hudson 2023 年 10 月 12 日
我们将从构建基本的 UI 结构开始开发这个应用,结构包括两个告知用户操作指令的标签(label),以及三个显示不同国家国旗的图片按钮。
首先,找到本项目的资源文件,并将其拖入你的资源目录(asset catalog)中。具体操作是:在 Xcode 中打开 Assets.xcassets,然后将项目 2 的文件夹(project2-files)中的国旗图片拖进去。你会注意到,这些图片的文件名包含国家名称,以及 @2x 或 @3x—— 这些分别是双倍分辨率和三倍分辨率的图片,用于适配不同类型的 iPhone 屏幕。
接下来,我们需要两个属性来存储游戏数据:一个数组,存储游戏中要显示的所有国家国旗图片名称;一个整数,存储正确答案对应的国旗图片在数组中的索引。
var countries = ["Estonia", "France", "Germany", "Ireland", "Italy", "Nigeria", "Poland", "Spain", "UK", "Ukraine", "US"]
var correctAnswer = Int.random(in: 0...2)Int.random(in:) 方法会自动生成一个随机数,非常适合这里的场景 —— 我们将用它来决定应该点击哪个国家的国旗。
在视图的 body 部分,我们需要用垂直堆叠视图(VStack)来布局游戏提示文本,先从这部分开始:
var body: some View {
VStack {
Text("Tap the flag of") // 点击以下国家的国旗
Text(countries[correctAnswer]) // 显示正确答案对应的国家名称
}
}在这部分下方,我们需要放置可点击的国旗按钮。虽然我们可以直接将这些按钮添加到同一个 VStack 中,但实际上创建一个第二个 VStack 能让我们更好地控制间距。
我们刚才创建的 VStack 包含两个文本视图,且没有设置自定义间距;而国旗之间需要设置 30 点(point)的间距,这样看起来会更美观。
因此,我们要用另一个 VStack 包裹之前的 VStack(这次设置间距为 30 点),然后添加一个新的 ForEach 循环:
VStack(spacing: 30) {
VStack {
Text("Tap the flag of")
Text(countries[correctAnswer])
}
ForEach(0..<3) { number in
Button {
// 国旗被点击时执行的代码
} label: {
Image(countries[number]) // 显示对应索引的国旗图片
}
}
}像这样嵌套两个垂直堆叠视图,能让我们更精确地定位元素:外层 VStack 中各个视图之间的间距为 30 点,而内层 VStack 没有自定义间距。
到这里,你已经能大致了解 UI 的基本结构了,但显然目前的外观并不好看 —— 有些国旗包含白色部分,会与背景融为一体,而且所有国旗都在屏幕垂直方向上居中显示。
之后我们会回来优化 UI,但现在先设置一个蓝色背景,让国旗更容易被看清。要在外侧 VStack 后面添加背景,我们还需要用到 ZStack(层级堆叠视图)。没错,我们会构建 “VStack 嵌套 VStack,再嵌套 ZStack” 的结构,这在 SwiftUI 中是非常正常的。
首先,用 ZStack 包裹外侧的 VStack,代码如下:
var body: some View {
ZStack {
// 之前的 VStack 代码
}
}然后在 ZStack 内部、外侧 VStack 之前添加以下代码,让背景显示在 VStack 后面:
Color.blue
.ignoresSafeArea().ignoresSafeArea() 修饰符能确保背景颜色延伸到屏幕边缘(不被安全区域裁剪)。
现在背景颜色变深了,我们需要将文本颜色调亮,使其更醒目:
Text("Tap the flag of")
.foregroundStyle(.white) // 设置文本颜色为白色
Text(countries[correctAnswer])
.foregroundStyle(.white)这个设计虽然不会惊艳众人,但却是一个坚实的开始!
用弹窗显示玩家得分
作者:Paul Hudson 2023 年 10 月 12 日
要让这个游戏有趣,我们需要实现以下功能:随机打乱国旗的显示顺序;玩家点击国旗时,弹出弹窗告知其答案正确与否;然后重新打乱国旗顺序。
我们已经将 correctAnswer 设置为随机整数,但国旗的显示顺序始终是固定的。要解决这个问题,我们需要在游戏启动时打乱 countries 数组的顺序,将该属性修改为以下代码:
var countries = ["Estonia", "France", "Germany", "Ireland", "Italy", "Nigeria", "Poland", "Spain", "UK", "Ukraine", "US"].shuffled()可以看到,shuffled() 方法会自动帮我们随机打乱数组顺序。
接下来是更关键的部分:当国旗被点击时,我们该做什么?我们需要将 // 国旗被点击时执行的代码 这段注释替换为具体逻辑,判断玩家点击的国旗是否正确。实现这一逻辑的最佳方式是编写一个新方法,接收按钮对应的索引整数,并将其与 correctAnswer 属性进行比较。
无论答案正确与否,我们都要向玩家显示弹窗,告知结果,方便他们跟踪游戏进度。因此,添加以下属性来存储弹窗是否显示:
@State private var showingScore = false再添加一个属性,存储弹窗中要显示的标题:
@State private var scoreTitle = ""所以,我们编写的方法需要接收被点击按钮的索引,将其与正确答案对比,然后设置上述两个新属性,以便显示有意义的弹窗。
在 body 属性后面直接添加以下方法:
func flagTapped(_ number: Int) {
if number == correctAnswer {
scoreTitle = "Correct" // 正确
} else {
scoreTitle = "Wrong" // 错误
}
showingScore = true // 显示弹窗
}现在,我们可以将 // 国旗被点击时执行的代码 这段注释替换为以下代码,调用上述方法:
flagTapped(number)由于 ForEach 会为我们提供 number(当前循环的索引),因此只需将其传递给 flagTapped() 方法即可。
在显示弹窗之前,我们需要考虑弹窗消失后的逻辑:游戏显然不应该就此结束,否则整个游戏体验会非常糟糕。
相反,我们要编写一个 askQuestion() 方法,通过打乱国家数组顺序和重新选择正确答案来重置游戏:
func askQuestion() {
countries.shuffle() // 打乱国家数组
correctAnswer = Int.random(in: 0...2) // 重新选择正确答案的索引
}这段代码目前无法编译,希望你能很快发现原因:我们试图修改视图中未用 @State 标记的属性,这在 SwiftUI 中是不允许的。因此,找到 countries 和 correctAnswer 的声明处,在它们前面添加 @State private,修改后的代码如下:
@State private var countries = ["Estonia", "France", "Germany", "Ireland", "Italy", "Nigeria", "Poland", "Spain", "UK", "Ukraine", "US"].shuffled()
@State private var correctAnswer = Int.random(in: 0...2)现在我们可以显示弹窗了。弹窗需要满足以下要求:
- 使用
alert()修饰符,当showingScore为true时显示弹窗。 - 显示我们在
scoreTitle中设置的标题。 - 有一个 “取消” 按钮,点击时调用
askQuestion()方法重置游戏。
因此,在 body 属性中 ZStack 的末尾添加以下代码:
.alert(scoreTitle, isPresented: $showingScore) {
Button("Continue", action: askQuestion) // “继续”按钮,点击触发重置游戏
} message: {
Text("Your score is ???") // 得分显示占位符,后续会完善
}是的,这里有三个问号,代表后续需要补充的得分值 —— 你很快就会完成这部分功能!
设置国旗样式
作者:Paul Hudson 2023 年 10 月 12 日
我们的游戏现在已经能正常运行了,但外观还不够美观。幸运的是,我们只需对设计做一些小调整,就能让整体效果焕然一新。
首先,我们将纯色蓝色背景替换为从蓝色到黑色的线性渐变(LinearGradient),这样即使某个国旗包含类似的蓝色条纹,也能与背景清晰区分。
找到以下代码行:
Color.blue
.ignoresSafeArea()将其替换为:
LinearGradient(colors: [.blue, .black], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()它仍然会忽略安全区域,确保背景延伸到屏幕边缘。
接下来,我们对字体做一些微调,让玩家需要猜测的国家名称成为屏幕上最醒目的文本,而 “Tap the flag of” 这段提示文本则设置为更小的字号并加粗。
我们可以使用 font() 修饰符控制文本的大小和样式,该修饰符允许我们从 iOS 的内置字体大小中进行选择。至于调整字体的粗细(比如超细线、粗体等),我们可以在指定字体后添加 weight() 修饰符,实现更精细的控制。
我们将同时使用这两个修饰符,让你直观感受它们的作用。在 “Tap the flag of” 文本后面直接添加以下修饰符:
.font(.subheadline.weight(.heavy))在 Text(countries[correctAnswer]) 视图后面添加以下修饰符:
.font(.largeTitle.weight(.semibold))“Large title”(大标题)是 iOS 提供的最大内置字体大小,并且会根据用户的字体设置自动放大或缩小 —— 这一功能被称为 “动态字体”(Dynamic Type)。我们通过 weight() 修饰符将字体调整为稍粗的样式,但它仍然会根据需要自动缩放。
最后,我们给国旗图片添加一些美化效果。SwiftUI 提供了许多修饰符来调整视图的显示效果,这里我们将使用两个:一个用于修改国旗的形状,另一个用于添加阴影。
SwiftUI 有四种内置形状:矩形(rectangle)、圆角矩形(rounded rectangle)、圆形(circle)和胶囊形(capsule)。我们这里将使用胶囊形:它会让视图较短边的 corners 完全圆润,而较长边保持平直 —— 非常适合按钮的外观。给图片设置胶囊形只需添加 .clipShape(.capsule) 修饰符,代码如下:
.clipShape(.capsule)最后,我们要给每个国旗添加阴影效果,使其与背景形成更明显的对比。这可以通过 shadow() 修饰符实现,该修饰符接收阴影的颜色、半径、X 轴偏移和 Y 轴偏移作为参数。如果省略颜色参数,会默认使用半透明黑色;如果省略 X 轴和 Y 轴偏移,会默认设置为 0—— 这些都是合理的默认值。
因此,在之前两个修饰符的下方添加以下修饰符:
.shadow(radius: 5)最终,国旗图片的完整代码如下:
Image(countries[number])
.clipShape(.capsule)
.shadow(radius: 5)SwiftUI 有大量用于调整字体和图片渲染效果的修饰符,每个修饰符只负责一项功能,因此像上面这样堆叠使用修饰符是很常见的做法。
升级我们的设计
作者:Paul Hudson 2023 年 10 月 12 日
到目前为止,我们已经完成了应用的开发,并且它能正常运行。但凭借你目前学到的所有 SwiftUI 技能,我们可以对现有项目进行 “重新设计”—— 为当前构建的项目打造一个全新的 UI。这完全不会影响游戏逻辑,只是尝试不同的 UI 风格,让你了解自己当前掌握的知识能实现哪些效果。
像这样尝试不同的设计非常有趣,但我需要提醒一句:至少要在所有尺寸的 iOS 设备上运行你的代码,从小巧的 iPhone SE 到大屏的 iPhone 15 Pro Max。要让设计在如此广泛的屏幕尺寸上都能良好适配,需要多花些心思!
首先,我们来修改国旗后面的蓝黑渐变背景。之前的设计足够让我们启动项目,但现在我们要尝试更精致的效果:带有自定义色标(stop)的径向渐变(RadialGradient)。
之前我向你展示过,如何通过精确设置渐变色标的位置来调整渐变的显示效果。如果我们创建两个完全相同的色标,渐变效果会完全消失 —— 颜色会直接从一个切换到另一个。让我们在当前设计中尝试一下:
RadialGradient(stops: [
.init(color: .blue, location: 0.3),
.init(color: .red, location: 0.3),
], center: .top, startRadius: 200, endRadius: 700)
.ignoresSafeArea()我觉得这个效果很有意思 —— 就像在红色背景上叠加了一个蓝色的圆形。但不得不说,它也很丑:红色和蓝色搭配在一起太刺眼了。
因此,我们可以使用这些颜色的低饱和度版本,让整体色调更和谐 —— 这些色调在国旗中也更常见:
RadialGradient(stops: [
.init(color: Color(red: 0.1, green: 0.2, blue: 0.45), location: 0.3), // 深蓝色
.init(color: Color(red: 0.76, green: 0.15, blue: 0.26), location: 0.3), // 深红色
], center: .top, startRadius: 200, endRadius: 400)
.ignoresSafeArea()接下来,目前我们用间距为 30 点的 VStack 来放置问题区域和国旗,但现在我想把间距减小到 15 点:
VStack(spacing: 15) {为什么要这样做?因为我们要将整个区域打造成 UI 中的一个视觉元素,给它添加彩色背景和圆角,让游戏的这部分在屏幕上更突出。
要实现这一点,在同一个 VStack 的末尾添加以下修饰符:
.frame(maxWidth: .infinity) // 水平方向上尽可能占满空间
.padding(.vertical, 20) // 垂直方向添加 20 点内边距
.background(.regularMaterial) // 设置半透明材质背景
.clipShape(.rect(cornerRadius: 20)) // 裁剪为圆角矩形这些修饰符会让 VStack 水平方向充满可用空间,添加垂直内边距,通过材质背景使其与红蓝色渐变背景区分开,最后将整个视图裁剪为圆角矩形。
我认为这样看起来已经好很多了,但我们可以继续优化!
下一步,我们要在主容器(上面的 VStack)前面添加一个标题,后面添加一个得分占位符。这意味着需要在现有内容外面再套一个 VStack,因为之前的 VStack(spacing: 15) 是我们添加材质效果的容器。
因此,用一个新的 VStack 包裹当前的 VStack,并在顶部添加标题,代码如下:
VStack {
Text("Guess the Flag") // “猜国旗”标题
.font(.largeTitle.weight(.bold))
.foregroundStyle(.white)
// 当前的 VStack(spacing: 15) 代码
}提示: 由于设置粗体字体非常常用,SwiftUI 提供了一个简洁的写法:.font(.largeTitle.bold())。
这会在顶部添加一个新标题,我们还可以在这个新 VStack 的底部添加一个得分标签,代码如下:
Text("Score: ???") // 得分占位符
.foregroundStyle(.white)
.font(.title.bold())“Guess the Flag” 标题和得分标签用白色文本看起来效果很好,但容器内部的文本却不是这样 —— 我们之前将其设置为白色,是因为它原本位于深色背景上,但现在白色文本在半透明材质背景上很难阅读。
要解决这个问题,我们可以删除 Text(countries[correctAnswer]) 的 foregroundStyle() 修饰符,让它默认使用系统的主色调(浅色模式下为黑色,深色模式下为白色)。
至于白色的 “Tap the flag of” 文本,我们可以使用 iOS 的动态色彩效果(vibrancy effect),让背景色的一部分透出来,使文本更协调。将它的 foregroundStyle() 修饰符修改为:
.foregroundStyle(.secondary) // 使用次要色调,与背景更协调到这里,我们的 UI 基本可以正常显示了,但我觉得布局还是太紧凑了 —— 如果在大屏设备上运行,你会发现内容都集中在屏幕中央,上下有大量空白空间,而且中间的白色容器几乎延伸到屏幕边缘。
要解决这个问题,我们需要做两件事:给最外层的 VStack 添加少量内边距;添加一些 Spacer() 视图,迫使 UI 元素分散开。在大屏设备上,这些间隔视图会平均分配可用空间;在小屏设备上,它们几乎会 “消失”—— 这是让 UI 在所有屏幕尺寸上都能良好适配的绝佳方法。
我希望你添加四个间隔视图:
- 一个直接放在 “Guess the Flag” 标题前面。
- 两个(没错,两个)直接放在 “Score: ???” 文本前面。
- 一个直接放在 “Score: ???” 文本后面。
记住,当你像这样添加多个间隔视图时,它们会自动平均分配可用空间 —— 两个间隔视图放在一起时,它们占用的空间会是单个间隔视图的两倍。
最后,给最外层的 VStack 添加少量内边距,代码如下:
.padding()至此,我们的全新设计就完成了!这些间隔视图的存在,使得应用在 iPod touch 这样的小屏设备上不会显得拥挤,同时也能平滑适配 Pro Max 这样的大屏 iPhone。
不过,这只是我们应用的一种可能设计 —— 也许你更喜欢之前的设计,或者想尝试其他风格。关键在于,你已经看到:即使只掌握了少量 SwiftUI 技能,也能构建出完全不同的设计。如果时间允许,我建议你多尝试,看看自己能做出什么样的效果!