.NET MAUI のページ遷移では「今のページに来る直前はどのページだったか?」を知りたくなる場面が多々あります。ところが OnNavigatedTo(NavigatedToEventArgs) の引数からは PreviousPage が公開されておらず、デバッガでチラ見えする情報をコードから取得できません。本稿では、MAUI の抽象化方針を踏まえつつ、実運用で役に立つ 3 つの解法(独自ナビゲーションサービス/NavigationPage/Shell)を、サンプルコードと設計の勘所、落とし穴まで含めて体系的に解説します。
.NET MAUI で「前のページ」を知りたい問題の本質
MAUI は Android・iOS・Windows の異なるナビゲーションモデル(Android の Activity/Back stack、iOS の UINavigationController、Windows の Frame)を単一の API に抽象化しています。プラットフォームごとに「前ページ」をどう定義するかは微妙に異なり、共通 API にその概念を直接さらすと整合性や将来の互換性に悪影響が出るため、MAUI のパブリック API はあえて「PreviousPage」を露出していません。したがって、実装側で履歴を扱うポリシーを決め、アプリ要件に沿った方法で「直前のページ」を把握する必要があります。
以降では、実装コスト・拡張性・テスト容易性の観点からおすすめ順に解法を解説します。
解法サマリー
| 方法 | 具体的手順 | 補足・適用範囲 |
|---|---|---|
| A. 独自ナビゲーションサービス(推奨) | INavigationService を定義し、内部で履歴 Stack<string> などを保持。 画面遷移の入口をサービスに集約し、遷移前に「現在ページ名」を Push。 GetPreviousPage()/GetPreviousPageName() で参照。 MauiProgram.cs で builder.Services.AddSingleton<INavigationService, NavigationService>() を登録。 | 純正 API で露出しない履歴を自前で確実に保持。戻る制御やログも一元化でき、ユニットテストしやすい。 |
| B. NavigationPage を利用 | var prev = Navigation.NavigationStack[^2]; で 1 つ前のページを取得。 | スタック数が 2 未満では例外になるため null/境界チェック必須。 クラシックな Push/Pop 構成向け。 |
| C. Shell を利用 | var stack = Shell.Current.Navigation.NavigationStack; から stack[^2] を参照。Shell.Current.CurrentState でルートや全体状態も把握可能。 | URI ルーティング/ディープリンク混在時は履歴ポリシーの明確化が鍵。 モーダルは ModalStack を別途考慮。 |
なぜ PreviousPage が公開されていないのか(設計背景)
MAUI の Page ライフサイクル(OnAppearing / OnDisappearing / OnNavigatedTo / OnNavigatedFrom)は、共通のイベント契約を提供するためのものです。各 OS のナビゲーションが持つ「戻る」動作やタスク再生成、モーダル、タブ切り替えなどはばらつきが大きく、1 つの PreviousPage を保証すると仕様が破綻します。そこで MAUI は「履歴の扱い」をアプリ開発者に委ねています。本稿のアプローチ A は、まさにこの前提を踏まえた標準解です。
アプローチ A:独自ナビゲーションサービスを作る(推奨)
メリットは次の通りです。
- 履歴の定義(「ページ」か「ルート」か)をアプリ都合で決められる。
- 戻る制御(ハードウェア戻る/モーダル/ルートリセット)を一元化可能。
- テレメトリ・診断ログ・A/B テストなどビジネスロジックと結合できる。
- ユニットテストしやすい(スタックの状態を検証できる)。
最小構成のインターフェース
public interface INavigationService
{
Task NavigateToAsync<TPage>(IDictionary<string, object>? parameters = null)
where TPage : Page;
Task GoBackAsync();
string? GetPreviousPageName();
Page? GetPreviousPage(); // 必要なら
IReadOnlyList<string> GetHistory();
void ClearHistory();
}
DI 登録(MauiProgram.cs)
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>();
// ページは解決可能に(例:Transient)
builder.Services.AddTransient<HomePage>();
builder.Services.AddTransient<DetailPage>();
// ナビゲーションサービスをシングルトン登録
builder.Services.AddSingleton<INavigationService, NavigationService>();
return builder.Build();
}
}
実装(履歴は名前ベース+リーク対策)
ページ インスタンスを強参照で積み上げると、戻らない限り GC されずリークします。実運用ではページ型名やルート名を履歴として持つ方が安全です。ページそのものが必要な場合は WeakReference<Page> を使います。
public sealed class NavigationService : INavigationService
{
private readonly IServiceProvider _sp;
private readonly Stack<string> _history = new();
private readonly object _gate = new();
public NavigationService(IServiceProvider sp) => _sp = sp;
public async Task NavigateToAsync<TPage>(IDictionary<string, object>? parameters = null)
where TPage : Page
{
var nav = Application.Current?.MainPage?.Navigation
?? throw new InvalidOperationException("Navigation is not available.");
// 現在ページ名を履歴へ
var current = GetTopPage(nav);
if (current is not null)
{
lock (_gate) _history.Push(current.GetType().Name);
}
// DI からページを解決して Push
var next = _sp.GetRequiredService<TPage>();
if (parameters is not null)
{
// Shell 互換のナビゲーションパラメータ注入(IQueryAttributable)
if (next is IQueryAttributable attributable)
attributable.ApplyQueryAttributes(parameters);
// ViewModel パターンの場合は BindingContext に渡す等、方針を統一する
}
await nav.PushAsync(next).ConfigureAwait(false);
}
public async Task GoBackAsync()
{
var nav = Application.Current?.MainPage?.Navigation
?? throw new InvalidOperationException("Navigation is not available.");
if (nav.NavigationStack.Count > 1)
{
await nav.PopAsync().ConfigureAwait(false);
// Pop 時は履歴から 1 件取り出す(リセットはしない)
lock (_gate)
{
if (_history.Count > 0) _history.Pop();
}
}
}
public string? GetPreviousPageName()
{
lock (_gate) return _history.TryPeek(out var name) ? name : null;
}
public Page? GetPreviousPage()
{
var nav = Application.Current?.MainPage?.Navigation;
if (nav is null) return null;
var stack = nav.ModalStack.Count > 0 ? nav.ModalStack : nav.NavigationStack;
return stack.Count >= 2 ? stack[^2] : null;
}
public IReadOnlyList<string> GetHistory()
{
lock (_gate) return _history.ToArray();
}
public void ClearHistory()
{
lock (_gate) _history.Clear();
}
private static Page? GetTopPage(INavigation nav)
{
// モーダルが優先でトップ
if (nav.ModalStack.Count > 0) return nav.ModalStack[^1];
if (nav.NavigationStack.Count > 0) return nav.NavigationStack[^1];
return null;
}
}
ページ側での利用例
public partial class DetailPage : ContentPage
{
private readonly INavigationService _nav;
public DetailPage(INavigationService nav)
{
InitializeComponent();
_nav = nav;
}
protected override void OnNavigatedTo(NavigatedToEventArgs args)
{
base.OnNavigatedTo(args);
var prev = _nav.GetPreviousPageName() ?? "(履歴なし)";
PreviousLabel.Text = $"前のページ:{prev}";
}
private async void OnBackClicked(object sender, EventArgs e)
=> await _nav.GoBackAsync();
}
Shell を併用する場合のルーティング対応
Shell の GoToAsync() を使うアプリでも、「遷移は必ずサービス経由」を守れば履歴は維持できます。Shell ルート名をスタックに積むバージョンの簡易例を示します。
public async Task GoToAsync(string route, IDictionary<string, object>? dict = null)
{
var currentRoute = Shell.Current.CurrentState.Location.OriginalString;
lock (_gate) _history.Push(currentRoute);
await Shell.Current.GoToAsync(route, dict);
}
ハードウェア戻る(Android)との整合
Android の戻るキーは通常 Navigation.PopAsync() 相当の動きをします。戻る動作をサービスに集約したい場合は、ContentPage の OnBackButtonPressed() を override し、サービス経由で戻る処理を呼びつつ true を返すことで OS 既定動作をキャンセルできます。
protected override bool OnBackButtonPressed()
{
_ = _nav.GoBackAsync();
return true; // OS 既定の戻るを抑止
}
ユニットテストの方針
DI を使っているため、INavigation を抽象化したテストダブルを差し込み、「遷移前に現在ページ名を Push しているか」を検証できます。履歴スタックの中身だけを assert すればよく、UI を起動せずにロジックを検証可能です。
アプローチ B:NavigationPage の NavigationStack を直接参照
クラシックな NavigationPage を使っている場合、Page.Navigation.NavigationStack から前のページを直接取り出せます。最小コードは次の通りです。
var stack = Navigation.NavigationStack;
Page? previous = stack.Count >= 2 ? stack[^2] : null;
if (previous is not null)
{
var name = previous.GetType().Name;
// 表示やログに使用
}
注意点:
- スタックが 2 未満のとき(ルートページ)は
[^2]が例外になるため、必ず境界チェックを行う。 ModalStackは別管理です。モーダルで遷移している場合はModalStack[^1]がトップ、ModalStack[^2]が前ページ候補となります。InsertPageBefore()/RemovePage()を使うと履歴が意図通りでなくなるため、業務要件に応じて使用ポリシーを決めると保守が楽になります。
アプローチ C:Shell の NavigationStack と CurrentState を参照
Shell ではタブ・フライアウト・ディープリンクなど、ルーティングが複雑になりがちです。基本の取り方は NavigationPage と同じですが、ルート名やクエリを合わせて見るのが実用的です。
// 表示中セクションのページスタック
var stack = Shell.Current.Navigation.NavigationStack;
var previous = stack.Count >= 2 ? stack[^2] : null;
// ルートやクエリ(例:app://.../detail?id=42)
var currentState = Shell.Current.CurrentState;
var currentRoute = currentState.Location.OriginalString;
// モーダルは別スタック
var modalTop = Shell.Current.Navigation.ModalStack.LastOrDefault();
Shell での履歴ポリシー策定例
- 「前ページ」= 現在セクションの
NavigationStackのひとつ前。 - モーダル表示中は「前ページ」=
ModalStack[^2]。 - ディープリンクで直接開かれた場合は「前ページ」なし(null)。
GoToAsync("//home")などルートリセットした場合は履歴もクリア。
最小実装(抜粋・日本語コメント付き)
コンパクトなナビゲーションサービスの例です。まずはこれから始め、必要に応じて Shell ルート対応やログ機構を足すのがおすすめです。
public class NavigationService : INavigationService
{
private readonly Stack<string> _history = new();
private Page? _current;
public async Task NavigateToAsync<T>() where T : Page, new()
{
if (_current != null) _history.Push(_current.GetType().Name);
_current = new T();
await Application.Current.MainPage.Navigation.PushAsync(_current);
}
public async Task GoBackAsync()
=> await Application.Current.MainPage.Navigation.PopAsync();
public string GetPreviousPageName()
=> _history.TryPeek(out var name) ? name : "履歴なし";
}
この最小版でも「前ページ名」の表示や、戻るボタンの一元化に十分機能します。
設計の勘所(チェックリスト)
| 観点 | ポイント | 推奨プラクティス |
|---|---|---|
| 履歴の単位 | Page インスタンス/型名/Shell ルートのいずれで持つか | 基本は型名 or ルートで十分。インスタンスが必要なら WeakReference を使用。 |
| モーダル | ModalStack は NavigationStack と別管理 | 「前ページ」定義にモーダルを含めるかを明文化し、サービスに実装。 |
| 戻る操作 | OS 戻るキー/UI ボタン/ジェスチャ | 戻るはサービス経由を原則にし、OnBackButtonPressed() で既定動作を統一。 |
| ルートリセット | PopToRootAsync/GoToAsync("//...") | リセット時は履歴も Clear()。ログ・テレメトリにも記録。 |
| 並行遷移 | 二重タップ等で遷移多重化 | サービス側でセマフォやフラグを持ち二重遷移を抑止。 |
| 例外/エラー | 解決失敗/ページ生成例外 | DI 解決に失敗したら明示的なメッセージを投げ、フェールファスト。 |
| ログ | 診断・再現 | 「現在→遷移先」「所要時間」「パラメータ」を構造化ログに。個人情報は除外/マスク。 |
よくある落とし穴と回避策
アプリ起動直後は「前ページ」が存在しない
ルートページでは履歴なしが正解です。UI では 「(なし)」 などにフォールバックし、例外を投げない設計にします。
タブ切り替えで OnNavigatedTo が呼ばれる
Shell のタブ切り替えなど、OnAppearing/OnNavigatedTo は「前後」関係が ページ遷移 とは限りません。「前ページ」はナビゲーションアクションの履歴として管理し、単なる可視化イベントと混同しないようにします。
InsertPageBefore で履歴が歪む
「A → B に遷移後、A の前に Login を挿入」などの操作は履歴ポリシーを複雑化させます。やむを得ず使う場合は操作直後にサービス側の履歴と実スタックを同期する関数を用意してください。
ディープリンク(外部 URI)から直接詳細ページに来た
この場合の「前ページ」は定義上存在しないと割り切るほうが実務的です。戻るボタンは ホームに戻る 動作とするなど、UX 設計を先に決めてからサービスへ実装しましょう。
応用:ViewModel と連携する(MVVM)
MVVM を採用するなら、INotificationService と同様に INavigationService を ViewModel に注入し、コマンドから遷移を行うと UI ロジックが薄くなります。IQueryAttributable でパラメータを受け取ると、View と ViewModel の結合を下げられます。
public partial class DetailViewModel : ObservableObject, IQueryAttributable
{
private readonly INavigationService _nav;
[ObservableProperty] private string? _previousPage;
public DetailViewModel(INavigationService nav) => _nav = nav;
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
// 例:外部から受け取った ID を処理
}
public void OnNavigatedTo()
{
PreviousPage = _nav.GetPreviousPageName() ?? "(なし)";
}
}
アプローチ比較(選定ガイド)
| 項目 | 独自サービス | NavigationPage 直参照 | Shell 直参照 |
|---|---|---|---|
| 実装コスト | 中 | 低 | 低〜中 |
| 拡張性 | 高(戻る制御・ログ・ポリシー集約) | 低 | 中(ルート設計次第) |
| テスト容易性 | 高(スタック検証) | 低 | 中 |
| Shell/Modal 混在 | ◎(設計で吸収) | △ | ◯(要ポリシー) |
| 小規模アプリ | ◯ | ◎ | ◯ |
| 中〜大規模アプリ | ◎ | △ | ◯ |
実践 Tips(品質・保守性を上げる工夫)
- 一貫性のある命名:履歴を型名で持つ場合は
HomePage/DetailPageのように末尾を統一。 - 遷移の単一経路:UI からの遷移・ViewModel からの遷移をすべてサービス経由にすると履歴の欠落を防げます。
- 二重タップ対策:遷移前にフラグを立て、完了後に解除。非同期遷移の連打でスタックが破綻する事故を避けます。
- ログにユーザー識別子を書かない:個人情報保護の観点から、ルートやページ名のみを記録しクエリ値はハッシュ化するなど工夫を。
- エラー時のフォールバック:ページ生成に失敗したらエラーページへ遷移する標準経路を用意。
デバッグ:NavigatedToEventArgs で見えるのに取れない理由
デバッガで内部情報に PreviousPage 的なものが見えることがありますが、それは内部実装の詳細であり、互換性の保証はありません。リフレクションで無理にアクセスするのは非推奨です。将来の更新で破綻するリスクが高いため、サービス方式で外側から制御しましょう。
ケース別サンプル集
「前ページ名」をトースト表示(Android)
protected override void OnNavigatedTo(NavigatedToEventArgs args)
{
base.OnNavigatedTo(args);
var prev = _nav.GetPreviousPageName();
#if ANDROID
if (!string.IsNullOrEmpty(prev))
Toast.MakeText(Platform.CurrentActivity, $"前ページ: {prev}", ToastLength.Short).Show();
#endif
}
「前へ戻る」アイコンを状況で出し分け
protected override void OnAppearing()
{
base.OnAppearing();
ToolbarItems.Clear();
if (_nav.GetPreviousPageName() is not null)
ToolbarItems.Add(new ToolbarItem { Text = "戻る", Command = new Command(async () => await _nav.GoBackAsync()) });
}
ルートリセット時の履歴クリア
// 例:ログアウトでホームへ戻す
await Shell.Current.GoToAsync("//login");
_nav.ClearHistory();
パフォーマンスとメモリ
履歴を文字列で持つ限り、メモリ消費はごく僅かです。大量の遷移ログを残す場合は上限(例:100 件)を設け、超過時に古いものから捨てるリングバッファ方式にします。インスタンス参照を持つ必要がある場合は WeakReference<Page> を使い、画面が解放されたら参照が切れる設計にします。
セキュリティとプライバシー
- クエリ文字列に個人情報を載せない。どうしても必要なら暗号化やトークン化を。
- 診断ログは匿名化し、ページ名やルートのみに留める。
- ディープリンクの検証(署名やホワイトリスト)を行い、想定外のルートへ遷移しない。
導入から実運用までの手順まとめ
- ポリシー決定:「前ページ」の定義(モーダル含む/含まない、ディープリンク時の扱い)を明文化。
- サービス実装:最小の
INavigationServiceを作り、遷移入口を集約。 - DI 配線:
MauiProgram.csに登録。ページ・ViewModel から注入。 - UI 反映:戻るボタン、パンくず、トーストなどに前ページ名を表示。
- テスト:二重タップ・モーダル・リセット・ディープリンクの 4 ケースは必ず自動化。
- 運用:ログを収集し、UX 改善や離脱分析に活用。
FAQ(よくある質問)
Q. OnNavigatedTo ではなく OnAppearing で取得してもよい?
A. どちらでも構いませんが、OnNavigatedTo はナビゲーション文脈が強く、OnAppearing は可視化イベントで呼ばれやすい点に注意。タブ切り替えで OnAppearing が頻発する設計なら、サービス側履歴と組み合わせて誤検出を避けましょう。
Q. 履歴をページタイトルで持つべき?型名で持つべき?
A. 一貫性が最優先です。タイトルは多言語化やランタイム変更の影響を受けやすいので、型名 or ルートを推奨します。
Q. 既存コードが Navigation.PushAsync を直接呼んでいる。全部置換するべき?
A. まずは変更頻度の高い経路(詳細ページ・検索結果など)だけサービス経由にし、段階的に移行するのが安全です。
まとめ
- 純正 API だけで「前ページ」を安全に取得するのは難しい(設計方針による)。
- 小〜中規模なら NavigationStack 直参照 が手早い。境界とモーダルの扱いに注意。
- アプリ全体の一貫性・テスト性・運用性を重視するなら、独自ナビゲーションサービスを DI 登録して履歴を管理するのが最善。
- Shell ではルートとモーダルを明確に分け、ポリシーをコードに落とすと将来の保守が楽になる。
以上の指針とサンプルをベースに、あなたのアプリの UX と保守性を両立する「前ページ」取得を実装してみてください。

コメント