SwiftUI 4.0 的全新導航系統

語言: CN / TW / HK

highlight: a11y-dark

原文發表在我的部落格 wwww.fatbobman.com

歡迎訂閱我的公共號:【肘子的Swift記事本】

長久以來,開發者對 SwiftUI 的導航系統頗有微詞。受 NavigationView 的能力限制,開發者需要動用各種技巧乃至黑科技才能實現一些本應具備基本功能(例如:返回根檢視、向堆疊新增任意檢視、返回任意層級檢視 、Deep Link 跳轉等 )。SwiftUI 4.0( iOS 16+ 、macOS 13+ )對導航系統作出了重大改變,提供了以檢視堆疊為管理物件的新 API ,讓開發者可以輕鬆實現程式設計式導航。本文將對新的導航系統作以介紹。

一分為二

新的導航系統最直接的變化是廢棄了 NavigationView,將其功能分成了兩個單獨的控制元件 NavigationStack 和 NavigationSplitView。

NavigationStack 針對的是單欄的使用場景,例如 iPhone 、Apple TV、Apple Watch:

swift NavigationStack {} // 相當於 NavigationView{} .navigationViewStyle(.stack)

NavigationSplitView 則針對的是多欄場景,例如 :iPadOS 、macOS:

```swift NavigationSplitView { SideBarView() } detail: { DetailView() }

// 對應的是雙列場景

NavigationView { SideBarView() DetailView() } .navigationViewStyle(.columns) ```

navigationSplitView_2_demo

```swift NavigationSplitView { SideBarView() } content: { ContentView() } detail: { DetailView }

// 對應的是三列場景 NavigationView { SideBarView() ContentView() DetailView() } .navigationViewStyle(.columns) ```

navigationSplitView_3_demo

相較於通過 navigationViewStyle 設定 NavigationView 樣式的做法,一分為二的方式將讓佈局表達更加清晰,同時也會強迫開發者為 SwiftUI 應用對 iPadOS 和 macOS 做更多的適配。

在 iPhone 這類裝置中,NavigationSplitView 會自動進行單欄適配。但是無論是切換動畫、程式設計式 API 介面等多方面都與 NavigationStack 明顯不同。因此對於支援多硬體平臺的應用來說,最好針對不同的場景分別使用對應的導航控制元件。

兩個元件兩種邏輯

相較於控制元件名稱上的改變,程式設計式導航 API 才是本次更新的最大亮點。使用新的程式設計式 API ,開發者可以輕鬆地實現例如:返回根檢視、在當前檢視堆疊中新增任意檢視( 檢視跳轉 )、檢視外跳轉( Deep Link )等功能。

蘋果為 NavigationStack 和 NavigationSplitView 提供了兩種不同邏輯的 API ,這點或許會給部分開發者造成困擾。

NavigationView 的程式設計式導航

NavigationView 其實是具備一定的程式設計式導航能力的,比如,我們可以通過以下兩種 NavigationLink 的構造方法來實現有限的程式設計式跳轉:

swift init<S>(_ title: S, isActive: Binding<Bool>, @ViewBuilder destination: () -> Destination) init<S, V>(_ title: S, tag: V, selection: Binding<V?>, @ViewBuilder destination: () -> Destination)

上述兩種方法有一定的侷限性:

  • 需要逐級檢視進行繫結,開發者如想實現返回任意層級檢視則需要自行管理狀態
  • 在宣告 NavigationLink 時仍需設定目標檢視,會造成不必要的例項建立開銷
  • 較難實現從檢視外呼叫導航功能

“能用,但不好用” 可能就是對老版本程式設計式導航比較貼切地總結。

NavigationStack

NavigationStack 從兩個角度入手以解決上述問題。

基於型別的響應式目標檢視處理機制

比如下面的程式碼是在老版本( 4.0 之前 )SwiftUI 中使用程式設計式跳轉的一種方式:

```swift struct NavigationViewDemo: View { @State var selectedTarget: Target? @State var target: Int? var body: some View { NavigationView{ List{ NavigationLink("SubView1", destination: SubView1(), tag: Target.subView1, selection: $selectedTarget) // SwiftUI 在進入當前檢視時,無論是否進入目標檢視,均將建立其例項( 不對 body 求值 ) NavigationLink("SubView2", destination: SubView2(), tag: Target.subView1, selection: $selectedTarget) NavigationLink("SubView3", destination: SubView3(), tag: 3, selection: $target) NavigationLink("SubView4", destination: SubView4(), tag: 4, selection: $target) } } }

enum Target {
    case subView1,subView2
}

} ```

NavigationStack 實現上述功能將更加地清晰、靈活和高效。

```swift struct NavigationStackDemo: View { var body: some View { NavigationStack { List { NavigationLink("SubView1", value: Target.subView1) // 只宣告關聯的狀態值 NavigationLink("SubView2", value: Target.subView2) NavigationLink("SubView3", value: 3) NavigationLink("SubView4", value: 4) } .navigationDestination(for: Target.self){ target in // 對同一型別進行統一處理,返回目標檢視 switch target { case .subView1: SubView1() case .subView2: SubView2() } } .navigationDestination(for: Int.self) { target in // 為不同的型別新增多個處理模組 switch target { case 3: SubView3() default: SubView4() } } } }

enum Target {
    case subView1,subView2
}

} ```

NavigationStack 的處理方式有以下特點和優勢:

  • 由於無需在 NavigationLink 中指定目標檢視,因此無須建立多餘的檢視例項
  • 對由同一型別的值驅動的目標進行統一管理( 可以將堆疊中所有檢視的 NavigationLink 處理程式統一到根檢視中 ),有利於複雜的邏輯判斷,也方便剝離程式碼
  • NavigationLink 將優先使用最接近的型別目標管理程式碼。例如根檢視,與第三層檢視都通過 navigationDestination 定義了對 Int 的響應,那麼第三層及其之上的檢視將使用第三層的處理邏輯

可管理的檢視堆疊系統

相較於基於型別的響應式目標檢視處理機制,可管理的檢視堆疊系統才是新導航系統的殺手鐗。

NavigationStack 支援兩種堆疊管理型別:

  • NavigationPath

通過新增多個的 navigationDestination ,NavigationStack 可以對多種型別值( Hashable )進行響應,使用 removeLast(_ k: Int = 1) 返回指定的層級,使用 append 進入新的層級

```swift class PathManager:ObservableObject{ @Published var path = NavigationPath() }

struct NavigationViewDemo1: View { @StateObject var pathManager = PathManager() var body: some View { NavigationStack(path:$pathManager.path) { List { NavigationLink("SubView1", value: 1) NavigationLink("SubView2", value: Target.subView2) NavigationLink("SubView3", value: 3) NavigationLink("SubView4", value: 4) } .navigationDestination(for: Target.self) { target in switch target { case .subView1: SubView1() case .subView2: SubView2() } } .navigationDestination(for: Int.self) { target in switch target { case 1: SubView1() case 3: SubView3() default: SubView4() } } } .environmentObject(pathManager) .task{ // 使用 append 可以跳入指定層級,下面將為 root -> SubView3 -> SubView1 -> SubView2 ,在初始狀態新增層級將遮蔽動畫 pathManager.path.append(3) pathManager.path.append(1) pathManager.path.append(Target.subView2) } } }

enum Target { case subView1, subView2 }

struct SubView1: View { @EnvironmentObject var pathManager:PathManager var body: some View { List{ // 仍然可以使用此種形式的 NavigationLink,目標檢視的處理在根檢視對應的 navigationDestination 中 NavigationLink("SubView2", destination: Target.subView2 ) NavigationLink("subView3",value: 3) Button("go to SubView3"){ pathManager.path.append(3) // 效果與上面的 NavigationLink("subView3",value: 3) 一樣 } Button("返回根檢視"){ pathManager.path.removeLast(pathManager.path.count)
} Button("返回上層檢視"){ pathManager.path.removeLast() } } } } ```

  • 元素為符合 Hashable 的單一型別序列

採用此種堆疊,NavigationStack 將只能響應該序列元素的特定型別

```swift class PathManager:ObservableObject{ @Published var path:[Int] = [] // Hashable 序列 }

struct NavigationViewDemo1: View { @StateObject var pathManager = PathManager() var body: some View { NavigationStack(path:$pathManager.path) { List { NavigationLink("SubView1", value: 1) NavigationLink("SubView3", value: 3) NavigationLink("SubView4", value: 4) } // 只能響應序列元素型別 .navigationDestination(for: Int.self) { target in switch target { case 1: SubView1() case 3: SubView3() default: SubView4() } } } .environmentObject(pathManager) .task{ pathManager.path = [3,4] // 直接跳轉到指定層級,賦值更加方便 } } }

struct SubView1: View { @EnvironmentObject var pathManager:PathManager var body: some View { List{ NavigationLink("subView3",value: 3) Button("go to SubView3"){ pathManager.path.append(3) // 效果與上面的 NavigationLink("subView3",value: 3) 一樣 } Button("返回根檢視"){ pathManager.path.removeAll() } Button("返回上層檢視"){ if pathManager.path.count > 0 { pathManager.path.removeLast() } } Button("響應 Deep Link,重置 Path Stack "){ pathManager.path = [3,1,1] // 會自動遮蔽動畫 }

    }
}

} ```

開發者可以根據自己的需求選擇對應的檢視堆疊型別。

⚠️ 在使用堆疊管理系統的情況下,請不要在程式設計式導航中混用宣告式導航,這樣會破壞當前的檢視堆疊資料

下面的程式碼,如果點選宣告式導航,將導致堆疊資料重置。

swift NavigationLink("SubView3",value: 3) NavigationLink("SubView4", destination: { SubView4() }) // 不要在程式設計式導航中混用宣告式導航

NavigationSplitView

如果說 NavigationStack 是在三維的空間裡堆疊檢視,那麼 NavigationSplitView 便是在二維的空間中於不同的欄之間動態切換檢視。

分欄佈局

在 SwiftUI 4.0 之前的版本,可以這樣使用 NavigationView 來建立擁有左右兩個欄的程式設計式導航檢視:

```swift class MyStore: ObservableObject { @Published var selection: Int? }

struct NavigationViewDoubleColumnView: View { @StateObject var store = MyStore() var body: some View { NavigationView { SideBarView() DetailView() } .environmentObject(store) } }

struct SideBarView: View { @EnvironmentObject var store: MyStore var body: some View { List(0..<30, id: .self) { i in // 此處我們沒有使用 NavigationLink 來切換右側檢視,而是改變了 seletion 的值,讓右側檢視響應該值的變化
Button("ID: (i)") { store.selection = i } } } }

struct DetailView: View { @EnvironmentObject var store: MyStore var body: some View { if let selection = store.selection { Text("檢視:(selection)") } else { Text("請選擇") } } } ```

double_colunm_2022-06-11_10.16.38

用 NavigationSplitView 實現上面的程式碼基本上一樣。最大的區別是,SwiftUI 4.0 為我們提供了在 NavigationSplitView 中通過 List 快速繫結資料的能力。

```swift struct NavigationSplitViewDoubleColumnView: View { @StateObject var store = MyStore() var body: some View { NavigationSplitView { SideBarView() } detail: { DetailView() } .environmentObject(store) } }

struct SideBarView: View { @EnvironmentObject var store: MyStore var body: some View { // 可以在 List 中直接繫結資料,無需通過 Button 顯式進行修改 List(0..<30, id: .self, selection: $store.selection) { i in NavigationLink("ID: (i)", value: i) // 使用程式設計式的 NavigationLink } } } ```

image-20220611104123815

由於 SwiftUI 4.0 為 List 提供了進一步的加強,我們還可以不使用 NavigationLink ,改寫成下面的程式碼:

swift struct SideBarView: View { @EnvironmentObject var store: MyStore var body: some View { List(0..<30, id: \.self, selection: $store.selection) { i in Text("ID: \(i)") // 也可以換成 Label 或其他檢視 ,但不能是 Button // NavigationLink("ID: \(i)", value: i) } } }

SwiftUI 4.0 中,在 List 綁定了資料後,通過 List 構造方法建立的迴圈或 ForEach 建立的迴圈中的內容( 不能自帶點選屬性,例如 Button 或 onTapGesture ),將被隱式新增 tag 修飾符,從而具備點選後可更改繫結資料的能力

無論將 List 放置在 NavigationSplitView 的最左側一欄( 雙欄模式 )還是左側兩欄中( 三欄模式 ),都可以通過 List 的繫結資料進行導航。這是 NavigationSplitView 的獨有功能。

與 NavigationStack 合作

在 SwiftUI 4.0 之前,對於多欄的 NavigationView ,如果我們想在 SideBar 欄內實現堆疊跳轉的話,可以使用如下程式碼:

swift struct SideBarView: View { @EnvironmentObject var store: MyStore var body: some View { List(0..<30, id: \.self) { i in NavigationLink("ID: \(i)", destination: Text("\(i)")) // 必須使用 NavigationLink .isDetailLink(false) // 指定 destination 不要顯示在 Detail 列中 } } }

但如果,我們想在 Detail 欄中也想嵌入一個可以實現堆疊跳轉的 NavigationView 則會有很大的問題。此時在 Detail 欄中將出現兩個 NavigationTitle 以及兩個 Toolbar 。

```swift struct NavigationViewDoubleColumnView: View { @StateObject var store = MyStore() var body: some View { NavigationView { SideBarView() DetailView() .navigationTitle("Detail") // 為 Detail 欄定義 title .toolbar{ EditButton() // 在 Detail 欄建立按鈕 } } .environmentObject(store) } }

struct SideBarView: View { @EnvironmentObject var store: MyStore var body: some View { List(0..<30, id: .self) { i in Button("ID: (i)") { store.selection = i } } } }

struct DetailView: View { @EnvironmentObject var store: MyStore var body: some View { NavigationView { VStack { if let selection = store.selection { NavigationLink("檢視詳情", destination: Text("(selection)")) } else { Text("請選擇") } } .toolbar{ EditButton() // 在 Detail 欄中的 NavigationView 建立按鈕 } .navigationTitle("Detail") // 為 Detail 欄中的 NavigationView 定義 Title .navigationBarTitleDisplayMode(.inline) } .navigationViewStyle(.stack) } } ```

image-20220611110657857

為此,我之前不得已在 iPad 版本的應用程式中,使用 HStack 來避免出現上述問題。詳情請參閱 在 SwiftUI 下對 iPad 進行適配

NavigationSpiteView 已經解決了上述問題,它現在可以同 NavigationStack 進行完美的合作。

```swift class MyStore: ObservableObject { @Published var selection: Int? }

struct NavigationSplitViewDoubleColumnView: View { @StateObject var store = MyStore() var body: some View { NavigationSplitView { SideBarView() } detail: { DetailView() .toolbar { EditButton() // 在 Detail 欄中的 NavigationView 建立按鈕 } .navigationTitle("Detail") } .environmentObject(store) } }

struct SideBarView: View { @EnvironmentObject var store: MyStore var body: some View { List(0..<30, id: .self, selection: $store.selection) { i in Text("ID: (i)") } } }

struct DetailView: View { @EnvironmentObject var store: MyStore var body: some View { NavigationStack { VStack { if let selection = store.selection { NavigationLink("檢視詳情", value: selection) } else { Text("請選擇") } } .navigationDestination(for: Int.self, destination: { Text("($0)") }) .toolbar { RenameButton() // 在 Detail 欄中的 NavigationView 建立按鈕 } .navigationTitle("Detail inLine") .navigationBarTitleDisplayMode(.inline) } } } ```

NavigationSplitView 會保留最近的 Title 設定,並對分別在 NavigationSplitView 和 NavigationStack 中為 Detail 欄新增的 Toolbar 按鈕進行合併。

image-20220611134247340

通過在 NavigationSplitView 中使用 NavigationStack ,開發者擁有了更加豐富的檢視排程能力。

動態控制多欄顯示狀態

另一個之前困擾多欄 NavigationView 的問題就是,無法通過程式設計的手段動態地控制多欄顯示狀態。NavigationSplitView 在構造方法中提供了 columnVisibility 引數 ( NavigationSplitViewVisibility 型別 ),通過設定該引數,開發者擁有了對導航欄顯示狀態的控制能力。

swift struct NavigationSplitViewDoubleColumnView: View { @StateObject var store = MyStore() @State var mode: NavigationSplitViewVisibility = .all var body: some View { NavigationSplitView(columnVisibility: $mode) { SideBarView() } content: { ContentColumnView() } detail: { DetailView() } .environmentObject(store) } }

three_column_2022-06-11_13.52.10

  • detailOnly 只顯示 Detail 欄( 最右側欄 )
  • doubleColumn 在三欄狀態下隱藏 Sidebar ( 最左側 )欄
  • all 顯示所有的欄
  • automatic 根據當前的上下文自動決定顯示行為

上述選項並非適用於所有的平臺,例如,在 macOS 上,detalOnly 不會起作用

如果想在 SwiftUI 4.0 之前的版本上使用類似的功能,可以參考我在 用 NavigationViewKit 增強 SwiftUI 的導航檢視 一文中的實現方法

其他增強

除了上述的功能, 新的導航系統還在很多其他的地方也進行了增強。

設定欄寬度

NavigationSplitView 為欄中的檢視提供了一個新的修飾符 navigationSplitViewColumnWidth ,通過它開發者可以修改欄的預設寬度:

swift struct NavigationSplitViewDemo: View { @State var mode: NavigationSplitViewVisibility = .all var body: some View { NavigationSplitView(columnVisibility: $mode) { SideBarView() .navigationSplitViewColumnWidth(200) } content: { ContentColumnView() .navigationSplitViewColumnWidth(min: 100, ideal: 150, max: 200) } detail: { DetailView() } } }

設定 NavigationSplitView 的樣式

使用 navigationSplitViewStyle 可以設定 NavigationSplitView 的樣式

swift struct NavigationSplitViewDemo: View { @State var mode: NavigationSplitViewVisibility = .all var body: some View { NavigationSplitView(columnVisibility: $mode) { SideBarView() } content: { ContentColumnView() } detail: { DetailView() } .navigationSplitViewStyle(.balanced) // 設定樣式 } }

  • prominentDetail

無論左側欄顯示與否,保持右側的 Detail 欄尺寸不變( 通常是全屏 )。iPad 在 Portrait 顯示狀態下,預設即為此種模式

  • balanced

在顯示左側欄的時候,縮小右側 Detail 欄的尺寸。iPad 在 landscape 顯示狀態下,預設即為此種模式

  • automatic

預設值,根據上下文自動調整外觀樣式

在 NavigationTitle 中新增選單

使用新的 navigationTitle 構造方法,可以將選單嵌入到標題欄中。

swift .navigationTitle( Text("Setting"), actions: { Button("Action1"){} Button("Action2"){} Button("Action3"){} })

image-20220612085945286

更改 NavigationBar 背景色

swift NavigationStack{ List(0..<30,id:\.self){ i in Text("\(i)") } .listStyle(.plain) .navigationTitle("Hello") .toolbarBackground(.pink, in: .navigationBar) }

RocketSim_Screenshot_iPhone_13_Pro_Max_2022-06-12_09.12.01

NavigationStack 的 toolbar 背景色只有在檢視上滾時才會顯示。

SwiftUI 4.0 中,將 toolbar 的認定範圍擴大到了 TabView 。在 toolbar 的設定中,通過 placement 可以設定適用的物件

隱藏 toolbar

swift NavigationStack { ContentView() .toolbar(.hidden, in: .navigationBar) }

設定 toolbar 的色彩外觀( Color Scheme )

swift .toolbarColorScheme(.dark, in: .navigationBar)

RocketSim_Screenshot_iPhone_13_Pro_Max_2022-06-12_09.21.29

Toolbar 角色

使用 toolbarRole 設定當前 toolbar 的角色身份。不同的角色將讓 toolbar 的外觀和排版有所不同( 視裝置而異 )。

swift struct ToolRoleTest: View { var body: some View { NavigationStack { List(0..<10, id: \.self) { NavigationLink("ID: \($0)", value: $0) } .navigationDestination(for: Int.self) { Text("\($0)") .navigationTitle("Title for \($0)") .toolbarRole(.editor) } .navigationTitle("Title") .navigationBarTitleDisplayMode(.inline) .toolbarRole(.browser) .toolbar { ToolbarItem(placement: .primaryAction) { EditButton() } } } } }

  • navigationStack

預設角色,長按可顯示檢視堆疊列表

  • browser

在 iPad 下,當前檢視的 Title 將顯示在左側

image-20220612190914949

  • editor

不顯示返回按鈕旁邊的上頁檢視 Title

image-20220612191040190

定製 NavigationLink 樣式

在之前版本的 SwiftUI 中,NavigationLink 其實一直都是作為一種特殊的 Button 存在的。到了 SwiftUI 4.0 版本後,SwiftUI 已經將其真正的視為了 Button 。

swift NavigationStack { VStack { NavigationLink("Hello world", value: "sub1") .buttonStyle(.bordered) .controlSize(.large) NavigationLink("Goto next", destination: Text("Next")) .buttonStyle(.borderedProminent) .controlSize(.large) .tint(.red) } .navigationDestination(for: String.self){ _ in Text("Sub View") } }

image-20220613220926715

總結

SwiftUI 4.0 導航系統的變化如此之大,開發者在驚喜的同時,也要冷靜的面對事實。相當一部分開發者由於版本適配的原因並不會使用新的 API ,因此,每個人都需要認真考慮如下問題:

  • 如何從新 API 中獲得靈感
  • 如何在老版本中運用程式設計式導航思想
  • 如何讓新老版本的程式都能享受系統提供的便利

另一方面,新導航系統也向每一個開發者傳遞了明確的訊號,蘋果希望應用能夠為 iPad 和 macOS 提供更加符合各自裝置特點的 UI 介面。這種訊號會越來越強,蘋果也為此會提供越來越多的 API。

目前已經有人實現了 NavigationStack 在低版本 SwiftUI 下的仿製品 —— NavigationBackport ,有興趣的朋友可以參考作者的實現方式

希望本文能夠對你有所幫助。

原文發表在我的部落格 wwww.fatbobman.com

歡迎訂閱我的公共號:【肘子的Swift記事本】