构建 View
接下来,我们将展开介绍如何构建 View。
View 的组合方式
在 body
中,有一些非常方便的方式来组合不同的 View。另外,body
还对其中的表达式有一定的限制。
组合
在 body
中,你可以通过这几种方式组合多个 View,不需要写出 return
:
条件控制语句
你可以用 if-else
语句有条件地显示不同的 View:
if type == .text {
Text("Hello, world!")
} else if type == .image {
Image("hello-world")
} else {
Color.blue
}
或者,只有 if
:
当然,if let
语句也是可以的:
你还可以用 switch
:(实际上编译器将其转化为 if-else
语句)
switch type {
case .text: Text("Hello, world!")
case .image: Image("hello-world")
default: Color.blue
}
多个 View
你可以一次返回多个 View,这种 View 叫做 TupleView
:
一般这种 TupleView
多用于 View 的排列。比如,将几个 View 在垂直方向依次排列:
我们将在下面进一步介绍 View 的排列。
VStack
构造函数的参数
上面的 VStack
以及大括号包括的内容,实际上是 VStack
的构造函数。根据文档,VStack
构造函数的签名如下:
我们前两个参数使用了提供的默认值,我们提供的实际上是第三个参数 content
:它是一个无参数、返回值为 Content
的匿名函数。(这里的 Content
是 VStack
的模版参数,表示「内容 View 的类型」)
在 Swift 中,如果函数的最后一个参数为函数,那么它可以写到括号外面,并且省略标签。上面 VStack
的构造函数实际上是:
顺便说一句,content
的返回值的类型是 TupleView<(Text, Image)>
,并不是一个真正的 Tuple
。
表达式
body
与普通函数不同,它对函数体内的允许的表达式有限制。
同时进行变量的声明和赋值是允许的:
但 var
和 let
的声明、赋值表达式不被允许:
// Error: Closure containing a declaration cannot be used with result builder 'ViewBuilder'
var body: some View {
var description: String
...
}
// Error: Type '()' cannot conform to 'View'
var body: some View {
var description = ""
description = "hello"
...
}
另外,for
循环也是不被允许的。我们有另外的方法进行类似的迭代。
上述例子中,类型遵循 View
的值(如 Text(description)
)会作为部分结果(partial result)递归地参与 body
最后结果的计算,而其余允许的表达式(如 let description = x > 127 ? "overflow" : "normal"
)在计算 body
的值的时候会被忽略。
简单介绍 ViewBuilder
实际上,body
之所以能够有这些特性,是因为 body
有隐式的 @ViewBuilder
修饰:
被 @ViewBuilder
修饰的函数,不使用显式的 return
来返回,而是通过“收集”函数体中的View
来构建最终的返回值。比如:
编译器“收集”到两个 View:Text
和 Color
(忽略 let greeting = "Hello"
)。接着,调用 ViewBuilder
的一个构建 View 的函数:
static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View
这个函数接受两个 View
作为参数,最终生成单个返回值 TupleView<(Text, Color)>
。
其他的特性,比如 if-else
语句,都是由类似的机制来实现的。不难理解,只要不断递归地将 View 合并,最终就能生成一个单一的返回值。
ViewBuilder
的机制实际上是通过一种叫 result builder 的语法实现的。如果感兴趣可以参考 SE0289: Result builders。
一些常用的 View
我们终于基本介绍完相关的语法规则了,接下来就是利用各种原生的 View 来构建我们的 UI 了。在这里只会介绍各种 View 最基本的用法,其余用法请参考开发者文档。
你最好亲自尝试一下下面的代码,并在预览模式中实时查看。
组件
只读文本 Text
用于显示只读文本。
你可以从字符串创建:
结果为:
也可以用 Markdown 字符串创建带格式的文本。注意只有字面值能够直接渲染为 Markdown 格式,对于字符串变量需要 AttributedString
:
Text("Hello, *world*!")
let string = "Hello, *world*!"
let attrStr = try! AttributedString(markdown: string)
Text(attrStr)
结果为:
可以使用 .font(_:)
、.foregroundColor(_:)
、.italic(_:)
等 modifier 改变字体、颜色等:
Text("Hello, world!")
.font(.system(size: 24, weight: .bold, design: .serif))
.italic()
.foregroundColor(.blue)
.underline()
最终的效果为:
最后,值得一提的是 Text
的加法运算符,它可以将两个样式不同的 Text
拼接在一起:
let start = Text("L").bold().font(.title)
let remaining = Text("orem ipsum dolor sit amet, consectetur adipiscing elit....")
start + remaining
效果为:
图片 Image
显示图标或资源文件图片。
添加图片资源的方法
- Swift Playgrounds:将图片拖动到文件目录中
- Xcode Project:将图片拖动到
Assets.xcassets
中。
使用 Image(systemName:)
显示系统图标:
结果为:
Info
关于系统内置图标的查询,请参考 SF Symbols。
使用 Image(_:)
显示资源文件。
比如,我们有一个如下图的名为 wwdc21
的图片文件:
以下几种不同的代码会产生不同的结果:
// Case 1
Image("wwdc21")
// Case 2
Image("wwdc21")
.resizable()
// Case 3
Image("wwdc21")
.resizable()
.aspectRatio(contentMode: .fit)
// Case 4
Image("wwdc21")
.resizable()
.aspectRatio(contentMode: .fill)
Case 1 | Case 2 | Case 3 | Case 4 |
---|---|---|---|
图片原有尺寸 | 占满可获得的空间 | 按原有比例适合空间 | 按原有比例占满空间 |
![]() |
![]() |
![]() |
![]() |
一个 Image
如果不加上 .resizable()
的话只会按照固有像素显示,只有加上 .resizable()
才能进行缩放。.aspectRatio(_:contentMode:)
使得 Image
能够根据可用空间和长宽比进行缩放。
Image
的布局性质
注意,Image
所占的空间可能会超出可用的空间。对于上面的 Case 4,在 Xcode 预览中选中图片,可以看到其大小:
这种性质在很多地方会有意想不到的效果,如即将介绍的 ZStack
。如果需要将图片限制在可用空间内,可以使用 Color
,它仅占据所有可用空间:
图标 & 文字 Label
显示图标和文字。
效果为:
另外,你可以用 .font(_:)
修改其字体。
Label
的特殊之处
Label
相较于普通的左右排布(接下来要介绍的 HStack
)的特殊之处在于,在某些系统原生的排列中,多个 Label
的图标和文字会自动对齐。
按钮 Button
按钮。
文字按钮:
以一个 View 作为按钮:
上面的代码也可以写成:
Multiple Trailing Closures
如果一个函数最后若干个参数均为函数,如:
那么,调用时这些函数参数可以写到小括号外面,第一个可以省略标签,其余需要加上标签:
foo(para1: 127) {
// func1 goes here
} func2: {
// func2 goes here
} func3: { value in
// func3 goes here
}
这种写法是 Swift 中一种叫 multiple trailing closures 的语法,详见官方文档。这种写法能提高代码的可读性,我们之后会尽量采用这种写法。
另外,我们可以用 .buttonStyle(_:)
来使用系统默认的一些样式:
效果为:
菜单 Menu
创建点击显示的菜单。
Menu("Actions") {
Button("Duplicate", action: {...})
Button("Rename", action: {...})
Button("Delete…", role: .destructive, action: {...})
Divider()
Menu("Copy") {
Button("Copy", action: {...})
Button("Copy Formatted", action: {...})
Button("Copy Library Path", action: {...})
}
}
值得注意的是,可以在一个 Menu
中内嵌另一级 Menu
。
效果为:
另外,可以用类似的方法给任何 View 添加长按菜单:
Image(systemName: "swift")
.contextMenu {
Button("Duplicate", action: {...})
Button("Rename", action: {...})
...
}
布局与排列
水平布局 HStack
水平布局。
效果为:
另外,HStack
还有两个可选参数 alignment
和 spacing
,用于设置对齐和间距。比如,HStack(alignment: .top, spacing: 20)
将使子 View 在顶部对齐,两两之间的间距为 20
。
不同 View 具有不同的布局特性
注意到,三个 Text
并没有占满所有所有空间,而是只占据了所需要的最小空间,这是由 Text
的布局特性决定的。与之相对,Color
将尽可能占据多的空间:
效果为:
不同的布局特性对我们排布多个 View 有重要影响。
限于篇幅,这部分内容无法展开叙述。总的来说,原生 View 有如下两类布局特性:
- 仅占需要的空间,如
Text
、Label
- 占据尽可能多的空间,如
Color
、Spacer
、VStack
等
通过 fixedSize
、layoutPriority
、frame
等 modifier,可以更改 View 的布局特性,在此不再展开。
垂直布局 VStack
垂直布局,与 HStack
类似。
效果为:
上下布局 ZStack
上下布局,先写的 View 排布在底层。
效果为:
与 HStack
和 VStack
类似,你可以通过构造函数中的 alignment
参数指定这些 View 对齐的方式。
ZStack
和 .overlay(_:)
我们还有另外一种方式进行 View 的层叠:
看上去与 ZStack
并无二致,其实不然。我们用 .frame()
显示地指定 Color.yellow
的长宽,来比较一下这两种方式:
// ZStack
ZStack {
Color.yellow
.frame(width: 40, height: 25)
Text("Overlay")
}
// overlay
Color.yellow
.frame(width: 40, height: 25)
.overlay {
Text("Overlay")
}
效果分别为:
ZStack |
.overlay() |
---|---|
![]() |
![]() |
可以看到,ZStack
以最大元素的大小作为其大小,而 .overlay()
始终限制在被其修饰的 View 的大小内。
间距工具 Spacer
间距工具。
Spacer
是一个弹性的间距。它本身不会显示,只是占据大于或等于特定宽度/高度的空间。
例如:
Note
这里的 .background(_:)
只是为了更明显地显示 HStack
内部的排列。
效果为:
我们注意到,整个 Text
被“挤”到了左边。
我们在上面提到了「大于或等于特定宽度/高度」。Spacer
构造函数有一个参数 minLength
,就是用来指定这个距离的,上面我们忽略了这个参数,使用系统默认的距离。
需要注意的是,Spacer
会占据 View 所不需要的空间,但不会挤占 View 需要的空间(即:Spacer
排布的优先级最低)。比如,对于之前提到的会占据所有空间的 Color
:
Spacer
只占据了默认的最小空间。实际上,如果你把 Spacer
的 minLength
参数设置为 0:
对于 Color
的情况,Spacer
就不会占据任何空间。然而,对于上面 Text
的情况,它仍然会占据所有剩余的空间。
另外,如果需要固定宽度/高度的 Spacer
,可以使用 .fixedSize()
:
其他容器
SwiftUI 中还有很多用于排布的容器 View,不再展开,具体可以参考 官方文档。一些常用的 View 有:
ScrollView
:可滑动视图List
:设置等系统 app 使用的列表视图NavigationView
:设置等系统 app 使用的可滑动返回的导航视图TabView
:时钟等系统 app 使用的分页视图LazyVStack
和LazyHStack
:惰性 Stack 容器LazyVGrid
和LazyHGrid
:网格容器
练习:原生 View 与基本布局
利用 VStack
、HStack
和其他原生 View,构建一个如下图所示的 View:
提示:
- 一个固定大小的圆可以用
Circle().frame(...)
生成 - 图标对应的 system name 为
star
参考代码
VStack(alignment: .leading, spacing: 15) {
HStack {
Circle()
.frame(width: 37, height: 37)
.foregroundColor(.yellow)
.overlay {
Text("A")
.font(.system(.body, weight: .heavy, design: .rounded))
.foregroundColor(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text("#123456")
.font(.system(size: 16, weight: .medium))
Text("1小时前")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.secondary)
}
Spacer()
Label("6", systemImage: "star")
}
Text("借助 SwiftUI...")
}
.padding()
注意,最外层 VStack
的 alignment
参数为 .leading
。请思考其中的原因。
组合
Group
将多个 View 组合起来处理,或用于添加逻辑控制。
一个 Group
对其中的子 View 没有任何影响,各个 View 仍然保持独立,对 Group
添加 modifier 相当于给每一个 View 添加 modifier:
效果为:
我们常用这种方法同时给多个 View 添加相同的 modifier。
另外,Group
的参数为 @ViewBuilder
,可用于在某些不支持 if-else
语句等地方添加逻辑控制。如:
Note
请查阅 .overlay()
的文档,找出这里的 .overlay()
和上面 ZStack
中提到的 .overlay()
的区别。
ForEach
迭代一个集合内的元素,并依次生成 View,类似于 for 循环。
但是,不能简单地迭代任意一个数组:
编译器会报错:
Referencing initializer 'init(_:content:)' on 'ForEach' requires that 'String' conform to 'Identifiable'
意思是,所迭代数组的元素的类型必须遵循 Identifiable
。简单来说,因为 SwiftUI 更新时需要确切知道哪个 View 依赖的数据更新了,生成 View 的数据必须是可以相互区分的。
通过使数组元素的类型遵循 Identifiable
,我们便可以安全地通过 ForEach
迭代数组,注意 id
必须互不相同,否则可能会导致崩溃:
struct Student: Identifiable {
var id: Int { return studentID } // Identifiable requirement
var studentID: Int // 学号,必然互不相同
var name: String
}
ForEach(students) { student in
Text(student.name)
}
在数组固定的情况下,也可以使用 count
或 indices
进行迭代,此时需要显式指出用于区分的 id
:
let students = ["Alice", "Bob", "Carol"]
ForEach(0..<students.count, id: \.self) { index in
Text(students[index])
}
ForEach(students.indices, id: \.self) { index in
Text(students[index])
}
这里 \.self
用到 Swift 的 KeyPath
,\.self
的意思是返回下标本身(Int
),使用其所为区分不同元素的 id。
Info
关于不同 View 在底层是如何区分的,参考 WWDC21: Demystify SwiftUI。
另外,需要注意的是 ForEach
和 Group
类似,并不把其中的 View 变成一整个 View;对其添加 modifier 相当于对各个子 View 添加。
Modifier
Modifier 的种类
不同 View modifier 的不同效果常常令初学者迷惑。一般来说,View modifier 的类型有如下几类:
改变环境变量,对其作用范围内的所有 View 均有效
典型的例子是 font
、foregroundColor
等。比如:
VStack {
Image(systemName: "globe")
HStack {
Text("Hello")
.bold()
Text("World")
.italic()
}
}
.font(.title3)
.foregroundColor(.orange)
这里,.font(.title3)
和 .foregroundColor(.orange)
对 VStack
内处于任意层级的 View 均有效。
仅修饰最外层 View
典型的例子是 padding
:
在这里,padding
仅在 HStack
外添加了边距,而对其中的两个 Color
无影响。
返回特定类型的 View
这些 modifier 并不是返回 some View
而是特定类型,因此后续可使用的 modifier 更多,最典型的是 Text
:
上面四个 modifier 的返回值均为 Text
,因此可以使用如 italic
这类只能用于 Text
的 modifier。
自定义 modifier
可以通过 extension
将一些样式组合成 modifier,方便重用:
extension View {
func defaultContainerStyle() -> some View {
self.padding(.horizontal, 10)
.padding(.vertical, 5)
}
}
VStack {
...
}
.defaultContainerStyle()
另外,可以通过 ViewModifier
协议定义作用更强的 modifier,可以拥有状态(见 数据流基础),在这里不再赘述。