はてなエンジニアアドベントカレンダー2025 50日目の記事です。
SwiftUIのNavigationSplitViewを使ってるアプリは多くありません。
また、詳細な解説記事も少なく、最初は自分も誤用していました。
【SwiftUI】NavigationSplitView誤用しててonAppear呼ばれなかった件
今回は、NavigationSplitViewとListを組み合わせた場合の挙動を見ていきます。
お題
まずは今回のお題を整理します。
環境
- Xcode Version 26.2 (17C48)
- SWIFT_VERSION = 6.0
- iOS/iPadOS 26.2
NavigationSplitView の2カラム表示
今回は簡略化のためNavigationSplitView.init(sidebar:detail:)を採用します。
sidebarにList表示、detailにList選択の詳細表示といった具合です。
NavigationSplitView.init(sidebar:content:detail:)は3カラムになりますが、利用方法は同じです。
Listは標準的すぎて扱いづらいという意見もありますが、プラットフォームごとのLook and Feelが適用されるので、好んで使用しています。
表示データ
表示用のデータは以下を用います。
Listに表示、selectionにBindする都合、HashableかつIdentifiableにしています。
struct Symbol: Hashable, Identifiable {
var name: String
var emoji: String
var id: String { name }
static var symbols: [Symbol] {
[
Symbol(name: "Rat", emoji: "🐀"),
Symbol(name: "Ox", emoji: "🐂"),
Symbol(name: "Tiger", emoji: "🐅"),
Symbol(name: "Rabbit", emoji: "🐇"),
Symbol(name: "Dragon", emoji: "🐉"),
Symbol(name: "Snake", emoji: "🐍"),
Symbol(name: "Horse", emoji: "🐎"),
Symbol(name: "Goat", emoji: "🐐"),
Symbol(name: "Monkey", emoji: "🐒"),
Symbol(name: "Rooster", emoji: "🐓"),
Symbol(name: "Dog", emoji: "🐕"),
Symbol(name: "Boar", emoji: "🐗"),
]
}
}
List表示とDetail遷移
表示と遷移を実装します。
実装
struct SplitView: View {
@State private var selection: Symbol?
var body: some View {
NavigationSplitView {
Sidebar(selection: $selection)
} detail: {
Detail(selection: selection)
}
}
}
[f:id:yutailang0119:20260117135652p:plain][f:id:yutailang0119:20260117141443p:plain]
extension SplitView {
struct Sidebar: View {
private let symbols: [Symbol] = Symbol.symbols
@Binding var selection: Symbol?
var body: some View {
List(symbols, id: \.self, selection: $selection) {
Text($0.name)
}
}
}
struct Detail: View {
let selection: Symbol?
var body: some View {
if let selection {
Text(selection.emoji).font(.largeTitle)
} else {
ContentUnavailableView("Select from sidebar", systemImage: "pawprint")
}
}
}
}
実行
| Regularサイズ表示 |
選択状態 |
 |
 |
GIF
Regularサイズ表示、選択状態
Detailへの表示とList.selectionをBindすると、選択状態がListに反映されます。
iPhone表示だと見えづらい部分ですが、表示条件分けを意識する必要がないのは素晴らしいですね。
Overviewにもある通りですが、List.selectionに適切にBindすると、List (またはその内部のForEach)に表示するRowContentはNavigationLinkにする必要はありません。
それどころかButtonでなくとも、Listがタップを判定してselectionに状態を伝えてくれます。
一点不明なのは、ドキュメントのサンプルにはない id: \.self, を指定しないと、Bindが動かなかったことです。
Identifiableなのですが...
List以外からの遷移
SidebarのToolbarからDetailに別画面を表示したいことは、典型的なユースケースです。
Detailの選択肢をenumで表現しましょう。
enum Selection: Equatable {
case symbol(Symbol)
case cats
}
実装
型が異なり、直接@BindingにBindできない箇所には、Binding.init(get:set:)を使って変換します。
struct SplitView: View {
@State private var selection: Selection?
var body: some View {
NavigationSplitView {
Sidebar(
selection: Binding {
switch selection {
case .symbol(let symbol): symbol
case .cats, nil: nil
}
} set: {
selection = $0.flatMap(Selection.symbol)
}
)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
selection = .cats
} label: {
Label("Cats", systemImage: "cat")
}
}
}
} detail: {
Detail(selection: selection)
}
}
}
extension SplitView {
struct Sidebar: View {
private let symbols: [Symbol] = Symbol.symbols
@Binding var selection: Symbol?
var body: some View {
List(symbols, id: \.self, selection: $selection) {
Text($0.name)
}
}
}
struct Detail: View {
let selection: Selection?
var body: some View {
switch selection {
case .symbol(let symbol):
Text(symbol.emoji).font(.largeTitle)
case .cats:
Text("🐈🐈<200d>⬛").font(.largeTitle)
default:
ContentUnavailableView("Select from sidebar", systemImage: "pawprint")
}
}
}
}
実行
| Regularサイズ表示 |
選択状態 |
 |
 |
しかし、Compact表示 (iPhone) ではToolbarのボタンからの遷移が機能しません。
Catボタンから遷移しない
デバッグしてみるとDetail: ViewのonAppear(perform:)は呼ばれ、
onDisappear(perform:)は呼ばれていませんでした。
NavigationSplitViewの表示管理がうまくいっていないようです。
この場合はNavigationSplitViewColumnのState管理を、自分でやるとうまくいきます。
Binding<NavigationSplitViewColumn>
NavigationSplitView.init(preferredCompactColumn:sidebar:detail:)を使うと、外側からNavigationSplitViewColumnをBindできます。
preferredCompactColumn: Binding<NavigationSplitViewColumn> なので、変化に連動してNavigationSplitViewが表示するカラムを決定します。
実装
struct SplitView: View {
@State private var preferredColumn: NavigationSplitViewColumn = .sidebar
var body: some View {
NavigationSplitView(preferredCompactColumn: $preferredColumn) {
Sidebar(...)
} detail: {
Detail(...)
}
.onChange(of: selection) { _, newValue in
preferredColumn = newValue == nil ? .sidebar : .detail
}
}
}
onChange(of:initial:_:)で、preferredColumnを更新するのがポイントです。
これで selection = .cats の代入に合わせて、preferredColumn = .detail が行われ、画面遷移が動きます。
実行
Catsに遷移できるようになった
まとめ
SwiftUI.NavigationSplitViewとListを組み合わせた画面遷移、選択状態の管理を解説しました。
NavigationSplitViewはクセが強いやつで、この他にも知らないと引っかかる罠が存在しますが、扱えるようになると選択できるUI表現の幅が広がります。
2026年はNavigationSplitViewが使われるアプリが増え、知見が広く共有されることを期待しています!
今年もお疲れ様でした。 はてなエンジニア Advent Calendar 2025 - Hatena Developer Blog
よいお年を!🐍🔜🐎
🐈🐈⬛