2025年5月09日·1分で読めます

予測可能なマルチステップフローのための SwiftUI NavigationStack パターン

オンボーディングや承認ウィザードなどのマルチステップフローで、明確なルーティング、安全な戻る挙動、実践的な例を使って SwiftUI NavigationStack のベストプラクティスを解説します。

予測可能なマルチステップフローのための SwiftUI NavigationStack パターン

マルチステップフローで何が問題になるのか

マルチステップフローとは、ステップ1が完了して初めてステップ2が意味を持つような一連の流れのことです。典型例はオンボーディング、承認リクエスト(確認、承認、送信)、複数画面にまたがるウィザード形式のデータ入力などです。

これらのフローは、Back がユーザーの期待どおりに振る舞うときだけ簡単に感じられます。Back が予想外の場所に戻すと、ユーザーはアプリを信頼しなくなります。その結果、誤った送信、オンボーディングの放棄、あるいは「元の画面に戻れない」といったサポート問い合わせが発生します。

乱れたナビゲーションはだいたい次のいずれかに見えます:

  • アプリが間違った画面にジャンプする、またはフローを早く終了してしまう。\n- 同じ画面が2回表示される(同じ画面を2度 push してしまった)。\n- Back でステップがリセットされ、ユーザーが下書きを失う。\n- ステップ1を完了していなくてもステップ3に到達できてしまい、不正な状態が生じる。\n- ディープリンクやアプリ再起動後に正しい画面が表示されてもデータが間違っている。

有用なメンタルモデル:マルチステップフローは「2つのものが一緒に動く」ものです。

まず、画面のスタック(ユーザーが Back で遡るもの)。次に、共有のフロー状態(下書きデータや進捗)があり、画面が消えたからといって消えてはならないものです。

多くの NavigationStack のセットアップは、画面スタックとフロー状態が離散してしまうと崩れます。たとえば、オンボーディングで "Create profile" を二回 push してしまい(重複ルート)、下書きプロフィールがビュー内にあり再レンダリングで再作成されると、Back 後に異なるバージョンのフォームが表示されてユーザーはアプリを信用できなくなります。

予測可能な挙動は、フローに名前を付け、各ステップで Back が何をするべきか定義し、フロー状態に一つの明確な居場所を与えることから始まります。

実際に必要な NavigationStack の基本

マルチステップフローには、古い NavigationView より NavigationStack を使ってください。NavigationView は iOS のバージョンによって挙動が変わることがあり、push/pop/復元の振る舞いを考えると扱いにくくなります。NavigationStack はスタックとしてのナビゲーションを明確に扱うモダンな API です。

NavigationStack はユーザーの履歴を保持します。各 push はスタックに目的地を追加し、各 Back 操作は最後の目的地を取り除きます。この単純なルールがフローを安定して感じさせます:UI は明確なステップの順序を反映すべきです。

スタックが実際に保持しているもの

SwiftUI はビューオブジェクト自体を保持しているわけではありません。ナビゲートに使ったデータ(ルート値)を保持し、それを使って目的地ビューを必要に応じて再構築します。これにはいくつかの実践的な帰結があります:

  • 重要なデータを維持するためにビューが生き続けることを当てにしないでください。\n- 画面が状態を必要とするなら、プッシュされたビューの外側にあるモデル(例えば ObservableObject)に置くべきです。\n- 同じ目的地を異なるデータで2回 push すると、SwiftUI はそれらを別個のスタックエントリとして扱います。

NavigationPath はフローが単純な固定の push 一つ二つではないときに使うべきものです。これは「どこへ行くか」を編集できるリストだと考えてください。ルートを append して前へ進み、最後のルートを削除して戻り、あるいはパス全体を置き換えて後のステップへジャンプできます。

ウィザード形式のステップが必要なとき、完了後にフローをリセットしたいとき、部分的なフローを保存状態から復元したいときに NavigationPath は適しています。

賢さより予測可能さ。自動ジャンプや暗黙の pop、ビュー駆動の副作用といった隠れたルールが少ないほど、あとで妙な Back スタックバグが減ります。

小さなルート enum でフローをモデル化する

予測可能なナビゲーションは一つの決定から始まります:ルーティングを一箇所にまとめ、フロー内のすべての画面を小さく明確な値にすること。

FlowRouterObservableObject)のような単一の真実のソースを作り、NavigationPath を所有させてください。これにより各 push/pop が一貫し、ナビゲーションがビューに散らばることを防ぎます。

シンプルなルーター構造

ステップを表すために enum を使います。関連値は ID のような軽量な識別子だけにし、モデル全体は渡さないでください。

enum Step: Hashable {
    case welcome
    case profile
    case verifyCode(phoneID: UUID)
    case review(applicationID: UUID)
    case done
}

final class FlowRouter: ObservableObject {
    @Published var path = NavigationPath()

    func go(_ step: Step) { path.append(step) }
    func back() { if !path.isEmpty { path.removeLast() } }
    func reset() { path = NavigationPath() }
}

ナビゲーション状態とフロー状態を分けて保つ

ナビゲーションは「ユーザーがどこにいるか」、フロー状態は「これまでに入力したもの」です。フローのデータは別のストア(例:名前・メール・アップロードした書類を持つ OnboardingState)に置き、画面が出たり消えたりしても安定しているようにしてください。

シンプルな経験則:

  • FlowRouter.pathStep 値のみを含む。\n- OnboardingState はユーザーの入力や下書きデータを含む。\n- ステップはデータそのものではなく ID を運ぶ。

こうすることで壊れやすいハッシュや巨大なパス、SwiftUI がビューを再構築したときの意図しないリセットを避けられます。

ステップごとに:NavigationPath でウィザードを作る

ウィザード形式の画面では、スタックを自分で制御するのが最もシンプルです。"今どこにいるか" の真実の一箇所と、前進/後退するための一つの方法を目指してください。

NavigationStack(path:)NavigationPath にバインドして始めます。各プッシュ画面は値(しばしば enum ケース)で表し、navigationDestination を一度だけ登録します。

import SwiftUI

enum WizardRoute: Hashable {
    case profile
    case verifyEmail
    case permissions
    case review
}

struct OnboardingWizard: View {
    @State private var path = NavigationPath()
    @State private var currentIndex = 0

    private let steps: [WizardRoute] = [.profile, .verifyEmail, .permissions, .review]

    var body: some View {
        NavigationStack(path: $path) {
            StartScreen {
                goToStep(0) // push first step
            }
            .navigationDestination(for: WizardRoute.self) { route in
                switch route {
                case .profile:
                    ProfileStep(onNext: { goToStep(1) })
                case .verifyEmail:
                    VerifyEmailStep(onNext: { goToStep(2) })
                case .permissions:
                    PermissionsStep(onNext: { goToStep(3) })
                case .review:
                    ReviewStep(onEditProfile: { popToStep(0) })
                }
            }
        }
    }

    private func goToStep(_ index: Int) {
        currentIndex = index
        path.append(steps[index])
    }

    private func popToStep(_ index: Int) {
        let toRemove = max(0, currentIndex - index)
        if toRemove > 0 { path.removeLast(toRemove) }
        currentIndex = index
    }
}

Back を予測可能に保つにはいくつかの習慣を守ります。前進にはちょうど一つのルートを append する、"Next" は線形に(次のステップだけを push する)、そして Review から "Edit profile" のようにジャンプが必要な場合はスタックを既知のインデックスまで切り詰める、などです。

これにより重複した画面の偶発的な発生を避け、Back がユーザーの期待どおりに一回のタップ=一歩を意味するようになります。

画面の出入りでデータを安定させる

グルーコードなしでフルスタックを構築する
同じフローデザインから本番対応のバックエンド、Web、モバイルアプリを生成します。
アプリを生成

各画面が自分の状態を持つとフローが信頼できなく感じられます。名前を入力して進み、戻るとフィールドが空になっている、というのはビューが再生成されるからです。

修正は単純です:フローを一つの下書きオブジェクトとして扱い、各ステップがそれを編集するようにします。

SwiftUI では、通常フローの開始時に一度だけ生成され、各ステップに渡される共有の ObservableObject を使います。各ビューの @State に下書きを保持するのは、その値が本当にその画面固有の場合だけにします。

final class OnboardingDraft: ObservableObject {
    @Published var fullName = ""
    @Published var email = ""
    @Published var wantsNotifications = false

    var canGoNextFromProfile: Bool {
        !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        && email.contains("@")
    }
}

エントリポイントで生成し、@StateObject@EnvironmentObject(あるいは明示的に渡す)で共有してください。これでスタックが変わってもデータは失われません。

Back ナビゲーションで何を残すか決める

すべてを常に保持すべきではありません。ルールを事前に決めてフローの一貫性を保ちます。

ユーザー入力(テキストフィールド、トグル、選択)は明示的にリセットしない限り保持します。ステップ固有の UI 状態(ローディングスピナー、一時的なアラート、短いアニメーション)はリセットします。ワンタイムコードなどの機密フィールドはそのステップを離れるときに消去します。選択が後続ステップに影響するなら、依存するフィールドだけをクリアします。

バリデーションはここで自然に入ります。ユーザーを次の画面へ行かせてからエラーを出すのではなく、現在のステップが有効になるまで留めておく方がよく、canGoNextFromProfile のような計算プロパティでボタンを無効化するだけで十分なことが多いです。

チェックポイントを保存するがやり過ぎない

一部の下書きはメモリ内だけでよく、他はアプリ再起動やクラッシュから生き残るべきです。実用的なデフォルト:

  • ユーザーがアクティブにステップを移動している間はメモリに保持する。\n- アカウント作成、承認送信、支払い開始などの明確なマイルストーンで永続化する。\n- フローが長い、またはデータ入力が1分以上かかる場合は早めに永続化する。

こうすると画面は自由に出入りでき、ユーザーの進捗は安定して時間を尊重する形で保持されます。

ディープリンクと途中復元

ディープリンクは重要です。現実のフローはめったにステップ1から始まりません。誰かがメールやプッシュ通知、共有リンクをタップしてステップ3や最終承認画面に到達することを期待します。

NavigationStack では、ディープリンクを単一ビューへの命令として扱うのではなく、有効なパスを構築する指示として扱ってください。フローの始まりから出発し、そのユーザーとセッションに対して真であるステップだけを append します。

外部リンクを安全なルート列に変換する

良いパターンは:外部 ID を解析し、必要最小限のデータを読み込み、それをルートの列に変換することです。

enum Route: Hashable {
    case start
    case profile
    case verifyEmail
    case approve(requestID: String)
}

func pathForDeepLink(requestID: String, hasProfile: Bool, emailVerified: Bool) -> [Route] {
    var routes: [Route] = [.start]
    if !hasProfile { routes.append(.profile) }
    if !emailVerified { routes.append(.verifyEmail) }
    routes.append(.approve(requestID: requestID))
    return routes
}

これらのチェックがガードレールになります。前提条件が不足しているなら、ステップ3 にユーザーを直接放り込んでエラーだけを見せるのではなく、最初に不足しているステップへ送ってください。バックスタックが一貫した物語を語るようにします。

途中のフローを復元する

再起動後に復元するには、最後に知られているルートの状態とユーザーが入力した下書きを保存します。その上で、人を驚かせない方法で再開するか決めます。

下書きが新しければ(数分〜数時間)「再開する」選択を明示的に提示します。古ければ最初から始めつつフィールドをプレフィルする方が驚きが少ないことが多いです。要件が変わっている場合は、同じガードレールを使ってパスを再構築します。

Push とモーダル:フローを抜けやすく保つ

フローから本番へ進める
準備ができたら、クラウドプロバイダや自前の環境にアプリをデプロイします。
デプロイ

フローは一つの主要な進み方があると予測可能に感じられます:画面を一つずつ push することです。サイドタスクにはシートやフルスクリーンカバーを使い、メインの道筋には使わないでください。

Push(NavigationStack)はユーザーが Back で手順をたどることを期待するときに合います。シートや fullScreenCover はサイドタスクや別体験(ログイン、カメラ撮影、長い契約書)に使います。確認ダイアログのような短い選択にはモーダルを使います。

いくつかのルール:

  • メインの道筋(ステップ1、ステップ2、ステップ3)は push。\n- 小さなオプション作業(日時選択、国の選択、ドキュメントスキャン)はシートに。\n- 「別世界」の体験(ログイン、カメラ)は fullScreenCover に。\n- キャンセルや下書き削除、送信確認などはモーダルで。

よくある間違いは主要なフローステップをシートに入れてしまうことです。ステップ2がシートだとスワイプで閉じられ、コンテキストを失い、スタックはステップ1のままなのにデータはステップ2が完了した状態になってしまう恐れがあります。

確認は逆に、ウィザードに “Are you sure?” を push するとスタックが散らかりループを生むことがあります(Step3 -> Confirm -> Back -> Step3 -> Back -> Confirm のような)。

“Done” 後にすべてをきれいに閉じる方法

“Done” が意味することを決めてください:ホーム画面に戻るのか、リストに戻るのか、成功画面を表示するのか。

フローが push されているなら NavigationPath を空にして最初に戻す。モーダルで提示されているなら環境の dismiss() を呼びます。モーダルの中に NavigationStack がある場合は、個々の画面をポップするのではなくモーダル自体を閉じてください。成功時には下書き状態もクリアして、再度開いたときにフローが新しく始まるようにします。

Back ボタンの挙動と「本当に中断しますか?」の瞬間

予測可能なマルチステップフローを構築する
ウィザード形式のフローを、手作業のナビゲーションなしで明確なステップと安定した下書きデータで構築します。
試す

ほとんどのマルチステップフローでは、最良の選択は何もしないことです:システムの Back ボタン(およびスワイプバック)に任せるのが自然で、UI とナビゲーション状態の不整合を避けます。

戻ることで本当に害がある場合(長い未保存フォームを失う、取り返しのつかない操作を放棄する等)にのみ介入する価値があります。安全に戻って続行できるなら、余計な摩擦を加えないでください。

実用的なアプローチはシステムナビゲーションを維持し、画面が編集済み(dirty)なときだけ確認を追加することです。その場合は独自の戻る処理を用意し、一度だけ確認を出して明確な退出方法を提供します。

@Environment(\.dismiss) private var dismiss
@State private var showLeaveConfirm = false
let hasUnsavedChanges: Bool

var body: some View {
  Form { /* fields */ }
    .navigationBarBackButtonHidden(hasUnsavedChanges)
    .toolbar {
      if hasUnsavedChanges {
        ToolbarItem(placement: .navigationBarLeading) {
          Button("Back") { showLeaveConfirm = true }
        }
      }
    }
    .confirmationDialog("Discard changes?", isPresented: $showLeaveConfirm) {
      Button("Discard", role: .destructive) { dismiss() }
      Button("Keep Editing", role: .cancel) {}
    }
}

罠にしないための注意点:

  • 結果を一言で説明できる場合にのみ確認する。\n- 安全な選択肢(キャンセル、編集中を続ける)と明確な退出(破棄、離脱)を提供する。\n- 戻るボタンを隠すなら、それを代替する明確な Back や Close を用意する。\n- ナビゲーション全体をブロックするより、取り返しのつかない操作(例:「承認」)そのものを確認する方が好ましい。

もし戻るジェスチャーを頻繁に抑えようとしているなら、自動保存、下書き保存、小さいステップに分割する必要がある兆候です。

奇妙なバックスタックを生むよくある間違い

ほとんどの「なぜそこに戻ったの?」バグは SwiftUI のランダムさではありません。ナビゲーション状態を不安定にするパターンが原因です。予測可能な挙動のために、バックスタックをアプリデータのように扱い:安定して、テスト可能で、一箇所が所有するようにしてください。

意図しない追加スタック

よくあるトラップは、気づかないうちに複数の NavigationStack を持ってしまうことです。例えば各タブがそれぞれルートスタックを持ち、さらに子ビューがフロー内で別の NavigationStack を追加すると、戻る挙動が混乱し、ナビゲーションバーが消えたり期待通りにポップしない画面が出ます。

他によくある問題は、NavigationPath を頻繁に再作成してしまうことです。パスがビュー内部で再初期化されると、状態変化でリセットされ、ユーザーがフィールドに入力している途中でステップ1にジャンプしてしまいます。

多くの奇妙なスタックの背後にあるミスは次のとおりです:

  • NavigationStack を別のスタック内にネストしている(タブやシート内でよく起きる)\n- ビューの更新中に NavigationPath() を再初期化している(代わりに長寿命の状態に置く)\n- ルートに安定しない値を入れている(変化するモデル等)は Hashable を壊しミスマッチを起こす\n- ボタンハンドラにナビゲーションロジックが散在していて「次」が誰にも説明できない状態になっている\n- 複数ソース(ビューとビューモデルなど)が同時に path を操作している

ステップ間でデータを渡す必要があるなら、ルートには安定した識別子(ID、ステップ enum)を使い、実際のフォームデータは共有状態に置いてください。

具体例:ルートが .profile(User)User がタイプ中に変わると、SwiftUI はそれを別のルートとみなしてスタックを組み替えてしまいます。ルートは .profile にして、下書きプロフィールを共有状態に保持してください。

予測可能なナビゲーションのためのクイックチェックリスト

下書きデータに居場所を与える
流れのデータをビジュアルなデータベース設計でモデル化し、ステップ間で一貫性を保ちます。
アプリを作成

フローが不調に感じられるとき、多くはバックスタックがユーザーの物語と一致していないことが原因です。UI を磨く前にナビゲーションルールを一通り点検してください。

実機でテストし、プレビューだけに頼らないこと。速いタップは重複 push や状態欠落を露呈します。

  • 最後の画面から最初の画面まで一歩ずつ戻り、各画面が以前に入力したデータを同じように表示するか確認する。\n- 各ステップからキャンセルをトリガーして(最初と最後を含む)、常にランダムな以前の画面ではなく妥当な場所に戻るか確認する。\n- フロー途中で強制終了して再起動する。パスを復元するか、保存済みデータで既知のステップから安全に再開できるか確認する。\n- ディープリンクやショートカットでフローを開き、目的のステップが有効か検証する。必要なデータが欠けているなら最も早い収集可能なステップへリダイレクトする。\n- Done で終了し、フローがきれいに削除されるか確認する。完了したウィザードに Back で再入できてはならない。

テストの簡単な方法:Profile、Permissions、Confirm の3画面のオンボーディングウィザードを想像してみてください。名前を入力して進み、戻って編集し、ディープリンクで Confirm にジャンプします。Confirm が古い名前を表示する、あるいは Back が重複した Profile 画面に行くならパス更新が一貫していません。

チェックリストに驚きなく合格すれば、ユーザーが離れて戻ってきてもフローは落ち着いて予測可能に感じられます。

現実的な例と次のステップ

経費申請のマネージャ承認フローを想像してください。4 ステップ:Review、Edit、Confirm、Receipt。ユーザーが望むのは一つだけです:Back は常に前のステップに戻り、以前に見たランダムな画面に飛ぶべきではないということ。

単純なルート enum がこれを予測可能にします。NavigationPath はルートと、状態を再読み込みするために必要な小さな識別子(例:expenseID と mode(review vs edit))だけを保持するべきです。大きく変化するモデルをパスに push すると復元やディープリンクが脆弱になります。

作業用の下書きはビューの外側の単一の真実のソースに置きます(@StateObject のフローモデルやストア)。各ステップはそのモデルを読み書きするので、画面が出たり消えたりしても入力は失われません。

最低限、次の3つを追跡します:

  • ルート(例:review(expenseID), edit(expenseID), confirm(expenseID), receipt(expenseID))\n- データ(明細やメモを持つ下書きオブジェクトと pending / approved / rejected のようなステータス)\n- 位置(フローモデル内の下書き、サーバ上の正規レコード、ローカルの復元トークン:expenseID + 最終ステップ)

エッジケースはフローが信頼を得るか失うかの分かれ目です。マネージャが Confirm で却下したら Back は Edit に戻すのかフローを終了するのか決めてください。後で戻るなら保存トークンから最後のステップを復元し下書きを読み込みます。別デバイスに切り替えた場合はサーバを真と見なし、サーバの状態からパスを再構築して適切なステップへ案内します。

次のステップ:ルート enum を文書化し(各ケースの意味と使われる状況)、パス構築と復元挙動の基本テストをいくつか追加し、次の1ルールを守ってください:ビューはナビゲーション決定を所有しない。

もし同じ種類のマルチステップフローを毎回ゼロから書きたくないなら、AppMaster (appmaster.io) のようなプラットフォームは同じ分離の考え方を適用しています:ステップのナビゲーションとビジネスデータを分離して、画面が変わってもユーザーの進捗が壊れないようにします。

よくある質問

マルチステップの SwiftUI フローで Back ボタンを予測可能に保つには?

単一の NavigationPath を管理する NavigationStack を使ってください。“Next” の操作ごとにルートを1つだけ append し、Back の操作ごとに1つだけ pop するようにします。「Review から Edit profile へ移動」のようなジャンプが必要な場合は、スタックを既知のインデックスに切り詰めてから移動するのが安全です。

なぜ戻るとフォームのデータが消えるのですか?

SwiftUI はビューインスタンスを保持するのではなく、ルート値から目的地ビューを再構築します。ビューの @State にフォームデータを置くと、ビューが再生成されたときにリセットされます。下書きデータは ObservableObject のような共有モデルに置き、プッシュされたビューの外側で管理してください。

スタックに同じ画面が二度表示されるのはなぜ?

同じルートを複数回 append してしまうと同じ画面が重複します(高速タップや複数のコードパスでナビゲーションがトリガーされるのが原因)。Next ボタンをナビゲーション中や検証/ロード中は無効化する、そしてナビゲーションの変更は一箇所で制御して一回だけ append されるようにすると防げます。

ルート列挙型には何を保持し、何を外すべきですか?

ルートには軽量で安定した値(enum ケース+IDなど)だけを入れ、可変なデータ(下書き)は別の共有オブジェクトに置きます。大きく変化するモデルを直接パスに入れると Hashable が壊れて目的地が一致しなくなります。

ナビゲーション状態とフロー状態をきれいに分離するには?

ナビゲーションは「ユーザーがどこにいるか」、フロー状態は「これまでに入力した内容」です。ルーター(またはトップレベルの状態)で path を所有し、下書きは別の ObservableObject で所有します。各画面は下書きを編集し、ルーターはステップだけを変更します。

ウィザードのステップ3へのディープリンクはどう安全に扱うべき?

ディープリンクは単一画面へのテレポートではなく、有効なステップ列を組み立てる命令として扱ってください。事前条件をチェックして不足があればそこへ誘導し、最後にターゲットステップを append します。これにより Back スタックが一貫した物語を語ります。

アプリ再起動後に途中のフローを復元するには?

最後の意味のあるルート(またはステップ識別子)と下書きデータの2点を保存します。再起動時はディープリンクと同じ事前条件チェックでパスを再構築し、下書きをロードします。下書きが古ければフィールドをプレフィルして最初から始めるほうが驚きが少ないこともあります。

マルチステップフローで Push とモーダルはいつ使い分けるべき?

メインの逐次的な道筋は Push(NavigationStack)で実装し、オプションのサブタスクは sheet、ログインやカメラのような別世界は fullScreenCover にします。コアなステップをモーダルに入れるとスワイプで閉じられてフロー状態とUIがズレることが多いので避けてください。

“本当に戻りますか?”のダイアログで戻るジェスチャーを上書きすべき?

既定で Back を介入させずシステム動作に任せてください。戻ることで重大な損失(長い未保存フォームの破棄など)が起きる場合のみ確認ダイアログを出す価値があります。画面が“dirty”なときだけ一度だけ確認し、簡潔に結果を説明しましょう。自動保存や下書き持ちにすると介入の必要が減ります。

変なバックスタックバグを生む最も一般的なミスは?

よくある原因は、複数の NavigationStack を無自覚にネストしてしまうこと、NavigationPath を再生成してしまうこと、そして複数箇所が同時に path を変更することです。フローごとに一つのスタックを持ち、パスは長寿命な状態(@StateObject や専用のルーター)で管理し、push/pop ロジックを一か所に集約してください。

始めやすい
何かを作成する 素晴らしい

無料プランで AppMaster を試してみてください。
準備が整ったら、適切なサブスクリプションを選択できます。

始める