最終更新: 2026年8月24日
SwiftUIのNavigationStack は、iOS 16以降で導入された型安全かつ値駆動(value-based)のナビゲーションコンテナで、非推奨のNavigationViewを置き換えます。NavigationPathとnavigationDestination(for:)を組み合わせることで、ディープリンク・状態復元・プログラマティックなポップまで宣言的に記述できます。この記事では、私が実プロダクトで踏み抜いた地雷とその回避策も含めて、2026年時点でのベストプラクティスを実装レベルで解説します。
NavigationStack はiOS 16で導入され、NavigationViewはiOS 16.0以降で非推奨(deprecated)扱いです。
新しいAPIは値ベースのナビゲーション で、NavigationLink(value:)とnavigationDestination(for:)をペアで使います。
NavigationPathを使うと、異なる型の画面遷移をひとつのスタックで型安全に管理できます。
ディープリンクはonOpenURLとpath.appendを組み合わせて実装します。iOS 26では@Observableルーターとの相性が抜群です。
iPad/MacではNavigationSplitView を優先し、必要に応じてNavigationStackを詳細ビュー側で入れ子にします。
NavigationPathはCodable準拠の値型ならエンコード可能で、SceneStorageで状態復元できます。
目次
NavigationStackとは何か?NavigationViewからの進化
NavigationStackとNavigationViewの違いは?
値ベースのNavigationLinkと型安全ルーティング
NavigationPathの使い方と実装パターン
NavigationStackでルートに戻るには?
ディープリンクをNavigationStackで実装する
NavigationSplitViewとの使い分け(iPad/Mac対応)
状態復元:Codable NavigationPathの永続化
アクセシビリティとVoiceOverの遷移アナウンス
よくある落とし穴と解決策
よくある質問
NavigationStackとは何か?NavigationViewからの進化
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: "エスプレッソ")]
}
ノート: navigationDestination(for:)は親のNavigationStackの中 で宣言する必要があります。Listの外や別モジュールに置くと「A NavigationLink is presenting a value…」という警告が出て遷移が動作しません。
NavigationStackとNavigationViewの違いは?
両者は名前は似ていますが、内部モデルは別物です。要点を表で整理します。
特徴 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の使い方と実装パターン
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)
}
}
Tip: NavigationPathから個別要素を読み取ることはできません(型消去のため)。「上から2番目のRecipeを取得したい」といったランダムアクセスが必要なら、[Route]で単一enumを使う方が扱いやすいです。
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の「戻る」アナウンスも中断されず、フォーカスが正しくルートビューへ戻ります。
ディープリンクをNavigationStackで実装する
ディープリンクは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) // 深くプッシュ
}
}
警告: ディープリンク処理は必ずpopToRoot()してからpushしてください。既存の階層に上乗せすると、ユーザーが戻るボタンを押したときに「前の画面」に想定外の履歴が出現します。iOS 26以降はApp Intents経由のショートカットも同様です。
Universal Links対応も同じonOpenURLで受けられるので、認可設定(Associated Domains)さえ済ませればサーバー側の変更なしで動きます。Swift 6.2の厳格な並行処理 下では、このhandle関数を@MainActorで明示的に囲むとコンパイラの警告が消えます。
NavigationSplitViewとの使い分け(iPad/Mac対応)
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の永続化
アプリが終了・再起動しても直前の画面階層を復元したい場合、NavigationPathはCodableな値型のみを含んでいれば、そのままエンコードできます。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.codableはNavigationPath.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秒の遷移アニメと同期します。
Tip: reduceMotionが有効なユーザーには、@Environment(\.accessibilityReduceMotion)を見て、withAnimationをスキップします。NavigationStack自体の遷移はシステムが制御するので気にしなくてOKですが、pathをまとめて操作する場面(popToRootなど)では自前アニメの抑制を忘れずに。
よくある落とし穴と解決策
ここまで書いた内容は理想論で、私が現場で殴られた地雷を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でもリンクでも遷移経路は同一です。