.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は例外として扱う。これだけで、戻る動作・履歴・運用時の一括制御がぐっと楽になります。

コメント