SwiftUI NavigationStack 完全ガイド【2026年版】:型安全ナビゲーション・NavigationPath・ディープリンク実装

SwiftUIのNavigationStackを、値ベースルーティング・NavigationPath・ディープリンク・NavigationSplitViewとの使い分け・状態復元まで実装コード付きで解説。iOS 26/Xcode 26に対応した2026年版ガイド。

SwiftUI NavigationStack完全ガイド【2026】

最終更新: 2026年8月24日

SwiftUIのNavigationStackは、iOS 16以降で導入された型安全かつ値駆動(value-based)のナビゲーションコンテナで、非推奨のNavigationViewを置き換えます。NavigationPathnavigationDestination(for:)を組み合わせることで、ディープリンク・状態復元・プログラマティックなポップまで宣言的に記述できます。この記事では、私が実プロダクトで踏み抜いた地雷とその回避策も含めて、2026年時点でのベストプラクティスを実装レベルで解説します。

  • NavigationStackはiOS 16で導入され、NavigationViewはiOS 16.0以降で非推奨(deprecated)扱いです。
  • 新しいAPIは値ベースのナビゲーションで、NavigationLink(value:)navigationDestination(for:)をペアで使います。
  • NavigationPathを使うと、異なる型の画面遷移をひとつのスタックで型安全に管理できます。
  • ディープリンクはonOpenURLpath.appendを組み合わせて実装します。iOS 26では@Observableルーターとの相性が抜群です。
  • iPad/MacではNavigationSplitViewを優先し、必要に応じてNavigationStackを詳細ビュー側で入れ子にします。
  • NavigationPathCodable準拠の値型ならエンコード可能で、SceneStorageで状態復元できます。

NavigationStackは、SwiftUIで階層的な画面遷移を実現するためのコンテナビューで、WWDC22(iOS 16)で発表されました。従来のNavigationViewが「View自体を積む」設計だったのに対して、NavigationStackは「値を積む」設計に変わっています。この違いは些細に見えて根本的で、私は実務でこの変更のおかげで型消去(AnyView)とNavigationLink(isActive:)のバインディング地獄から解放されました。

宣言的アニメーションの観点でも、値駆動化されたことで遷移中の状態がSwiftUIランタイム側で管理されるようになり、matchedGeometryEffectやZoom Transition(iOS 18+)との連携が滑らかになりました。iOS 26で刷新されたLiquid Glassデザインのツールバー効果も、NavigationStackを前提に最適化されています。

最小の使い方は次の通りです。ルートビューをNavigationStackで囲み、遷移先の値をnavigationDestination(for:)で宣言します。

import SwiftUI

struct RootView: View {
    var body: some View {
        NavigationStack {
            List(Recipe.samples) { recipe in
                NavigationLink(recipe.name, value: recipe)
            }
            .navigationTitle("レシピ")
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetailView(recipe: recipe)
            }
        }
    }
}

struct Recipe: Hashable, Identifiable {
    let id = UUID()
    let name: String
    static let samples = [Recipe(name: "抹茶ラテ"), Recipe(name: "エスプレッソ")]
}

両者は名前は似ていますが、内部モデルは別物です。要点を表で整理します。

特徴NavigationView(〜iOS 15)NavigationStack(iOS 16+)
ステータスiOS 16以降で非推奨推奨API
ナビゲーション方式View駆動(NavigationLinkがViewを持つ)値駆動(Hashable値を積む)
プログラマティック制御isActive: Binding<Bool>(複数階層で破綻しがち)NavigationPathで任意深さを配列操作
複数型の遷移不可能/型消去が必要NavigationPathで異なる型を混在可能
ディープリンク実装が煩雑path.appendだけで完結
状態復元手動シリアライズCodable準拠なら自動シリアライズ可能
iPad分割ビューNavigationView(column style)NavigationSplitViewが専用

移行に際して私が最初にやるのは、NavigationLink(destination:)のイニシャライザを全部潰すことです。値ベースのNavigationLink(_:value:)に置き換えないと、SwiftUIがルーティングを最適化できません。とくにディープリンクを予定しているアプリでは、NavigationLinkが持つViewは絶対に事前構築しないのが鉄則です。

値ベースのNavigationLinkと型安全ルーティング

値ベースのNavigationLinkは、次の3つが揃って初めて機能します:(1)遷移先の値型がHashableである、(2)NavigationStackの子孫でnavigationDestination(for:)が宣言されている、(3)親のスタックで型が解決可能である。この3点セットを覚えておけば、90%のバグは自動的に回避できます。

複数の遷移先型を扱う場合、私はルートenumを必ず定義します。これはSwiftUIチームがWWDC22で紹介したパターンで、ドメインモデル(Recipeなど)と経路(Route.recipe)を分離できます。The SwiftUI cookbook for navigationで公式が推している設計です。

enum Route: Hashable {
    case recipe(Recipe)
    case ingredient(Ingredient)
    case settings
}

struct AppRoot: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .recipe(let r):    RecipeDetailView(recipe: r)
                    case .ingredient(let i): IngredientView(ingredient: i)
                    case .settings:          SettingsView()
                    }
                }
        }
    }
}

このenum中心アプローチには、@Observableマクロと組み合わせた「Routerパターン」がよく合います。@ObservableのRouterにpathを持たせておくと、深い階層のビューからでもEnvironment経由でプッシュ操作ができます。動作原理として、値がpathに追加されるとSwiftUIはランタイム側でnavigationDestinationのクロージャを解決し、対応するビューをレイジーに構築します。これがプッシュ時のクロスフェード遷移が0.3秒スムーズに走る理由です。

NavigationPathは、異なるHashable型を同じスタックに積むための型消去コンテナです。[Route]のような具体的な配列と違い、任意の型を混在できます。フレームワーク横断のルーティングを書くときや、複数モジュールで型を共有できないときに真価を発揮します。

@Observable
final class AppRouter {
    var path = NavigationPath()

    func push<V: Hashable>(_ value: V) {
        path.append(value)
    }

    func pop(_ count: Int = 1) {
        path.removeLast(min(count, path.count))
    }

    func popToRoot() {
        path = NavigationPath()
    }
}

struct AppRoot: View {
    @State private var router = AppRouter()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: Recipe.self)    { RecipeDetailView(recipe: $0) }
                .navigationDestination(for: Ingredient.self) { IngredientView(ingredient: $0) }
        }
        .environment(router)
    }
}

NavigationStackでルートに戻るには?

ルートビューまで戻すには、pathを空にするだけです。[Route]ならpath.removeAll()NavigationPathならpath = NavigationPath()のいずれかで一気に消えます。iOS 15までのNavigationLink(isActive:)連鎖を巻き戻す苦行と比べると、感動的なほど宣言的です。

struct DeepChildView: View {
    @Environment(AppRouter.self) private var router

    var body: some View {
        Button("ホームに戻る") {
            withAnimation(.smooth(duration: 0.35)) {
                router.popToRoot()
            }
        }
        .accessibilityHint("ナビゲーションをリセットしてホーム画面に戻ります")
    }
}

私はここにwithAnimation(.smooth)を必ず巻いています。理由は、Push/Pop自体のシステム遷移は0.35秒のイージング(近似的にcubic-bezier(0.4, 0.0, 0.2, 1.0))で走りますが、pathを一気に空にすると中間ビューが破棄されるタイミングが視覚的に飛ぶことがあるためです。.smoothで巻いておくとVoiceOverの「戻る」アナウンスも中断されず、フォーカスが正しくルートビューへ戻ります。

ディープリンクはonOpenURLで受け取ったURLをパースして、router.path.appendで目的の値を積むだけです。従来のNavigationLink(isActive:)ではネスト階層ごとにフラグを立てる必要がありましたが、NavigationStackなら1行です。

@main
struct RecipeApp: App {
    @State private var router = AppRouter()

    var body: some Scene {
        WindowGroup {
            AppRoot()
                .environment(router)
                .onOpenURL { url in
                    handle(url)
                }
        }
    }

    private func handle(_ url: URL) {
        // recipes://open/abc123 → 該当レシピを積む
        guard url.scheme == "recipes",
              url.host == "open",
              let id = url.pathComponents.dropFirst().first,
              let recipe = Recipe.find(id: id) else { return }

        router.popToRoot()          // 既存スタックをクリアしてから
        router.push(recipe)         // 深くプッシュ
    }
}

Universal Links対応も同じonOpenURLで受けられるので、認可設定(Associated Domains)さえ済ませればサーバー側の変更なしで動きます。Swift 6.2の厳格な並行処理下では、このhandle関数を@MainActorで明示的に囲むとコンパイラの警告が消えます。

NavigationStackはスタックベースの階層遷移、NavigationSplitViewは2〜3カラムのマスター/ディテール構成に最適です。iPad・Mac・visionOSで正しくレイアウトさせたいなら、ルートはNavigationSplitView、詳細カラムの中にNavigationStackを入れ子にするのが2026年時点の推奨です。

struct AdaptiveRoot: View {
    @State private var selectedCategory: Category?
    @State private var detailPath: [Route] = []

    var body: some View {
        NavigationSplitView {
            SidebarView(selection: $selectedCategory)
        } detail: {
            NavigationStack(path: $detailPath) {
                if let category = selectedCategory {
                    CategoryDetailView(category: category)
                        .navigationDestination(for: Route.self) { route in
                            RouteView(route: route)
                        }
                } else {
                    ContentUnavailableView("カテゴリを選択",
                                           systemImage: "square.grid.2x2")
                }
            }
            .id(selectedCategory)  // カテゴリ切替時にスタックをリセット
        }
    }
}

.id(selectedCategory)を付けることで、サイドバー選択が変わった際に詳細スタックがまるごと再構築されます。これを忘れると「別カテゴリを選んだのに前カテゴリの深い階層が残っている」というUXバグに直結します。私はこれで一度Appleの審査コメントを食らいました。

状態復元:Codable NavigationPathの永続化

アプリが終了・再起動しても直前の画面階層を復元したい場合、NavigationPathCodableな値型のみを含んでいれば、そのままエンコードできます。Apple公式のNavigationPathドキュメントにもこのパターンが記載されています。

@Observable
final class PersistedRouter {
    var path = NavigationPath()

    func save() -> Data? {
        guard let representation = path.codable else { return nil }
        return try? JSONEncoder().encode(representation)
    }

    func restore(from data: Data) {
        guard let representation = try? JSONDecoder()
            .decode(NavigationPath.CodableRepresentation.self, from: data)
        else { return }
        path = NavigationPath(representation)
    }
}

struct AppRoot: View {
    @State private var router = PersistedRouter()
    @SceneStorage("nav.path") private var storedPath: Data?

    var body: some View {
        NavigationStack(path: Bindable(router).path) {
            HomeView()
        }
        .onAppear {
            if let data = storedPath { router.restore(from: data) }
        }
        .onChange(of: router.path) { _, _ in
            storedPath = router.save()
        }
    }
}

path.codableNavigationPath.CodableRepresentation?を返します。nilが返ってきたら、スタックの中に非Codable型が混ざっている合図です。私は本番アプリではRouteという単一enumだけを積むように制約して、この失敗を構造的に防いでいます。

アクセシビリティとVoiceOverの遷移アナウンス

NavigationStackはVoiceOverと素晴らしく統合されていますが、放置すると「戻る」ボタンが英語で「Back」とだけ読まれることがあります。navigationTitleを各画面できちんと設定すると、VoiceOverが「〈タイトル〉、戻る、ボタン」と読み上げます。これは必須の設定です。

struct RecipeDetailView: View {
    let recipe: Recipe

    var body: some View {
        ScrollView { /* ... */ }
            .navigationTitle(recipe.name)
            .navigationBarTitleDisplayMode(.large)
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("お気に入り", systemImage: "heart") { /* ... */ }
                        .accessibilityLabel("お気に入りに追加")
                        .accessibilityHint("このレシピをお気に入りリストに保存します")
                }
            }
    }
}

プッシュ直後に特定要素へフォーカスを移したい場合は、AccessibilityFocusStateを使います。詳細画面を開いた瞬間にVoiceOverフォーカスをタイトルに固定するだけで、視覚障害のあるユーザーの体験は劇的に変わります。動作としては、pathへの追加→ビュー生成→SwiftUIランタイムがフォーカス要求を検知→VoiceOverが該当要素を読み上げ、という順で0.35秒の遷移アニメと同期します。

よくある落とし穴と解決策

ここまで書いた内容は理想論で、私が現場で殴られた地雷を4つ紹介します。

1. NavigationLink(destination:)の生存問題

旧APIのNavigationLink(destination:)は、リスト表示された瞬間に全遷移先ViewをSwiftUIが評価します。100件のリストで各詳細画面が重い場合、スクロールが引っかかります。NavigationLink(value:)にすると評価は遷移時まで遅延されます。

2. path.appendが反映されない

原因の8割は「navigationDestination(for:)で宣言している型と、appendした値の型が一致していない」ことです。Route.recipe(r)をappendしたのに、.navigationDestination(for: Recipe.self)としているケースです。型を揃えてください。

3. モーダル(sheet)の中でNavigationStackが動かない

これは正しい動作です。sheet内は独立したビュー階層になるため、sheet内で自前のNavigationStackを立てる必要があります。sheet内ルーターを別インスタンスで持つのがクリーンです。

4. TabView内でスタックが共有される

各タブに独立したスタックを持たせたい場合、タブごとにNavigationStack(path: $tabAPath)を分けます。ひとつのpathを共有すると、タブ切替でスタックが混線します。

よくある質問

NavigationStackはiOSの何バージョンから使えますか?

NavigationStackはiOS 16.0、iPadOS 16.0、macOS 13.0、tvOS 16.0、watchOS 9.0以上で利用できます。それ未満のOSをサポートする場合は#availableで分岐してNavigationViewのフォールバックが必要ですが、2026年時点でiOS 15以下のサポートを続けているプロジェクトは稀です。

NavigationStackとNavigationSplitViewはどちらを使うべきですか?

iPhoneオンリーならNavigationStackで十分です。iPad・Mac・visionOSも対象なら、ルートをNavigationSplitViewにして、詳細カラム内にNavigationStackをネストする構成が推奨されます。この組み合わせはAppleがWWDC22で公式に紹介したパターンです。

NavigationPathと配列([Route])はどちらが良いですか?

単一のenumだけを積むなら[Route]が読みやすく、要素の読み書きも自由です。複数の型を混在させたい・モジュール横断のルーティングが必要な場合はNavigationPathを選びます。ただしNavigationPathは要素を型ごとに読み取れない制約があります。

プログラマティックに複数階層をまとめてプッシュできますか?

できます。path.append(A())を連続して呼ぶか、[Route]ならpath = [.a, .b, .c]と代入するだけです。アニメーションは最終状態にひとつだけ走り、中間ビューは瞬時に構築されずスキップされます。ディープリンクからの復元でこの挙動が重要です。

NavigationLinkのタップでカスタムアクションを挟むには?

NavigationLinkはタップ時にpath更新しか行わないため、代わりにButtonを使って自前でrouter.push(value)を呼び、その中で解析イベントやプリロードを行います。値ベース設計のおかげで、Buttonでもリンクでも遷移経路は同一です。

Ava Thompson
著者について Ava Thompson

SwiftUI engineer focused on declarative animations and accessibility. Will fight you about navigation stacks.