第20天 项目2 第一部分
你觉得昨天的挑战日怎么样?除非你是一位尚未被发掘的编程天才,否则你很可能在过程中犯了一些错误,而且你的应用程序甚至有可能还存在一些你尚未发现的漏洞。
但你知道吗?这很正常。斯坦福大学荣誉退休计算机科学教授唐纳德・克努斯(Donald Knuth)曾写道:“当心上面代码中的漏洞;我只证明了它的正确性,却没有实际测试过。” 如果连唐纳德・克努斯都提醒人们注意潜在漏洞,那么我们自己的代码中存在一些漏洞也没什么大不了的。
今天我们要开始第二个项目了,所以还是先进行概览,这样我们就能学习一些新的 SwiftUI 技巧。这又是一个相对简单的项目,但你会学到很多核心的 SwiftUI 技巧,这些技巧在未来几年里你都会经常用到。
今天你需要学习六个主题,还会接触到 VStack、Image、LinearGradient 等更多内容。
- 猜国旗:简介
- 使用栈(Stack)排列视图
- 颜色与框架(Colors and Frames)
- 渐变(Gradients)
- 按钮与图片(Buttons and Images)
- 显示警告消息(Showing Alert Messages)
学完这些主题后,一定要在网上某个平台分享你的学习进度哦。
猜国旗游戏:介绍
作者:Paul Hudson 2023 年 10 月 11 日
在这个 SwiftUI 第二个项目中,我们将制作一个猜谜游戏,帮助用户认识世界上的一些国旗。
这个项目仍然会比较简单,但能让我有机会向你介绍一系列全新的 SwiftUI 功能:栈(stack)、按钮(button)、图片(image)、弹窗(alert)、资源目录(asset catalog)等等。
我们的第一个应用采用了完全标准的 iOS 外观风格,而在本项目中,我们将制作一个更具个性化的应用,让你看看使用 SwiftUI 实现个性化有多简单。
你需要为本项目下载一些文件,可以从 GitHub 上获取:https://github.com/twostraws/HackingWithSwift —— 请确保查看文件的 SwiftUI 部分。
下载完成后,在 Xcode 中创建一个新的 App 项目,命名为 GuessTheFlag,使用与之前相同的设置。和之前一样,我们将首先概述构建该应用所需的各种 SwiftUI 技术,现在就让我们开始吧……
使用栈排列视图
作者:Paul Hudson 2024 年 4 月 25 日
当我们为body返回some View时,SwiftUI 期望接收一个可以在屏幕上显示的视图。这个视图可以是导航视图(navigation view)、表单(form)、文本视图(text view)、选择器(picker)等,但它必须遵循View协议,才能在屏幕上绘制出来。
如果我们想整齐地返回多个视图,有多种选择,但其中三种尤为常用:HStack、VStack和ZStack,它们分别用于处理水平(horizontal)、垂直(vertical)和深度(depth,即 “z 轴方向”)排列。
现在让我们实际尝试一下。默认的模板代码如下:
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}这里已经包含了一个VStack,这意味着图片会显示在文本上方。不过,我们可以把代码简化成这样:
var body: some View {
Text("Hello, world!")
Text("This is another text view")
}我们没有明确声明这是一个VStack,但 SwiftUI 会自动识别并处理。所以,我们既可以像这样简单地放置两个文本视图,也可以显式地使用VStack:
var body: some View {
VStack {
Text("Hello, world!")
Text("This is inside a stack")
}
}是的,这两种写法的结果看起来相同,但存在三个重要区别:
- 显式声明栈允许我们指定视图之间的间距大小。
- 显式声明还允许我们指定对齐方式—— 视图应靠左、靠右还是居中排列。
- 如果不明确指定垂直栈,SwiftUI 可能会以其他方式排列这些视图 —— 例如,如果它们位于一个使用水平布局的更大视图内部,就可能会水平排列。
默认情况下,VStack会在两个视图之间自动添加一定的间距,但我们可以在创建栈时通过参数控制间距,如下所示:
VStack(spacing: 20) {
Text("Hello, world!")
Text("This is inside a stack")
}默认情况下,VStack会将其包含的视图居中对齐,但你可以通过alignment属性控制对齐方式。例如,下面的代码会将文本视图沿前缘(leading edge)对齐 —— 在英语等从左到右书写的语言中,这意味着文本会左对齐:
VStack(alignment: .leading) {
Text("Hello, world!")
Text("This is inside a stack")
}除了VStack,我们还有HStack用于水平排列视图。它的语法与VStack相同,也支持设置间距和对齐方式:
HStack(spacing: 20) {
Text("Hello, world!")
Text("This is inside a stack")
}垂直栈和水平栈会自动适应其内容的大小,并且默认倾向于在可用空间的中心对齐。如果你想改变这种情况,可以使用一个或多个Spacer视图将栈的内容推到某一侧。Spacer会自动占据所有剩余空间,因此如果在VStack的末尾添加一个Spacer,所有视图都会被推到屏幕顶部:
VStack {
Text("First")
Text("Second")
Text("Third")
Spacer()
}如果添加多个Spacer,它们会平分可用空间。例如,我们可以让顶部占据三分之一空间,底部占据三分之二空间,代码如下:
VStack {
Spacer()
Text("First")
Text("Second")
Text("Third")
Spacer()
Spacer()
}我们还有ZStack用于按深度排列视图 —— 它会让视图重叠显示。对于两个文本视图来说,这种排列方式会让文本难以阅读:
ZStack {
Text("Hello, world!")
Text("This is inside a stack")
}ZStack没有 “间距” 的概念(因为视图是重叠的),但它支持对齐方式。因此,如果你在ZStack中有一个大视图和一个小视图,可以像这样让两个视图都顶部对齐:ZStack(alignment: .top) {。
ZStack会按照代码中视图的顺序从后往前绘制内容(先写的在下层,后写的在上层)。这意味着如果你先写一个图片,再写一段文本,ZStack会先绘制图片,然后将文本放在图片上方。
尝试将多个水平栈放在一个垂直栈内部 —— 你能做出一个 3x3 的网格吗?
颜色与框架
作者:Paul Hudson 2023 年 10 月 11 日
SwiftUI 提供了一系列渲染颜色的功能,并且做到了简单性与强大性的结合 —— 这两者很难兼顾,但 SwiftUI 确实做到了。
为了实际演示,让我们创建一个包含单个文本标签的ZStack:
ZStack {
Text("Your content")
}如果我们想在文本后面添加一些内容,需要将其放在ZStack中文本的上方(代码顺序上更早)。但如果我们想在文本后面添加红色背景,该怎么做呢?
一种方法是使用background()修饰符,给它指定一种颜色即可,代码如下:
ZStack {
Text("Your content")
}
.background(.red)这可能符合你的预期,但也很可能让你感到意外:只有文本视图有红色背景,尽管我们是给整个ZStack添加的修饰符。
事实上,上面的代码与下面这段代码没有区别:
ZStack {
Text("Your content")
.background(.red)
}如果你想让文本后面的整个区域都填充红色,应该将颜色作为一个独立的视图放入ZStack中,代码如下:
ZStack {
Color.red
Text("Your content")
}实际上,Color.red本身就是一个独立的视图,这也是它能像形状和文本一样被使用的原因。
提示:当我们使用background()修饰符时,SwiftUI 能够推断出.red指的是Color.red。但当我们将颜色作为独立视图使用时,Swift 没有上下文来推断.red的含义,因此需要明确指定为Color.red。
颜色会自动占据所有可用空间,但你也可以使用frame()修饰符指定特定的大小。例如,我们可以创建一个 200x200 的红色正方形,代码如下:
Color.red
.frame(width: 200, height: 200)你还可以指定宽度和高度的最小值与最大值,具体取决于你想要的布局。例如,我们可以创建一个高度不超过 200 点、宽度至少为 200 点(且可拉伸以填充其他内容未占用的所有宽度)的颜色视图:
Color.red
.frame(minWidth: 200, maxWidth: .infinity, maxHeight: 200)SwiftUI 提供了多种内置颜色,例如Color.blue、Color.green、Color.indigo等。此外,还有一些语义化颜色(semantic colors)—— 这些颜色不指定具体的色调,而是描述其用途。
例如,Color.primary是 SwiftUI 中文本的默认颜色,会根据用户设备的显示模式(浅色模式或深色模式)自动变为黑色或白色。还有Color.secondary,它同样会根据设备模式变为黑色或白色,但会带有轻微的透明度,以便让下方的部分颜色透出来。
如果你需要特定的颜色,可以通过传入 0 到 1 之间的红、绿、蓝数值来创建自定义颜色,代码如下:
Color(red: 1, green: 0.8, blue: 0)即使颜色视图占据了整个屏幕,你也会发现Color.red仍会留下一些白色空间。
白色空间的大小取决于你的设备,但在带有 Face ID 的 iPhone(例如 iPhone 15)上,你会发现动态岛区域(顶部的胶囊形区域)和主屏幕指示器(底部的水平条)都不会被着色。
这些空间是特意留白的,因为苹果不希望重要内容被其他 UI 元素或设备的圆角遮挡。因此,中间的整个区域被称为安全区域(safe area),你可以在其中自由绘制内容,无需担心会被 iPhone 的刘海遮挡。
如果你希望内容延伸到安全区域之外,可以使用.ignoresSafeArea()修饰符指定要延伸到屏幕的哪些边缘,或者不指定任何边缘(默认延伸到整个屏幕边缘)。例如,下面的代码创建了一个ZStack,用红色填充整个屏幕(包括安全区域外),然后在上方绘制一段文本:
ZStack {
Color.red
Text("Your content")
}
.ignoresSafeArea()重要提示:切勿将重要内容放在安全区域之外,因为用户可能难以甚至无法看到这些内容。有些视图(如List)允许内容滚动到安全区域之外,但会添加额外的内边距,以便用户可以将内容滚动到可见区域。
如果你的内容只是装饰性的(如我们这里的背景色),那么将其延伸到安全区域之外是可以的。
最后,还有一点需要提及:除了使用.red、.green等固定颜色外,background()修饰符还可以接受材质(materials)。这些材质会在下方内容上应用毛玻璃效果,让我们能够创建精美的深度效果。
为了演示这一点,我们可以构建一个ZStack,在其中放入一个包含两种颜色的VStack(让它们平分可用空间)。然后,给文本视图添加几个修饰符,使其呈现灰色,并在背后添加极薄的材质:
ZStack {
VStack(spacing: 0) {
Color.red
Color.blue
}
Text("Your content")
.foregroundStyle(.secondary)
.padding(50)
.background(.ultraThinMaterial)
}
.ignoresSafeArea()这里使用的是最薄的材质,这意味着毛玻璃效果会让下方的背景色大量透出来。iOS 会根据用户启用的是浅色模式还是深色模式自动调整效果 —— 我们使用的材质会相应地变为浅色或深色。
根据你想要的效果,还有其他厚度的材质可供选择,但我还想向你展示一个更巧妙的功能。不过这个功能比较细微,所以建议你点击 SwiftUI 预览窗口底部的小放大镜图标,近距离查看 “Your content” 这段文本。
你可能会注意到,这段文本不只是单纯的灰色,还会让下方红色和蓝色的背景色轻微透出来。透出来的量很少,只是一点点,但如果使用得当,这种效果能确保文本在任何背景下都能清晰显示。iOS 将这种效果称为活力效果(vibrancy),并在整个系统中大量使用。
渐变
作者:Paul Hudson 2023 年 10 月 11 日
SwiftUI 提供了四种渐变类型,和颜色一样,其中大多数渐变也可以作为视图在 UI 中绘制。
渐变由以下几个部分组成:
- 要显示的颜色数组
- 大小和方向信息
- 渐变类型
例如,线性渐变(linear gradient)沿一个方向渐变,因此我们需要为它指定起始点和终点,代码如下:
LinearGradient(colors: [.white, .black], startPoint: .top, endPoint: .bottom)我们还可以为线性渐变指定渐变停靠点(gradient stops),通过停靠点可以同时指定颜色和该颜色在渐变中所处的位置。例如,我们可以指定渐变从开始到 45% 的位置为白色,从 55% 的位置到结束为黑色:
LinearGradient(stops: [
Gradient.Stop(color: .white, location: 0.45),
Gradient.Stop(color: .black, location: 0.55),
], startPoint: .top, endPoint: .bottom)这样会创建一个过渡更锐利的渐变 —— 渐变效果会压缩在中间的一小块区域内。
提示:Swift 知道我们在这里创建的是渐变停靠点,因此可以使用简写形式.init代替Gradient.Stop,代码如下:
LinearGradient(stops: [
.init(color: .white, location: 0.45),
.init(color: .black, location: 0.55),
], startPoint: .top, endPoint: .bottom)另一种渐变是径向渐变(radial gradient),它沿圆形向外扩散,因此不需要指定方向,而是需要指定起始半径和结束半径 —— 即颜色从圆心开始变化和停止变化的距离。例如:
RadialGradient(colors: [.blue, .black], center: .center, startRadius: 20, endRadius: 200)第三种可以作为视图的渐变是角度渐变(angular gradient),你可能在其他地方听过它被称为圆锥渐变(conic gradient)。它围绕一个圆循环显示颜色,能够创建精美的效果。
例如,下面的代码会在渐变的中心周围循环显示一系列颜色:
AngularGradient(colors: [.red, .yellow, .green, .blue, .purple, .red], center: .center)以上所有渐变类型都可以指定停靠点(而非简单的颜色数组),并且它们既可以作为布局中的独立视图,也可以作为修饰符的一部分(例如,用作文本视图的背景)。
SwiftUI 还提供了第四种渐变类型,它比前三种更简单 —— 你无法对其进行任何控制,并且只能将其用作背景和前景样式,而不能作为独立视图。
只需在任何颜色后添加.gradient,即可创建这种渐变 ——SwiftUI 会自动将该颜色转换为一个非常柔和的线性渐变。尝试以下代码:
Text("Your content")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundStyle(.white)
.background(.red.gradient)这种渐变非常细微,但几乎无需额外工作就能提升你的设计质感 —— 你会发现我经常使用它!
按钮与图片
作者:Paul Hudson 2024 年 4 月 11 日
我们之前简要介绍过 SwiftUI 的按钮,但它们非常灵活,能够适应各种使用场景。
创建按钮最简单的方式我们之前已经见过:如果按钮只包含文本,只需传入按钮标题和一个闭包(按钮被点击时执行)即可:
Button("Delete selection") {
print("Now deleting…")
}当然,闭包也可以替换为任意函数,因此下面这种写法也是可行的:
struct ContentView: View {
var body: some View {
Button("Delete selection", action: executeDelete)
}
func executeDelete() {
print("Now deleting…")
}
}有几种方法可以自定义按钮的外观: 首先,可以给按钮添加一个角色(role),iOS 会根据角色调整按钮的视觉外观和屏幕阅读器的朗读内容。例如,我们可以将 “删除” 按钮的角色指定为 “破坏性”(destructive),代码如下:
Button("Delete selection", role: .destructive, action: executeDelete)其次,可以使用按钮的内置样式:.bordered(带边框)和.borderedProminent(突出显示的边框样式)。这些样式可以单独使用,也可以与角色结合使用:
VStack {
Button("Button 1") { }
.buttonStyle(.bordered)
Button("Button 2", role: .destructive) { }
.buttonStyle(.bordered)
Button("Button 3") { }
.buttonStyle(.borderedProminent)
Button("Button 4", role: .destructive) { }
.buttonStyle(.borderedProminent)
}如果你想自定义带边框按钮的颜色,可以使用tint()修饰符,代码如下:
Button("Button 3") { }
.buttonStyle(.borderedProminent)
.tint(.mint)重要提示:苹果明确建议不要使用过多突出显示的按钮(borderedProminent),因为如果所有按钮都突出,就等于没有按钮突出了。
如果你想要完全自定义的按钮,可以通过第二个尾随闭包传入自定义标签:
Button {
print("Button was tapped")
} label: {
Text("Tap me!")
.padding()
.foregroundStyle(.white)
.background(.red)
}SwiftUI 有一个专门的Image类型用于处理应用中的图片,创建图片主要有三种方式:
Image("pencil"):加载你添加到项目中的名为 “Pencil” 的图片。Image(decorative: "pencil"):加载同一张图片,但不会被屏幕阅读器(供视障用户使用)朗读。这种方式适用于不传递额外重要信息的装饰性图片。Image(systemName: "pencil"):加载 iOS 内置的铅笔图标。这使用的是苹果的 SF Symbols 图标集,你可以搜索喜欢的图标 —— 从网上下载苹果免费的 SF Symbols 应用,即可查看完整的图标集。
默认情况下,如果启用了屏幕阅读器,它会朗读图片的名称,因此如果你想避免混淆用户,请给图片起清晰的名称。如果图片没有传递屏幕上其他地方没有的信息,请使用Image(decorative:)初始化方法。
由于按钮的完整形式可以包含任意类型的视图,因此可以像这样使用图片:
Button {
print("Edit button was tapped")
} label: {
Image(systemName: "pencil")
}如果你想同时显示文本和图片,有两种选择。第一种是直接将两者传入Button,代码如下:
Button("Edit", systemImage: "pencil") {
print("Edit button was tapped")
}但如果你想要更自定义的效果,SwiftUI 提供了一个专门的Label类型:
Button {
print("Edit button was tapped")
} label: {
Label("Edit", systemImage: "pencil")
.padding()
.foregroundStyle(.white)
.background(.red)
}这两种方式都会同时显示铅笔图标和 “Edit” 文本,表面上看与使用简单的HStack效果相同。但 SwiftUI 非常智能:它会根据视图在布局中的使用场景,自动决定显示图标、文本,还是两者都显示 —— 因此这种方式是更好的选择。
显示弹窗消息
作者:Paul Hudson 2023 年 10 月 11 日
当发生重要事件时,通知用户的常用方式是使用弹窗(alert)—— 一个包含标题、消息和一两个按钮(根据需求而定)的弹出窗口。
但请思考一下:弹窗应该何时显示,以及如何显示?视图是程序状态的函数,弹窗也不例外。因此,我们不是直接 “显示弹窗”,而是创建弹窗并设置其显示条件。
一个基本的 SwiftUI 弹窗包含标题和一个关闭按钮,但更关键的是弹窗的显示方式:我们不会将弹窗赋值给变量,然后调用myAlert.show()这样的方法 —— 因为这会回到旧的 “事件序列” 思维模式。
相反,我们需要创建一个状态变量来跟踪弹窗是否显示,代码如下:
@State private var showingAlert = false然后,将弹窗附加到 UI 的某个位置,并告诉它使用该状态变量来判断是否显示弹窗。SwiftUI 会监听showingAlert变量,一旦该变量变为true,就会显示弹窗。
将这些步骤整合起来,下面是一个点击按钮显示弹窗的示例代码:
struct ContentView: View {
@State private var showingAlert = false
var body: some View {
Button("Show Alert") {
showingAlert = true
}
.alert("Important message", isPresented: $showingAlert) {
Button("OK") { }
}
}
}这里将弹窗附加到了按钮上,但实际上alert()修饰符附加到哪里并不重要 —— 我们只是声明 “存在一个弹窗,当showingAlert为true时显示”。
仔细看一下alert()修饰符:
alert("Important message", isPresented: $showingAlert)第一部分是弹窗标题,这很简单;第二部分是双向数据绑定($showingAlert),因为当弹窗被关闭时,SwiftUI 会自动将showingAlert设回false。
再看一下按钮:
Button("OK") { }这是一个空闭包,意味着我们没有为按钮添加点击后的功能。但这没关系,因为弹窗中的任何按钮都会自动关闭弹窗—— 这个闭包的作用只是让我们添加关闭弹窗之外的额外功能。
你可以给弹窗添加更多按钮,并且这里非常适合给按钮添加角色,以明确每个按钮的用途:
.alert("Important message", isPresented: $showingAlert) {
Button("Delete", role: .destructive) { }
Button("Cancel", role: .cancel) { }
}最后,你可以通过第二个尾随闭包为弹窗添加消息文本(与标题配合使用),代码如下:
Button("Show Alert") {
showingAlert = true
}
.alert("Important message", isPresented: $showingAlert) {
Button("OK", role: .cancel) { }
} message: {
Text("Please read this.")
}以上就是本项目概述的最后一部分,现在差不多可以开始编写实际代码了。如果你想保存之前编写的示例代码,可以将项目目录复制到其他位置。
准备就绪后,请将 ContentView.swift 恢复到项目刚创建时的状态,以便我们有一个干净的开始。