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)
.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 所示。
預設上,嵌入堆疊的視圖是對齊中心位置。當要將兩個視圖靠左對齊時,你可以指定 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)
的話,則會將視圖縮小一半。
在本章所準備的範例檔中,有完整的專案與作業解答可以下載: