利用 SwiftUI 的新 NavigationSplitView 為使用者創建多列導航的體驗

在 iOS 16 中,Apple 除了推出新的 NavigationStack 外,還有一個新的視圖容器 NavigationSplitView,讓開發者創建兩列或三列的導航界面。如果你想構建類似內置郵件 App 的 UI,這個視圖元件就可以大派用場了。
利用 SwiftUI 的新 NavigationSplitView 為使用者創建多列導航的體驗
Photo by zanwei guo on Unsplash
利用 SwiftUI 的新 NavigationSplitView 為使用者創建多列導航的體驗
Photo by zanwei guo on Unsplash

在 iOS 16 中,Apple 除了推出新的 NavigationStack 外,還有一個新的視圖容器 NavigationSplitView,讓開發者創建兩列 (column) 或三列的導航界面。如果你想構建類似內置郵件 App 的 UI,就應該看看這個 Split 視圖元件。

雖然 NavigationSplitView 比較適合用於 iPadOS 和 macOS App,但我們也可以在 iPhone 的 App 上使用它。這個視圖元件會自動適應 iPhone 螢幕,因此它不會顯示多列界面,而是會顯示單列界面。

新的 NavigationSplitView 有各種選項,讓我們可以客製化其外觀和操作。我們可以更改列的寬度,並以編程方式設定顯示或隱藏列。

在這篇教學文章中,我們會利用 NavigationSplitView 來創建一個三列的導航界面。

讓我們開始吧!

NavigationSplitView 支援兩列和三列的導航界面,其實作非常相似。我們可以如此編寫程式碼,來創建一個兩列的導航 UI:

NavigationSplitView {
  // Menu bar
} detail: {
  // Detail view for each of the menu item
}

如果要創建一個三列的導航界面,我們就可以在中間添加 content 參數 (parameter):

NavigationSplitView {
  // Menu bar
} content: {
  // Sub menu
} detail: {
  // Detail view for each of the sub-menu item
}

讓我們先從兩列的導航 UI 開始,之後再構建三列的設計。

構建一個兩列的導航界面

我之前寫過一篇有關展開式列表視圖 (expandable list view) 的教學文章,如果你有讀過,就應該會知道我十分喜歡 La Marzocco。在那篇文章中,我就教過大家利用 Inset Grouped 樣式來構建展開式列表視圖。

現在,讓我們把這個展開式列表視圖變成一個兩列的導航界面吧:

swiftui-navigationsplitview-two-column

在建立 Split 視圖之前,讓我們先從 data model 開始。首先,我們要建立一個結構來塑造選單項目 (menu item):

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

如果我們想製作一個嵌套列表 (nested list),最重要的就是要有一個包含 optional 子陣列(即 subMenuItems)的屬性 (property)。你會看到子級與父級的類型 (type) 是相同的。

我們可以利用以下的程式碼,為最頂層的選單項目創建一個 MenuItem 陣列:

let topMenuItems = [ MenuItem(name: "Espresso Machines", image: "linea-mini", subMenuItems: espressoMachineMenuItems),
                        MenuItem(name: "Grinders", image: "swift-mini", subMenuItems: grinderMenuItems),
                        MenuItem(name: "Other Equipments", image: "espresso-ep", subMenuItems: otherMenuItems)
                    ]

接下來,讓我們為每個選單項目指定子選單項目的陣列。如果沒有子選單項目,我們可以省略 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")
                        ]

之後,我們要創建了一個 CoffeeEquipmentModel 結構,來組織好 data model:

struct CoffeeEquipmenModel {
    let mainMenuItems = {
        // Top menu items
        let topMenuItems = [ MenuItem(name: "Espresso Machines", image: "linea-mini", subMenuItems: espressoMachineMenuItems),
                                MenuItem(name: "Grinders", image: "swift-mini", subMenuItems: grinderMenuItems),
                                MenuItem(name: "Other Equipments", image: "espresso-ep", subMenuItems: otherMenuItems)
                            ]

        // Sub-menu items for Espresso 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")
                                ]

        return topMenuItems
    }()

    func subMenuItems(for id: MenuItem.ID) -> [MenuItem]? {
        guard let menuItem = mainMenuItems.first(where: { $0.id == id }) else {
            return nil
        }

        return menuItem.subMenuItems
    }

    func menuItem(for categoryID: MenuItem.ID, itemID: MenuItem.ID) -> MenuItem? {

        guard let subMenuItems = subMenuItems(for: categoryID) else {
            return nil
        }

        guard let menuItem = subMenuItems.first(where: { $0.id == itemID }) else {
            return nil
        }

        return menuItem
    }
}

mainMenuItems 陣列包含了範例選單項目,subMenuItemsmenuItem 輔助方法可以用來查找特定類別或選單項目。

準備好 data model 之後,我們就可以開始實作 NavigationSplitView。讓我們利用 SwiftUI 視圖模板建立一個新檔案  TwoColumnSplitView.swift,並如此更新 TwoColumnSplitView 結構:

struct TwoColumnSplitView: View {

    @State private var selectedCategoryId: MenuItem.ID?

    private var dataModel = CoffeeEquipmenModel()

    var body: some View {
        NavigationSplitView {
            List(dataModel.mainMenuItems, selection: $selectedCategoryId) { item in
                HStack {
                    Image(item.image)
                        .resizable()
                        .scaledToFit()
                        .frame(width: 50, height: 50)
                    Text(item.name)
                        .font(.system(.title3, design: .rounded))
                        .bold()
                }
            }

            .navigationTitle("Coffee")

        } detail: {
            if let selectedCategoryId,
               let categoryItems = dataModel.subMenuItems(for: selectedCategoryId) {

                List(categoryItems) { item in
                    HStack {
                        Image(item.image)
                            .resizable()
                            .scaledToFit()
                            .frame(width: 50, height: 50)
                        Text(item.name)
                            .font(.system(.title3, design: .rounded))
                            .bold()
                    }
                }
                .listStyle(.plain)
                .navigationBarTitleDisplayMode(.inline)

            } else {
                Text("Please select a category")
            }
        }

    }
}

NavigationSplitView 的第一個閉包就是主選單項目。讓我們使用 List 視圖來 loop through data model 中所有 mainMenuItem,並使用 HStack 視圖顯示每個選單項目。

我們也有一個狀態變數 (state variable),用於保存所選的主選單項目。

我們會在 detail 閉包中渲染子選單項目。如果一個類別被選擇了,我們就會調用 subMenuItems 方法,來獲取那個類別的子選單項目,並使用 List 視圖來顯示子選單項目。相反,在沒有選擇類別的情況下,我們就會顯示一個文本訊息,指示使用者選擇一個類別。

改好程式碼之後,你應該會在預覽版面中看到一個兩列的導航 UI。

swiftui-split-view-three-columns

構建一個三列的導航界面

現在我們構建好一個兩列的導航界面,接下來就看看如何為使用者提供三列的導航體驗吧!我們會利用新添加的一列來展示所選設備的照片。

swiftui-navigation-split-view-three-column-demo

如果我們想把兩列導航界面轉換為三列,就需要在 NavigationSplitView 實作一個 content 參數。讓我們這樣創建一個 ThreeColumnSplitView 新視圖:

struct ThreeColumnSplitView: View {
    @State private var selectedCategoryId: MenuItem.ID?
    @State private var selectedItem: MenuItem?

    private var dataModel = CoffeeEquipmenModel()

    var body: some View {
        NavigationSplitView {

            List(dataModel.mainMenuItems, selection: $selectedCategoryId) { item in
                HStack {
                    Image(item.image)
                        .resizable()
                        .scaledToFit()
                        .frame(width: 50, height: 50)
                    Text(item.name)
                        .font(.system(.title3, design: .rounded))
                        .bold()
                }
            }

            .navigationTitle("Coffee")
        } content: {

            if let selectedCategoryId,
               let subMenuItems = dataModel.subMenuItems(for: selectedCategoryId) {
                List(subMenuItems, selection: $selectedItem) { item in
                    NavigationLink(value: item) {
                        HStack {
                            Image(item.image)
                                .resizable()
                                .scaledToFit()
                                .frame(width: 50, height: 50)
                            Text(item.name)
                                .font(.system(.title3, design: .rounded))
                                .bold()
                        }
                    }
                }
                .listStyle(.plain)
                .navigationBarTitleDisplayMode(.inline)

            } else {
                Text("Please select a menu item")
            }

        } detail: {
            if let selectedItem {
                Image(selectedItem.image)
                    .resizable()
                    .scaledToFit()
            } else {
                Text("Please select an item")
            }
        }

    }
}

基本上,content 閉包中的程式碼應該與之前十分相似。content 參數是用來顯示子選單項目的。因此,我們會使用 List 視圖來顯示所選類別的子選單項目。

我們希望在子選單選擇了一個項目後,App 會顯示設備的照片。我們要在 detail 閉包中編寫程式碼,來實作這個功能。

更改程式碼後,我們應該會在預覽版面中看到一個兩列佈局。

swiftui-navigation-empty

在預設情況下,第一列會是隱藏的。我們需要點擊左上角的選單按鈕,才能顯示第一列。

如果我們想控制 split 視圖是否可見,可以宣告 NavigationSplitViewVisibility 類型的狀態變數,並把數值設置為 .all

@State private var columnVisibility = NavigationSplitViewVisibility.all

在實例化 NavigationSplitView 時,有一個 option 參數 columnVisibility。我們只需要傳遞 columnVisibility 的 binding,來控制不同的列是否可見。

swiftui-column-visiblity-split-view

NavigationSplitViewVisibility.all 值會讓 iPadOS 顯示全部三列。其他選項包括:

  • .automatic:使用當前設備預設的設定。
  • .doubleColumn:顯示三列 split 視圖的 content 和 detail 列。
  • .detailOnly:隱藏三列 split 視圖的前兩列,也就是說,只顯示 detail 列。

客製化 Navigation Split 視圖的樣式

你有沒有在 iPad 直向模式 (Portrait mode) 中測試過 App?在預設情況下,當 iPad 處於直向模式時,detail 列會佔據整個屏幕。 因此,當我們調出主選單和子選單時,detail 列就會被隱藏在這兩列後面。

swiftui-portrait-multiple-column

如果我們不喜歡這個樣式,可以把 .navigationSplitViewStyle 修飾符附加到 NavigationSplitView

NavigationSplitView(columnVisibility: $columnVisibility) {
  .
  .
  .
}
.navigationSplitViewStyle(.balanced)

預設值會是 .automatic。如果我們把數值設置為 .balanced,detail 到就會縮窄,並同時顯示前面的兩列。

swiftui-splitview-multi-column-balanced

總結

這篇教學文章簡單介紹了 iOS 16 的 NavigationSplitView。我們很容易就可以為 iPad 使用者創建多列導航的體驗,即使你的 App 是在 iPhone 上使用的,NavigationSplitView 都可以也可以自動適應 iPhone 更窄的螢幕。例如,當 iPhone 13 Pro Max 處於直向模式時,split 視圖就會顯示只有一列的導航界面,如果旋轉螢幕,split 視圖就會變成多列佈局。

在適合的情況下,大家都可以花點時間研究一下這個 split 視圖元件,並在 App 中應用。

如果你想更深入了解 NavigationSplitView,可以參閱這段 WWDC 影片

如果你喜歡這篇文章,又有興趣深入學習 SwiftUI,歡迎查閱我們的《精通 SwiftUI》一書。

譯者簡介:Kelly Chan-AppCoda 編輯小姐。
作者
Simon Ng
軟體工程師,AppCoda 創辦人。著有《iOS 17 App 程式設計實戰心法》、《iOS 17 App程式設計進階攻略》以及《精通SwiftUI》。曾任職於HSBC, FedEx等跨國企業,專責軟體開發、系統設計。2012年創立AppCoda技術部落格,定期發表iOS程式教學文章。現時專注發展AppCoda業務,致力於iOS程式教學、產品設計及開發。你可以到推特與我聯絡。
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。