精通 SwiftUI - iOS 17 版

第 29 章
使用 LazyVGrid 和 LazyHGrid 構建集合視圖

SwiftUI 最初的版本沒有原生集合視圖 (collection view)。你可以自己構建一個解決方案,或是使用第三方程式庫。在2020年的 WWDC 中,Apple 為 SwiftUI 框架引入了許多新功能,其中一個就是實作 Grid 視圖。SwiftUI 現在為開發者提供兩個新的 UI 組件: LazyVGrid 和 LazyHGrid,一個用於創建垂直 Grid,另一種用於創建水平 Grid。正如 Apple 所說,“Lazy” 一詞意思是 Grid 視圖在需要時才會創建項目。

在這章中,我會教你如何創建水平和垂直視圖。LazyVGrid 和 LazyHGrid 都設計得十分靈活,因此開發者可以輕鬆創建各種類型的 Grid 佈局。我們還會深入了解如何更改 Grid 項目的大小,以實現不同的佈局(如圖29.1)。

圖 29.1. 集合視圖範例
圖 29.1. 集合視圖範例

SwiftUI Grid 佈局的基礎

我們可以依照以下步驟,創建水平或垂直的網格(Grid) 佈局:

  1. 準備要顯示在網格中的原始數據。例如,以下是我們將在範例 App 中顯示的一組 SF Symbol:

    private var symbols = ["keyboard", "hifispeaker.fill", "printer.fill", "tv.fill", "desktopcomputer", "headphones", "tv.music.note", "mic", "plus.bubble", "video"]
    
  2. 創建一個 GridItem 陣列,來描述 Grid 的外觀。比方說,Grid 需要有多少列?以下是描述一個 3 列 (column) 網格的範例程式碼:

    private var threeColumnGrid = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
    
  3. 使用 LazyVGridScrollView 佈局 Grid。可以參考以下這是範例程式碼:

    ScrollView {
        LazyVGrid(columns: threeColumnGrid) {
            // Display the item
        }
    }
    
  4. 如果你想要構建水平 Grid,就如此使用 LazyHGrid

    ScrollView(.horizontal) {
        LazyHGrid(rows: threeColumnGrid) {
            // Display the item
        }
    }
    

使用 LazyVGrid 來創建垂直 Grid

對 Grid 佈局有一些基本了解之後,讓我們開始編寫程式碼吧!我們將從構建一個 3 列的網格(Grid)開始。打開 Xcode 並使用 App 模板創建一個新項目。 請確保選擇 SwiftUI 作為界面選項。 將項目命名為 SwiftUIGridLayout 或你喜歡的名稱。

圖 29.2. 使用 App 模板創建一個新項目
圖 29.2. 使用 App 模板創建一個新項目

之後,在 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。

圖 29.3. 一個三列 Grid
圖 29.3. 一個三列 Grid

我們成功創建了一個三列的垂直 Grid 了!現在,圖像框架 (frame) 的大小固定為 50 x 50 point。你可以如此更改框架修飾符 (modifier):

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

然後,圖像的寬度就會擴大到列的寬度(如圖29.4)。

圖 29.4. 更改網格項目的框架大小
圖 29.4. 更改網格項目的框架大小

請注意,列和行之間有一個空格。 有時,你可能想要建立一個沒有任何空格的網格。 要怎麼才能做到這一點? 行之間的間距由 LazyVGridspacing 參數控制。 我們已將其值設置為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)

如果你已更新相關程式碼,你的預覽畫布應該會顯示一個沒有任何間距的網格視圖。

Figure 5. Removing the spacing between columns and rows
Figure 5. Removing the spacing between columns and rows

利用 GridItem 更改 Grid 佈局 (Flexible/ Fixed/ Adaptive)

讓我們進一步看看 GridItem。你可以使用 GridItem 實例在 LazyHGridLazyVGrid 視圖中配置項目的佈局。在前文中,我們定義了一個有三個 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 佈局。

圖 29.6. 使用 adaptive 尺寸型別
圖 29.6. 使用 adaptive 尺寸型別

我們使用了 .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。

圖 29.7. 具有固定大小項目的網格
圖 29.7. 具有固定大小項目的網格

你可以混合使用不同的尺寸型別,來創建更複雜的 Grid 佈局。例如,你可以定義一個 .fixedGridItem,然後定義一個 .adaptive 的GridItem,像這樣:

private var gridItemLayout = [GridItem(.fixed(150)), GridItem(.adaptive(minimum: 50))]

在這個情況下,LazyVGrid 將創建一個固定尺寸的列,其寬度為 100 point。然後,它會讓盡可能多的項目填滿剩餘空間。

圖 29.8. 混合使用不同的尺寸型別
圖 29.8. 混合使用不同的尺寸型別

使用 LazyHGrid 來創建水平 Grid

現在,你已經創建了垂直 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。

Figure 9. Creating a horizontal grid with LazyHGrid
Figure 9. Creating a horizontal grid with LazyHGrid

在不同的網格佈局之間切換

現在你已經對 LazyVGrid 和 LazyHGrid 有所認識,讓我們創建一些更複雜的東西。 想像一下,你要構建一個顯示咖啡照片集合的App。 在App中,使用者可以隨時改變 Grid 的列數。在預設的情況下,它會在一列中顯示所有照片。 使用者可以點擊 Grid 按鈕將列表視圖更改為具有 2 列的網格視圖。 再次點擊相同的按鈕以進行 3 列佈局,然後是 4 列佈局。

圖 29.10. 改變 Grid 的列數
圖 29.10. 改變 Grid 的列數

要開發這個範例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 項目。 我們將網格嵌入到滾動視圖中,使其可滾動並用導航視圖包裝。 更改程式碼後,你應該會在預覽畫布中看到照片列表。

圖 29.11. 使用 LazyVGrid 創建列表視圖
圖 29.11. 使用 LazyVGrid 創建列表視圖

現在我們需要一個按鈕讓用戶在不同的佈局之間切換。 我們將按鈕添加到導航欄。自 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 都會更新網格視圖。

圖 29.12. 添加用於切換網格佈局的按鈕
圖 29.12. 添加用於切換網格佈局的按鈕

你可以執行該App進行快速測試。 點擊網格按鈕將切換到另一個網格佈局。

我們有幾件事需要改進。 首先,對於具有兩列或更多列的網格,應將網格項的高度調整為 100 點。 使用 height 參數更新 .frame 修飾器,如下所示:

.frame(height: gridLayout.count == 1 ? 200 : 100)

其次,當你從一種網格佈局切換到另一種時,SwiftUI 只會簡單地重繪網格視圖。 如果我們在佈局更改之間添加一個漂亮的過場動畫是不是會更好呢? 為此,你只需添加一行程式碼。 在 .padding(.all, 10) 之後插入以下程式碼:

.animation(.interactiveSpring(), value: gridLayout.count)

這就是 SwiftUI 的強大之處。 通過告訴 SwiftUI 你想要動畫變化,框架會處理剩下的事情,而使用者就會看到漂亮的過場動畫。

圖 29.13. SwiftUI 自動設置過場動畫
圖 29.13. SwiftUI 自動設置過場動畫

使用多個網格構建網格佈局

你不僅限於在你的App中使用單個 LazyVGridLazyHGrid。 通過組合多個LazyVGrid,你可以構建一些有趣的佈局。 看一下圖 29.14。我們將創建這種網格佈局, 網格顯示咖啡館照片列表。 在每張咖啡館照片下,它顯示了一份咖啡照片列表。 當設備處於橫向時,咖啡廳照片和咖啡照片列表將並排排列。

圖 29.14. 使用兩個網格構建複雜的網格佈局
圖 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")
}

相信我不需要再次重複講解這個程式碼,因為它與我們之前編寫的程式碼幾乎相同。 如果跟著做,你應該會看到一個列表視圖,其中顯示了咖啡館照片的集合。

圖 29.15. 咖啡館照片列表
圖 29.15. 咖啡館照片列表

添加額外的網格(Grid)

要如何在每張照片下顯示另一個網格? 你需要做的就是在 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 所示。

圖 29.16. 添加額外的網格顯示圖片
圖 29.16. 添加額外的網格顯示圖片

如果你喜歡並排排列咖啡館和咖啡的照片,你可以像這樣修改 gridLayout 變數:

@State var gridLayout = [ GridItem(.adaptive(minimum: 100)), GridItem(.flexible()) ]

當你更改了 gridLayout 變數,你的預覽將隨即更新為並以另一種形式顯示咖啡館和咖啡的照片。

圖 29.17. 並排排列咖啡館和咖啡的照片
圖 29.17. 並排排列咖啡館和咖啡的照片

處理橫向方向

要在 Xcode 預覽中測試橫向模式,你可以選擇 Device Settings 選項並啟用 Orientation 選項。 將其設置為 Landscape (Left)Landscape (Right)

圖 29.18. 橫向模式下的 App UI
圖 29.18. 橫向模式下的 App UI

另外,你也可以在模擬器上運行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()) ]
}

我們偵測 horizontalSizeClassverticalSizeClass 變數的任何變化。 每當有改變時,我們都會用新的網格配置更新 gridLayout 變數。 iPhone 在橫向上具有 compact 高度。 因此,如果 verticalSizeClass 的值等於 .compact,我們將網格項的最小大小更改為 250 點。

現在再次在 iPhone 模擬器上運行App。 當你將iPhone向橫轉動時,它就會並排顯示咖啡館照片和咖啡照片。

圖 29.19. 橫向形式的 App UI
圖 29.19. 橫向形式的 App UI

練習

我有幾個簡單練習給你。 首先,App UI 在 iPad 上看起來不是太好。 請修改一下程式碼並解決下圖的問題,使其僅顯示兩列:一列用於咖啡館照片,另一列用於咖啡照片。

圖 29.21. 在iPad上的UI
圖 29.21. 在iPad上的UI

下一個練習會稍為複雜一點,以下是你要做的事項:

  1. iPhone 和 iPad 的不同預設網格佈局 - 當首次執行App時,它會在iPhone直向模式下顯示單個列網格。 但當 iPad 和 iPhone 在橫向模式,App就會以 2 列網格顯示咖啡館照片。
  2. 顯示/隱藏咖啡照片 - 在導航欄中添加一個新按鈕,用於顯示/隱藏咖啡照片。 在預設的情況下,App僅顯示咖啡館照片列表。 點擊此按鈕時,就會顯示咖啡照片網格。
  3. 建立另一個用於切換網格佈局的按鈕 - 添加另一個條形按鈕,使用者可以用於切換一列和兩列的網格佈局。
圖 29.22. 加強 App 在 iPhone 和 iPad 上的功能
圖 29.22. 加強 App 在 iPhone 和 iPad 上的功能

為了幫助你更理解這個練習,請去 https://link.appcoda.com/multigrid-demo 觀看此動畫示範。

總結

第一個版本的 SwiftUI 框架缺少了的集合視圖,新版本終於解決了這個問題。 蘋果公司加入了 LazyVGridLazyHGrid 讓開發者可以用幾行程式碼就能建立不同類型的網格佈局。 本章只是簡略示範了這兩個新 UI 組件的運作, 我鼓勵你嘗試不同的 GridItem 配置,看看可以實現哪些網格佈局。

在本章所準備的範例檔中,有最後完整的 Xcode 專案,可供你下載參考: