在 UIKit 中,表格視圖是 iOS 中最常見的 UI 控制元件之一。如果你之前使用過 UIKit 開發 App,則你應該知道可使用表格視圖顯示資料清單。這個 UI 控制元件在以內容為主的App 很常見,例如:報紙 App。圖 10.1 展示了一些清單 / 表格視圖,你可在諸如 Instagram、Twitter、Airbnb 與 Apple News 等流行應用程式中找到這些視圖。
在 SwiftUI 中,我們使用 List
代替表格視圖來顯示資料列。如果你之前使用過 UIKit
建立表格視圖,你應該知道實作一個簡單的表格視圖,就需要花一點工夫。而若是要建立自訂 Cell 佈局的表格視圖,則要做的工作更多。SwiftUI 簡化了整個過程,只需幾行程式碼,你就能以表格形式來陳列資料。即使你需要自訂列的佈局,也只需要極少的工夫即可辦到。
仍是覺得困惑嗎?待會你就能明白我的意思。
在本章中,我們將從一個簡單的清單來開始。當你了解這些基礎知識,我將教你如何以更複雜的佈局來陳列資料清單,如圖 10.2 所示。
我們從簡單的清單來開始。首先,開啟 Xcode,並使用「App」模板建立一個新專案。在下一個畫面中,設定專案名稱為「SwiftUIList」(或你喜歡的任何名稱),並填入所有必填的值,只需確保在「Interface」選項中選取「SwiftUI」。
Xcode 應會在 ContentView.swift
檔中產生一些程式碼。現在更新程式碼如下:
struct ContentView: View {
var body: some View {
List {
Text("Item 1")
Text("Item 2")
Text("Item 3")
Text("Item 4")
}
}
}
以上是建立一個簡單的清單或表格所需要的程式碼。當你將文字視圖嵌入 List
中時,清單視圖會以列顯示資料。這裡,每一列顯示具不同敘述的文字視圖。
相同的程式碼片段可以使用 ForEach
來編寫,如下所示:
struct ContentView: View {
var body: some View {
List {
ForEach(1...4, id: \.self) { index in
Text("Item \(index)")
}
}
}
}
由於這些文字視圖非常相似,因此你可在 SwiftUI 中使用 ForEach
迴圈來建立視圖。
從已識別的底層集合中,依照需求計算視圖的一種結構。
- Apple 官方文件 (https://developer.apple.com/documentation/swiftui/foreach)
你可以提供 ForEach
一組資料集合或一個範圍。不過,你必須要注意的是,你需要告訴 ForEach
如何識別集合中的每個項目,參數 id
的目的在於此,為什麼 ForEach
需要識別項目的唯一性呢? SwiftUI 功能強大,可在部分或全部集合內的項目更改時自動更新 UI, 因此當更新或刪除項目時,需要一個識別碼來識別該項目。
在上面的程式碼中,我們傳送給 ForEach
一個範圍的值來逐一執行。該識別碼設定為其值(即 1、2、3、4),index
參數儲存迴圈的目前值,例如:它從「1」這個值開始, index
參數的值則為「1」。
在閉包中,即是渲染視圖所需的程式碼,這裡也是我們需要建立的文字視圖,其敘述將依據迴圈中的 index
值而變化。如此,你就可以在清單中建立四個不同標題的項目。
我再教你一種技巧,相同的程式碼片段也可以進一步重寫如下:
struct ContentView: View {
var body: some View {
List {
ForEach(1...4, id: \.self) {
Text("Item \($0)")
}
}
}
}
你可以省略 index
參數,並使用參數名稱縮寫 $0
,它表示閉包的第一個參數。
我們進一步將程式碼重寫得更簡單些,你可將資料集合直接傳送到 List
視圖,程式碼如下:
struct ContentView: View {
var body: some View {
List(1...4, id: \.self) {
Text("Item \($0)")
}
}
}
如你所見,只需兩行程式碼,即可建立一個簡單的清單 / 表格。
現在你已經知道如何建立一個簡單的清單,接著我們來看如何使用更多樣化的佈局。在大多數的情況下,清單視圖的項目皆會包含文字與圖片,而你該如何實作呢?如果你知道 Image
、Text
、VStack
與 HStack
的用法的話,你應該對如何建立它有概念了。
如果你閱讀過《iOS App 程式開發實務心法》一書,你應該非常熟悉這個範例。我們以此為例來看使用 SwiftUI 建立相同的表格有多麼容易,如圖 10.4 所示。
要使用 UIKit 建立表格,你需要建立一個表格視圖或表格視圖控制器,然後自訂 Prototype Cell。另外,你還必須編寫表格視圖資料來源的程式碼,以提供資料。建立一個表格UI 需要很多的步驟,現在我們來看如何在 SwiftUI 中實作相同的表格視圖。
首先,我們至下列網址來下載圖片素材:https://www.appcoda.com/resources/swiftui/SwiftUISimpleTableImages.zip 然後將 zip 檔解壓縮,並把所有圖片匯入素材目錄,如圖 10.5 所示。
現在,切換到 ContentView.swift
來編寫 UI 的程式碼。首先,我們在 ContentView
中宣告兩個陣列,這些陣列是用來儲存餐廳名稱與圖片。下列是完整的程式碼:
struct ContentView: View {
var restaurantNames = ["Cafe Deadend", "Homei", "Teakha", "Cafe Loisl", "Petite Oyster", "For Kee Restaurant", "Po's Atelier", "Bourke Street Bakery", "Haigh's Chocolate", "Palomino Espresso", "Upstate", "Traif", "Graham Avenue Meats And Deli", "Waffle & Wolf", "Five Leaves", "Cafe Lore", "Confessional", "Barrafina", "Donostia", "Royal Oak", "CASK Pub and Kitchen"]
var restaurantImages = ["cafedeadend", "homei", "teakha", "cafeloisl", "petiteoyster", "forkeerestaurant", "posatelier", "bourkestreetbakery", "haighschocolate", "palominoespresso", "upstate", "traif", "grahamavenuemeats", "wafflewolf", "fiveleaves", "cafelore", "confessional", "barrafina", "donostia", "royaloak", "caskpubkitchen"]
var body: some View {
List(1...4, id: \.self) {
Text("Item \($0)")
}
}
}
兩個陣列具有相同項目數,restaurantNames
陣列儲存餐廳名稱,restaurantImages
變數儲存你剛匯入的圖片名稱。要建立如圖 10.4 所示的清單視圖,你只需要更新 body
變數如下: :
var body: some View {
List(restaurantNames.indices, id: \.self) { index in
HStack {
Image(self.restaurantImages[index])
.resizable()
.frame(width: 40, height: 40)
.cornerRadius(5)
Text(self.restaurantNames[index])
}
}
}
我們做了一些修改,首先是 List
視圖,我們傳送餐廳名稱的範圍(即 restaurantNames. indices
),而不是一個固定的範圍。例如:restaurantNames
陣列有 21 個項目,範圍是從 0 至 20。
在閉包中,程式碼會更新,以建立列的佈局,我將不會深入探討細節,因為如果你對堆疊視圖已經完全了解,那麼程式碼一看便明白了。為了更改List
視圖的樣式,我們附加了listStyle
修飾器並將樣式設置為plain
。
就是這樣,使用不到 10 行的程式碼,我們已經建立了一個自訂佈局的清單(或表格)。
如前所述,List
可以帶入一個範圍或一個資料集合。你已經學過如何處理範圍,我們來看如何將 List
與餐廳資料的陣列一起使用。
我們將建立一個 Restaurant
結構來加以組織資料,而不是將餐廳資料儲存在兩個單獨的陣列中。這個結構有兩個屬性:「name」與「image」。在 ContentView.swift
檔的最後面, 插入下列程式碼:
struct Restaurant {
var name: String
var image: String
}
使用這個結構,我們可以將 restaurantNames
與 restaurantImages
陣列合併為一個陣列。現在刪除restaurantNames
與 restaurantImages
變數,並以 ContentView
中的這個變數來代替:
var restaurants = [ Restaurant(name: "Cafe Deadend", image: "cafedeadend"),
Restaurant(name: "Homei", image: "homei"),
Restaurant(name: "Teakha", image: "teakha"),
Restaurant(name: "Cafe Loisl", image: "cafeloisl"),
Restaurant(name: "Petite Oyster", image: "petiteoyster"),
Restaurant(name: "For Kee Restaurant", image: "forkeerestaurant"),
Restaurant(name: "Po's Atelier", image: "posatelier"),
Restaurant(name: "Bourke Street Bakery", image: "bourkestreetbakery"),
Restaurant(name: "Haigh's Chocolate", image: "haighschocolate"),
Restaurant(name: "Palomino Espresso", image: "palominoespresso"),
Restaurant(name: "Upstate", image: "upstate"),
Restaurant(name: "Traif", image: "traif"),
Restaurant(name: "Graham Avenue Meats And Deli", image: "grahamavenuemeats"),
Restaurant(name: "Waffle & Wolf", image: "wafflewolf"),
Restaurant(name: "Five Leaves", image: "fiveleaves"),
Restaurant(name: "Cafe Lore", image: "cafelore"),
Restaurant(name: "Confessional", image: "confessional"),
Restaurant(name: "Barrafina", image: "barrafina"),
Restaurant(name: "Donostia", image: "donostia"),
Restaurant(name: "Royal Oak", image: "royaloak"),
Restaurant(name: "CASK Pub and Kitchen", image: "caskpubkitchen")
]
如果你是 Swift 新手,這裡做個解釋,陣列的每一個項目表示一筆特定餐廳的紀錄。當你進行更改後,你將會在Xcode 中見到一個錯誤,其指出遺失了restaurantNames
變數, 這很正常,因為我們剛才刪除這個變數了。
現在更新 body
變數如下:
var body: some View {
List(restaurants, id: \.name) { restaurant in
HStack {
Image(restaurant.image)
.resizable()
.frame(width: 40, height: 40)
.cornerRadius(5)
Text(restaurant.name)
}
}
.listStyle(.plain)
}
看一下我們傳入 List
的參數,我們沒有傳送範圍,而是傳送 restaurants
陣列,並告訴 List
使用其 name
屬性作為識別碼。List
將逐一執行陣列,並讓我們知道它在閉包中正在處理的目前餐廳。因此,在閉包中,我們指示清單如何顯示餐廳列,這裡我們只在 HStack
中同時顯示餐廳圖片與餐廳名稱。
一切都沒有改變,UI 仍然相同,不過底層程式碼已經修改為利用 List
與資料集合了。
為了讓你更了解 List
內 id
參數的用途,我們對 restaurants
陣列做一個小變更。由於我們使用餐廳名稱作為識別碼,因此我們來看當有兩筆資料具有相同的餐廳名稱時會發生什麼事?現在將 restaurants
陣列中的「Upstate」更新為「Homei」,如下所示:
Restaurant(name: "Homei", image: "upstate")
請注意,我們只更改「name」屬性值,圖片仍保持為 upstate
,如圖 10.8 所示。再次檢查預覽畫布,看看你得到了什麼。
你有看到圖 10.8 中的問題嗎?我們現在有兩筆名稱是「Homei」的紀錄。你可能希望第二筆「Homei」紀錄會顯示 upstate 圖片,但實際上,iOS 會以相同的文字與圖片來渲染這兩筆紀錄。在程式碼中,我們告訴 List
使用餐廳名稱作為唯一的識別碼,當兩間餐廳的名稱相同時,iOS 會將這兩間餐廳視為同一餐廳,因此它重用相同的視圖,並渲染相同的圖片。
那麼,你該如何修正這個問題呢?
其實非常簡單,你應該給每間餐廳一個唯一的識別碼,而不是使用名稱作為 ID。現在更新 Restaurant
結構如下:
struct Restaurant {
var id = UUID()
var name: String
var image: String
}
在上列的程式碼中,我們加入了 id
屬性,並以唯一識別碼來初始化它。這個 UUID()
函數的作用是產生通用唯一的隨機識別碼,UUID 是由128 位元數所組成,因此理論上要同時產生兩個相同識別碼的機率幾乎為零。
現在,每間餐廳應該皆有一個唯一的 ID,但是在修正這個錯誤之前,我們還需要再做一個修改。對於 List
,將 id
參數的值從 \.name
改為 \.id
:
List(restaurants, id: \.id)
這告訴 List
視圖使用餐廳的 id
屬性作為唯一的識別碼。再看一遍預覽,第二筆的「Homei」紀錄應該顯示它自己的圖片了,如圖 10.9 所示。
透過讓 Restaurant
結構遵循 Identifiable
協定,我們可進一步簡化程式碼。這個協定只有一個要求,就是實作協定的型別應該具備某種 id
作為唯一識別碼。現在更新 Restauran
t 來實作 Identifiable
協定,如下所示:
struct Restaurant: Identifiable {
var id = UUID()
var name: String
var image: String
}
由於 Restaurant
已經提供了唯一的 id
屬性,因此符合協定的要求。
那麼,這裡實作 Identifiable
協定的目的是什麼呢?它可以進一步節省一些程式碼,當 Restaurant
結構遵循 Identifiable
協定時,你可不使用 id
參數來初始化這個 List
,更新後的清單視圖程式碼,如下所示:
List(restaurants) { restaurant in
HStack {
Image(restaurant.image)
.resizable()
.frame(width: 40, height: 40)
.cornerRadius(5)
Text(restaurant.name)
}
.listStyle(.plain)
}
這就是使用 List
顯示資料集合的方式。
程式碼運作正常,不過將程式碼重構,讓它變得更好,始終是一件好事。你已經學過如何取出視圖,現在我們將取出 HStack
至一個單獨的結構中。按住鍵不放, 並點擊 HStack
, 選擇「Extract subview」來取出程式碼, 並將結構重新命名為 BasicImageRow
。
當你更改後,Xcode 會立即顯示一個錯誤。由於取出的子視圖沒有 restaurant
屬性,因此像這樣更新BasicImageRow
結構,以宣告 restaurant
屬性:
struct BasicImageRow: View {
var restaurant: Restaurant
var body: some View {
HStack {
Image(restaurant.image)
.resizable()
.frame(width: 40, height: 40)
.cornerRadius(5)
Text(restaurant.name)
}
}
}
接著,更新 List
視圖來傳送 restaurant
參數:
List(restaurants) { restaurant in
BasicImageRow(restaurant: restaurant)
}
現在一切都應該正常運作。這個清單視圖渲染後看起來仍然相同,不過底層的程式碼更具易讀性與組織性,而且更容易修改程式碼。例如:你將列建立為其他佈局,如下所示:
struct FullImageRow: View {
var restaurant: Restaurant
var body: some View {
ZStack {
Image(restaurant.image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 200)
.cornerRadius(10)
.overlay(
Rectangle()
.foregroundColor(.black)
.cornerRadius(10)
.opacity(0.2)
)
Text(restaurant.name)
.font(.system(.title, design: .rounded))
.fontWeight(.black)
.foregroundColor(.white)
}
}
}
這個列佈局是用於顯示更大的餐廳圖片,並將餐廳名稱疊在上面。由於我們已經重構程式碼,因此非常容易變更 App,來使用新的佈局,你只需要在 List
閉包中,將 BasicImageRow
改成 FullImageRow
即可:
List(restaurants) { restaurant in
FullImageRow(restaurant: restaurant)
}
更改一行程式碼後,這個 App 會立即切換至另一個佈局,如圖 10.11 所示。
你可以進一步混合列佈局, 以建立更有趣的 UI。舉例而言, 新的設計是使用 FullImageRow
來顯示前兩列的資料,其餘的列則利用 BasicImageRow
,如圖 10.12 所示。你可以更新 List
如下:
List {
ForEach(restaurants.indices, id: \.self) { index in
if (0...1).contains(index) {
FullImageRow(restaurant: self.restaurants[index])
} else {
BasicImageRow(restaurant: self.restaurants[index])
}
}
}
.listStyle(.plain)
由於我們需要檢索列索引,因此我們向 List
傳送餐廳資料的範圍。在閉包中,我們檢查 index
的值來決定要使用哪一種列佈局。
從 iOS 15 開始,Apple 為開發人員提供了自定義列表視圖外觀的選項。 要更改行分隔符的色調顏色,可以使用 listRowSeparatorTint
修飾符,如下所示:
List(restaurants) { restaurant in
ForEach(restaurants.indices, id: \.self) { index in
if (0...1).contains(index) {
FullImageRow(restaurant: self.restaurants[index])
} else {
BasicImageRow(restaurant: self.restaurants[index])
}
}
.listRowSeparatorTint(.green)
}
.listStyle(.plain)
在以上的程式碼中,我們將行分隔符的顏色更改為綠色。
你也可以使用 listRowSeparator
修飾器並將其值設置為 .hidden
以隱藏分隔線。以下是一個範例:
List {
ForEach(restaurants.indices) { index in
if (0...1).contains(index) {
FullImageRow(restaurant: self.restaurants[index])
} else {
BasicImageRow(restaurant: self.restaurants[index])
}
}
.listRowSeparator(.hidden)
}
.listStyle(.plain)
listRowSeparator
修飾器應該嵌入在 List
視圖中。 要使列表分隔線再次出現,你可以將修飾器的值設置為.visible
。 又或者你可以簡單地刪除 listRowSeparator
修飾器。
如果你想對列表分隔線進行更精細的控制,可以使用.listRowSeparator
修飾器內的 edges
參數。 比如說,如果你想讓分隔線保持在列表視圖的頂部,你可以這樣寫程式碼:
.listRowSeparator(.hidden, edges: .bottom)
在 iOS 16 中,你可以自定義列表視圖的可滾動區域的顏色。 只需將 scrollContentBackground
修飾符附加到 List
視圖並將其設置為您喜歡的顏色。 這是一個例子:
List(restaurants) { restaurant in
.
.
.
}
.background(.yellow)
.scrollContentBackground(.hidden)
除了使用純色之外,你還可以使用圖像作為背景。 更新這樣的程式碼試試:
List(restaurants) { restaurant in
.
.
.
}
.background {
Image("homei")
.resizable()
.scaledToFill()
.clipped()
.ignoresSafeArea()
}
.scrollContentBackground(.hidden)
我們使用background
修飾符來設置背景圖片, 然後我們將 scrollContentBackground
修飾符設置為 Color.clear
以使可滾動區域透明。
在進入下一章之前,自我挑戰一下,建立如圖 10.13 所示的清單視圖,它看起來有些複雜,但是若你完全了解我在本章所教過的內容,則你應該能夠建立這個 UI。請花點時間來練習這個作業,我保證你會學習到很多。
為了節省你尋找圖片的時間,你可以至下列網站:https://www.appcoda.com/resources/swiftui/SwiftUIArticleImages.zip 。
在本章所準備的範例檔中,有完整的專案與作業解答可以下載: