精通 SwiftUI - iOS 17 版

第 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)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}

為了顯示如圖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. 你的作業-建立新佈局

在本章所準備的範例檔中,有完整的專案與作業解答可以下載: