以下の内容はhttps://maiyama4.hatenablog.com/entry/2025/12/18/164324より取得しました。


(Yet Another) フル SwiftUI での画面遷移の考え方と方法

こんにちは、この記事は Kyash Advent Calendar 2025 の 18 日目です。 iOS アプリ開発の話をします。

概要

SwiftUI もリリースから数年が経って機能が充実し、多くの UI が不自由なく作れるようになってきました。しかし、現状だと画面の中の UI は SwiftUI で書いていても、画面遷移は UIKit でやっている iOS アプリが多いのではないでしょうか。UIHostingController で SwiftUI の View を UIKit に持ち込み、それを UINavigationControllerUITabBarController に組み込むような構成です。Kyash の iOS アプリでもそのような構成をとっています。

UIKit での画面遷移に特別なデメリットがあるわけではありません。既存のアプリですでに UIKit ベースの画面遷移をしている場合、わざわざフル SwiftUI に移行するのは現時点ではそれほどコスパが良くないと思います。しかし、最近の SwiftUI の進化により、新しくアプリを書くときは SwiftUI で画面遷移をすることが現実的になっていると思います。フル SwiftUI でアプリを書くことによって、 SwiftUI と UIKit の橋渡しのコードを書かなくてよくなったり、 Environment が自動的にすべての画面に伝搬して便利になったりしてうれしいですね。

この記事では、画面遷移も含めて iOS アプリをフル SwiftUI で実装するために必要な考え方や実装方法をまとめてみます。

念のため断りをいれておくと、画面遷移はアプリによって要件が違うことが多くすべてのアプリにとって最適な方法というのはないと思っています。世の中には SwiftUI の画面遷移についての記事は色々とあるので、この記事のタイトルに Yet Another と入れているとおりその中の1つの考えとして読んでいただければ幸いです。

SwiftUI の画面遷移の難しさ

SwiftUI の画面遷移には、 UIKit とは違った難しさがあります。シンプルなアプリであれば、公式の API の使い方さえ理解しておけばとくに問題なく実装できるかもしれません。しかし、一定以上の規模のアプリにおいてはしっかり方針を決めた上で SwiftUI での画面遷移を実装しないと困りが出てくると思います。具体的に、とくに難しいと思う点2つを挙げておきます。

難しさ 1: グローバルな画面遷移

画面遷移に関わる画面が遷移元と遷移先の2つしかないとは限りません。例えば以下のような場合は多数の画面が関係する可能性があり、そのような遷移を SwiftUI で行うには工夫が必要です。

  • タブ横断遷移: タブ A 内のボタンをタップしたとき、タブ B をアクティブにした上で特定の画面を push したい
  • ディープリンク: アプリの外から特定の画面を表示したい

一番シンプルな画面遷移の実装例で考えましょう。以下のように NavigationLink を利用することで、 AScreen から BScreen に遷移をすることができます。

struct AScreen: View {
    var body: some View {
        NavigationStack {
            NavigationLink {
                BScreen()
            } label: {
                Text("Push BScreen")
            }
        }
    }
}

しかし、この書き方では AScreen から BScreen に遷移する方法が画面内でユーザが実際に NavigationLink をタップすることしかありません。例えばディープリンクによる遷移で AScreenBScreen を push したいと思っても、画面の外部からプログラム的にその遷移をする方法がないということです。

SwiftUI においてなにかをプログラム的に操作するには、操作対象を状態として管理する必要があります。

SwiftUI の登場からしばらくはナビゲーションのスタックに積む画面を状態管理する方法がなかったのですが、 iOS 16 でそのための API である NavigationStack も追加されました。実装方法の一例は、以下のように

  • まずは画面を表す Hashable な値として enum を定義する
  • enum と実際の画面を紐づけるために、 .navigationDestination 内で enum が表している画面を返す
  • その enum の配列を NavigationStack に Binding として渡す
  • enum の配列にプログラムから遷移対象の画面を表す enum case を追加する

という流れになります。

enum Screen: Hashable {
    case b
}

struct AScreen: View {
    @State private var path: [Screen] = []
    
    var body: some View {
        NavigationStack(path: $path) {
            Button {
                path.append(Screen.b)
            } label: {
                Text("Push BScreen")
            }
            .navigationDestination(for: Screen.self) { screen in
                switch screen {
                case .b:
                    BScreen()
                }
            }
        }
    }
}

画面の状態を @State で持つことにより、プログラム的に画面遷移をすることができます。配列である path に画面を表す enum を追加することで、画面上で BScreen を push することができます。これにより、以下のようにディープリンクの遷移に対応することができそうな気がします。

struct AScreen: View {
    @State private var path: [Screen] = []
    
    var body: some View {
        NavigationStack(path: $path) {
            // ...
        }
        .onOpenURL { url in
            switch url.host() {
            case "b":
                path.append(Screen.b)
            default:
                break
            }
        }
    }
}

しかし、この実装には @State が画面からしかアクセスできないという問題があります。 AScreen の中からしか path を操作できないため、他の画面は AScreenNavigationStack を操作できないし、逆に AScreen は自分の NavigationStack しか操作できません。画面の @State という、アプリのエンドポイントからアクセス・管理できない「野良」の状態に画面遷移のデータを持たせてしまうと、例えばディープリンクを受けて sheet を present してから特定の画面を push したり、アクティブなタブを切り替えるといった要件の実現が難しくなります。

これを解決するためには、アプリの画面の状態をグローバルに管理する必要があります。そうすることで、アプリの現在の画面の状態を把握した上で操作することができるようになります。NavigationStack のような画面遷移を状態として持てる API を使うのは前提として、その状態を各画面に散らばった野良の状態として持たせるのではなく、一括管理するということが必要ということです。

難しさ 2: alert や sheet の管理

SwiftUI では alert や sheet の管理が難しいという問題もあります。上記の AScreenBScreen を push する例で考えると、 AScreenBScreen はいずれも sheet を表示することができます。

struct AScreen: View {
    // ...
    @State private var isSheetPresented: Bool = false
    
    var body: some View {
        // ...
            .sheet(isPresented: $isSheetPresented) {
                Text("SheetA")
            }
    }
}

struct BScreen: View {
    @State private var isSheetPresented: Bool = false
    
    var body: some View {
        // ...
            .sheet(isPresented: $isSheetPresented) {
                Text("SheetB")
            }
    }
}

この場合、コード上は2つの画面の isSheetPresented が同時に true になることがありえます。しかし、 AScreenBScreen は同じ NavigationStack に属しているため、同時に2つの sheet を表示することはできません。

実際に BScreen -> AScreen の順でわずかに時間をずらして isSheetPresented を true にしてみると

  • B の sheet が表示される
  • B の sheet を dismiss したら自動で A の sheet が表示される

という挙動になりました。

この挙動自体は手元の環境(Xcode 26.1 + iOS 26.1)のものであって将来的には変わるかもしれませんが、いずれにしてもこのような実装はなんらか予期しない挙動を引き起こす可能性があるので問題です。

(Yet Another) フル SwiftUI での画面遷移の方法

フル SwiftUI で画面遷移をするにはこれまで見てきた難しさを解決していく必要があります。これまでみた難しさを裏返して、 SwiftUI の画面遷移で守りたいポイントに以下があると思っています。

  • 複雑な画面遷移も実現できるように画面のグローバルな状態を管理する
  • sheet や alert の表示状態が不正になりえないようにする

いろいろなアプローチがありそうですが、そのうちの一つを紹介します。

サンプルアプリ

この記事で紹介する内容を実装したサンプルアプリを用意しました。フルーツと、フルーツレシピの情報を閲覧できる 2 タブ構成のアプリです。以下のフルーツ一覧とレシピ一覧からそれぞれの詳細画面にアクセスできます。

フルーツ一覧 レシピ一覧

コードは以下のリポジトリにあります。

github.com

実装の流れ

最初に、全体的な実装のステップを示しておきます。

  1. 画面遷移の単位として、一つの NavigationStack を管理する NavigationFlow というクラスを作る
  2. NavigationFlow を組み合わせて、アプリ全体の画面状態を統括するためのクラスである RootRouter というクラスを作る

画面遷移の状態を管理する単位として NavigationFlow を作っていきましょう。

NavigationFlow は一つの NavigationStack を管理し、そのスタック上の push 遷移を管理します。また、alert / sheet / fullScreenCover の表示状態も管理することで、複数の sheet が表示されうるような不正な状態を作らないようにします。

NavigationFlow の概念図

アプリ全体の画面遷移は、複数の NavigationFlow を組み合わせて作ります。例えばタブ構成のアプリでは、各タブでそれぞれ push 遷移ができるはずなのでタブごとに NavigationFlow を持つ形になります。また、 NavigationFlow から表示する sheet / fullScreenCover も NavigationStack を持ちたい場合を考慮して、表示先の sheet / fullScreenCover にも対応する NavigationFlow を生成します。

今から NavigationFlow の実装をしていきますが、一気に完成系を示すのではなく段階的に機能を追加していきます。

Step 1: Push 遷移

まずは NavigationFlow の一番基本的な機能である push 遷移を実装しましょう。

前のセクションで説明したように、画面遷移をプログラム的に制御するには画面を表す enum を定義し、その配列を NavigationStack にバインドします。push 遷移で表示できる画面を表す enum として PushableDestination を定義します。以下の例はフルーツ一覧から遷移できる、フルーツ詳細とフルーツの産地一覧画面です。

public enum PushableDestination: Hashable {
    case fruitDetail(fruitID: String)
    case fruitProductionAreaList(fruitID: String)
}

NavigationFlow はこの PushableDestination の配列を持ち、push() で配列に追加することで push 遷移を制御します。

@Observable
@MainActor
public final class NavigationFlow {
    internal var pushedDestinations: [PushableDestination] = []

    public init() {}

    public func push(_ destination: PushableDestination) {
        pushedDestinations.append(destination)
    }
}

NavigationFlow は純粋に画面遷移の状態を保持するクラスです。この状態を SwiftUI の View に反映させるため、対応する UI として NavigationFlowContainer も定義します。

public struct NavigationFlowContainer<RootScreen: View>: View {
    private let navigationFlow: NavigationFlow
    private let rootScreenBuilder: () -> RootScreen
    private let pushableDestinationBuilder: (PushableDestination) -> any View

    public init(
        navigationFlow: NavigationFlow,
        rootScreenBuilder: @escaping () -> RootScreen,
        pushableDestinationBuilder: @escaping (PushableDestination) -> any View
    ) {
        self.navigationFlow = navigationFlow
        self.rootScreenBuilder = rootScreenBuilder
        self.pushableDestinationBuilder = pushableDestinationBuilder
    }

    public var body: some View {
        @Bindable var navigationFlow = navigationFlow

        NavigationStack(path: $navigationFlow.pushedDestinations) {
            rootScreenBuilder()
                .navigationDestination(for: PushableDestination.self) { destination in
                    AnyView(pushableDestinationBuilder(destination))
                }
        }
        .environment(navigationFlow)
    }
}

NavigationFlowContainer は以下のことをして、 Navigationflow の状態を画面に反映させています。

  • pushedDestinationsNavigationStackpath にバインド
  • .navigationDestinationPushableDestination から実際の画面を生成
    • 画面の生成ロジック自体は外部から受け取る

.environment(navigationFlow)NavigationFlow を注入しているため、各画面では @Environment(NavigationFlow.self)NavigationFlow を取得し、push() を呼ぶことで画面遷移できます。

以下はフルーツ一覧からフルーツ詳細に遷移するためのコードです。

public struct AllFruitListScreen: View {
    @Environment(NavigationFlow.self) private var navigationFlow

    public var body: some View {
        List {
            ForEach(fruits, id: \.id) { fruit in
                Button {
                    navigationFlow.push(.fruitDetail(fruitID: fruit.id))
                } label: {
                    Text(fruit.name)
                }
            }
        }
    }
}

画面から遷移するときは navigationFlow.push(.fruitDetail(fruitID: fruit.id)) のように画面を表す enum を使って NavigationFlow のメソッドを呼ぶだけで、実際に遷移先の画面を知る必要はありません。 enum から画面の変換は、 NavigationFlowContainernavigationDestination で行われているためです。

NavigationFlowNavigationFlowContainer を使う側のコードは以下のようになります。

struct ContentView: View {
    @State private var navigationFlow = NavigationFlow()

    var body: some View {
        NavigationFlowContainer(
            navigationFlow: navigationFlow,
            rootScreenBuilder: { AllFruitListScreen() },
            pushableDestinationBuilder: { destination in
                switch destination {
                case .fruitDetail(let fruitID):
                    FruitDetailScreen(fruitID: fruitID)
                case .fruitProductionAreaList(let fruitID):
                    FruitProductionAreaListScreen(fruitID: fruitID)
                }
            }
        )
    }
}

pushableDestinationBuilderPushableDestination から実際の画面を生成しています。このクロージャをアプリのルート付近で定義することで、画面の生成ロジックを一箇所に集約できます。

以上の実装をすることで、フルーツ一覧からフルーツ詳細、さらにフルーツの産地一覧画面と push することができます。

必要に応じて、スタックから画面を取り除く pop()popToRoot() を実装してもよいでしょう。ここでは popToRoot() を実装しておきます。

 public final class NavigationFlow {
     // ...

+    public func popToRoot() {
+        pushedDestinations = []
+    }
 }

これにより、例えば2段階 push した後のフルーツ産地画面から一気にフルーツ一覧に戻ることができます。

Step 2: Sheet の表示

次に sheet を表示できるようにします。sheet で表示できる画面を表す enum として PresentableSheet を定義します。例として、特定の色のフルーツ一覧画面に対応する coloredFruitList を case に追加しています。 SwiftUI の .sheet modifier に渡す値が Identifiable である必要があることから PresentableSheetIdentifiable に準拠させています。

public enum PresentableSheet: Identifiable {
    case coloredFruitList(color: FruitColor)

    public var id: String {
        switch self {
        case .coloredFruitList(let color):
            "ColoredFruitList:\(color.rawValue)"
        }
    }
}

NavigationFlow に sheet の状態を追加します。

 @Observable
 @MainActor
 public final class NavigationFlow {
     internal var pushedDestinations: [PushableDestination] = []
+    internal var presentedSheet: PresentableSheet? = nil

     // ...

+    public func presentSheet(_ sheet: PresentableSheet) {
+        presentedSheet = sheet
+    }
 }

NavigationFlowContainer にも .sheet を追加し、実際に sheet を表示できるようにします。

 public struct NavigationFlowContainer<RootScreen: View>: View {
     private let navigationFlow: NavigationFlow
     private let rootScreenBuilder: () -> RootScreen
     private let pushableDestinationBuilder: (PushableDestination) -> any View
+    private let presentableSheetBuilder: (PresentableSheet) -> any View

     // ...

     public var body: some View {
         @Bindable var navigationFlow = navigationFlow

         NavigationStack(path: $navigationFlow.pushedDestinations) {
             rootScreenBuilder()
                 .navigationDestination(for: PushableDestination.self) { destination in
                     AnyView(pushableDestinationBuilder(destination))
                 }
+                .sheet(item: $navigationFlow.presentedSheet) { sheet in
+                    AnyView(presentableSheetBuilder(sheet))
+                }
         }
         .environment(navigationFlow)
     }
 }

これで一応 sheet は表示できるようになったのですが、この実装では表示した sheet 内での push 遷移ができません。もちろん .sheet で表示する画面を NavigationStack に包めば push 遷移自体はできるのですが、その NavigationStack の状態に外からアクセスすることができないので sheet を表示した上で別の特定の画面を push する、というような遷移ができなくなります。そのような野良の NavigationStack を作ってしまうとアプリ全体の画面遷移がコントロールできなくなります。

そこで、 sheet を表示するときに新しい NavigationFlow を生成し、sheet 内の push 遷移もコントロールできるようにします。sheet で表示する内容と、その sheet 用の NavigationFlow をまとめた型を定義します。

internal struct PresentedSheetContent: Identifiable {
    internal let navigationFlow: NavigationFlow
    internal let sheet: PresentableSheet

    internal var id: String { sheet.id }
}

NavigationFlow 側も PresentedSheetContent を利用するように更新します。

 @Observable
 @MainActor
 public final class NavigationFlow {
     internal var pushedDestinations: [PushableDestination] = []
-    internal var presentedSheet: PresentableSheet? = nil
+    internal var presentedSheetContent: PresentedSheetContent? = nil

     // ...

     public func presentSheet(_ sheet: PresentableSheet) {
-        presentedSheet = sheet
+        presentedSheetContent = PresentedSheetContent(
+            navigationFlow: NavigationFlow(),
+            sheet: sheet
+        )
     }
 }

NavigationFlowContainer では、sheet 内でも NavigationFlowContainer を使うことで、sheet 内での push 遷移も可能になります。

 public var body: some View {
     @Bindable var navigationFlow = navigationFlow

     NavigationStack(path: $navigationFlow.pushedDestinations) {
         rootScreenBuilder()
             .navigationDestination(for: PushableDestination.self) { destination in
                 AnyView(pushableDestinationBuilder(destination))
             }
-            .sheet(item: $navigationFlow.presentedSheet) { sheet in
-                AnyView(presentableSheetBuilder(sheet))
+            .sheet(
+                item: .init(
+                    get: { navigationFlow.presentedSheetContent },
+                    set: { _ in navigationFlow.presentedSheetContent = nil }
+                )
+            ) { presentedSheetContent in
+                NavigationFlowContainer<AnyView>(
+                    navigationFlow: presentedSheetContent.navigationFlow,
+                    rootScreenBuilder: { AnyView(presentableSheetBuilder(presentedSheetContent.sheet)) },
+                    pushableDestinationBuilder: pushableDestinationBuilder,
+                    presentableSheetBuilder: presentableSheetBuilder
+                )
             }
     }
     .environment(navigationFlow)
 }

以上で、 sheet の表示と sheet 内での push 遷移ができるようになりました。例えば、フルーツ詳細から同じ色のフルーツ一覧画面の sheet を表示し、さらに別のフルーツ詳細を push することができます。

Step 3: dismiss 機能

前のステップで sheet を表示できるようになりましたが、現状では sheet をプログラム的に閉じる方法がないので実装しましょう。 sheet として表示されている側の画面から自らを閉じるためには、自らを表示している NavigationFlowpresentedSheetContentnil にすればよいです。そのために、 sheet を表示するときに自らを表示する親の NavigationFlow の参照を渡します。 presentedSheetContentnil にする dismiss() メソッドを呼び出すことで sheet を閉じられるようにします。

 @Observable
 @MainActor
 public final class NavigationFlow {
     internal var pushedDestinations: [PushableDestination] = []
     internal var presentedSheetContent: PresentedSheetContent? = nil
+    internal weak var parentFlow: NavigationFlow?
+
+    internal var canDismiss: Bool { parentFlow != nil }

-    public init() {}
+    public init(parentFlow: NavigationFlow? = nil) {
+        self.parentFlow = parentFlow
+    }

     // ...

     public func presentSheet(_ sheet: PresentableSheet) {
         presentedSheetContent = PresentedSheetContent(
-            navigationFlow: NavigationFlow(),
+            navigationFlow: NavigationFlow(parentFlow: self),
             sheet: sheet
         )
     }

+    public func dismiss() {
+        parentFlow?.presentedSheetContent = nil
+    }
 }

表示された sheet には必ず閉じるボタンをつけるようにしてもよいでしょう。その場合は、以下のように NavigationFlowContainer に実装を追加します。

 NavigationStack(path: $navigationFlow.pushedDestinations) {
     rootScreenBuilder()
         // ...
+        .toolbar {
+            if navigationFlow.canDismiss {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button {
+                        navigationFlow.dismiss()
+                    } label: {
+                        Image(systemName: "xmark")
+                    }
+                }
+            }
+        }
 }

このステップで実装した内容は、単に sheet を閉じられるようにするだけのものではありません。より本質的なのは、NavigationFlow に親子関係ができたことです。

parentFlow を通じて親の NavigationFlow への参照を持つことで、sheet からさらに sheet を表示し、その中で push 遷移をするといった複雑な画面構成も可能になります。また、深くネストした sheet から parentFlow を辿って大元の NavigationFlow にアクセスすることで、複数の sheet を一気に閉じるような操作も実現できます。

アプリ内の NavigationFlow は独立して存在させず、親子関係を通じて互いに連携しながらアプリ全体の画面遷移を構成していきます。

Step 4: FullScreen の表示

sheet に加えて fullScreenCover も表示できるようにしておきたいこともあるでしょう。ちょっとした工夫は必要なのですが、基本的には sheet と同じ方法の

  • fullScreenCover で表示する画面を enum で表す
  • NavigationFlow に fullScreenCover の表示状態を追加する
  • NavigationFlowContainer にて表示状態を SwiftUI の .fullScreenCover modifier にバインドする

で問題ないので、具体的な実装については省略します。

気になる方は、 サンプルリポジトリ該当コミット を参照してみてください。

Step 5: Alert の表示

最後に alert を表示できるようにします。基本的な考え方は sheet と同様です。まずは NavigationFlow において alert を表すための型を定義します。

public struct PresentableAlert {
    public let message: String
    public let actions: () -> any View

    public init(message: String, actions: @escaping () -> any View) {
        self.message = message
        self.actions = actions
    }
}

NavigationFlow に alert の状態を追加します。また、エラー表示用のヘルパーメソッドも用意しておくと便利です。

 @Observable
 @MainActor
 public final class NavigationFlow {
     internal var pushedDestinations: [PushableDestination] = []
     internal var presentedSheetContent: PresentedSheetContent? = nil
+    internal var presentedAlert: PresentableAlert? = nil
     internal weak var parentFlow: NavigationFlow?

     // ...

+    public func presentAlert(_ alert: PresentableAlert) {
+        presentedAlert = alert
+    }
+
+    public func presentErrorAlert(_ error: any Error) {
+        presentedAlert = PresentableAlert(
+            message: error.localizedDescription,
+            actions: { [weak self] in
+                Button {
+                    self?.presentedAlert = nil
+                } label: {
+                    Text("OK")
+                }
+            }
+        )
+    }
 }

NavigationFlowContainer にも .alert を追加します。

 NavigationStack(path: $navigationFlow.pushedDestinations) {
     rootScreenBuilder()
         // ...
+        .alert(
+            "",
+            isPresented: .init(
+                get: { navigationFlow.presentedAlert != nil },
+                set: { _ in navigationFlow.presentedAlert = nil }
+            ),
+            presenting: navigationFlow.presentedAlert,
+            actions: { alert in AnyView(alert.actions()) },
+            message: { alert in Text(alert.message) }
+        )
 }

これで各画面からエラー表示を統一的に行えるようになりました。

public struct AllFruitListScreen: View {
    @Environment(NavigationFlow.self) private var navigationFlow

    public var body: some View {
        List { /* ... */ }
        .task {
            do {
                fruits = try await dataFetcher.fetchFruits()
            } catch {
                navigationFlow.presentErrorAlert(error)
            }
        }
    }
}

RootRouter を作る

ここまでで、画面遷移の単位である NavigationFlow は完成しました。

NavigationFlow はある程度のアプリで共通して利用できる実装になっていると思います。この NavigationFlow を部品として使ってアプリ全体の画面遷移を作っていきますが、そのやり方はアプリの構成によって変わってきます。シンプルな push 遷移で完結するアプリ、下タブがあるアプリ、ログイン・ログアウト状態があるアプリなど、要件がアプリによって様々なためです。

今回のサンプルアプリは下タブを持つものなので、その想定でアプリ全体の画面遷移を統括する RootRouter を作っていきます。重要なのは、今回の RootRouter はすべてのアプリに適用できるものではなく、アプリの画面遷移の要件に合わせて NavigationFlow を組み合わせて適切に RootRouter に相当するクラスを作ることです。

タブごとに NavigationFlow を持つ

サンプルアプリはフルーツタブとレシピタブの 2 タブ構成です。それぞれのタブで独立した push 遷移ができるようにするため、各タブに対応する NavigationFlowRootRouter に持たせます。

@Observable
@MainActor
public final class RootRouter {
    public var selectedTab: SelectableTab = .fruit

    public var fruitTabNavigationFlow: NavigationFlow!
    public var recipeTabNavigationFlow: NavigationFlow!

    public init() {
        fruitTabNavigationFlow = NavigationFlow(rootRouter: self)
        recipeTabNavigationFlow = NavigationFlow(rootRouter: self)
    }
}

アプリのエントリーポイントでは RootRouter を作成し、TabViewNavigationFlowContainer を組み合わせます。

public struct IOSAppRoot: View {
    @State private var rootRouter: RootRouter = RootRouter()

    public var body: some View {
        @Bindable var rootRouter = rootRouter

        TabView(selection: $rootRouter.selectedTab) {
            // フルーツタブ
            NavigationFlowContainer(
                navigationFlow: rootRouter.fruitTabNavigationFlow,
                rootScreenBuilder: { AllFruitListScreen() },
                pushableDestinationBuilder: { destination in
                    switch destination {
                    case .fruitDetail(let fruitID):
                        FruitDetailScreen(fruitID: fruitID)
                    case .fruitProductionAreaList(let fruitID):
                        FruitProductionAreaListScreen(fruitID: fruitID)
                    case .recipeDetail(let recipeID):
                        RecipeDetailScreen(recipeID: recipeID)
                    }
                },
                presentableSheetBuilder: { sheet in
                    switch sheet {
                    case .coloredFruitList(let color):
                        ColoredFruitListScreen(color: color)
                    }
                }
            )
            .tabItem { Label("フルーツ", systemImage: "leaf") }
            .tag(SelectableTab.fruit)

            // レシピタブも同様
            // ...
        }
    }
}

各タブがそれぞれ独立した NavigationFlow を持つことで、タブを切り替えても各タブの遷移スタックが保持されます。

例えばアクティブなタブを切り替えるためには、タブの状態を管理する RootRouter にアクセスする必要があります。各画面は @Environment を通じて自分を表示している NavigationFlow にアクセスできるので、 NavigationFlowRootRouter への参照を持たせておくと任意の画面から RootRouter にアクセスすることができます。

 public final class NavigationFlow {
+    public weak var rootRouter: RootRouter!

-    public init(parentFlow: NavigationFlow? = nil) {
+    public init(rootRouter: RootRouter, parentFlow: NavigationFlow? = nil) {
+        self.rootRouter = rootRouter
         self.parentFlow = parentFlow
     }

     public func presentSheet(_ sheet: PresentableSheet) {
         presentedSheetContent = PresentedSheetContent(
-            navigationFlow: NavigationFlow(parentFlow: self),
+            navigationFlow: NavigationFlow(rootRouter: rootRouter, parentFlow: self),
             sheet: sheet
         )
     }
 }

sheet で新しい NavigationFlow を生成するときも rootRouter を引き継ぐことで、モーダル内の画面からも RootRouter にアクセスできます。

ケーススタディ

RootRouterNavigationFlow を互いに連携させたことで、複雑な画面遷移ができるようになっています。それを示すための例として、タブ横断の遷移とディープリンク対応を実装してみます。

タブ横断遷移を実装する

レシピ詳細画面からそのレシピで使用しているフルーツの詳細画面に遷移することを考えましょう。レシピ詳細はレシピタブにありますが、フルーツ詳細はフルーツタブで表示したいとします。その場合、タブを切り替えてからフルーツ詳細画面を表示する必要があります。

このようなタブ横断遷移を RootRouter に実装します。単にフルーツ詳細画面を push するのではなく、事前にフルーツタブの状態をリセットしています。

public final class RootRouter {
    // ...

    public func showFruitDetail(fruitID: String) {
        // 1. 全タブのモーダルをクリア
        fruitTabNavigationFlow.presentedContent = nil
        recipeTabNavigationFlow.presentedContent = nil

        // 2. フルーツタブに切り替え
        selectedTab = .fruit
        // 3. フルーツタブのスタックをルートまで戻す
        fruitTabNavigationFlow.popToRoot()
        // 4. 少し待ってからフルーツ詳細画面を push
        Task {
            try? await Task.sleep(for: .milliseconds(500))
            fruitTabNavigationFlow.push(.fruitDetail(fruitID: fruitID))
        }
    }
}

レシピ詳細画面からは navigationFlow.rootRouter.showFruitDetail() を呼ぶことでタブ横断遷移ができます。

public struct RecipeDetailScreen: View {
    @Environment(NavigationFlow.self) private var navigationFlow

    public var body: some View {
        List {
            if let recipe {
                Section("使用するフルーツ") {
                    ForEach(recipe.fruits, id: \.id) { fruit in
                        Button {
                            navigationFlow.rootRouter.showFruitDetail(fruitID: fruit.id)
                        } label: {
                            Text(fruit.name)
                        }
                    }
                }
            }
        }
    }
}

遷移の様子は以下のようになります。

ディープリンク対応

続いて、ディープリンクに対応しましょう。やや複雑な例として「特定の色のフルーツ一覧を sheet で表示した上でその中の特定のフルーツ詳細を push する」という遷移を実装してみます。

RootRouter に以下のメソッドを追加します。

public final class RootRouter {
    // ...

    public func showColoredFruitDetail(color: FruitColor, fruitID: String) {
        // 1. 全タブのモーダルをクリア
        fruitTabNavigationFlow.presentedContent = nil
        recipeTabNavigationFlow.presentedContent = nil

        // 2. フルーツタブに切り替え、スタックをルートに戻す
        selectedTab = .fruit
        fruitTabNavigationFlow.popToRoot()

        Task {
            // 3. 色別フルーツ一覧を sheet で表示
            try? await Task.sleep(for: .milliseconds(500))
            fruitTabNavigationFlow.presentSheet(.coloredFruitList(color: color))

            // 4. sheet 内でフルーツ詳細を push
            try? await Task.sleep(for: .milliseconds(500))
            fruitTabNavigationFlow.presentedContent?.navigationFlow.push(.fruitDetail(fruitID: fruitID))
        }
    }
}

ポイントは、presentedContent?.navigationFlow を通じて sheet 用に生成された子の NavigationFlow にアクセスし、その中で push 遷移を行っている点です。NavigationFlow の親子関係があることで、このような複雑な遷移も実現できます。

.onOpenURL でこのメソッドを呼び出すことで、ディープリンクに対応できます。

.onOpenURL { url in
    switch url.host() {
    case "coloredFruit":
        // URL から color と fruitID を取り出す 
        rootRouter.showColoredFruitDetail(color: color, fruitID: fruitID)
    default:
        break
    }
}

まとめ

この記事では、フル SwiftUI で画面遷移を実装するための考え方と、具体的な方法の1つを解説しました。考え方としてとにかく大事なのは、アプリのエンドポイントからアクセスできない「野良」の状態(=例えば各画面の @State)に画面の状態を持つのではなく、アプリ全体の画面状態を管理する存在(=この記事の RootRouter)を作るということだと思っています。

そのための実装方法の1つとして、この記事では以下のことを行いました。

  • 画面を表す enum を作り、画面同士がお互いの作成方法を知らないようにする
  • 画面遷移の単位として NavigationFlow を作り push / sheet / fullScreenCover / alert の状態を管理する
  • NavigationFlow を組み合わせて RootRouter でアプリ全体の画面状態を管理し、ディープリンクやタブ横断遷移に対応する

当たり前ですが、この記事の方法が唯一の正解というわけではなく、この方法では対応できない要件や、そもそも見落としている考えがあると思います。 SwiftUI の画面遷移をどのように設計するかについての一つの考え方として、参考になれば幸いです。




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

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