.NET MAUI Shellの画面遷移はGoToAsyncが正解:PushAsyncを使わないルーティングと画面スタック管理

.NET MAUIでShellをルートにしたアプリを作っていると、「画面をどんどん積み上げて遷移したいときも、従来どおりContentPage.Navigation.PushAsync()でいいの?」と迷いがちです。結論から言うと、Shell配下の通常遷移はShellのルーティングを登録し、Shell.Current.GoToAsync()で統一するのが安全で、戻る動作や履歴管理も破綻しにくくなります。

目次

結論:.NET MAUI Shell配下の画面スタックは「ルーティング登録+GoToAsync」で遷移する

Shellを採用しているアプリでは、画面遷移の中心はShellのルーティングです。サブページ(詳細、編集、確認など)を積み上げる「スタック型」の遷移も、PushAsyncではなくGoToAsyncで実現できます。

  • 遷移先ページに対してRoute(ルート)を登録する
  • Shell.Current.GoToAsync()で遷移する(相対ルートで積み上げ、絶対ルートで履歴を作り直す)
  • 遷移の前後にNavigating / Navigatedイベントが発火し、アプリ全体で一元的に制御できる

「Shellを使っているのにPushAsyncで遷移している」状態は、短期的には動いても、画面数が増えるほど戻る動作・深いリンク・履歴の整合性で苦しくなりやすいです。まずはShellの流儀に寄せるのがベストプラクティスです。

PushAsyncを多用すると起きやすい問題:Shellのナビゲーションと混在するから

ShellアプリでもContentPage.Navigation(INavigation)は存在するため、PushAsync自体は「使えてしまう」ことがあります。しかし、Shellが提供するナビゲーション(ルーティング、セクション、タブ、フライアウトなど)と、NavigationPage由来のスタック操作(PushAsync/PopAsync)を混在させると、アプリの規模が大きくなるほど問題が表面化します。

観点ShellのGoToAsync(推奨)PushAsync(混在時に注意)
履歴(バックスタック)Shellが一貫して管理。相対遷移で積み上げ、絶対遷移で作り直しがしやすいShellの履歴と別のスタックができやすく、戻る挙動が複雑化
戻るボタン(Android/戻るジェスチャ)Shellの標準挙動と整合しやすい「どのスタックを戻るのか」が曖昧になり、意図しない戻りが起きることがある
ディープリンク/URI的な遷移Routeを軸にしやすく、設計が整理される画面は開けても「どこにいるか」の状態表現が難しい
アプリ全体の遷移監視Navigating/Navigatedで横断的に制御できるPushAsync経由の遷移はShellイベントと分断され、監視漏れが出やすい
チーム開発/保守ルート命名と登録でルール化しやすいページごとに「どのナビゲーションで遷移しているか」が混在しやすい

特に厄介なのが「戻る」の整合性です。Shellはタブやフライアウトというナビゲーションの階層を持つため、単純なスタック操作だけで考えると破綻します。PushAsyncの“気軽さ”が、後で一番高くつくケースが多いです。

Shellナビゲーションの基本:表示階層のページと、Route登録して開くページ

Shellは、アプリの主要導線(タブ、フライアウト、セクション)をXAMLで宣言的に構成できます。一方で、詳細画面や設定の奥など「表示階層に常駐させないページ」は、Route登録して必要なときに呼び出すのが基本です。

分類代表設計の考え方
常駐ページ(シェル階層に置く)ホーム、一覧、タブのトップ、メニュー項目ShellContent/FlyoutItem/TabBarなどで構成し、ユーザーがいつでも到達できる導線にする
サブページ(Route登録して呼ぶ)詳細、編集、確認、ウィザード、検索結果Routing.RegisterRouteで登録し、GoToAsyncで積み上げる

「一覧ページはShellに置き、詳細ページはRoute登録して開く」といった分離を最初に決めると、画面数が増えても迷いにくくなります。

実装手順:AppShellでRoute登録してGoToAsyncで遷移する

Shell配下のサブページ遷移は、Route登録が出発点です。一般的にはAppShellのコンストラクタで登録します。

public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();

        // サブページ(詳細・編集など)をRoute登録
        Routing.RegisterRoute(nameof(DetailPage), typeof(DetailPage));
        Routing.RegisterRoute(nameof(EditPage), typeof(EditPage));
        Routing.RegisterRoute(nameof(ConfirmPage), typeof(ConfirmPage));
    }
}

Route名は文字列でも指定できますが、保守性の観点ではnameof(ページクラス)を基本にするのがおすすめです。文字列の打ち間違いによる実行時エラーを減らせます。

遷移はGoToAsyncで行います。Shell階層内で現在位置から積み上げたい場合は、相対ルートとしてシンプルにRoute名を指定します。

// 一覧 → 詳細(スタックに積み上げ)
await Shell.Current.GoToAsync(nameof(DetailPage));

この時点で、PushAsyncに頼らなくても「ページを積み上げて遷移する」動きができます。Shellの思想では、GoToAsyncが“Pushに相当する役割”を担います。

「複数画面を積み上げる」例:一覧→詳細→編集→確認

実務でよくある、段階的にサブページを積み上げる流れを例にします。

  • ProductListPage(一覧)
  • DetailPage(詳細)
  • EditPage(編集)
  • ConfirmPage(確認)

それぞれのページがShell階層に常駐する必要がないなら、AppShellでRoute登録したうえで、各画面からGoToAsyncを呼びます。

// 一覧 → 詳細
await Shell.Current.GoToAsync(nameof(DetailPage));

// 詳細 → 編集
await Shell.Current.GoToAsync(nameof(EditPage));

// 編集 → 確認
await Shell.Current.GoToAsync(nameof(ConfirmPage));

この書き方だけでも、ユーザーが「戻る」を押したときに、確認→編集→詳細→一覧と自然に戻っていきます。Shellでスタック遷移を作る最短ルートは、“相対Routeで前に進む”ことです。

戻る(Pop)もGoToAsyncで表現できる

Shellでは、戻る操作もルート文字列で表現できます。画面を閉じてひとつ戻るなら「..」を使います。

// 1つ戻る(Confirm → Edit など)
await Shell.Current.GoToAsync("..");

複数階層まとめて戻したいなら、必要に応じて「../..」のように指定します。

// 2つ戻る(Confirm → Detail など)
await Shell.Current.GoToAsync("../..");
やりたいことポイント
1つ戻るGoToAsync("..")Shellの戻るは「相対ルート」で表現する
複数戻るGoToAsync("../..")ウィザード/多段遷移で便利
トップに戻る(履歴を作り直す)GoToAsync($"//{nameof(HomePage)}")ログイン後など「戻れない」状態を作りたい時に使う

パラメータ渡し:QueryPropertyとIQueryAttributableで安全に受け取る

Shellの強みのひとつは、遷移ルートがURIのように扱えることです。つまり、クエリ文字列でパラメータを渡す設計と相性が良いです(ディープリンクや復元にも寄せやすい)。

文字列/数値などを渡す(クエリ文字列)

例えば一覧から詳細に「ID」を渡すなら、ルートにクエリを付けます。

var id = 42;
await Shell.Current.GoToAsync($"{nameof(DetailPage)}?id={id}");

受け取り側は、QueryProperty属性で受け取れます。

[QueryProperty(nameof(Id), "id")]
public partial class DetailPage : ContentPage
{
    public string Id { get; set; } = "";

    public DetailPage()
    {
        InitializeComponent();
    }
}

QueryPropertyはシンプルですが、複数の値や変換が必要な場合は、次のIQueryAttributableが便利です。

複数パラメータや型変換を行う(IQueryAttributable)

public partial class DetailPage : ContentPage, IQueryAttributable
{
    public int Id { get; private set; }
    public string Mode { get; private set; } = "";


public void ApplyQueryAttributes(IDictionary<string, object> query)
{
    if (query.TryGetValue("id", out var idObj) && int.TryParse(idObj?.ToString(), out var id))
        Id = id;

    if (query.TryGetValue("mode", out var modeObj))
        Mode = modeObj?.ToString() ?? "";
}


}

この方式にしておくと、受け取り側でバリデーションしやすく、変換失敗時のフォールバック(例:一覧へ戻す、エラー表示)も組み込みやすくなります。

オブジェクトを渡したいときの現実的な落としどころ

「詳細画面に遷移するとき、モデル(Productなど)を丸ごと渡したい」という要望もよくあります。Shellでは、辞書でオブジェクトを渡す形も可能です。ただしこの方式はアプリプロセス内でのみ有効で、ディープリンクやプロセス再起動をまたいだ復元には不向きです。

await Shell.Current.GoToAsync(nameof(DetailPage),
    new Dictionary<string, object>
    {
        ["product"] = selectedProduct
    });

受け取り側はIQueryAttributableで受け取れます。

public void ApplyQueryAttributes(IDictionary<string, object> query)
{
    if (query.TryGetValue("product", out var productObj) && productObj is Product p)
    {
        // 画面に反映
        BindingContext = new DetailViewModel(p);
    }
}

実務では、基本は「IDを渡して再取得」、必要に応じて「短命な画面だけオブジェクト渡し」を併用すると破綻しにくいです。

MVVMの実務パターン:ViewModelからShellを直呼びしないナビゲーション設計

サンプルコードでは分かりやすさ優先で Shell.Current.GoToAsync() をそのまま呼びましたが、実務ではViewModelがUIフレームワーク(Shell)に直接依存しすぎると、テストや差し替えが難しくなります。そこで、Shell操作を小さなサービスに閉じ込めると運用が安定します。

置き場所メリット注意点
ページのコードビハインドでGoToAsync最短で実装できる画面が増えると遷移ロジックが散らばりやすい
ViewModelから直接Shell.Currentを呼ぶMVVMの形にしやすいViewModelがShellに依存し、テストや再利用が難しくなる
ナビゲーションサービス(推奨)遷移を集約でき、ルート設計や例外処理を統一できる薄いラッパーに留め、巨大な神クラスにしない

たとえば、アプリ内の遷移を表現するインターフェースを作り、Shell操作はその実装に閉じ込めます。

public interface IAppNavigator
{
    Task GoToDetailAsync(int id);
    Task GoToEditAsync(int id);
    Task GoBackAsync();
    Task ResetToHomeAsync();
}
public sealed class ShellAppNavigator : IAppNavigator
{
    public Task GoToDetailAsync(int id)
        => Shell.Current.GoToAsync($"{nameof(DetailPage)}?id={id}");

    public Task GoToEditAsync(int id)
        => Shell.Current.GoToAsync($"{nameof(EditPage)}?id={id}");

    public Task GoBackAsync()
        => Shell.Current.GoToAsync("..");

    public Task ResetToHomeAsync()
        => Shell.Current.GoToAsync($"//{nameof(HomePage)}");
}

ViewModelはインターフェースだけを知っていればよくなり、遷移の統一ルール(例:二重タップ防止、ログ、例外時のフォールバック)も集約できます。「Shellを使う=GoToAsyncで統一する」だけでなく、「GoToAsync呼び出しの散らばり」を抑えるのが、長期運用のコツです。

ログイン後など“戻れない状態”を作りたい:絶対ルートでスタックをリセットする

ログイン画面→ホーム画面のように、「ログイン後に戻るボタンでログインへ戻れてしまう」のを防ぎたいケースがあります。Shellでは、絶対ルート指定でトップを作り直すのが定石です。

// ログイン成功後:ホームへ遷移し、履歴を作り直す
await Shell.Current.GoToAsync($"//{nameof(HomePage)}");

この手法は、ウィザード完了後に最初の画面へ戻す、あるいは権限変更後にメニュー構造を変えたい、といった場面でも有効です。「戻れない」設計が必要なところだけ、意図的に絶対ルートへ寄せると、UXが安定します。

Navigating / Navigatedイベントで遷移を横断的に制御する

Shellナビゲーションに統一するメリットのひとつが、遷移を“アプリ全体で”監視しやすい点です。例えば以下のような用途で役に立ちます。

  • 未保存の変更があるときに、遷移前に確認ダイアログを出す
  • ログ収集(どの画面がどれだけ閲覧されたか)
  • 権限制御(特定ページへの遷移をブロックしてログインへ飛ばす)
public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();

        Navigating += OnShellNavigating;
        Navigated += OnShellNavigated;
    }

    private void OnShellNavigating(object? sender, ShellNavigatingEventArgs e)
    {
        // 例:特定の条件で遷移をキャンセルする
        // e.Cancel(); を使うと遷移を止められる
    }

    private void OnShellNavigated(object? sender, ShellNavigatedEventArgs e)
    {
        // 例:遷移ログ
        // Debug.WriteLine($"Navigated: {e.Current.Location}");
    }
}

PushAsyncが混ざると、このような“まとめて監視”が効きにくくなります。運用フェーズで「例外処理を散らさない」ためにも、Shell統一は効いてきます。

それでもPushAsync/PushModalAsyncを使う“例外”と、使うなら守りたいルール

原則はShell統一ですが、現場では例外もあります。代表的には次のようなケースです。

ケースPush系が選ばれがちな理由おすすめの整理
特殊なモーダル遷移(フルスクリーンの入力フローなど)「戻る」よりも「閉じる」が主体で、独立したナビゲーションにしたい可能ならShellのモーダル表示(ページ側のPresentationMode設定など)を検討し、混在を最小化する
既存資産がNavigationPage前提(移植中)段階的移行のため、全てを一気にShellへ寄せられない移植期間を決め、最終的にRoute/GoToAsyncへ収束させる
サードパーティUI/画面がINavigation前提外部コンポーネントがPushAsyncでの表示を要求する表示範囲を限定し、そこだけ独立したNavigationPageとして閉じ込める

どうしてもPush系を使うなら、以下のルールを意識すると事故が減ります。

  • 「Shell配下の通常遷移」はGoToAsync、「例外の領域」だけPush系、と境界を明確にする
  • 例外領域の入口と出口(どこから開き、どこへ戻すか)を決め、画面遷移の責務を分散させない
  • 戻る挙動(Androidの戻るボタン、iOSのスワイプ)を必ず実機で確認する

実務でハマりがちなポイントと対策

症状よくある原因対策
「Routeが見つからない」例外が出るRouting.RegisterRouteを忘れた/Route名が一致していないAppShellでnameof(ページ)で登録し、遷移側もnameofで統一する
戻ると想定外の画面に戻るGoToAsyncとPushAsyncが混在して複数のスタックが存在しているShell配下の通常遷移はGoToAsyncに寄せ、Push系は例外領域に閉じ込める
詳細画面でデータが取れない/nullになるオブジェクト渡しに依存し、プロセス再起動や状態復元で破綻ID渡し+再取得を基本にし、オブジェクト渡しは短命な画面に限定する
遷移のたびにイベントが二重に動くNavigating/Navigatedへの購読解除漏れ、または複数回購読AppShell生成時に1回だけ購読し、ページ単位の購読は必要最小限にする

チェックリスト:Shellアプリの画面遷移を“事故らせない”設計

  • サブページはRoute登録し、GoToAsyncで開く
  • Route名はnameofで統一し、文字列直書きを避ける
  • スタックを積むのは相対Route、履歴を作り直すのは絶対Route(//)
  • パラメータは「ID渡し」を基本に、必要ならIQueryAttributableで受け取りを固める
  • 例外的にPush系を使うなら、範囲を閉じ、戻る挙動を必ず実機確認する

まとめ:Shellのルーティングを軸にすると、画面が増えても破綻しにくい

.NET MAUIでShellを採用する最大のメリットは、タブ・フライアウト・ディープリンク的な導線を含めて、アプリ全体の画面遷移をひとつの思想で整理できる点にあります。Shell配下の通常遷移は「Route登録+GoToAsync」に寄せ、PushAsyncは例外として扱う。これだけで、戻る動作・履歴・運用時の一括制御がぐっと楽になります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次