在 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》一書。