表单
用户输入一些信息,SwiftUI 为这种场景专门提供了一个视图类型,称为Form
,Form是一个可滚动列表,除了可以包含文本和图像这样的静态控件,也可以包含文本框,开关,按钮这些用户可交互的控件。
可以在form里放进任意多你想要的元素,不过如果元素超过10个,SwiftUI会要求你对元素进行分组。
提示:
SwiftUI中有一个限制:Form知道如何添加一个、两个、 三个,直到十个元素到自身,但超过十个就不行。这是因为必须有一条界线。实际上,10个子元素的限制在SwiftUI中随处可见。
1 | Form { |
注意:Group实际上并没有改变UI的视觉,只是让你可以解决10个子元素的限制。
如果你确实要求Form把元素分成不同的组块,你可以使用Section
视图,它会将form拆分成视觉上具体呈现的分组,就像设置app里的做法:
1 | Form { |
修改程序状态
视图是它们状态的函数
想象你在玩一个格斗游戏,你有几条命,拿到了一些分数,收集了一些财宝,说不定里面还有非常强大的武器。编程上,我们把这些东西称为 状态 —— 所有描述游戏当前情况的活跃设定。
当你退出游戏时,状态将被保存;让你稍后再回到游戏时,你将重新加载游戏,回到上一次的游戏的地方,这就叫做 状态:所有的整数,字符串,布尔值等, 所有被存储在 RAM 中用以描述你刚才正在做的事情的数据。
事件序列如何实现
“事件序列”这种方式意味着存储app的状态会很困难,因为完美的拿回同样的状态需要精确执行用户曾经触发过的所有事件序列。这也是某些app甚至就干脆不尝试存储你的任何状态。因此,你的新闻app不会尝试返回你上一次读的文章。
SwiftUI实现
1 | struct ContentView: View { |
这份代码是无法编译通过的——因为 ContentView
是一个结构体,是以常量方式创建。它是 immutable
的,不能改变值。
当创建了结构体属性,如果你想要在方法中改变这些属性,你需要在方面前面添加 mutating
关键字,例如 mutating func doSomeWork()
。但是,Swift
不允许我们创建可变的计算属性,因此我们不能写 mutating var body: some View
,这是不允许的。
Swift
给我们一种被称为 属性包装器 的特殊解决方案:它是一种放在属性前面的特性。为了存储一个像按钮点击次数这样的数字作为状态,我们可以用到 SwiftUI
中一个称为 @State
的属性包装器,就像下面这样:
1 | struct ContentView: View { |
@State 让我们可以冲破结构体的限制:因为结构体是固定的,我们不能改变它们的属性,
但 @State 由 SwiftUI 将这些属性存储在一个特殊的区域,从而变成可以修改的。
提示: SwiftUI
中有许多种存储程序状态的方法,你将逐一学习它们。@State
专门为简单属性而设计,并且只服务于单个视图。 因此,Apple
建议我们在这些属性前面添加 private
访问控制,就像这样:@State private var tapCount = 0
。
绑定状态到 UI 控件
在 Swift 中,我们用一种特殊的符号标记这种双向绑定,在属性前写一个$
符号。它告诉 Swift 不仅需要读取属性的值,也需要在绑定对象的内容改变时,把值写回属性。
1 | struct ContentView: View { |
利用循环创建视图
ForEach
视图类型。它会遍历数组和范围,尽可能地按需创建视图。不仅如此,ForEach
并不受最多10
个子元素的限制。
来定义一个这样的视图:
- 有一个所有可能的学生名字的数组
- 有一个@State 属性,存储当前选中的学生名字
- 创建一个 Picker 视图,要求用户选择它们最喜欢的,用上双向绑定的 @State 属性
- 使用 ForEach 遍历所有可能的学生名字,将它们变成文本视图
1 | struct ContentView: View { |
有几点需要明确:
students
数组不需要被标记@State
,因为它是常量,不会改变。selectedStudent
属性从0开始,并且可以改变,因此用@State
标记。Picker
有一个标签,“Select your student
”, 它告诉用户自己的功能,同时也提供给屏幕辅助描述性的文字。Picker
有一个对selectedStudent
的双向绑定,这意味着一开始选择第0个,但当用户移动选项时,这个属性也会更新。- 在
ForEach
内部,我们从0开始计数,直到学生名字数组的长度(但不包括) - 对于每个学生名字,我们都创建了一个文本视图,展示那个学生的名字。
ContentView 背后
给这个文本视图加一个背景色,然后期望这个颜色填满这个屏幕。
1 | struct ContentView: View { |
对 SwiftUI 开发者来说,我们的视图背后,什么也没有。内容视图背后,至少有一样东西,它叫UIHostingController
:它是 UIKit
(Apple 原生的 iOS UI 框架) 和 SwiftUI
之间的桥梁。
1 | Text("Hello World") |
使用 maxWidth
和 maxHeight
不同于 width
和 height
—— 我们并不是在要求文本视图一定要占满空间,而是在它可以的前提下。如果周围有其他的视图,SwiftUI
会确保大家都得到足够的空间。
Modifier 的顺序
当我们应用一个 modifier
到 SwiftUI
视图时,我们实际上是创建了应用一个应用了改变的新视图 —— 我们并不是在修改已经存在的视图。
请看下面的代码:
1 | Button("Hello World") { |
你认为运行起来会是什么样子呢?
有可能你会猜错:你不会看到一个 200x200
的红色按钮,中间是 “Hello World”
文字。相反,你会看到一个 200x200
的空的矩形,"Hello World"
在中间,而红色矩形只出现在 "Hello World"
周围。就像下面这样:
如果你思考过 modifier
的工作方式,你就会理解发生了什么:每个 modifier
都创建了新的结构体,而非在原有视图上设置属性。
你可以借由打印视图的 body
的类型来一窥 SwiftUI
的底盘。把按钮的代码改成这样:
1 | Button("Hello World") { |
Swift
的type(of:)
方法可以打印出特定值的精确类型,在这个实例中它会打印出:ModifiedContent<ModifiedContent<Button<Text>, _BackgroundModifier<Color>>, _FrameLayout>
你会发现两个东西:
- 每次我们修改 视图时,
SwiftUI
通过泛型ModifiedContent<OurThing, OurModifier>
来应用modifier
。 - 当我们应用了多个
modifier
时,它们层层叠加:ModifiedContent<ModifiedContent<…
为了读懂这个类型,我们从最深处开始:
- 最深处的类型是
ModifiedContent<Button<Text>, _BackgroundModifier<Color>:
带文本的按钮,应用一个背景色。 - 在它周围是
ModifiedContent<…, _FrameLayout>
,它取刚才那个视图作为第一个参数,然后加一个更大的frame
。
如你所见,我们最后会得到一些叠加在一起的ModifiedContent
—— 其中的每一层都接收一个视图,然后添加一个改变完成变换,而非直接修改视图。
这意味着,你的 modifier
的顺序至关重要。 如果我们把代码重写成下面这样,在 frame
之后应用背景色,那么你将得到之前你所期望的视觉效果:
1 | Button("Hello World") { |
考虑这个机制,最佳的方式是想象 SwiftUI 在每一个 modifier
之后渲染你的视图。因此,只要当你说 .background(Color.red)
它就把背景填充成红色。如果你之后又延展了 frame
,它并不会魔法般地自动重绘背景 —— 它之前已经应用过了。
使用 modifier 的一个重要的副作用是我们可以重复地应用相同的效果多次:每一个都只是在前面的基础上叠加。
例如,SwiftUI 提供给我们 padding()
modifier ,它会在视图周围添加一些留白,以便视图不与其他视图或者屏幕的边缘靠在一起。如果我们先应用了 padding 然后再应用 background color ,然后是新的 padding 和不一样的 background color ,我们将会得到一个拥有多层边框的视图,代码如下:
1 | Text("Hello World") |
条件化 Modifier
在实践中,希望 modifier 在某些条件满足时才应用的需求很常见。SwiftUI 实现这个目的的最简单方式是三元操作符。
举个例子,如果你有一个属性既可能是 true 也可能是 false ,你可以用它来控制按钮的前景色,像下面这样:
1 | struct ContentView: View { |
你可以用常规的 if 条件来基于某些 state 返回不同的视图,但只限于少数一些情况。
举个例子,下面的代码是不合法的:
1 | var body: some View { |
记住,some View
指的是 “某种特定类型的 View 会被返回,但我们不想指明具体是什么类型。” 因为 SwiftUI 基于泛型 ModifiedContent
包装器创建视图的方式,Text(…)
a和 Text(…).background(Color.red)
实际上是不同的类型,因此和 some View 不兼容。
为什么 SwiftUI 用 “some View” 作为视图类型?
SwiftUI 高度依赖 Swift 5.1 引入的一个强大特性,它叫 “opaque return types”
,它可以用于函数、方法和属性返回一些值,无需向调用API的客户端揭示该值的具体类型。每一次你看到 some View
的地方就是它了。它表示 “某个遵循View协议的特定类型,但我们不必说具体是什么”
返回 some View
相较只返回 View 有两个重要的区别:
- 我们必须总是返回相同的类型。
- 尽管我们并不知道返回的 view 的类型,但编译器知道。
SwiftUI 如何处理VStack
如果你创建一个 VStack
,里面有两个文本视图,SwiftUI 会静默地创建一个TupleView
,包含这两个文本视图 —— 这是一种特殊的视图,它只包含两个视图在里面。因此,VStack实际上是以包含两个文本视图的TupleView 来回答那个问题。如果 VStack里有三个文本视图呢? 那么就会是一个包含三个视图的 TupleView,或者 4 个视图,8个视图,甚至 10 个视图 —— 确实有可以追踪 10 个不同类型内容的TupleView 版本:
TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)>
这也是为什么 SwiftUI 不允许一个父节点拥有超过 10 个视图:他们写了可以处理 2 个视图到 10 个视图的 TupleView 版本,但不支持更多了。
环境 Modifier
许多 modifier 不仅可以应用在各种视图上,也能应在容器上。
举个例子,如果我们在一个 VStack
里有四个文本视图,并且希望给他们设置相同的字号 modifier
, 我们可以把 modifier
直接应用在 VStack
上,而不用分别应用在四个文本视图上。
1 | VStack { |
这种 modifier
称为环境 modifier
,它有别于常规的应用于视图的 modifier
。但是,如果某个子视图覆盖了相同的 modifier
,他们的表现比较精妙 —— 子视图的版本优先级更高。
font() 是一个环境 modifier
像下面这样的代码,先给一个 VStack应用模糊效果,然后再试图在子视图上禁用模糊的做法会失败:
1 | VStack { |
之所以行不通,因为blur() 在这里是一个常规 modifier
,所以子视图上再应用的 blur 是追加而不是替换。
并没有方法可以提前知道哪些 modifier 是环境 modifier ,哪些 modifier 是常规 modifier —— 你只能做实验。
自定义modifier
举个例子,我们可以让我们的 app 中的所有标题拥有一个特别的样式,首先我们创建一个自定义的 ViewModifier
结构体来实现我们想做的事情:
1 | struct Title: ViewModifier { |
现在我们可以通过 modifier()
方法来使用这个 modifier —— 是的,这是一个叫 “modifier
” 的 modifier ,但它可以让我们应用任意类型的 modifier
到一个视图,像下面这样:
Text("Hello World") .modifier(Title())
使用自定义 modifier 的时候,基于 View 创建扩展是个好主意。例如,我们可以把 Title modifier
封装成扩展的形式,像下面这样:
1 | extension View { |
然后这样使用:
Text("Hello World") .titleStyle()
modifiers
是返回新的对象,而不是修改已经存在的对象,因此我们可以把视图嵌到一个栈里,并且添加其他视图:
1 | struct Watermark: ViewModifier { |
上面的代码就位后,我们就可以像下面这样给任何视图添加水印了:
1 | Color.blue |
自定义容器
创建一个新的 stack 类型,它叫 GridStack
,可以让我们以网格的形式创建任意多的视图。我们要声明一个叫GridStack的遵循View 协议的结构体,它有行和宽的数字,在网格内有许多内容单元,这些单元本身也要遵循View
协议。
1 | struct GridStack<Content: View>: View { |
第一行 —— struct GridStack<Content: View>: View
—— 用一个 Swift 的高级特别叫 泛型 ,在这里它的意思是 “你可以提供任意类型的内容,但它必须遵循 View
协议。” 在冒号之后,我们又加了一个 View
,声明 GridStack
本身也遵循View
协议。
特别注意一下 let content
这行 —— 它定义了一个闭包 —— 必须接收两个整数,并且返回某种我们可以显示的内容。
通过组合多个 vertical
和 horizontal
的 stack 来按要求创建许多单元。我们不需要说明每个单元里有什么,因为我们可以通过合适的行号和列号来调用 content
闭包,
1 | var body: some View { |
现在我们拥有了一个自定义容器,我们可以用它写一个视图,像下面这样:
1 | struct ContentView: View { |
可以给单元创建自己的 stack
1 | GridStack(rows: 4, columns: 4) { row, col in |
更进一步
如果想获取更多的弹性,我们还可以用 SwiftUI 的一个特性,叫 view builder
,它允许我们传入多个视图,让 view builder 隐式地为我们创建 stack 。
为了使用 view builder
,我们需要给GridStack
结构体创建自定义的构造器,因此我们用 SwiftUI
的 view builder
系统来标记 content
闭包:
1 | init(rows: Int, columns: Int, content: @escaping (Int, Int) -> Content) { |
这个过程基本上是把结构体的属性复制到构造器中作为参数,不过要留意 @ViewBuilder
特性。你还看到 @escaping
特性,它允许我们存储闭包,以便稍后使用。
有了上面的代码,SwiftUI 现在可以为单元自动地创建一个 horizontal stack
:
1 | GridStack(rows: 4, columns: 4) { row, col in |
结构体和类,ForEach,绑定
结构体 vs 类
结构体和类之间有五个关键的差异:
- 类没有逐一成员构造函数;
- 结构体默认获得逐一构造成函数。
- 类可以使用继承来构建功能;结构不能。
- 如果你复制一个类,两份拷贝都会指向相同的数据;但结构体的拷贝,其数据是各自独立的。
- 类可以有析构函数;结构体没有。你可以在常量类实例里改变变量属性的值;但常量结构体实例里的属性是固定的,不管它是常量还是变量。
Donald Knuth 说过,“程序是给人读的,偶尔给计算机运行”
提示: SwiftUI 有一个迷人的细节是它扭转了我们使用结构体和类的方式。在 UIKit 中我们针对数据使用结构体,针对 UI 使用类,但在 SwiftUI 中完全相反
ForEach
我们如何遍历这些字符串,以创建文本视图呢?
1 | let agents = ["Cyril", "Lana", "Pam", "Sterling"] |
一个选项是用我们已经有的构建方式:
1 | VStack { |
不过 SwiftUI 提供了第二种选择:我们可以直接遍历数组。这种方式需要多费点思考,因为 SwiftUI 需要知道如何识别数组中的每一项。
思考一下:如果我们遍历一个 4 个元素的数组,我们会创建 4 个视图,但是如果 body 重新调用,我们的数组现在包含 5 个元素了,SwiftUI 需要知道哪个视图是新的以便展示它。 SwiftUI 最不愿意做的事情:每当一个小改变发生时,丢弃整个布局,从头开始。相反,它希望做尽可能少的工作 —— 它希望保持已经存在的 4 个视图,只添加第 5 个。
因此,让我们回到 Swift 识别数组中元素的地方。当我们用诸如 0 ..< 5
或者 0 ..< agents.count
这样的范围时,Swift 已经确信每个元素都是唯一的,因为每个元素在循环中都只使用一次,所以一定是唯一的。
而在我们的字符串数组中,这一点变得不可能。我们无法清晰地确信每个值是唯一的: 它要求 [“Cyril”, “Lana”, “Pam”, “Sterling”] 不重复。因此,我们能做的是把字符串本身告诉 SwiftUI —— “Cyril” ,“Lana” ,等等 —— 它们是用来在循环中唯一标识每个视图的东西。 代码是这样的:
1 | VStack { |
随着你对 SwiftUI 的精进,我们会看到第三种标识视图的方式,它是用 Identifiable
协议
绑定
先来看下自定义绑定的最简单形式,它存了另外一个 @State 属性的值:
1 | struct ContentView: View { |
所以,这里的绑定扮演的角色是透传 —— 它自己实际上并不存储或者计算任何数据,只是充当我们的 UI 和下面的状态值之间的一个 ”夹片“ 。
注意一下,现在 picker 是通过 selection: binding
创建,不再需要 $
符号了。 我们并不需要显式要求双向绑定,因为它本身已经是了。
举个例子,想象我们有一个表单,里面有三个开关:用户同意条款,用户同意隐式政策,用户同意接收邮件。
我们可能用三个布尔型的 @State
属性表示它们:
1 | var agreedToTerms = false |
虽然用户是逐个触发它们的,我们可以用一个自定义绑定来实现它们。这个绑定只有在三个布尔值都为 true 时才为 true ,像这样:
1 | let agreedToAll = Binding( |
现在我们还可以做四个开关的实现:每个独立布尔值一个,一个同意或者不同意的总开关:
1 | struct ContentView: View { |
自定义绑定不是你会希望经常用到的东西。