第23天 项目3 第一部分
华特・迪士尼曾说过:“魔法之中并无魔法,奥秘全在细节。” 这句话用在 SwiftUI 上也同样贴切:初看之下,你可能会觉得它之所以能高效运行,背后一定藏着各种 “魔法”,但只要拨开表象,你就能看清它的工作原理 —— 而理解了这些原理,你也能更好地掌握 SwiftUI 的使用方法。
今天是我们的第一个技巧性项目,我们将聚焦 SwiftUI 的两个基础组件:视图(view)和修饰符(modifier)。虽然我们之前已经用过它们,但对于其具体工作机制,我们只是一笔带过。不过今天,这种情况就要改变了:我们会深入探讨它们是什么、如何工作以及为何会以这样的方式工作。
如果一切顺利,最终你将不再觉得 SwiftUI 充满 “魔法”,而是能看清其中的每一个细节 —— 你依然会享受使用 SwiftUI 的过程,但同时也能确切知道它是如何运作的。
今天你需要学习 10 个主题,通过这些内容,你将学会构建自定义视图修饰符和自定义容器,同时开始理解 SwiftUI 内部的实际工作方式。
- 视图与修饰符:简介
- 为何 SwiftUI 要用结构体来定义视图?
- 主 SwiftUI 视图背后是什么?
- 为何修饰符的顺序很重要
- 为何 SwiftUI 要用 “some View” 作为视图类型?
- 条件修饰符
- 环境修饰符
- 作为属性的视图
- 视图组合
- 自定义修饰符
这是一大段学习内容,但如果你还想深入了解更多,可以阅读最后的自定义容器教程 —— 这个教程是可选的,所以只有在你有时间的情况下再去完成。
视图与修饰符:简介
作者:Paul Hudson 2023 年 1 月 31 日
这个第三个 SwiftUI 项目,实际上是我们的第一个技巧性项目—— 节奏上会有所变化,我们将深入探究 SwiftUI 的某些特性,详细了解它们的工作方式以及背后的原因。
在这个技巧性项目中,我们将深入研究视图和视图修饰符,希望能解答大家在此时常有的一些疑问 —— 为何 SwiftUI 要用结构体来定义视图?为何它频繁使用 “some View”?修饰符到底是如何工作的?我希望在项目结束时,你能彻底理解 SwiftUI 的运作原理。
和之前的学习内容一样,最好在 Xcode 项目中实际操作,这样你就能看到代码的运行效果。所以,请创建一个新的 App 项目,命名为 ViewsAndModifiers。
为何 SwiftUI 要用结构体来定义视图?
作者:Paul Hudson 2023 年 10 月 14 日
如果你有过 UIKit 或 AppKit(苹果为 iOS 和 macOS 开发的原始用户界面框架)的编程经验,就会知道它们是用类(class)来定义视图的,而不是结构体(struct)。但 SwiftUI 并非如此:我们始终倾向于用结构体来定义视图,这背后有几个原因。
首先,这涉及到性能问题:结构体比类更简单、速度更快。我说 “涉及到” 性能,是因为很多人认为这是 SwiftUI 采用结构体的主要原因,但实际上,这只是整体情况中的一部分。
在 UIKit 中,每个视图都继承自一个名为 “UIView” 的类,这个类包含许多属性和方法 —— 比如背景颜色、决定视图位置的约束、用于渲染内容的图层等等。这类属性和方法数量众多,而且每个 UIView 及其子类都必须包含它们,因为这是继承的工作方式。
而在 SwiftUI 中,我们所有的视图都是简单的结构体,创建它们的成本几乎可以忽略不计。试想一下:如果你定义一个只包含一个整数的结构体,那么这个结构体的整体大小…… 就只是那个整数的大小。没有其他额外内容。不会从父类、祖父类或曾祖父类那里继承那些意想不到的额外值 —— 结构体中只包含你能看到的内容,别无其他。
得益于现代 iPhone 的强大性能,创建 1000 个甚至 100000 个整数对我来说根本不值一提 —— 这在一瞬间就能完成。创建 1000 个甚至 100000 个 SwiftUI 视图也是如此;它们的创建速度非常快,快到无需为此操心。
不过,尽管性能很重要,但结构体作为视图还有一个更关键的优势:它能迫使我们以一种清晰的方式来隔离状态。要知道,类可以自由修改自身的值,这很容易导致代码混乱 —— 如果视图的值发生了变化,SwiftUI 怎么才能知道要更新用户界面呢?
通过让视图不随时间变化(不可变),SwiftUI 鼓励我们采用更偏向函数式的设计思路:我们的视图变成了简单、被动的组件,只负责将数据转换为用户界面,而不是会变得难以控制、功能复杂的 “智能” 组件。
你可以从 “哪些东西可以作为视图” 这一点中看到这种设计思路的体现。我们之前已经将 Color.red 和 LinearGradient 用作视图了 —— 它们都是简单的类型,只包含极少的数据。事实上,用 Color.red 作为视图再简单不过了:它所包含的信息只有 “用红色填充我的空间”。
相比之下,苹果的 UIView 文档(https://developer.apple.com/documentation/uikit/uiview) 中列出了 UIView 拥有的大约 200 个属性和方法,无论子类是否需要,这些属性和方法都会被继承下去。
提示: 如果你用类来定义视图,可能会发现代码要么无法编译,要么在运行时崩溃。相信我:就用结构体。
主 SwiftUI 视图背后是什么?
作者:Paul Hudson 2023 年 10 月 14 日
刚开始学习 SwiftUI 时,你会看到这样一段代码:
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}通常,你可能会给这个 VStack 添加一个背景色,并期望它能填满整个屏幕:
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
.background(.red)
}
}但实际情况并非如此。相反,你会看到屏幕中央有一个小小的红色视图,周围则是一大片白色区域。
这让很多人感到困惑,通常会引出这样一个问题 ——“如何让视图背后的空白区域也变成红色?”
我要清楚地告诉你:对于 SwiftUI 开发者来说,我们的视图背后没有任何东西。 你不应该尝试用奇怪的技巧或变通方法让那片白色区域变成红色,更不应该试图跳出 SwiftUI 的框架去做这件事。
目前来看,在我们的内容视图背后确实有一个叫做 “UIHostingController” 的组件:它是 UIKit(苹果最初的 iOS 界面框架)和 SwiftUI 之间的桥梁。但是,如果你开始尝试修改它,就会发现你的代码在苹果的其他平台上无法运行,甚至可能在未来某个时候,在 iOS 上也完全无法运行。
相反,你应该试着转变思路:我们的视图背后没有任何东西 —— 你所看到的就是我们拥有的全部。
一旦你接受了这个思路,正确的解决方案就很明确了:让 VStack 占据更多空间;允许它填满整个屏幕,而不是仅仅围绕其内容来确定大小。要实现这一点,我们可以使用 frame () 修饰符,并将其最大宽度和最大高度都设置为 .infinity。
所以,将 padding () 修饰符替换为以下代码:
.frame(maxWidth: .infinity, maxHeight: .infinity)需要注意的是,maxWidth 和 maxHeight 与 width 和 height 是不同的 —— 我们并不是说 VStack 必须占据所有这些空间,而只是说它可以占据这些空间。如果周围还有其他视图,SwiftUI 会确保所有视图都能获得足够的空间。
为何修饰符的顺序很重要
作者:Paul Hudson 2023 年 10 月 14 日
几乎每次我们给 SwiftUI 视图添加修饰符时,实际上都是创建了一个应用了该修改的新视图 —— 而不是在原有视图的基础上直接修改。仔细想想,这种行为是合理的:我们的视图只包含我们赋予它的确切属性,所以如果我们要设置背景颜色或字体大小,原有视图中并没有存储这些数据的地方。
我们很快就会探讨这种行为背后的原因,但首先,我想先看看这种行为在实际使用中的影响。请看下面这段代码:
Button("Hello, world!") {
// 不执行任何操作
}
.background(.red)
.frame(width: 200, height: 200)你认为这段代码运行后会呈现出什么样子?
很可能你的猜测是错误的:你不会看到一个 200x200 大小、背景为红色、中间显示 “Hello, world!” 的按钮。相反,你会看到一个 200x200 大小的空白方块,中间有 “Hello, world!” 的文字,而文字周围有一个红色的矩形背景。
如果你理解了修饰符的工作方式,就能明白这是怎么回事:每一个修饰符都会创建一个应用了该修饰符的新结构体,而不是直接在视图上设置属性。
我们可以通过查看视图 body 的类型,来一窥 SwiftUI 的内部机制。将按钮代码修改为如下所示:
Button("Hello, world!") {
print(type(of: self.body))
}
.background(.red)
.frame(width: 200, height: 200)Swift 中的 type (of:) 函数会打印出特定值的确切类型,在这个例子中,它会打印出以下内容:ModifiedContent<ModifiedContent<Button<Text>, _BackgroundStyleModifier<Color>>, _FrameLayout>
从这个结果中,你可以发现两点:
- 每次我们给视图添加修饰符时,SwiftUI 都会通过泛型来应用该修饰符:
ModifiedContent<我们的对象, 我们的修饰符>。 - 当我们应用多个修饰符时,它们会层层嵌套:
ModifiedContent<ModifiedContent<…
要理解这个类型的含义,需要从最内层的类型开始,逐步向外分析:
- 最内层的类型是
ModifiedContent<Button<Text>, _BackgroundStyleModifier<Color>:这表示我们的按钮(包含文字)应用了背景颜色修饰符。 - 包裹在它外面的是
ModifiedContent<…, _FrameLayout>,这表示将前面的视图(按钮 + 背景颜色)设置为更大的尺寸。
由此可见,最终我们得到的是层层嵌套的 ModifiedContent 类型 —— 每一层都包含一个要转换的视图和具体的转换操作,而不是直接修改原有视图。
这就意味着,修饰符的顺序非常重要。 如果我们调整代码顺序,在设置尺寸之后再应用背景颜色,或许就能得到你期望的结果:
Button("Hello, world!") {
print(type(of: self.body))
}
.frame(width: 200, height: 200)
.background(.red)目前,理解这一点的最佳方法是:想象 SwiftUI 会在应用每一个修饰符之后,立即渲染视图。所以,一旦你添加了 .background(.red),视图的背景就会被染成红色,无论之后你给它设置多大的尺寸。如果你之后再扩大视图的尺寸,背景也不会自动重新渲染 —— 因为背景颜色修饰符早已应用过了。
当然,这并不是 SwiftUI 实际的工作方式,如果真是这样,性能会非常糟糕。但对于初学者来说,这是一个简单易懂的思维模型。
使用修饰符还有一个重要的副作用:我们可以多次应用同一个修饰符,每一次应用都会在之前的基础上叠加效果。
例如,SwiftUI 提供了 padding () 修饰符,它会在视图周围添加一些空白,避免视图与其他视图或屏幕边缘挤在一起。如果我们先添加内边距,再设置背景颜色,接着再添加内边距并设置另一种背景颜色,就能给视图创建多层边框,代码如下:
Text("Hello, world!")
.padding()
.background(.red)
.padding()
.background(.blue)
.padding()
.background(.green)
.padding()
.background(.yellow)为何 SwiftUI 要用 “some View” 作为视图类型?
作者:Paul Hudson 2023 年 10 月 14 日
SwiftUI 在很大程度上依赖于 Swift 的一项强大特性 ——“不透明返回类型”(opaque return type),每当你写下 “some View” 时,就是在使用这项特性。它的含义是 “一个符合 View 协议的对象,但我们不需要指明具体是哪一个”。
返回 “some View” 意味着,虽然我们不知道返回的具体视图类型是什么,但编译器知道。这听起来似乎微不足道,但却有着重要的意义。
首先,使用 “some View” 对性能至关重要:SwiftUI 需要能够查看我们正在显示的视图,并了解这些视图是如何变化的,这样才能正确地更新用户界面。如果 SwiftUI 没有这些额外信息,要弄清楚到底哪些部分发生了变化会非常缓慢 —— 几乎每次有微小变化时,都需要重新构建整个界面。
第二个重要性体现在 SwiftUI 如何使用 ModifiedContent 来构建数据结构上。之前我给你展示过这段代码:
Button("Hello World") {
print(type(of: self.body))
}
.frame(width: 200, height: 200)
.background(.red)这段代码创建了一个简单的按钮,然后打印出它的确切 Swift 类型,输出结果中会包含多个 ModifiedContent 实例。
View 协议有一个关联类型(associated type),这是 Swift 的一种表达方式,意味着 “View 协议本身并没有实际意义 —— 我们需要指明它具体是哪种视图”。这就好比 Swift 不允许我们只说 “这个变量是一个数组”,而是要求我们说明数组中存储的是什么类型的数据:“这个变量是一个字符串数组”。
因此,我们不能像下面这样定义视图:
struct ContentView: View {
var body: View {
Text("Hello, world!")
}
}但像下面这样定义视图是完全合法的:
struct ContentView: View {
var body: Text {
Text("Hello, world!")
}
}返回 “View” 是没有意义的,因为 Swift 需要知道视图的具体类型 ——View 协议中存在一个必须填补的 “空缺”。相反,返回 “Text” 是可行的,因为我们已经填补了这个 “空缺”;Swift 知道这个视图具体是什么类型。
现在,让我们回到之前的代码:
Button("Hello World") {
print(type(of: self.body))
}
.frame(width: 200, height: 200)
.background(.red)如果我们想从 body 属性中返回这样一个视图,应该怎么写呢?你或许可以尝试弄清楚需要使用哪些 ModifiedContent 结构体的组合,但这会非常麻烦,而且实际上我们也不需要关心这些 —— 因为这些都是 SwiftUI 的内部实现细节。
“some View” 让我们可以这样表达:“这是一个视图,比如 Button 或 Text,但我不需要指明具体是哪一个”。这样一来,View 协议中的 “空缺” 会由一个实际的视图对象来填补,而我们无需写出那些冗长复杂的确切类型。
在两个场景下,情况会稍微复杂一些:
- VStack 是如何工作的?它符合 View 协议,但如果它内部可以包含多种不同类型的视图,那么它如何填补 “包含的内容是什么类型” 这个 “空缺” 呢?
- 如果我们不从 body 属性中返回包裹在栈(stack)中的视图,而是直接返回两个视图,会发生什么?
先回答第一个问题:如果你在 VStack 中放入两个文本视图,SwiftUI 会自动创建一个 TupleView 来包含这两个视图 ——TupleView 是一种特殊的视图类型,专门用于容纳固定数量的视图。因此,VStack 会这样填补 “这是什么类型的视图” 这个 “空缺”:“这是一个包含两个文本视图的 TupleView”。
那如果 VStack 中包含三个文本视图呢?这时它就会是一个包含三个视图的 TupleView。包含四个、八个甚至十个视图也是如此 ——TupleView 会根据需要 “扩展” 以容纳更多视图。
至于第二个问题:Swift 会自动给 body 属性添加一个特殊的属性包装器 ——@ViewBuilder。它的作用是将多个视图自动包裹到一个 TupleView 容器中,这样一来,即使看起来我们返回了多个视图,它们最终也会被组合成一个 TupleView。
这种行为并非 “魔法”:如果你右键点击 View 协议,选择 “跳转到定义”(Jump to Definition),就能看到 body 属性的要求,同时也能发现它被标记了 @ViewBuilder 属性:
@ViewBuilder @MainActor var body: Self.Body { get }当然,SwiftUI 如何解释 “不使用栈包裹就直接返回多个视图” 的行为,并没有在任何地方给出明确的定义,但正如你之后会了解到的,这种模糊性实际上是有好处的。
条件修饰符
作者:Paul Hudson 2023 年 10 月 14 日
在开发中,我们经常需要让修饰符只在特定条件满足时才应用。在 SwiftUI 中,实现这一点最简单的方法就是使用三元条件运算符。
提醒一下,使用三元运算符的格式是:先写判断条件,然后用问号分隔 “条件为真时使用的内容”,再用冒号分隔 “条件为假时使用的内容”。如果你经常忘记这个顺序,可以记住斯科特・米肖(Scott Michaud)提出的一个实用记忆法:[你要检查什么(What),条件为真时用什么(True),条件为假时用什么(False),简称为 “WTF”]。
例如,如果你有一个布尔类型的属性,就可以用它来控制按钮的前景样式,代码如下:
struct ContentView: View {
@State private var useRedText = false
var body: some View {
Button("Hello World") {
// 切换布尔值的真假
useRedText.toggle()
}
.foregroundStyle(useRedText ? .red : .blue)
}
}因此,当 useRedText 为 true 时,修饰符实际上相当于 .foregroundStyle(.red);当 useRedText 为 false 时,修饰符则变成 .foregroundStyle(.blue)。由于 SwiftUI 会监听 @State 属性的变化,并重新调用 body 属性,所以只要这个属性发生变化,按钮的颜色就会立即更新。
你也可以使用常规的 if 条件语句,根据不同的状态返回不同的视图,但这会给 SwiftUI 增加更多工作 ——SwiftUI 不会将其视为 “同一个按钮应用了不同颜色”,而是会认为这是 “两个不同的按钮视图”。当我们切换布尔条件时,它会销毁原来的按钮,再创建一个新的按钮,而不是仅仅给现有按钮重新上色。
因此,下面这段代码看起来效果可能一样,但实际上效率更低:
var body: some View {
if useRedText {
Button("Hello World") {
useRedText.toggle()
}
.foregroundStyle(.red)
} else {
Button("Hello World") {
useRedText.toggle()
}
.foregroundStyle(.blue)
}
}有时候,使用 if 语句是不可避免的,但在可能的情况下,最好还是优先使用三元运算符。
环境修饰符
作者:Paul Hudson 2023 年 10 月 14 日
很多修饰符都可以应用到容器上,这样我们就能同时给多个视图应用同一个修饰符。
例如,如果我们在 VStack 中有四个文本视图,并且希望它们都使用相同的字体修饰符,就可以直接给 VStack 应用这个修饰符,这样所有四个文本视图都会受到影响:
VStack {
Text("格兰芬多(Gryffindor)")
Text("赫奇帕奇(Hufflepuff)")
Text("拉文克劳(Ravenclaw)")
Text("斯莱特林(Slytherin)")
}
.font(.title)这种修饰符被称为环境修饰符(environment modifier),它与直接应用到单个视图上的常规修饰符有所不同。
从代码编写的角度来看,环境修饰符的使用方式与常规修饰符完全相同。但它们的行为存在细微差别:如果任何子视图重写了同一个修饰符,那么子视图的修饰符会优先生效。
举个例子,下面这段代码中,四个文本视图原本都使用标题字体,但其中一个使用了大号标题字体:
VStack {
Text("格兰芬多(Gryffindor)")
.font(.largeTitle)
Text("赫奇帕奇(Hufflepuff)")
Text("拉文克劳(Ravenclaw)")
Text("斯莱特林(Slytherin)")
}
.font(.title)在这里,font () 是一个环境修饰符,这意味着 “格兰芬多” 文本视图可以通过自定义字体来重写父容器的字体设置。
不过,下面这段代码的情况就不同了:它给 VStack 应用了模糊效果,然后试图取消其中一个文本视图的模糊效果:
VStack {
Text("格兰芬多(Gryffindor)")
.blur(radius: 0)
Text("赫奇帕奇(Hufflepuff)")
Text("拉文克劳(Ravenclaw)")
Text("斯莱特林(Slytherin)")
}
.blur(radius: 5)这种做法不会达到预期效果:blur () 是一个常规修饰符,所以应用到子视图上的任何模糊效果,都会叠加在 VStack 的模糊效果之上,而不是替换它。
据我所知,目前没有办法提前判断哪些修饰符是环境修饰符,哪些是常规修饰符 —— 除非查阅每个修饰符的单独文档,并希望文档中会提及这一点。不过,有环境修饰符总比没有好:只需应用一次修饰符,就能作用于所有相关视图,这比在多个地方复制粘贴同一个修饰符要方便得多。
作为属性的视图
作者:Paul Hudson 2023 年 10 月 14 日
在 SwiftUI 中,有很多方法可以简化复杂视图层级的使用,其中一种方法就是将视图作为属性 —— 把一个视图定义为当前视图的属性,然后在布局中使用这个属性。
例如,我们可以像下面这样,将两个文本视图定义为属性,然后在 VStack 中使用它们:
struct ContentView: View {
let motto1 = Text("Draco dormiens") // 拉丁文,意为“沉睡的龙”
let motto2 = Text("nunquam titillandus") // 拉丁文,意为“不可轻易招惹”
var body: some View {
VStack {
motto1
motto2
}
}
}在使用这些属性时,你甚至可以直接给它们应用修饰符,如下所示:
VStack {
motto1
.foregroundStyle(.red)
motto2
.foregroundStyle(.blue)
}将视图定义为属性,有助于让 body 代码更加清晰 —— 这不仅能避免代码重复,还能将复杂的代码从 body 属性中抽离出来。
需要注意的是,Swift 不允许我们创建一个引用其他存储属性的存储属性,因为这会在对象创建时引发问题。这意味着,如果试图创建一个绑定到本地属性的 TextField,就会遇到麻烦。
不过,你可以创建计算属性来解决这个问题,代码如下:
var motto1: some View {
Text("Draco dormiens")
}这通常是将复杂视图拆分成更小部分的好方法,但也要注意:与 body 属性不同,Swift 不会自动给计算属性应用 @ViewBuilder 属性。因此,如果你想从计算属性中返回多个视图,有三种选择。
第一种选择是将它们放在一个栈中,代码如下:
var spells: some View {
VStack {
Text("荧光闪烁(Lumos)")
Text("一忘皆空(Obliviate)")
}
}如果你不想特意用栈来组织这些视图,也可以返回一个 Group。这种情况下,视图的排列方式将由你在代码其他地方使用它们的方式来决定:
var spells: some View {
Group {
Text("荧光闪烁(Lumos)")
Text("一忘皆空(Obliviate)")
}
}第三种选择是自己给计算属性添加 @ViewBuilder 属性,代码如下:
@ViewBuilder var spells: some View {
Text("荧光闪烁(Lumos)")
Text("一忘皆空(Obliviate)")
}在这三种方法中,我更倾向于使用 @ViewBuilder,因为它与 body 属性的工作方式一致。不过,当我看到有人在属性中塞进大量功能时,也会有所警惕 —— 这通常意味着他们的视图变得过于复杂,需要进一步拆分。说到拆分视图,我们接下来就来探讨这个话题……
视图组合
作者:Paul Hudson 2023 年 10 月 14 日
SwiftUI 允许我们将复杂的视图拆分成多个小视图,而且几乎不会对性能造成任何影响。这意味着,我们可以将一个大型视图拆分为多个小型视图,而 SwiftUI 会负责将它们重新组合起来。
例如,在下面这个视图中,我们对文本视图采用了一种特定的样式 —— 大字体、一些内边距、特定的前景色和背景色,再加上胶囊形状的裁剪:
struct ContentView: View {
var body: some View {
VStack(spacing: 10) {
Text("First")
.font(.largeTitle)
.padding()
.foregroundStyle(.white)
.background(.blue)
.clipShape(.capsule)
Text("Second")
.font(.largeTitle)
.padding()
.foregroundStyle(.white)
.background(.blue)
.clipShape(.capsule)
}
}
}由于这两个文本视图除了文字内容不同外,其他部分完全相同,我们可以将它们封装成一个新的自定义视图,代码如下:
struct CapsuleText: View {
var text: String
var body: some View {
Text(text)
.font(.largeTitle)
.padding()
.foregroundStyle(.white)
.background(.blue)
.clipShape(.capsule)
}
}然后,我们就可以在原来的视图中使用这个 CapsuleText 视图了,代码如下:
struct ContentView: View {
var body: some View {
VStack(spacing: 10) {
CapsuleText(text: "First")
CapsuleText(text: "Second")
}
}
}当然,我们也可以在自定义视图中保留一部分修饰符,然后在使用该视图时自定义其他修饰符。例如,如果我们从 CapsuleText 中移除 foregroundStyle () 修饰符,就可以在创建该视图的实例时,为其应用自定义的颜色,代码如下:
VStack(spacing: 10) {
CapsuleText(text: "First")
.foregroundStyle(.white)
CapsuleText(text: "Second")
.foregroundStyle(.yellow)
}不用担心这里的性能问题 —— 以这种方式拆分 SwiftUI 视图是非常高效的。
自定义修饰符
作者:Paul Hudson 2023 年 10 月 14 日
SwiftUI 为我们提供了一系列内置修饰符,例如 font ()、background () 和 clipShape ()。不过,我们也可以创建具有特定功能的自定义修饰符。
要创建自定义修饰符,需要定义一个新的结构体,并让它符合 ViewModifier 协议。该协议只有一个要求:实现一个名为 body 的方法,该方法接收要处理的内容,并返回 some View。
例如,假设我们希望应用中的所有标题都采用特定的样式,那么首先需要创建一个符合 ViewModifier 协议的自定义结构体,实现我们需要的功能:
struct Title: ViewModifier {
func body(content: Content) -> some View {
content
.font(.largeTitle)
.foregroundStyle(.white)
.padding()
.background(.blue)
.clipShape(.rect(cornerRadius: 10))
}
}现在,我们可以使用 modifier () 修饰符来应用这个自定义修饰符 —— 没错,确实有一个名为 “modifier” 的修饰符,它可以让我们给视图应用任何类型的修饰符,代码如下:
Text("Hello World")
.modifier(Title())在使用自定义修饰符时,通常明智的做法是给 View 扩展一个方法,让自定义修饰符的使用更加便捷。例如,我们可以像下面这样,将 Title 修饰符封装在一个 View 扩展中:
extension View {
func titleStyle() -> some View {
modifier(Title())
}
}这样一来,我们就可以像使用内置修饰符一样使用这个自定义修饰符了:
Text("Hello World")
.titleStyle()自定义修饰符的功能远不止应用现有的修饰符 —— 它们还可以根据需要创建新的视图结构。要记住,修饰符会返回新的对象,而不是修改现有的对象。因此,我们可以创建一个这样的自定义修饰符:将视图嵌入到一个栈中,并添加另一个视图:
struct Watermark: ViewModifier {
var text: String
func body(content: Content) -> some View {
ZStack(alignment: .bottomTrailing) {
content
Text(text)
.font(.caption)
.foregroundStyle(.white)
.padding(5)
.background(.black)
}
}
}
extension View {
func watermarked(with text: String) -> some View {
modifier(Watermark(text: text))
}
}完成这些设置后,我们就可以给任何视图添加水印了,代码如下:
Color.blue
.frame(width: 300, height: 200)
.watermarked(with: "Hacking with Swift")提示: 很多人会疑惑,什么时候适合创建自定义视图修饰符,什么时候直接给 View 添加新方法就可以了。其实,主要区别在于一点:自定义视图修饰符可以拥有自己的存储属性,而 View 的扩展则不能。
【可选阅读】自定义容器
作者:Paul Hudson 2023 年 10 月 14 日
虽然你可能不常需要这么做,但我还是想向你展示:在 SwiftUI 应用中创建自定义容器是完全可行的。这需要一些更高级的 Swift 知识,因为它会用到 Swift 的一些高级特性,所以如果你觉得难以理解,跳过这部分也没关系。
为了实际演示,我们将创建一种新的栈类型,名为 GridStack,它可以让我们在固定的网格中创建任意数量的视图。我们的目标是定义一个名为 GridStack 的结构体,它符合 View 协议,具有固定的行数和列数,并且网格内部可以包含多个内容单元格,每个单元格本身也必须符合 View 协议。
在 Swift 中,我们可以这样编写:
struct GridStack<Content: View>: View {
let rows: Int
let columns: Int
let content: (Int, Int) -> Content
var body: some View {
// 后续补充代码
}
}第一行代码 ——struct GridStack<Content: View>: View—— 使用了 Swift 的一项高级特性:泛型(generics)。在这里,它的含义是 “你可以提供任何类型的内容,但无论是什么类型,都必须符合 View 协议”。冒号后面再次写 View,是为了表明 GridStack 本身也符合 View 协议。
请特别注意 let content 这一行 —— 它定义了一个闭包,这个闭包必须能够接收两个整数作为参数,并返回某种可显示的内容(符合 View 协议)。
接下来,我们需要完善 body 属性,通过组合多个垂直栈和水平栈,创建出指定数量的单元格。我们不需要指定每个单元格中具体是什么内容,因为可以通过调用 content 闭包,并传入相应的行号和列号来获取单元格内容。
因此,我们可以这样填充 body 属性的代码:
var body: some View {
VStack {
ForEach(0..<rows, id: \.self) { row in
HStack {
ForEach(0..<columns, id: \.self) { column in
content(row, column)
}
}
}
}
}提示: 当遍历范围时,只有当我们能确定范围中的值不会随时间变化时,SwiftUI 才能直接使用该范围。在这个例子中,我们使用 ForEach 遍历 0..<rows 和 0..<columns,这两个范围的值可能会随时间变化(例如,我们可能会增加行数)。在这种情况下,我们需要给 ForEach 添加第二个参数 id: \.self,告诉 SwiftUI 如何识别循环中的每个视图。我们会在项目 5 中更详细地探讨这个问题。
现在我们已经创建了一个自定义容器,接下来就可以像下面这样,在视图中使用它了:
struct ContentView: View {
var body: some View {
GridStack(rows: 4, columns: 4) { row, col in
Text("第\(row)行 第\(col)列")
}
}
}我们的 GridStack 可以接收任何类型的单元格内容,只要这些内容符合 View 协议。因此,如果我们愿意,也可以给单元格添加一个自己的栈:
GridStack(rows: 4, columns: 4) { row, col in
HStack {
Image(systemName: "\(row * 4 + col).circle")
Text("第\(row)行 第\(col)列")
}
}为了增加灵活性,我们还可以利用 SwiftUI 中用于视图 body 属性的 @ViewBuilder 属性。将 GridStack 的 content 属性修改为如下所示:
@ViewBuilder let content: (Int, Int) -> Content这样设置后,SwiftUI 会自动在我们的单元格闭包中创建一个隐式的水平栈:
GridStack(rows: 4, columns: 4) { row, col in
Image(systemName: "\(row * 4 + col).circle")
Text("第\(row)行 第\(col)列")
}两种方式都能正常工作,你可以根据自己的喜好选择其中一种。