精通 SwiftUI - iOS 17 版

第 28 章
如何建立展開式列表視圖和大綱視圖

SwiftUI 列表視圖 (list view) 和 UIKit 的 UITableView 很類似。在 SwiftUI 最初的版本中,Apple 的工程師已經把建立列表視圖的過程變得輕而易舉,我們不需要創建 prototype cell,也不需要委派 (delegate) 或 data source 的協定。我們只需要用幾行程式碼,就可以使用客製化單元格來建構一個列表視圖。

在 iOS 14 (和以上版本),Apple 繼續改善列表視圖,並添加了一些新功能。在本篇教學文章中,我們將會看看如何建構一個展開式列表視圖 (expandable list view) 或大綱視圖 (outline view),並探索 inset grouped 的列表樣式 (list style)。

範例 App

首先,讓我們看看本篇教學的成品。我十分喜歡 La Marzocco,所以我用了它網站的導覽選單 (navigation menu) 為例子。以下的列表視圖展示了選單的大綱,使用者可以點擊顯示按鈕來展開列表。

圖 28.1. 展開式列表視圖
圖 28.1. 展開式列表視圖

當然,你也可以用自己的實作方法,來建構這個大綱視圖。但在最新版本的 SwiftUI 中,Apple 讓開發者可以更簡單地建構這種大綱視圖,並自動適應於 iOS、iPadOS、和 macOS 版本。

建構大綱視圖

要繼續閱讀本章的教學的話,請先從 https://www.appcoda.com/resources/swiftui/expandablelist-images.zip 下載範例圖片。 然後,在 Xcode 使用 App 模板創建一個新的 SwiftUI 項目。 我將項目命名為 SwiftUIExpandableList,但您可以隨意將名稱設置為任何名稱。

建立項目後,解壓縮圖像存檔並將圖像添加到 Assets。在項目導航器中,右鍵單擊 SwiftUIExpandableList 並選擇創建一個新文件檔。 選擇 Swift File 模板並將其命名為 MenuItem.swift

設置數據模型

要讓列表視圖可以展開,你只需要如此建立一個數據模型 (data model)。請加入以下程式碼至 MenuItem.swift:

struct MenuItem: Identifiable {
    var id = UUID()
    var name: String
    var image: String
    var subMenuItems: [MenuItem]?
}

在上面的程式碼中,我們有一個用來建造選單物件的結構 (struct)。要創建一個巢狀列表 (nested list),關鍵就是要加入一個屬性,包含子級的可選陣列 (optional array) subMenuItems。請注意,子級與其父級的型別 (type) 是一樣的。

對於頂層 (top level) 選單物件,我們可以如此創建一個 MenuItem 陣列:

// Main menu items
let sampleMenuItems = [ MenuItem(name: "Espresso Machines", image: "linea-mini", subMenuItems: espressoMachineMenuItems),
                        MenuItem(name: "Grinders", image: "swift-mini", subMenuItems: grinderMenuItems),
                        MenuItem(name: "Other Equipment", image: "espresso-ep", subMenuItems: otherMenuItems)
                    ]

我們會針對每個選單物件,指定子選單 (sub-menu) 物件的陣列。如果沒有子選單物件,則可以省略 subMenuItems 參數,或傳遞 nil 值。我們可以這樣定義子選單物件:

// Sub-menu items for Espressco Machines
let espressoMachineMenuItems = [ MenuItem(name: "Leva", image: "leva-x", subMenuItems: [ MenuItem(name: "Leva X", image: "leva-x"), MenuItem(name: "Leva S", image: "leva-s") ]),
                                 MenuItem(name: "Strada", image: "strada-ep", subMenuItems: [ MenuItem(name: "Strada EP", image: "strada-ep"), MenuItem(name: "Strada AV", image: "strada-av"), MenuItem(name: "Strada MP", image: "strada-mp"), MenuItem(name: "Strada EE", image: "strada-ee") ]),
                                 MenuItem(name: "KB90", image: "kb90"),
                                 MenuItem(name: "Linea", image: "linea-pb-x", subMenuItems: [ MenuItem(name: "Linea PB X", image: "linea-pb-x"), MenuItem(name: "Linea PB", image: "linea-pb"), MenuItem(name: "Linea Classic", image: "linea-classic") ]),
                                 MenuItem(name: "GB5", image: "gb5"),
                                 MenuItem(name: "Home", image: "gs3", subMenuItems: [ MenuItem(name: "GS3", image: "gs3"), MenuItem(name: "Linea Mini", image: "linea-mini") ])
                                ]

// Sub-menu items for Grinder
let grinderMenuItems = [ MenuItem(name: "Swift", image: "swift"),
                         MenuItem(name: "Vulcano", image: "vulcano"),
                         MenuItem(name: "Swift Mini", image: "swift-mini"),
                         MenuItem(name: "Lux D", image: "lux-d")
                        ]

// Sub-menu items for other equipment
let otherMenuItems = [ MenuItem(name: "Espresso AV", image: "espresso-av"),
                         MenuItem(name: "Espresso EP", image: "espresso-ep"),
                         MenuItem(name: "Pour Over", image: "pourover"),
                         MenuItem(name: "Steam", image: "steam")
                        ]

顯示列表

準備好數據模型後,我們可以編寫程式碼以呈現列表視圖。List 視圖現在有了可選的 children 參數。所以如果有任何子物件,你可以提供其 key path \.subMenuItems。 然後,SwiftUI 將遞歸 (recursively) 查找子選單物件,並以大綱形式顯示。打開 ContentView.swift 並在 body 中插入以下程式碼:

List(sampleMenuItems, children: \.subMenuItems) { item in
    HStack {
        Image(item.image)
            .resizable()
            .scaledToFit()
            .frame(width: 50, height: 50)

        Text(item.name)
            .font(.system(.title3, design: .rounded))
            .bold()
    }
}

List 視圖閉包中,我們會描述每一行的外觀。在範例程式碼中,我們使用了 HStack 佈局圖像和文本視圖。如果你已經在 ContentView 中正確地添加了程式碼的話,SwiftUI 應該會如此呈現大綱視圖:

圖 28.2. 展開式列表視圖
圖 28.2. 展開式列表視圖

要測試App,請在模擬器或預覽畫布中運行它。 您可以點擊披露指示器以顯示子菜單。

使用普通列表樣式

在 iOS 中,Apple 將列表視圖的預設樣式設置為 Inset Grouped,其中分組的部分以圓角嵌入。 如果要將其切換回普通列表樣式,可以將 .listStyle 修飾器附加到 List 視圖並將其值設置為 .plain,如下所示:

List {
  ...
}
.listStyle(.plain)

如果你沒有加錯位置,列表視圖現在應該更改為普通樣式。

圖 28.3. 使用普通列表樣式
圖 28.3. 使用普通列表樣式

使用 OutlineGroup 列表樣式

正如您在前面的示例中所見,使用 List 視圖創建大綱視圖非常容易。 但是,如果您想更好地控制大綱視圖的外觀(例如添加部分標題),則需要使用 OutlineGroup。 這個視圖用於呈現數據的層次結構。

如果您已明白如何構建可擴展的列表視圖,「OutlineGroup」的用法也非常相似。 例如,以下程式碼允許您構建相同的可擴展列表視圖,如圖 28.1 所示:

List {
    OutlineGroup(sampleMenuItems, children: \.subMenuItems) {  item in
        HStack {
            Image(item.image)
                .resizable()
                .scaledToFit()
                .frame(width: 50, height: 50)

            Text(item.name)
                .font(.system(.title3, design: .rounded))
                .bold()
        }
    }
}

List 視圖類似,您只需要在建立OutlineGroup 時傳入要顯示的項目並指定子菜單項(或子項)的鍵路徑(Key path)。

有了 OutlineGroup,您可以更好地控制大綱視圖的外觀。 例如,我們希望將頂級菜單項顯示為節標題。 你可以這樣寫程式碼:

List {
    ForEach(sampleMenuItems) { menuItem in

        Section(header:
            HStack {

                Text(menuItem.name)
                    .font(.title3)
                    .fontWeight(.heavy)

                Image(menuItem.image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 30, height: 30)

            }
            .padding(.vertical)

        ) {
            OutlineGroup(menuItem.subMenuItems ?? [MenuItem](), children: \.subMenuItems) {  item in
                HStack {
                    Image(item.image)
                        .resizable()
                        .scaledToFit()
                        .frame(width: 50, height: 50)

                    Text(item.name)
                        .font(.system(.title3, design: .rounded))
                        .bold()
                }
            }
        }
    }
}

在上面的程式碼中,我們使用 ForEach 來遍歷菜單項。 我們將頂層項目呈現為節標題。 對於其餘的子菜單項,我們依靠「OutlineGroup」來創建數據層次結構。 如果您更改 ContentView.swift內的程式碼,您應該會看到如圖 28.4 所示的大綱視圖。

圖 28.4. 使用 OutlineGroup 構建大綱視圖
圖 28.4. 使用 OutlineGroup 構建大綱視圖

同樣地,如果您更喜歡使用普通列表樣式,可以將 listStyle 修飾器附加到 List 視圖:

.listStyle(.plain)

然後你會得到圖 28.5 的結果。

圖 28.5. 使用普通列表樣式
圖 28.5. 使用普通列表樣式

了解 DisclosureGroup

在大綱視圖中,您可以通過點擊顯示指示器來顯示/隱藏子菜單。 無論您使用 List 還是 OutlineGroup 來實現可展開列表,iOS 中引入的名為 DisclosureGroup 的新視圖都支持這種「展開和折疊」功能。

公開組視圖(Disclosure Group View)是為顯示或隱藏另一個內容視圖而設計。 雖然 DisclosureGroup 會自動嵌入到 OutlineGroup 中,但您也可以獨立使用此視圖。 例如,您可以使用以下程式碼來顯示和隱藏問題和答案:

DisclosureGroup(
    content: {
        Text("Absolutely! You are allowed to reuse the source code in your own projects (personal/commercial). However, you're not allowed to distribute or sell the source code without prior authorization.")
            .font(.body)
            .fontWeight(.light)
    },
    label: {
        Text("1. Can I reuse the source code?")
            .font(.body)
            .bold()
            .foregroundColor(.black)
    }
)

公開組視圖有兩個參數:labelcontent。 在上面的程式碼中,我們在 label 參數中指定了問題,在 content 參數中指定了答案。 圖 28.6 顯示了結果。

圖 28.6. 利用 DisclosureGroup 顯示和隱藏內容
圖 28.6. 利用 DisclosureGroup 顯示和隱藏內容

在預設的情況下,公開組視圖處於隱藏模式。 要顯示內容視圖,請點擊顯示指示器(>)以將顯示組視圖切換到「展開」狀態。

另外,您可以通過傳一個綁定來控制 DisclosureGroup 的狀態,該綁定直接控制披露指示器的狀態(展開或折疊),如下所示:

struct FaqView: View {
    @State var showContent = true

    var body: some View {
        DisclosureGroup(
            isExpanded: $showContent,
            content: {
                ...
            },
            label: {
                ...
            }
        )
        .padding()
    }
}

練習

DisclosureGroup 視圖允許您更好地控制披露指標的狀態。 你的練習是建立一個類似於圖 28.7 所示的 FAQ 屏幕。

圖 28.7. 練習
圖 28.7. 練習

使用者可以點擊披露指示器來顯示或隱藏單個問題。 此外,App也提供了一個「Show all」按鈕來展開所有問題並立即顯示答案。

總結

在本章中,我介紹了 SwiftUI 的一些新功能。 正如您在演示中看到的那樣,構建大綱視圖或可擴展列表視圖一點也不難。 您需要做的就是定義一個正確的數據模型。 List 視圖處理其餘部分並呈現大綱視圖。 最重要的是,新的更新提供了 OutlineGroupDisclosureGroup 供您進一步客製化大綱視圖。

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

https://www.appcoda.com/resources/swiftui5/SwiftUIExpandableList.zip

請注意,您可以參考 FaqView.swift 來獲得練習的解決方案。