SwiftUI 的“堆叠”(Stack )和在 UIKit 的堆叠视图一样,通过水平与垂直堆叠结合视图,你可以为 App 建构复杂的使用者介面。对 UIKit 而言,使用自动布局( Auto Layout )来建立相容所有屏幕尺寸的介面是无法避免的。对初学者而言,自动布局是一个复杂的主题且难以学习,但好消息是你不再需要在 SwiftUI 中使用自动布局,所有东西都是堆叠,包括了 VStack、HStack 与 ZStack。
在本章中,我将会介绍所有类型的堆叠,并使用堆叠来建立网格布局(Grid Layout ), 那么,你将进行什么项目呢?参考图 4.1,我们会逐步布局一个简单的网格介面。学习完本章的内容之后,你将能够结合视图与堆叠,并建立想要的 UI。
SwiftUI 为开发者提供了三种不同类型的堆叠,以在不同方向上结合视图。依据你如何去排列视图,而可以使用:
图4.2 展示了如何使用这些堆叠来组织视图。
首先,开启Xcode,并使用 iOS页签下的“App”模板来建立一个新项目。于下一个画面中,输入项目的名称,我将它设定为“SwiftUIStacks”,不过你可以自由使用其他的名称。你只须确保在 Interface 选取“SwiftUI”,如图 4.3 所示。
当你储存项目后,Xcode 应该能够载入 ContentView.swift
档,并在设计划布中显示预览画面。
我们将建立如图 4.1 所示的UI,不过我们把UI 分成几个小部分来制作。我们将先进行标题部分,如图 4.4 所示。
目前,Xcode 应该已经产生了下列代码来显示“Hello World”标签:
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Hello, world!")
}
}
}
为了显示如图4.4 所示的文字,我们将会以 VStack
来结合两个 Text
视图,如下所示:
struct ContentView: View {
var body: some View {
VStack {
Text("Choose")
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.black)
Text("Your Plan")
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.black)
}
}
}
当你在 VStack
嵌入视图,视图将会垂直排列,如图 4.5 所示。
默认上,嵌入堆叠的视图是对齐中心位置。当要将两个视图靠左对齐时,你可以指定 alignment
参数,并将其值设定为 .leading
,如下所示:
VStack(alignment: .leading, spacing: 2) {
Text("Choose")
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.black)
Text("Your Plan")
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.black)
}
此外,你可以使用 space
参数来调整嵌入视图的间距。图 4.6 为调整后的视图。上面的代码将参数spacing
添加到VStack并将其值设置为2
。
接下来,我们布局两个售价方案。如果你比较“Basic”与“Pro”方案,这两个组件的外观非常相似。以“Basic”方案为例,要实现这样的布局,你可以使用 VStack
结合三个文字视图,如图 4.7 所示。
“Basic”与“Pro”组件是并排排列。使用 HStack
,你可以水平布局视图。堆叠可以使用巢状结构,以致于你能够在堆叠视图之中放入另一个堆叠视图。由于售价方案区块位于标题视图的下方,因此我们会使用另外一个VStack 来嵌入一个垂直堆叠(即 Choose Your Plan )与一个水平堆叠(即售价方案区块),如图 4.8 所示。
现在,你应该对如何使用 VStack
与 HStack
来实现 UI 有了一些基本概念,让我们进入代码部分。
要将目前的 VStack
嵌入另外一个 VStack
,你可以按住 command 键,并点选 VStack
关键字,这会弹出一个显示所有可用选项的内容菜单(content menu ),选择“Embed in VStack”来嵌入 VStack
,如图 4.9 所示。
Xcode 将会产生嵌入到此堆叠的所需代码,你的代码应如下所示:
struct ContentView: View {
var body: some View {
VStack {
VStack(alignment: .leading, spacing: 2) {
Text("Choose")
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.black)
Text("Your Plan")
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.black)
}
}
}
}
在我们继续布局这个 UI 之前,让我教你一些整理代码的技巧。当你要建立一个包含好几个组件的复杂 UI 时,在 ContentView
内的代码最后会变成一个大而冗长的代码区块,而难以检查与除错,因此较佳的做法是将代码分拆成小块,如此代码才能更易阅读与维护。
Xcode 内建了重构 SwiftUI 代码的功能。现在按住 command 键不放,并点选包含文字视图的 VStack(即是第 13 行),然后选择“Extract Subview”来取出代码,如图 4.10 所示。
Xcode 取出代码区块, 并建立一个名为 ExtractedView
的默认结构, 输入 HeaderView
来为它命名更合适的名称(详细资讯请参考图 4.11)。
UI 现在仍然相同,不过请查看 ContentView
中的代码区块,现在它变得更为简洁且易于阅读。
我们继续实现售价方案的UI。首先,建立“Basic”方案的UI,然后如下所示修改 ContentView
:
struct ContentView: View {
var body: some View {
VStack {
HeaderView()
VStack {
Text("Basic")
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.foregroundColor(.white)
Text("$9")
.font(.system(size: 40, weight: .heavy, design: .rounded))
.foregroundColor(.white)
Text("per month")
.font(.headline)
.foregroundColor(.white)
}
.padding(40)
.background(Color.purple)
.cornerRadius(10)
}
}
}
这里,我们只是在 HeaderView
下加入另一个 VStack
。这个 VStack
是用来存放三个文字视图,以显示“Basic”方案。我将不再讨论有关 padding
、background
与 cornerRadius
的详细内容,因为我们已经在前面的章节中已讨论过这些修饰器了。
接下来,我们将实现“Pro”方案的UI。这个“Pro”方案应该要放在“Basic”方案的旁边,因此你需要将“Basic”方案的 VStack
嵌入在 HStack
中。现在,按住 command 键不放,并点选 VStack
关键字,选择“Embed in HStack”,如图 4.13 所示。
Xcode 应该建立 HStack
的代码,并在水平堆叠中嵌入所选的 VStack
,如下所示:
HStack {
VStack {
Text("Basic")
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.foregroundColor(.white)
Text("$9")
.font(.system(size: 40, weight: .heavy, design: .rounded))
.foregroundColor(.white)
Text("per month")
.font(.headline)
.foregroundColor(.white)
}
.padding(40)
.background(Color.purple)
.cornerRadius(10)
}
现在,我们准备建立“Pro”方案的 UI。除了背景颜色与文字颜色之外,这个代码和“Basic”方案很相似。在cornerRadius(10)
的下方插入下列的代码:
VStack {
Text("Pro")
.font(.system(.title, design: .rounded))
.fontWeight(.black)
Text("$19")
.font(.system(size: 40, weight: .heavy, design: .rounded))
Text("per month")
.font(.headline)
.foregroundColor(.gray)
}
.padding(40)
.background(Color(red: 240/255, green: 240/255, blue: 240/255))
.cornerRadius(10)
当你插入代码后,你应该会在画布中见到如图4.14 所示的布局。
售价区块的目前尺寸大小看起来很相似,不过实际上它们会根据文字的长度而变化。例如:如果将“Pro”这个字改成“Professional”,灰色区域将会扩展开来,以对应这个修改。简单而言,这个视图定义它自己的尺寸大小,并且该尺寸大小刚好足够容纳其内容。
如果你再次参考图 4.1,两个售价方案都具有相同的大小。要将这两个区块调整为相同的大小,你可以使用 .frame
修饰器来将 maxWidth
设定为“.infinity”,如下所示:
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 100)
.frame
修饰器可让你定义框架的尺寸。你可以指定尺寸大小为固定值。举例而言,在上列的代码中,我们将minHeight
设定为“100 点”,当你设定 maxWidth
为 .infinity
时, 此视图将会调整自己来填满最大宽度,例如:如果只有一个售价区块,则它会占满整个屏幕宽度,如图 4.16 所示。
对于这两个售价区块,当 maxWidth
设定为.infinity
时,iOS 将平均填满填满区块。现在将上列代码插入至每一个售价区块中,则你应该可完成如图 4.17 所示的屏幕画面。
要让水平堆叠一些间距,则你可以加入一个 .padding
修饰器,如图4.18 所示。
.horizontal
参数表示我们只为 HStack
的前缘(leading)及后缘(trailing)加入一些间距。
同样的,在我们布局其余的UI 组件之前,让我们先重构目前的代码,以使其更有条理。如果你同时查看用来布局“Basic”与“Pro”售价方案的这两个堆叠,其代码除了下列的项目之外,其他都很相似。
要简化这个代码,并改善可重用性(reusability),我们可以取出 VStack
代码区块, 并让它能适应不同售价方案的值。
我们来看看如何做到这件事。
回到代码编辑器,按住 command 键不放,并点选“Basic”方案的 VStack
。当 Xcode 取出代码后,将这个子视图重新命名,名称从 ExtractedView
改成 PricingView
。
如前所述,PricingView
应该可弹性显示不同的售价方案,我们将会在 PricingView
结构中加入四个变量,现在修改 PricingView
如下:
struct PricingView: View {
var title: String
var price: String
var textColor: Color
var bgColor: Color
var body: some View {
VStack {
Text(title)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.foregroundColor(textColor)
Text(price)
.font(.system(size: 40, weight: .heavy, design: .rounded))
.foregroundColor(textColor)
Text("per month")
.font(.headline)
.foregroundColor(textColor)
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 100)
.padding(40)
.background(bgColor)
.cornerRadius(10)
}
}
这里我们为售价区块的标题、售价、文字与背景颜色加入变量。另外,我们在代码中使用这些变量来修改标题、售价、文字与背景颜色。
当修改完成后,你会见到一个错误,如图 4.20 所示,指出 PricingView
少了一些参数。
之前,我们在视图中导入了四个变量。调用 PricingView
时,我们现在应该能提供这些参数的值,因此将PricingView()
修改如下:
PricingView(title: "Basic", price: "$9", textColor: .white, bgColor: .purple)
另外,你也可以 PricingView
取代“Pro”方案的 VStack
,如下所示:
PricingView(title: "Pro", price: "$19", textColor: .black, bgColor: Color(red: 240/255, green: 240/255, blue: 240/255))
售价区块的布局虽相同,但是底层代码(underlying code )已经变得更简洁且易于阅读了,如图 4.21 所示。
现在,你已经布局了售价区块,并且重构了代码,不过对于“Pro”售价仍有一件事情漏掉了,在此设计中,我们要以黄色色块在售价区块重叠一个信息。为此,我们可以使用 ZStack
,这让你可叠一个视图在目前的视图之上。
现在以 ZStack
嵌入“Pro”方案的 PricingView
,并加入Text
视图,如下所示:
ZStack {
PricingView(title: "Pro", price: "$19", textColor: .black, bgColor: Color(red: 240/255, green: 240/255, blue: 240/255))
Text("Best for designer")
.font(.system(.caption, design: .rounded))
.fontWeight(.bold)
.foregroundColor(.white)
.padding(5)
.background(Color(red: 255/255, green: 183/255, blue: 37/255))
}
嵌入在 ZStack
的视图顺序,决定了视图之间的重叠方式。对于上列的代码,Text
视图会叠在售价视图之上。在画布中,你应该会见到如图 4.22 所示的售价布局。
要调整文字的位置,你可以使用 .offset
修饰器,在 Text
视图的结尾处插入下列这行程序:
.offset(x: 0, y: 87)
这个“Best for designer”标签将会移到区块的底部,如图4.23 所示。如果你要重新放置它的话,将 y
设定为负值,则标签会移至顶部。
另外,如果你想要调整“Basic”与“Pro”售价区块之间的间距,则可以在 HStack
中指定 spacing
参数,如下所示:
HStack(spacing: 15) {
...
}
我们还没有完成,我想要与你讨论如何在 SwiftUI 中处理Optional,并介绍另一个称为“留白”(Spacer )的视图组件。在继续往下之前,我们来做一个简单的作业,你的任务是布局“ Team”售价方案,如图 4.24 所示。关于这个图片,我是使用来自 SF Symbols、名称为“wand.and.rays”的系统图片。
请先不要看解答,自己要开发自己的解决方案。
你是否有试着提出作业的解决方案?这个“Team”方案的布局与“Basic & Pro”方案很类似。你可以复制这两个方案的 VStack
,并建立“Team”方案。但是,让我来介绍一个更优雅的解决方案。
我们可以重新使用 PricingView
来建立“Team”方案。不过, 你可能会发现这个“Team”方案,有个图示位于标题上方。为了布局这个图示,我们需要修改 PricingView
来相容这个需求。因为这个图示并非售价方案强制性需要的,在 PricingView
中声明一个 Optional:
var icon: String?
如果你对Swift 感到陌生的话,所谓的 Optional 是表示变量可能有值或没有值。这里我们定义一个名为icon
的变量,其类型为 String
。如果售价方案需要显示图示时,则预计会有调用者传递图片名称,否则此变量默认为nil
(空值)。
那么,如何在SwiftUI 中处理Optional 呢?在Swift 中,我们有两个方法处理 Optional。其中一种方式是检查 Optional 是否具有一个非空值。例如:我们要在显示图片之前检查 icon
是否有一个值,我们可以将代码撰写如下:
if icon != nil {
Image(systemName: icon!)
.font(.largeTitle)
.foregroundColor(textColor)
}
另一个方法是使用 if let
来检查一个Optional 是否有值并解开(unwrap )它。SwiftUI 框架已经支持 if let
的用法,这个写法比较常用亦更加清晰。代码可以重新撰写如下:
if let icon = icon {
Image(systemName: icon)
.font(.largeTitle)
.foregroundColor(textColor)
}
要支持图示的渲染,PricingView
的最后代码应修改如下:
struct PricingView: View {
var title: String
var price: String
var textColor: Color
var bgColor: Color
var icon: String?
var body: some View {
VStack {
if let icon = icon {
Image(systemName: icon)
.font(.largeTitle)
.foregroundColor(textColor)
}
Text(title)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.foregroundColor(textColor)
Text(price)
.font(.system(size: 40, weight: .heavy, design: .rounded))
.foregroundColor(textColor)
Text("per month")
.font(.headline)
.foregroundColor(textColor)
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 100)
.padding(40)
.background(bgColor)
.cornerRadius(10)
}
}
当你修改完成之后,就可以使用 ZStack
与 PricingView
来建立一个“Team”方案,如下所示,你可以将程序放在 ContentView
内,于 .padding(.horiontal)
后插入:
ZStack {
PricingView(title: "Team", price: "$299", textColor: .white, bgColor: Color(red: 62/255, green: 63/255, blue: 70/255), icon: "wand.and.rays")
.padding()
Text("Perfect for teams with 20 members")
.font(.system(.caption, design: .rounded))
.fontWeight(.bold)
.foregroundColor(.white)
.padding(5)
.background(Color(red: 255/255, green: 183/255, blue: 37/255))
.offset(x: 0, y: 110)
}
将你目前的 UI 与图 4.1 进行比较,你看出任何差异了吗?你可能会注意两个差异点:
在UIKit 中,你可定义自动布局约束条件来放置视图。SwiftUI 没有自动布局,而是提供一个称为“留白”(Spacer )的视图来建立复杂的布局。
弹性空间(flexible space )沿着堆叠布局内的长轴(major axis )来扩展,或者如果不在堆叠中,则沿着两轴扩展。
- SwiftUI 文件 (https://developer.apple.com/documentation/swiftui/spacer)
要修复第一个项目,让我们修改 HeaderView
如下:
struct HeaderView: View {
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Choose")
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.black)
Text("Your Plan")
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.black)
}
Spacer()
}
.padding()
}
}
这里我们以一个 HStack
嵌入原来的 VStack
与一个 Spacer
。通过使用一个留白,则会将 VStack
往左推。图 4.25 说明了留白的用法。
现在你可能知道如何修复第二个问题。解决方案是在 ContentView
的 VStack
结尾处加入一个留白,如下所示:
struct ContentView: View {
var body: some View {
VStack {
HeaderView()
HStack(spacing: 15) {
...
}
.padding(.horizontal)
ZStack {
...
}
// Add a spacer
Spacer()
}
}
}
同样的,图 4.26 向你形象地展示了留白的用法。
现在你应该已经知道 VStack
、HStack
与 ZStack
的用法,你的最后作业是建立一个如图 4.28 所示的布局。对于作业中的图示,我使用 SF Symbols 的系统图片,你可以自由选择任何图片,而不必跟随我。提示这里可以使用 .scale
修饰器来缩放视图。譬如于视图上加上 .scale(0.5)
的话,则会将视图缩小一半。
在本章所准备的示例档中,有完整的项目与作业解答可以下载: