Post

SwiftUI NavigationSplitView Inside NavigationStack on iPhone

SwiftUI NavigationSplitView Inside NavigationStack on iPhone

背景

我在一個 JLPT 文法 app 裡做三欄導覽:

1
2
3
分類列表
→ 文法列表
→ 文法詳細

iPad / macOS 用 NavigationSplitView 很合理,因為它本來就是拿來做 sidebar、content、detail 這種多欄導覽。

原本首頁是另一個 NavigationStack

1
2
3
4
5
6
ContentView
└─ NavigationStack
   └─ HomeMenuView
      └─ 點「文法」
         └─ ThreeColumnContentView
            └─ NavigationSplitView

也就是說,NavigationSplitView 不是 app root,而是被首頁的 NavigationStack 推進去的下一頁。

這個結構在 Preview 看起來正常,在 landscape 也正常,但 iPhone portrait 實機測起來會出問題。

奇怪的現象

實機測到的狀況很詭異:

  1. 點首頁的「文法」後,Categories 標題會短暫出現。
  2. 接著畫面變空,只剩左上角返回鈕。
  3. 這時把手機轉成 landscape,畫面會正常出現兩欄。
  4. 再轉回 portrait,Categories 列表又正常出現。
  5. 但這時 Categories 標題離左上角返回鈕很遠,版面看起來不像正常進入畫面時的狀態。
  6. landscape 時可以正確點分類,右側顯示文法列表。
  7. portrait 初次進入時點分類可能會當掉;轉成 landscape 後同樣操作又正常。

這些現象不像資料沒載入,因為旋轉後同一份資料會出現。也不像 CategoryListView 本身壞掉,因為 landscape 可以正常操作。

比較像是:NavigationSplitView 在 iPhone compact 尺寸折疊成單一 stack 時,初次 presentation 的欄位狀態和外層 NavigationStack 發生不穩定。

試過的方法

1. 單一 NavigationSplitView

第一個方向是模仿 Apple 範例,讓 ThreeColumnContentView 永遠使用 NavigationSplitView

1
2
3
4
5
6
7
8
9
10
11
12
NavigationSplitView(
	columnVisibility: $navigationContext.columnVisibility,
	preferredCompactColumn: $navigationContext.preferredCompactColumn
) {
	CategoryListView()
} content: {
	PatternListView(categoryName: navigationContext.selectedCategoryName)
} detail: {
	NavigationStack {
		PatternDetailView(gacha: navigationContext.selectedPattern)
	}
}

Preview 正常,build 也正常。但 iPhone portrait 實機會出現前面說的空白畫面。

2. 用 preferredCompactColumn 指定初始欄位

接著嘗試在進入畫面時明確指定 compact 欄位:

1
2
3
4
5
6
7
if navigationContext.selectedPattern != nil {
	navigationContext.preferredCompactColumn = .detail
} else if navigationContext.selectedCategoryName != nil {
	navigationContext.preferredCompactColumn = .content
} else {
	navigationContext.preferredCompactColumn = .sidebar
}

理論上,沒有選分類、沒有選文法時,應該顯示 sidebar,也就是分類列表。

但實機仍不穩。preferredCompactColumn 比較像「偏好」,不是保證能修掉外層 NavigationStack 加上內層 NavigationSplitView 的初次折疊問題。

3. 延遲重新設定欄位

也想過在畫面出現後延遲一下,再設定一次:

1
2
3
4
5
.task {
	updatePreferredCompactColumn()
	try? await Task.sleep(nanoseconds: 250_000_000)
	updatePreferredCompactColumn()
}

這種做法太脆弱。它依賴 timing,而且無法保證不同 iPhone、不同 iOS 版本、不同動畫狀態都穩定。

最後沒有採用。

4. Preview 縮小實驗

我也做過一個 preview,把 NavigationSplitView 放進 NavigationStack destination,模擬首頁推進文法頁。

結果 Preview 會正常顯示分類列表。

這反而說明 Preview 不能代表實機。真正的問題發生在 iPhone portrait 實機初次 presentation 與折疊導覽狀態,不是單純語法或資料問題。

目前解法

最後採用的解法是:

  • regular 尺寸:繼續使用 NavigationSplitView
  • compact 尺寸:改用明確的 push-style NavigationStack 流程

也就是在 ThreeColumnContentView 判斷尺寸:

1
2
3
4
5
6
7
8
9
10
11
12
13
if horizontalSizeClass == .compact {
	GrammarCompactRootView()
} else {
	NavigationSplitView {
		CategoryListView()
	} content: {
		PatternListView(categoryName: navigationContext.selectedCategoryName)
	} detail: {
		NavigationStack {
			PatternDetailView(gacha: navigationContext.selectedPattern)
		}
	}
}

compact 版的第一層是分類列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private struct GrammarCompactRootView: View {
	@Query(sort: \Category.no) private var categories: [Category]

	var body: some View {
		List {
			NavigationLink {
				GrammarCompactPatternListView(
					categoryName: nil,
					title: GrammarNavigationText.allPatterns
				)
			} label: {
				Text(GrammarNavigationText.allPatterns)
			}

			ForEach(categories) { category in
				NavigationLink {
					GrammarCompactPatternListView(
						categoryName: category.name,
						title: category.name
					)
				} label: {
					Text(category.name)
				}
			}
		}
		.navigationTitle("Categories")
	}
}

compact 版的第二層是文法列表,點進去直接 push 詳細頁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private struct GrammarCompactPatternListView: View {
	let title: String
	@Query private var patterns: [Pattern]

	init(categoryName: String?, title: String) {
		self.title = title

		if let categoryName, categoryName.isEmpty == false {
			let predicate = #Predicate<Pattern> { pattern in
				pattern.category?.name == categoryName
			}
			_patterns = Query(
				filter: predicate,
				sort: [SortDescriptor(\Pattern.no, order: .forward)]
			)
		} else {
			_patterns = Query(
				sort: [SortDescriptor(\Pattern.no, order: .forward)]
			)
		}
	}

	var body: some View {
		List(patterns) { pattern in
			NavigationLink {
				PatternDetailView(gacha: pattern)
			} label: {
				HStack {
					Text(String(pattern.no))
						.font(.caption)
						.foregroundStyle(.secondary)
					Text(pattern.pattern)
				}
			}
		}
		.navigationTitle(title)
	}
}

這樣 iPhone portrait 不再依賴 NavigationSplitView 的折疊欄位推導,而是明確走:

1
2
3
分類列表
→ 文法列表
→ 文法詳細

iPad / macOS 則保留多欄體驗。

為什麼不是硬修 NavigationSplitView

這次的目標不是證明 NavigationSplitView 不能放在 NavigationStack 裡,而是讓這個 app 的導覽穩定。

Apple 文件提到 NavigationSplitView 在窄尺寸會折疊成單一 stack,並可用 preferredCompactColumn 提供偏好的欄位。但這不等於所有外層導覽組合都會在實機初次 presentation 時穩定。

Apple Developer Forums 上也有人遇到類似結構問題,回覆方向是避免把 NavigationSplitView 放進另一個 NavigationStack 裡;更典型的架構是讓 NavigationSplitView 位在較上層,必要的 NavigationStack 放在 detail 或各欄內。

如果這個 app 之後願意重做 app-level navigation,可以考慮:

  • 讓文法區的 NavigationSplitView 成為 root
  • 改成 TabView
  • 改成 app-level enum state,不用首頁 NavigationStack push 文法頁

但在目前首頁按鈕設計下,compact 尺寸使用專用 push-style stack 是比較穩的取捨。

參考資料

This post is licensed under CC BY 4.0 by the author.