在 iOS 16 中,Apple 除了推出新的 NavigationStack 外,還有一個新的視圖容器 NavigationSplitView,讓開發者創建兩列 (column) 或三列的導航界面。如果你想構建類似內置郵件 App 的 UI,就應該看看這個 Split 視圖元件。
雖然 NavigationSplitView 比較適合用於 iPadOS 和 macOS App,但我們也可以在 iPhone 的 App 上使用它。這個視圖元件會自動適應 iPhone 螢幕,因此它不會顯示多列界面,而是會顯示單列界面。
新的 NavigationSplitView 有各種選項,讓我們可以客製化其外觀和操作。我們可以更改列的寬度,並以編程方式設定顯示或隱藏列。
在這篇教學文章中,我們會利用 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 樣式來構建展開式列表視圖。

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

在建立 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 陣列包含了範例選單項目,subMenuItems 和 menuItem 輔助方法可以用來查找特定類別或選單項目。
準備好 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。

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

如果我們想把兩列導航界面轉換為三列,就需要在 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 閉包中編寫程式碼,來實作這個功能。
更改程式碼後,我們應該會在預覽版面中看到一個兩列佈局。

在預設情況下,第一列會是隱藏的。我們需要點擊左上角的選單按鈕,才能顯示第一列。
如果我們想控制 split 視圖是否可見,可以宣告 NavigationSplitViewVisibility 類型的狀態變數,並把數值設置為 .all:
@State private var columnVisibility = NavigationSplitViewVisibility.all在實例化 NavigationSplitView 時,有一個 option 參數 columnVisibility。我們只需要傳遞 columnVisibility 的 binding,來控制不同的列是否可見。

NavigationSplitViewVisibility.all 值會讓 iPadOS 顯示全部三列。其他選項包括:
.automatic:使用當前設備預設的設定。.doubleColumn:顯示三列 split 視圖的 content 和 detail 列。.detailOnly:隱藏三列 split 視圖的前兩列,也就是說,只顯示 detail 列。
客製化 Navigation Split 視圖的樣式
你有沒有在 iPad 直向模式 (Portrait mode) 中測試過 App?在預設情況下,當 iPad 處於直向模式時,detail 列會佔據整個屏幕。 因此,當我們調出主選單和子選單時,detail 列就會被隱藏在這兩列後面。

如果我們不喜歡這個樣式,可以把 .navigationSplitViewStyle 修飾符附加到 NavigationSplitView:
NavigationSplitView(columnVisibility: $columnVisibility) {
.
.
.
}
.navigationSplitViewStyle(.balanced)預設值會是 .automatic。如果我們把數值設置為 .balanced,detail 到就會縮窄,並同時顯示前面的兩列。

總結
這篇教學文章簡單介紹了 iOS 16 的 NavigationSplitView。我們很容易就可以為 iPad 使用者創建多列導航的體驗,即使你的 App 是在 iPhone 上使用的,NavigationSplitView 都可以也可以自動適應 iPhone 更窄的螢幕。例如,當 iPhone 13 Pro Max 處於直向模式時,split 視圖就會顯示只有一列的導航界面,如果旋轉螢幕,split 視圖就會變成多列佈局。
在適合的情況下,大家都可以花點時間研究一下這個 split 視圖元件,並在 App 中應用。
如果你想更深入了解 NavigationSplitView,可以參閱這段 WWDC 影片。
如果你喜歡這篇文章,又有興趣深入學習 SwiftUI,歡迎查閱我們的《精通 SwiftUI》一書。