以下の内容はhttps://yutailang0119.hatenablog.com/entry/2026/01/19/000000より取得しました。


SwiftUI.NavigationSplitViewとList.selectionのBinding

はてなエンジニアアドベントカレンダー2025 50日目の記事です。

SwiftUIのNavigationSplitViewを使ってるアプリは多くありません。
また、詳細な解説記事も少なく、最初は自分も誤用していました。
【SwiftUI】NavigationSplitView誤用しててonAppear呼ばれなかった件

今回は、NavigationSplitViewListを組み合わせた場合の挙動を見ていきます。

お題

まずは今回のお題を整理します。

環境

  • Xcode Version 26.2 (17C48)
  • SWIFT_VERSION = 6.0
  • iOS/iPadOS 26.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: ViewonAppear(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が使われるアプリが増え、知見が広く共有されることを期待しています!

はてなエンジニアアドベントカレンダー2025

今年もお疲れ様でした。 はてなエンジニア Advent Calendar 2025 - Hatena Developer Blog
よいお年を!🐍🔜🐎

🐈🐈‍⬛




以上の内容はhttps://yutailang0119.hatenablog.com/entry/2026/01/19/000000より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14