本文是斯坦福大学 cs193p 公开课程第06集的相关笔记。
cs193p 课程介绍:
The lectures for the Spring 2023 version of Stanford University’s course CS193p (Developing Applications for iOS using SwiftUI) were given in person but, unfortunately, were not video recorded. However, we did capture the laptop screen of the presentations and demos as well as the associated audio. You can watch these screen captures using the links below. You’ll also find links to supporting material that was distributed to students during the quarter (homework, demo code, etc.).
cs193p 课程网址: https://cs193p.sites.stanford.edu/2023
0 本节概要
-
布局
Swift 如何将空间分配给其视图?
演示:根据提供的空间为我们的卡片选择适当的大小。 -
@ViewBuilder
关于它如何工作的更多信息。
演示:创建我们自己的“组合视图” (AspectVGrid)。
1. 布局
视图是如何分配屏幕上的空间的?
- 容器视图为内部视图提供部分或者全部的容器视图空间。
- 内部视图接着选择它们想要的大小(只有它们可以这样做)。
- 然后容器视图对内部视图进行位置排列。
1.1 堆叠视图 HStack
、VStack
堆叠视图划分空间的步骤如下:
-
堆叠视图 将它们获得的空间划分,然后将空间提供给内部视图。它首先将空间提供给其“最不灵活”(即尺寸方面) 的子视图。
- “不灵活”视图的示例:图像(它希望有一个固定的大小)。
- 另一个示例(稍微灵活一些):文本(总是希望根据其文本精准调整大小)。
- 非常灵活视图示例:圆角矩形(总是会使用所提供的任何空间)。
-
在一个视图选择它想要的大小后,其大小将从可用空间中移除。然后堆叠视图将继续处理下一个“最不灵活”的视图。
-
非常灵活的视图(即那些将占用所有提供空间的视图)会大致均匀分配空间。
-
重复上述过程。
需要注意的是:
- 在堆叠视图内部,视图选择自己的大小后,堆叠视图会调整其大小以适应它们。
- 如果堆叠中的任何视图是“非常灵活的”,那么堆叠视图也将是“非常灵活的”。
1.1.1 布局工具
有几个非常有价值的布局视图,通常被放置在堆栈中:
-
Spacer (minLength: CGFloat)
总是占用其所分配的所有空间。
不绘制任何内容。
minLength
默认设置为在特定平台上你可能希望的最小间距。 -
Divider()
在堆叠视图的布局方向上绘制分割线。
例如,在HStack
中,Divider 会绘制一条垂直线。
在堆叠的方向上,它只占用绘制该线所需的最小空间。
在横向(交叉)方向上,它会占用所有提供给它的空间。
1.1.2 布局优先级
堆叠视图选择接下来要提供空间的视图,可以通过 .layoutPriority(Double)
被覆盖。换句话说,布局优先级可以优先于“最不灵活”的视图。
HStack {
Text("Important").layoutPriority(100) // 任何浮点数都可以
Image(systemName: "arrow.up") // 默认布局优先级为 0
Text("Unimportant")
}
- 上面的
Text("important")
文本将首先获得所需空间。 - 然后
Image
会获得它的空间(因为它比Text("Unimportant")
更不灵活)。 - 最后,
Text("Unimportant")
将必须尝试适应剩余空间。如果文本获得的空间不足,它将省略显示(例如,“Swift is…”而不是“Swift is great!”)。
1.1.3 视图对齐
堆叠视图布局内部视图的另一个重要方面是对齐方式。
当 VStack
将视图按列布局时,如果视图的宽度不相同,会如何处理?它是“左对齐”它们,还是将它们居中,或者以其他方式排列?这可以通过传递给堆叠视图的参数来指定:
VStack(alignment: .leading) {
// ...
}
-
为什么使用
.leading
而不是.left
?
因为堆叠视图会自动适应文本从右向左的环境(例如阿拉伯语或希伯来语)。leading
对齐方式会将VStack
中的内容排列到文本开始的边缘。 -
文本基线也可以用于对齐(例如
HStack(alignment: .firstTextBaseline) { ... }
)。 -
您甚至可以定义自己的“对齐指南”,来指定自定义的对齐方式。目前只使用内置的对齐方式(
.textBaselines
、.center
、.top
、.trailing
等)。
1.2 LazyVStack
、LazyHStack
、LazyVGrid
、LazyHGrid
和 Grid
1.2.1 LazyVStack
和 LazyHStack
- 这些“lazy”的堆叠版本不会创建任何不可见的视图。
- 即使内部有灵活视图,它们也不会占用所有提供给它们的空间。
- 在
ScrollView
中时,也可以使用这些堆叠视图。
1.2.2 LazyVGrid
和 LazyHGrid
- 我们看到它们是如何布局其视图的。
- 视图的大小基于提供给
LazyVGrid
或LazyHGrid
的参数信息(例如,columns
)。 - 另一个方向可以随着更多视图的添加而增长或缩小。
- 如果不需要,仍不会占用所有提供给它的空间。
1.2.3 Grid
- 在水平和垂直方向上为它的视图分配空间(注意它的名称中没有
H
或V
)。 - 在列和行之间有许多对齐选项(例如,使用
grid*()
修饰符)。 - 通常用作“电子表格视图”或“数据表格视图”。
1.3 ScrollView
、ViewThatFits
、Form
、 List
、Custom Layout
1.3.1 ScrollView
ScrollView
占用所有提供给它的空间。- 其中的视图会自动调整以适应您正在滚动的方向。
1.3.2 ViewThatFits
- 接受一组容器视图(例如
HStack
和VStack
),并选择一个适合的视图。 - 当为横屏与竖屏布局时,这非常有用。或者当使用动态类型字号布局时(较大的字体可能无法在横向上适应)。
1.3.3 Form
、List
、OutlineGroup
和 DisclosureGroup
- 这些类似于“非常智能的
VStack
”(具备滚动、选择、层级等功能)。 - 将在后面的课程中讨论它们。
1.3.4 自定义布局协议实现
- 您可以自定义一个“提供空间、让视图选择其大小,然后对其进行排列”的过程。
- 实现一个实现
Layout
协议的视图(sizeThatFits
、placeSubviews
)。
1.4 ZStack
1.4.1 ZStack
ZStack
自适应调整以适应其子视图。- 如果其中任何一个子视图是完全灵活的,则
ZStack
也会是灵活的。
1.4.2 .background
修饰符
Text("hello").background(Rectangle().foregroundColor(.red))
- 这类似于将
Text
和Rectangle
的ZStack
叠加(Text
在前)。 - 然而,这与使用
ZStack
叠加它们之间存在很大不同。 - 在这种情况下,生成的视图会根据
Text
的大小来调整(Rectangle
不参与)。 - 换句话说,
Text
完全决定了这个“两个迷你ZStack
”的布局。
1.4.3 .overlay
修饰符
与 .background
相同的布局规则,但叠加方向相反。
Circle().overlay(Text("Hello"), alignment: .center)
- 这将根据
Circle
的大小调整(即它将是完全灵活的)。 Text
将被叠加在Circle
上(在Circle
的指定对齐方式内)。
1.5 修饰符
1.5.1 .padding
请记住,视图修饰符函数(如 .padding
)本身返回一个视图。概念上,这个视图 包裹
着它所修饰的视图。
其中许多仅将提供给它们的大小传递下去(如 .font
或 .foregroundColor
)。
但也可能有修饰符参与布局过程。例如,.padding(10)
返回的视图会提供一个与它所修改的视图大小相同,但每一侧减少 10 个点的空间。返回的视图 .padding(10)
的尺寸将比它所修饰的视图选择的尺寸大 10 个点。
1.5.2 .aspectRatio
另一个例子是我们已经使用过的修饰符:.aspectRatio
。
通过 .aspectRatio
修饰符返回的视图会根据其比例选择适合的大小。它可以选择较小的尺寸(.fit
),以适应给定的空间,或选择较大的尺寸(.fill
),以尽可能使用所有可用空间(甚至超过提供的空间),同时遵循比例。(视图可以选择一个大于其所提供空间的大小!)
.aspectRatio
会将选择的空间传递给其修饰的视图,作为其“容器”。
1.6 GeometryReader
动态布局
大多数视图会自动调整大小,以充分利用所提供的空间。例如,各种形状(如圆角矩形)通常会自我调整以适应容器。而自定义视图,如 CardView,也应该根据提供的空间进行适应,以保持美观性。比如,可以调整卡片内文本的字体大小,使其表情符号能填满空间。
然而,当视图需要根据提供的空间进行动态调整时,例如在选择卡片大小以适配 LazyVGrid 使其不需要滚动时,就需要用到一个名为 GeometryReader 的容器视图来获取所提供的空间大小,从而进行动态的布局调整。
您可以用这个 GeometryReader
视图包裹通常出现在您的视图主体中的内容:
var body: some View {
GeometryReader { geometry in // 使用尾随闭包语法
// ...
}
}
geometry
参数是一个 GeometryProxy
对象。
struct GeometryProxy {
var size: CGSize
func frame(in coordinateSpace: CoordinateSpace) -> CGRect
var safeAreaInsets: EdgeInsets
}
size
变量是我们的容器提供给我们的“空间大小”。- 现在我们可以选择一个适合这个大小空间的卡片尺寸。
GeometryReader
本身(它只是一个视图)始终接受所有提供给它的空间。
1.7 safeArea
通常,当视图被提供空间时,这个空间并不包括“安全区域”。最明显的安全区域出现在 iPhone X 及其后续型号的刘海部分,这个区域通常用于保护前置摄像头、传感器和扬声器等组件。因此,应用视图时,内容一般不应该绘制在这些安全区域内。
不过,开发者也可以选择忽略这一限制,在指定的边缘内进行绘制。
ZStack { ... }.edgesIgnoringSafeArea([.top]) // 在顶部边缘的“安全区域”中绘制
2. Demo:布局
2.1 LazyVGrid
、GridItem
这里使用 GridItem()
来设置 Grid
布局中的每一列,[GridItem()]
显示为一列,[GridItem(),GridItem()]
则显示为两列
其中,GridItem.size
为枚举类型,有 adaptive
、fixed
和 flexible
三种 case。
我们用 adaptive
来自适应调整 GridItem,当调整为合适的宽度时,可以在屏幕中全部呈现,不需要 scrollView。
但将 scrollView
去除后,这个 Vstack
无法容纳所有的 card,将底部的 shuffle
从容器中推了出来。
那如何调整 GridItem
的大小,让 LazyVGrid
占据所有空间?
2.2 GeometryReader
可以使用 GeometryReader
来动态调整 GridItem
的大小。
struct EmojiMemoryGameView: View {
...
var cards: some View {
// creates a vertically scrollable collection of views
// lazy implies that the views are only created when SwiftUI needs to display them
GeometryReader { geometry in
let gridItemSize = gridItemWidthThatFits(count: viewModel.cards.count, size: geometry.size, atAspectRatio: 2/3)
LazyVGrid(columns: [GridItem(.adaptive(minimum: gridItemSize),spacing: 0)],spacing: 0) {
ForEach(viewModel.cards) { card in
CardView(card: card)
.aspectRatio(2/3, contentMode: .fit)
.padding(8)
.onTapGesture {
viewModel.choose(card)
}
}
}
}
.foregroundColor(.orange)
}
func gridItemWidthThatFits (
count:Int,
size:CGSize,
atAspectRatio aspectRatio:CGFloat
) -> CGFloat {
var columnCount = 1
repeat {
let width = size.width / columnCount
let height = width / aspectRatio
let rowCount = (count / columnCount).rounded(.up)
if rowCount * height < size.height {
return (size.width / columnCount).rounded(.down)
}
columnCount += 1
} while columnCount < count
return min(size.width / count, size.height * aspectRatio).rounded(.down)
}
...
}
上述在 gridItemWidthThat
中会报错,因为返回类型为 CGFloat
, columnCount
和 count
都为 Int
类型,无法进行运算,需要做类型转化。
//EmojiMemoryGameView.swift
struct EmojiMemoryGameView:View {
...
func gridItemWidthThatFits (
count:Int,
size:CGSize,
atAspectRatio aspectRatio:CGFloat
) -> CGFloat {
let count = CGFloat(count)
var columnCount = 1.0
...
}
...
}
此时视图会随着卡片对数的变化而变化,且始终保持在 Vstack 容器内。
2.3 ViewBuilder
我们将 aspectRatio 提取为变量,替换相应代码。
//EmojiMemoryGameView.swift
struct EmojiMemoryGameView:View {
...
var cards: some View {
let aspectRatio : CGFloat = 2/3
// creates a vertically scrollable collection of views
// lazy implies that the views are only created when SwiftUI needs to display them
GeometryReader { geometry in
let gridItemSize = gridItemWidthThatFits(count: viewModel.cards.count, size: geometry.size, atAspectRatio: aspectRatio)
LazyVGrid(columns: [GridItem(.adaptive(minimum: gridItemSize),spacing: 0)],spacing: 0) {
ForEach(viewModel.cards) { card in
CardView(card: card)
.aspectRatio(aspectRatio, contentMode: .fit)
.padding(8)
.onTapGesture {
viewModel.choose(card)
}
}
}
}
.foregroundColor(.orange)
}
...
}
上述代码会报错 Function declares an opaque return type, but has no return statements in its body from which to infer an underlying type
,因为需要返回一个View。可以在 GeometryReader
前加上 return
或者使用 @ViewBuilder
都可以处理该问题。
//EmojiMemoryGameView.swift
struct EmojiMemoryGameView:View {
...
@ViewBuilder
var cards: some View {
let aspectRatio : CGFloat = 2/3
// creates a vertically scrollable collection of views
// lazy implies that the views are only created when SwiftUI needs to display them
GeometryReader { geometry in
...
}
.foregroundColor(.orange)
}
...
}
但是,这里可以将 aspectRatio
提取出来作为私有变量是一种更简洁易读的办法。
//EmojiMemoryGameView.swift
struct EmojiMemoryGameView:View {
...
private let aspectRatio : CGFloat = 2/3
...
private var cards: some View {
// creates a vertically scrollable collection of views
// lazy implies that the views are only created when SwiftUI needs to display them
GeometryReader { geometry in
...
}
.foregroundColor(.orange)
}
...
}
我们会将所有变量设为
private
的,除非它需要共享,或者是协议本身有定义的例如body
。
3. @ViewBuilder
基于 Swift 中的一种机制,增强了变量的功能,使其具备特殊的作用。这个机制为视图列表提供了更便捷的语法支持,开发者可以将其应用于任何返回符合 View 协议的函数。
当使用这个机制时,函数仍然返回符合 View 的内容,但它会将这些内容视作一个视图列表,然后将它们合并成一个视图。
合并后的视图可以是多个视图组成的 TupleView,也可以是包含条件判断的 _ConditionalContent
视图,甚至在列表为空的情况下会变成 EmptyView,虽然这可能看起来有些奇怪,但这是被允许的。此外,这些组合可以是更复杂的,例如在条件内部再嵌套条件等。
需要注意的是,某些功能(比如 _ConditionalContent
)目前尚未完全公开为 API。然而,对我们来说,我们并不关心合并后生成的具体视图,只要它始终返回某种视图就可以了。
任何函数或只读计算属性都可以标记为 @ViewBuilder
。一旦标记,该函数或属性的内容将被解释为视图列表。例如,如果我们想将用于构建卡片前面部分的视图提取出来
func front(of card: Card) -> some View {
let shape = RoundedRectangle (cornerRadius: 20)
shape.fill(.white)
shape.stroke()
Text (card.content)
}
这段代码并不是一个合法的函数语法,而仅仅是一个视图列表。我们不能像这样列出视图 Views。
但是如果我们把 @ViewBuilder
放在前面是可以的。
@ViewBuilder
func front(of card: Card) -> some View {
let shape = RoundedRectangle (cornerRadius: 20)
shape.fill(.white)
shape.stroke()
Text (card.content)
}
你可以合法地使用简单的 if-else 语句来控制列表中包含哪些视图。(不过,这只是我们卡片的前面部分,所以不需要使用任何条件判断。)
上述代码将返回一个 TupleView<RoundedRectangle, RoundedRectangle, Text>
。
你还可以使用 @ViewBuilder
来标记函数或初始化方法的参数。该参数的类型必须是“返回 View 的函数”。例如,ZStack
、HStack
、VStack
、ForEach
、LazyGrid
等都采用这种方式(它们的 content:
参数)。我们将在后面的演示中展示这一点。
需要注意的是, @ViewBuilder
的内容只是一个视图列表,并不是任意代码。可以使用 if-else(或 switch、if let)语句来选择要包含在列表中的视图。你也可以使用局部的 let 语句。其他类型的代码是不允许的(至少在本次讲座时是这样的)。
4. Demo:创建“合并视图”
为了更好地理解像 HStack 或 LazyVGrid 这样的组件如何接收参数,接下来将通过创建一个“合并视图” AspectVGrid
来实现这一点。它类似于 LazyVGrid,但不同的是会根据提供给它的空间调整其内容视图的大小。
首先创建一个新的 SwiftUI 文档,并命名为 AspectVGrid.swift
。
我们将 EmojiMemoryGameView
中的 GeometryReader
和 gridItemWidthThatFits
移至 AspectVGrid.swift
, 将 viewModel.cards 修改为 [Item]
类型变量,其中 Item
为 generic type
。
//EmojiMemoryGameView.swift
struct EmojiMemoryGameView:View{
...
var cards: some View {
AspectVGrid(viewModel.cards,aspectRatio:aspectRatio) { card in
CardView(card: card)
.aspectRatio(aspectRatio, contentMode: .fit)
.padding(8)
.onTapGesture {
viewModel.choose(card)
}
}
.foregroundColor(.orange)
}
...
}
//AspectVGrid.swift
import SwiftUI
struct AspectVGrid<Item:Identifiable>:View {
var items:[Item]
var aspectRatio:CGFloat = 1
var body:some View {
GeometryReader { geometry in
let gridItemSize = gridItemWidthThatFits(count: items.count, size: geometry.size, atAspectRatio: aspectRatio)
LazyVGrid(columns: [GridItem(.adaptive(minimum: gridItemSize),spacing: 0)],spacing: 0) {
ForEach(items) {item in
}
}
}
}
func gridItemWidthThatFits (
count:Int,
size:CGSize,
atAspectRatio aspectRatio:CGFloat
) -> CGFloat {
let count = CGFloat(count)
var columnCount = 1.0
repeat {
let width = size.width / columnCount
let height = width / aspectRatio
let rowCount = (count / columnCount).rounded(.up)
if rowCount * height < size.height {
return (size.width / columnCount).rounded(.down)
}
columnCount += 1
} while columnCount < count
return min(size.width / count, size.height * aspectRatio).rounded(.down)
}
}
上述代码会报错:Failed to produce diagnostic for expression; please submit a bug report
。这表面 view Builder
存在问题。因为此处的 aspectRatio
没有定义。而 ForEach
要求 Item
为 Identifiable
。
//AspectVGrid.swift
struct AspectVGrid<Item:Identifiable>: View {
var items:[Item]
var aspectRatio:CGFloat = 1
...
}
此时再回到 EmojiMemoryGameView.swift
,我们需要将 {card in ...}
这个尾随闭包传递给 AspectVGrid
,在 AspectVGrid
中定义一个返回值为符合 View 协议的视图的函数类型参数 content
,在 ForEach
中补充相应 View。
//AspectVGrid.swift
struct AspectVGrid<Item:Identifiable, ItemView:View>: View {
var items:[Item]
var aspectRatio:CGFloat = 1
var content:(View) -> (ItemView)
var body:some View {
GeometryReader { geometry in
let gridItemSize = gridItemWidthThatFits(count: items.count, size: geometry.size, atAspectRatio: aspectRatio)
LazyVGrid(columns: [GridItem(.adaptive(minimum: gridItemSize),spacing: 0)],spacing: 0) {
ForEach(items) {item in
content(item)
.aspectRatio(aspectRatio, contentMode:.fit)
}
}
}
}
...
}
//EmojiMemoryGameView.swift
struct EmojiMemoryGameView:View{
...
var cards: some View {
AspectVGrid(items: viewModel.cards,aspectRatio:aspectRatio) { card in
CardView(card: card)
.aspectRatio(aspectRatio, contentMode: .fit)
.padding(8)
.onTapGesture {
viewModel.choose(card)
}
}
.foregroundColor(.orange)
}
...
}