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 實機測起來會出問題。
奇怪的現象
實機測到的狀況很詭異:
- 點首頁的「文法」後,
Categories標題會短暫出現。 - 接著畫面變空,只剩左上角返回鈕。
- 這時把手機轉成 landscape,畫面會正常出現兩欄。
- 再轉回 portrait,
Categories列表又正常出現。 - 但這時
Categories標題離左上角返回鈕很遠,版面看起來不像正常進入畫面時的狀態。 - landscape 時可以正確點分類,右側顯示文法列表。
- 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,不用首頁
NavigationStackpush 文法頁
但在目前首頁按鈕設計下,compact 尺寸使用專用 push-style stack 是比較穩的取捨。