精通 SwiftUI - iOS 16 版

第 4 章
以堆叠布局使用者介面

SwiftUI 的“堆叠”(Stack )和在 UIKit 的堆叠视图一样,通过水平与垂直堆叠结合视图,你可以为 App 建构复杂的使用者介面。对 UIKit 而言,使用自动布局( Auto Layout )来建立相容所有屏幕尺寸的介面是无法避免的。对初学者而言,自动布局是一个复杂的主题且难以学习,但好消息是你不再需要在 SwiftUI 中使用自动布局,所有东西都是堆叠,包括了 VStack、HStack 与 ZStack。

在本章中,我将会介绍所有类型的堆叠,并使用堆叠来建立网格布局(Grid Layout ), 那么,你将进行什么项目呢?参考图 4.1,我们会逐步布局一个简单的网格介面。学习完本章的内容之后,你将能够结合视图与堆叠,并建立想要的 UI。

图 4.1. 示例App
图 4.1. 示例App

认识 VStack、HStack 与 ZStack

SwiftUI 为开发者提供了三种不同类型的堆叠,以在不同方向上结合视图。依据你如何去排列视图,而可以使用:

  • HStack - 水平排列视图。
  • VStack - 垂直排列视图。
  • ZStack - 在一个视图重叠在其他视图之上。

图4.2 展示了如何使用这些堆叠来组织视图。

图4.2 不同型态的堆叠视图
图4.2 不同型态的堆叠视图

启用 SwiftUI 建立新项目

首先,开启Xcode,并使用 iOS页签下的“App”模板来建立一个新项目。于下一个画面中,输入项目的名称,我将它设定为“SwiftUIStacks”,不过你可以自由使用其他的名称。你只须确保在 Interface 选取“SwiftUI”,如图 4.3 所示。

图 4.3. 建立新项目
图 4.3. 建立新项目

当你储存项目后,Xcode 应该能够载入 ContentView.swift 档,并在设计划布中显示预览画面。

使用 VStack

我们将建立如图 4.1 所示的UI,不过我们把UI 分成几个小部分来制作。我们将先进行标题部分,如图 4.4 所示。

图 4.4 标题
图 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 所示。

图 4.5. 使用VStack 来结合两个文字视图
图 4.5. 使用VStack 来结合两个文字视图

默认上,嵌入堆叠的视图是对齐中心位置。当要将两个视图靠左对齐时,你可以指定 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

图4.6. 修改 VStack 的对齐方式
图4.6. 修改 VStack 的对齐方式

使用 HStack

接下来,我们布局两个售价方案。如果你比较“Basic”与“Pro”方案,这两个组件的外观非常相似。以“Basic”方案为例,要实现这样的布局,你可以使用 VStack 结合三个文字视图,如图 4.7 所示。

图 4.7. 布局售价方案
图 4.7. 布局售价方案

“Basic”与“Pro”组件是并排排列。使用 HStack,你可以水平布局视图。堆叠可以使用巢状结构,以致于你能够在堆叠视图之中放入另一个堆叠视图。由于售价方案区块位于标题视图的下方,因此我们会使用另外一个VStack 来嵌入一个垂直堆叠(即 Choose Your Plan )与一个水平堆叠(即售价方案区块),如图 4.8 所示。

图 4.8. 使用VStack 来嵌入其他堆叠视图
图 4.8. 使用VStack 来嵌入其他堆叠视图

现在,你应该对如何使用 VStackHStack 来实现 UI 有了一些基本概念,让我们进入代码部分。

要将目前的 VStack 嵌入另外一个 VStack,你可以按住 command 键,并点选 VStack 关键字,这会弹出一个显示所有可用选项的内容菜单(content menu ),选择“Embed in VStack”来嵌入 VStack,如图 4.9 所示。

图 4.9. 在VStack 嵌入
图 4.9. 在VStack 嵌入

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 所示。

图 4.10. 取出子视图
图 4.10. 取出子视图

Xcode 取出代码区块, 并建立一个名为 ExtractedView 的默认结构, 输入 HeaderView 来为它命名更合适的名称(详细资讯请参考图 4.11)。

图 4.11 取出子视图
图 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”方案。我将不再讨论有关 paddingbackgroundcornerRadius 的详细内容,因为我们已经在前面的章节中已讨论过这些修饰器了。

图 4.12 Basic 方案
图 4.12 Basic 方案

接下来,我们将实现“Pro”方案的UI。这个“Pro”方案应该要放在“Basic”方案的旁边,因此你需要将“Basic”方案的 VStack 嵌入在 HStack 中。现在,按住 command 键不放,并点选 VStack 关键字,选择“Embed in HStack”,如图 4.13 所示。

图 4.13. Embed in HStack
图 4.13. Embed in HStack

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 所示的布局。

图 4.14. 使用HStack 水平布局两个视图
图 4.14. 使用HStack 水平布局两个视图

售价区块的目前尺寸大小看起来很相似,不过实际上它们会根据文字的长度而变化。例如:如果将“Pro”这个字改成“Professional”,灰色区域将会扩展开来,以对应这个修改。简单而言,这个视图定义它自己的尺寸大小,并且该尺寸大小刚好足够容纳其内容。

图 4.15 Pro 区块的尺寸大小变宽
图 4.15 Pro 区块的尺寸大小变宽

如果你再次参考图 4.1,两个售价方案都具有相同的大小。要将这两个区块调整为相同的大小,你可以使用 .frame 修饰器来将 maxWidth 设定为“.infinity”,如下所示:

.frame(minWidth: 0, maxWidth: .infinity, minHeight: 100)

.frame 修饰器可让你定义框架的尺寸。你可以指定尺寸大小为固定值。举例而言,在上列的代码中,我们将minHeight 设定为“100 点”,当你设定 maxWidth.infinity时, 此视图将会调整自己来填满最大宽度,例如:如果只有一个售价区块,则它会占满整个屏幕宽度,如图 4.16 所示。

图4.16 设定 maxWidth 为“.infinity”
图4.16 设定 maxWidth 为“.infinity”

对于这两个售价区块,当 maxWidth 设定为.infinity时,iOS 将平均填满填满区块。现在将上列代码插入至每一个售价区块中,则你应该可完成如图 4.17 所示的屏幕画面。

图 4.17. 以等宽来排列两个售价区块
图 4.17. 以等宽来排列两个售价区块

要让水平堆叠一些间距,则你可以加入一个 .padding 修饰器,如图4.18 所示。

图 4.18 为堆叠视图加入一些间距
图 4.18 为堆叠视图加入一些间距

.horizontal 参数表示我们只为 HStack 的前缘(leading)及后缘(trailing)加入一些间距。

整理代码

同样的,在我们布局其余的UI 组件之前,让我们先重构目前的代码,以使其更有条理。如果你同时查看用来布局“Basic”与“Pro”售价方案的这两个堆叠,其代码除了下列的项目之外,其他都很相似。

  • 售价方案的名称。
  • 售价。
  • 文字颜色。
  • 售价区块的背景颜色

要简化这个代码,并改善可重用性(reusability),我们可以取出 VStack 代码区块, 并让它能适应不同售价方案的值。

我们来看看如何做到这件事。

回到代码编辑器,按住 command 键不放,并点选“Basic”方案的 VStack。当 Xcode 取出代码后,将这个子视图重新命名,名称从 ExtractedView改成 PricingView

图 4.19. 取出子视图
图 4.19. 取出子视图

如前所述,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 少了一些参数。

图 4.20. Xcode 指出 PricingView 中的错误
图 4.20. Xcode 指出 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 所示。

图 4.21. 重构代码后的 ContentView
图 4.21. 重构代码后的 ContentView

使用ZStack

现在,你已经布局了售价区块,并且重构了代码,不过对于“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 所示的售价布局。

图 4.22. 使用Zstack 重叠视图
图 4.22. 使用Zstack 重叠视图

要调整文字的位置,你可以使用 .offset 修饰器,在 Text 视图的结尾处插入下列这行程序:

.offset(x: 0, y: 87)

这个“Best for designer”标签将会移到区块的底部,如图4.23 所示。如果你要重新放置它的话,将 y 设定为负值,则标签会移至顶部。

图 4.23. 使用.offset 来放置文字视图
图 4.23. 使用.offset 来放置文字视图

另外,如果你想要调整“Basic”与“Pro”售价区块之间的间距,则可以在 HStack 中指定 spacing 参数,如下所示:

HStack(spacing: 15) {
  ...
}

作业 #1

我们还没有完成,我想要与你讨论如何在 SwiftUI 中处理Optional,并介绍另一个称为“留白”(Spacer )的视图组件。在继续往下之前,我们来做一个简单的作业,你的任务是布局“ Team”售价方案,如图 4.24 所示。关于这个图片,我是使用来自 SF Symbols、名称为“wand.and.rays”的系统图片。

图 4.24. 加入Team 方案
图 4.24. 加入Team 方案

请先不要看解答,自己要开发自己的解决方案。

SwiftUI 中Optionals 的处理

你是否有试着提出作业的解决方案?这个“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)
    }
}

当你修改完成之后,就可以使用 ZStackPricingView 来建立一个“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 进行比较,你看出任何差异了吗?你可能会注意两个差异点:

  1. “Choose Your Plan”标签没有靠左对齐。
  2. “Choose Your Plan”标签与售价方案应该要对齐屏幕的顶部。

在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 说明了留白的用法。

图 4.25. 在HStack 中使用弹性空间
图 4.25. 在HStack 中使用弹性空间

现在你可能知道如何修复第二个问题。解决方案是在 ContentViewVStack 结尾处加入一个留白,如下所示:

struct ContentView: View {
    var body: some View {
        VStack {
            HeaderView()

            HStack(spacing: 15) {
                ...
            }
            .padding(.horizontal)

            ZStack {
                ...
            }

              // Add a spacer
            Spacer()
        }
    }
}

同样的,图 4.26 向你形象地展示了留白的用法。

图 4.26. 在VStack 中使用留白
图 4.26. 在VStack 中使用留白

作业 #2

现在你应该已经知道 VStackHStackZStack 的用法,你的最后作业是建立一个如图 4.28 所示的布局。对于作业中的图示,我使用 SF Symbols 的系统图片,你可以自由选择任何图片,而不必跟随我。提示这里可以使用 .scale 修饰器来缩放视图。譬如于视图上加上 .scale(0.5) 的话,则会将视图缩小一半。

图 4.28. 你的作业-建立新布局
图 4.28. 你的作业-建立新布局

在本章所准备的示例档中,有完整的项目与作业解答可以下载: