SwiftUI 最初的版本沒有原生集合視圖 (collection view)。你可以自己構建一個解決方案,或是使用第三方程式庫。在2020年的 WWDC 中,Apple 為 SwiftUI 框架引入了許多新功能,其中一個就是實作 Grid 視圖。SwiftUI 現在為開發者提供兩個新的 UI 組件: LazyVGrid 和 LazyHGrid,一個用於創建垂直 Grid,另一種用於創建水平 Grid。正如 Apple 所說,“Lazy” 一詞意思是 Grid 視圖在需要時才會創建項目。
在這章中,我會教你如何創建水平和垂直視圖。LazyVGrid 和 LazyHGrid 都設計得十分靈活,因此開發者可以輕鬆創建各種類型的 Grid 佈局。我們還會深入了解如何更改 Grid 項目的大小,以實現不同的佈局(如圖29.1)。
我們可以依照以下步驟,創建水平或垂直的網格(Grid) 佈局:
準備要顯示在網格中的原始數據。例如,以下是我們將在範例 App 中顯示的一組 SF Symbol:
private var symbols = ["keyboard", "hifispeaker.fill", "printer.fill", "tv.fill", "desktopcomputer", "headphones", "tv.music.note", "mic", "plus.bubble", "video"]
創建一個 GridItem
陣列,來描述 Grid 的外觀。比方說,Grid 需要有多少列?以下是描述一個 3 列 (column) 網格的範例程式碼:
private var threeColumnGrid = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
使用 LazyVGrid
和 ScrollView
佈局 Grid。可以參考以下這是範例程式碼:
ScrollView {
LazyVGrid(columns: threeColumnGrid) {
// Display the item
}
}
如果你想要構建水平 Grid,就如此使用 LazyHGrid
:
ScrollView(.horizontal) {
LazyHGrid(rows: threeColumnGrid) {
// Display the item
}
}
對 Grid 佈局有一些基本了解之後,讓我們開始編寫程式碼吧!我們將從構建一個 3 列的網格(Grid)開始。打開 Xcode 並使用 App 模板創建一個新項目。 請確保選擇 SwiftUI 作為界面選項。 將項目命名為 SwiftUIGridLayout 或你喜歡的名稱。
之後,在 ContentView.swift
內,宣告以下變數 (variable):
private var symbols = ["keyboard", "hifispeaker.fill", "printer.fill", "tv.fill", "desktopcomputer", "headphones", "tv.music.note", "mic", "plus.bubble", "video"]
private var colors: [Color] = [.yellow, .purple, .green]
private var gridItemLayout = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
我們將要在一個 3 列網格中顯示一組 SF Symbol。如此更新 body
變數來顯示網格:
var body: some View {
ScrollView {
LazyVGrid(columns: gridItemLayout, spacing: 20) {
ForEach((0...9999), id: \.self) {
Image(systemName: symbols[$0 % symbols.count])
.font(.system(size: 30))
.frame(width: 50, height: 50)
.background(colors[$0 % colors.count])
.cornerRadius(10)
}
}
}
}
我們使用 LazyVGrid
,並告訴垂直 Grid 使用 3 列佈局。我們還指定行與行之間有 20 point 的間距。在程式碼中,我們有一個 ForEach
迴圈 (loop),來顯示總共 10,000 個圖像視圖。如果有正確地跟隨步驟,你就會在預覽中看到一個三列 Grid。
我們成功創建了一個三列的垂直 Grid 了!現在,圖像框架 (frame) 的大小固定為 50 x 50 point。你可以如此更改框架修飾符 (modifier):
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 50)
然後,圖像的寬度就會擴大到列的寬度(如圖29.4)。
請注意,列和行之間有一個空格。 有時,你可能想要建立一個沒有任何空格的網格。 要怎麼才能做到這一點? 行之間的間距由 LazyVGrid
的 spacing
參數控制。 我們已將其值設置為20點。 你可以簡單地將其更改為“0”,這樣行之間就沒有空格。
網格項之間的間距由在 gridItemLayout
中的 GridItem
來控制。 你可以通過將值傳給 spacing
參數來設置項目之間的間距。 因此,要刪除行之間的空白地方,你可以像這樣初始化 gridLayout
變數:
private var gridItemLayout = [GridItem(.flexible(), spacing: 0), GridItem(.flexible(), spacing: 0), GridItem(.flexible(), spacing: 0)]
對於每個「GridItem」,我們指定為零間距。 為簡單起見,上面的程式碼可以改寫成這樣:
private var gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 0), count: 3)
如果你已更新相關程式碼,你的預覽畫布應該會顯示一個沒有任何間距的網格視圖。
讓我們進一步看看 GridItem
。你可以使用 GridItem
實例在 LazyHGrid
和 LazyVGrid
視圖中配置項目的佈局。在前文中,我們定義了一個有三個 GridItem
實例的陣列,而每個實例都使用尺寸型別 (Size Type) .flexible()
。Flexible 尺寸型別讓我們可以創建三個大小相等的列。如果想要一個 6 列的 Grid,則可以如此創建 GridItem
陣列:
private var sixColumnGrid: [GridItem] = Array(repeating: .init(.flexible()), count: 6)
.flexible()
只是其中一個用於控制 Grid 佈局的尺寸型別。如果要在一行中放置盡可能多的項目,你可以使用如下的 adaptive 尺寸型別:
private var gridItemLayout = [GridItem(.adaptive(minimum: 50))]
Adaptive 尺寸型別要求你指定項目的最小尺寸 (minimum size)。在上面的程式碼中,我們設定了每個 Grid 項目的最小尺寸為 50。如果你像這樣修改 gridItemLayout
變數,就可以達到以下的 Grid 佈局。
我們使用了 .adaptive(minimum: 50)
,指示 LazyVGrid
在一行中填滿盡可能多的圖像,而每個項目的最小尺寸為 50 point。
注意:我使用 iPhone 13 Pro 作為模擬器。 如果你使用其他屏幕尺寸不同的 iOS 模擬器,你可能會獲得不同的結果。
除了 .flexible
和 .adaptive
之外,如果想創建固定寬度的列,我們可以使用 .fixed
。例如,我們想將圖像分為兩列,第一列的寬度為 100 point,第二列的寬度為 150 point,我們可以這樣編寫程式碼:
private var gridItemLayout = [GridItem(.fixed(100)), GridItem(.fixed(150))]
同樣地,如果你像這樣更新 gridItemLayout
變數,就會出現尺寸不同的兩列 Grid。
你可以混合使用不同的尺寸型別,來創建更複雜的 Grid 佈局。例如,你可以定義一個 .fixed
的GridItem
,然後定義一個 .adaptive
的GridItem,像這樣:
private var gridItemLayout = [GridItem(.fixed(150)), GridItem(.adaptive(minimum: 50))]
在這個情況下,LazyVGrid
將創建一個固定尺寸的列,其寬度為 100 point。然後,它會讓盡可能多的項目填滿剩餘空間。
現在,你已經創建了垂直 Grid,LazyHGrid
可以很容易地將垂直 Grid 轉換為水平 Grid。水平 Grid 的用法與 LazyVGrid
幾乎完全相同,只是我們會將其嵌入到水平滾動視圖中。此外,LazyHGrid
的參數是 rows
而不是 columns
。
因此,我們只需要重寫幾行程式碼,就可以將 Grid 視圖從垂直方向轉換為水平方向:
ScrollView(.horizontal) {
LazyHGrid(rows: gridItemLayout, spacing: 20) {
ForEach((0...9999), id: \.self) {
Image(systemName: symbols[$0 % symbols.count])
.font(.system(size: 30))
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 50, maxHeight: .infinity)
.background(colors[$0 % colors.count])
.cornerRadius(10)
}
}
}
在預覽中執行範例或在模擬器上進行測試,你應該會看到一個水平 Grid。最棒的是,此 Grid 視圖可以自動支援 iPhone 和 iPad。
現在你已經對 LazyVGrid 和 LazyHGrid 有所認識,讓我們創建一些更複雜的東西。 想像一下,你要構建一個顯示咖啡照片集合的App。 在App中,使用者可以隨時改變 Grid 的列數。在預設的情況下,它會在一列中顯示所有照片。 使用者可以點擊 Grid 按鈕將列表視圖更改為具有 2 列的網格視圖。 再次點擊相同的按鈕以進行 3 列佈局,然後是 4 列佈局。
要開發這個範例App,請先創建一個新的 Xcode 項目。 今次也是選擇 App 模板並將項目命名為 SwiftUIPhotoGrid。 接下來,在 https://www.appcoda.com/resources/swiftui/coffeeimages.zip 下載圖像包。 解壓縮圖像並將它們添加到Assets
。
在建立網格視圖之前,我們將為照片集合創建數據模型。 在項目導航器中,右鍵單擊 SwiftUIGridView 並選擇 New file... 以加入新文件。 選擇 Swift File 模板並將文件命名為 Photo.swift。
在 Photo.swift
文件中插入以下程式碼以建立 Photo
結構:
struct Photo: Identifiable {
var id = UUID()
var name: String
}
let samplePhotos = (1...20).map { Photo(name: "coffee-\($0)") }
圖像包中有 20 張咖啡照片,因此我們初始化了一個包含 20 個Photo
實體的陣列。 準備好數據模型後,讓我們切換到 ContentView.swift
來構建網格。
首先,宣告一個 gridLayout
變數來定義我們首選的網格佈局:
@State var gridLayout: [GridItem] = [ GridItem() ]
在預設情況下,我們希望顯示一個列表視圖。 除了使用 List
,你實際上還可以使用 LazyVGrid
來構建列表視圖。 我們通過使用一個網格項定義 gridLayout
來做到這一點。 通過告訴 LazyVGrid
使用單列網格佈局,它將像列表視圖一樣排列項目。 在 body
中插入以下程式碼以創建網格視圖:
NavigationStack {
ScrollView {
LazyVGrid(columns: gridLayout, alignment: .center, spacing: 10) {
ForEach(samplePhotos.indices, id: \.self) { index in
Image(samplePhotos[index].name)
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 200)
.cornerRadius(10)
.shadow(color: Color.primary.opacity(0.3), radius: 1)
}
}
.padding(.all, 10)
}
.navigationTitle("Coffee Feed")
}
我們使用 LazyVGrid
創建一個垂直網格,行間距為 10 點。 Grid用於顯示咖啡照片,因此我們使用 ForEach
取得所有 samplePhotos
項目。 我們將網格嵌入到滾動視圖中,使其可滾動並用導航視圖包裝。 更改程式碼後,你應該會在預覽畫布中看到照片列表。
現在我們需要一個按鈕讓用戶在不同的佈局之間切換。 我們將按鈕添加到導航欄。自 iOS 14 ,Apple 加入了一個名為.toolbar
的新修飾器,用於填充導航欄中的項目。 在 .navigationTitle
之後,插入以下程式碼:
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
self.gridLayout = Array(repeating: .init(.flexible()), count: self.gridLayout.count % 4 + 1)
} label: {
Image(systemName: "square.grid.2x2")
.font(.title)
}
.tint(.primary)
}
}
在上面的程式碼中,我們更新了gridLayout
變數並初始化了GridItem
的陣列。 假設當前項目數為 1,我們將創建一個包含兩個 GridItem
的數組以更改為 2 列網格。 由於我們將 gridLayout
標記為狀態變數,因此每次更改變數時,SwiftUI 都會更新網格視圖。
你可以執行該App進行快速測試。 點擊網格按鈕將切換到另一個網格佈局。
我們有幾件事需要改進。 首先,對於具有兩列或更多列的網格,應將網格項的高度調整為 100 點。 使用 height
參數更新 .frame
修飾器,如下所示:
.frame(height: gridLayout.count == 1 ? 200 : 100)
其次,當你從一種網格佈局切換到另一種時,SwiftUI 只會簡單地重繪網格視圖。 如果我們在佈局更改之間添加一個漂亮的過場動畫是不是會更好呢? 為此,你只需添加一行程式碼。 在 .padding(.all, 10)
之後插入以下程式碼:
.animation(.interactiveSpring(), value: gridLayout.count)
這就是 SwiftUI 的強大之處。 通過告訴 SwiftUI 你想要動畫變化,框架會處理剩下的事情,而使用者就會看到漂亮的過場動畫。
你不僅限於在你的App中使用單個 LazyVGrid
或 LazyHGrid
。 通過組合多個LazyVGrid
,你可以構建一些有趣的佈局。 看一下圖 29.14。我們將創建這種網格佈局, 網格顯示咖啡館照片列表。 在每張咖啡館照片下,它顯示了一份咖啡照片列表。 當設備處於橫向時,咖啡廳照片和咖啡照片列表將並排排列。
讓我們回到 Xcode 項目,首先創建數據模型。 你之前下載的圖像包帶有一組咖啡館照片。 因此,創建一個新的 Swift 文件並將其命名為 Cafe.swift。 在文件中,加入以下程式碼:
struct Cafe: Identifiable {
var id = UUID()
var image: String
var coffeePhotos: [Photo] = []
}
let sampleCafes: [Cafe] = {
var cafes = (1...18).map { Cafe(image: "cafe-\($0)") }
for index in cafes.indices {
let randomNumber = Int.random(in: (2...12))
cafes[index].coffeePhotos = (1...randomNumber).map { Photo(name: "coffee-\($0)") }
}
return cafes
}()
Cafe
結構是不言自明的。 它有一個用於存儲咖啡照片的image
屬性和用於存儲咖啡照片列表的coffeePhotos
屬性。 在上面的程式碼中,我們還創建了一個Cafe
陣列。 對於每個咖啡館,我們隨機挑選一些咖啡照片。 如果你有其他喜歡的圖像,請隨時修改程式碼。
讓我們創建一個新檔案來實現這個網格視圖。 右鍵單擊 SwiftUIPhotoGrid 並選擇 New File...。 選擇 SwiftUI View 模板並將文件命名為 MultiGridView。
與前面的實現類似,讓我們宣告了一個 gridLayout
變數來儲存當前的網格佈局:
@State var gridLayout = [ GridItem() ]
在預設的情況下,我們的網格使用一個 GridItem
進行初始化。 接下來,在 body
中加入以下程式碼以創建具有單列的垂直網格:
NavigationStack {
ScrollView {
LazyVGrid(columns: gridLayout, alignment: .center, spacing: 10) {
ForEach(sampleCafes) { cafe in
Image(cafe.image)
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(maxHeight: 150)
.cornerRadius(10)
.shadow(color: .primary.opacity(0.3), radius: 1)
}
}
.padding(.all, 10)
.animation(.interactiveSpring(), value: gridLayout.count)
}
.navigationTitle("Coffee Feed")
}
相信我不需要再次重複講解這個程式碼,因為它與我們之前編寫的程式碼幾乎相同。 如果跟著做,你應該會看到一個列表視圖,其中顯示了咖啡館照片的集合。
要如何在每張照片下顯示另一個網格? 你需要做的就是在 ForEach
循環中添加另一個 LazyVGrid
。 在 Image
視圖之後加入以下程式碼:
LazyVGrid(columns: [GridItem(.adaptive(minimum: 50))]) {
ForEach(cafe.coffeePhotos) { photo in
Image(photo.name)
.resizable()
.scaledToFill()
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 50)
.cornerRadius(10)
}
}
.frame(minHeight: 0, maxHeight: .infinity, alignment: .top)
.animation(.easeIn, value: gridLayout.count)
我們為照片創建另一個垂直網格(Grid)。 通過使用 adaptive 尺寸類型,此網格將在一行中盡量填滿最多的照片。 更改程式碼後,App UI 就會如圖 29.16 所示。
如果你喜歡並排排列咖啡館和咖啡的照片,你可以像這樣修改 gridLayout
變數:
@State var gridLayout = [ GridItem(.adaptive(minimum: 100)), GridItem(.flexible()) ]
當你更改了 gridLayout
變數,你的預覽將隨即更新為並以另一種形式顯示咖啡館和咖啡的照片。
要在 Xcode 預覽中測試橫向模式,你可以選擇 Device Settings 選項並啟用 Orientation 選項。 將其設置為 Landscape (Left) 或 Landscape (Right)。
另外,你也可以在模擬器上運行App,然後將模擬器轉為橫向試一試。
但在執行App之前,你需要在 SwiftUIGridLayoutApp.swift
中做一個小的改動。 由於我們已經創建了一個新文件來實現這個多網格,所以將 WindowGroup
中的 ContentView()
視圖 修改為 MultiGridView()
,如下所示:
struct SwiftUIPhotoGridApp: App {
var body: some Scene {
WindowGroup {
MultiGridView()
}
}
}
現在你可以在 iPhone 模擬器中運行該App。 在直向上,App的UI沒有任何問題,就像預覽時一樣。 但是,如果你通過 command + 左(或右)鍵來旋轉模擬器,網格佈局看起來就不像預期的那樣。 我們期望的是它看起來應該與直向模式時差不多。
為了解決這個問題,我們可以調整自適應網格項的最小寬度,並在iPhone處於橫向時使其更寬一些。 問題是如何檢測iPhone的旋轉方向變化?
在 SwiftUI 中,每個視圖都帶有一組環境變數。 你可以通過這組環境變數取得iPhone的營幕方向,如下所示:
@Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
@Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass?
@Environment
屬性包裝器允許你讀取環境值。 在上面的程式碼中,我們告訴 SwiftUI 要同時讀取水平和垂直尺寸類別,並偵測它們的變化。 換句話說,只要iPhone的營幕方向改變,App就會收到通知。
如果你還沒有這樣做,請確保將上面的程式碼插入到 SwiftUIPhotoGrid
中。
下一個問題是我們如何取得通知並作出相對應的行動? SwiftUI 提供了一個名為 .onChange()
的修飾器。 你可以將此修飾器附加到任何視圖以偵視任何狀態更改。 在這種情況下,我們可以像以程式碼將修飾器附加到 NavigationView
:
.onChange(of: verticalSizeClass) {
self.gridLayout = [ GridItem(.adaptive(minimum: verticalSizeClass == .compact ? 100 : 250)), GridItem(.flexible()) ]
}
我們偵測 horizontalSizeClass
和 verticalSizeClass
變數的任何變化。 每當有改變時,我們都會用新的網格配置更新 gridLayout
變數。 iPhone 在橫向上具有 compact 高度。 因此,如果 verticalSizeClass
的值等於 .compact
,我們將網格項的最小大小更改為 250 點。
現在再次在 iPhone 模擬器上運行App。 當你將iPhone向橫轉動時,它就會並排顯示咖啡館照片和咖啡照片。
我有幾個簡單練習給你。 首先,App UI 在 iPad 上看起來不是太好。 請修改一下程式碼並解決下圖的問題,使其僅顯示兩列:一列用於咖啡館照片,另一列用於咖啡照片。
下一個練習會稍為複雜一點,以下是你要做的事項:
為了幫助你更理解這個練習,請去 https://link.appcoda.com/multigrid-demo 觀看此動畫示範。
第一個版本的 SwiftUI 框架缺少了的集合視圖,新版本終於解決了這個問題。 蘋果公司加入了 LazyVGrid
和 LazyHGrid
讓開發者可以用幾行程式碼就能建立不同類型的網格佈局。 本章只是簡略示範了這兩個新 UI 組件的運作, 我鼓勵你嘗試不同的 GridItem
配置,看看可以實現哪些網格佈局。
在本章所準備的範例檔中,有最後完整的 Xcode 專案,可供你下載參考: