【iOS】.NET MAUI NavigationPageでOnBackButtonPressedが呼ばれない原因と対策(Custom NavigationRenderer)

.NET MAUI で NavigationPage を MainPage にした iOS アプリで、ナビゲーションバーの戻る操作をしても ContentPage の OnBackButtonPressed() が一度も呼ばれないことがあります。Shell を使えない場合でも、iOS 側で戻るをフックしてページに通知する方法を具体例つきで解説します。

目次

起きている現象

App.xaml.cs(または App.xaml.cs 相当の起動コード)で次のように NavigationPage をメインにしているとします。

public partial class App : Application
{
    public App()
    {
        InitializeComponent();


    MainPage = new NavigationPage(new MainPage());
}


}

この構成で、各 ContentPage で次を override しても iOS では一切呼ばれない、というのが今回の問題です。

protected override bool OnBackButtonPressed()
{
    // ここにブレークポイントを置いても iOS では止まらない
    return base.OnBackButtonPressed();
}

Android だと端末の「戻る」ボタンで OnBackButtonPressed() を拾えるため、同じコードが iOS では動かず戸惑いやすいポイントです。iOS には端末共通の戻るボタンがない代わりに、主に「ナビゲーションバーの戻る(<)」や「画面左端からのスワイプ」で戻りますが、NavigationPage 構成だとこの戻り処理がページ側に届かないケースがあります。

一方、AppShell(Shell ナビゲーション)を使うと戻るイベントが期待どおり動くケースが多いものの、プロジェクト事情で Shell を採用できない場面もあります。そこで「NavigationPage 構成のまま、iOS の戻る操作を確実に捕まえる」ことを目的にします。

構成iOSでの戻る(ナビゲーションバー)OnBackButtonPressed() への到達
Shell(AppShell)Shellの戻るに統合されやすい呼ばれるケースが多い
NavigationPage(MainPage = new NavigationPage(…))ネイティブの UINavigationController で処理されやすい呼ばれないケースがある

結論:iOS 側で “戻る” をフックし、ページに SendBackButtonPressed() を通知する

iOS の NavigationPage(iOS 側の NavigationRenderer / NavigationPage の実装)では、戻る操作が Page.OnBackButtonPressed() に伝播しない経路が存在します。回避策は、iOS のナビゲーションコントローラーが pop(戻る)を実行するタイミングを横取りし、現在表示中のページへ SendBackButtonPressed() を呼び出して疑似的に OnBackButtonPressed() を発火させることです。

SendBackButtonPressed() の戻り値は「OnBackButtonPressed() が戻る処理を扱ったかどうか」を表し、true のときは処理済み(=戻るを止めたい)、false のときは未処理(=既定の戻るに任せる)として扱えます。

ポイントは次の2つです。

  • SendBackButtonPressed() の戻り値が true(= ページ側が戻るを処理済み)なら、既定の pop をキャンセルする
  • 戻り値が false(= ページ側で処理しない)なら、既定どおり pop して前のページへ戻す
ページ側(OnBackButtonPressed の戻り値)意味iOS側の動き(今回の回避策)
true戻るをアプリ側で処理した(既定の戻るは不要/抑止したい)Pop を中止(戻らない)
false既定の戻るに任せる通常どおり Pop(戻る)

実装の全体像

やることは大きく3ステップです。いずれも NavigationPage 構成を崩さず、iOS だけに限定して適用できます。

  1. iOS プラットフォーム側にカスタム NavigationRenderer を作る(PopViewController を override)
  2. MauiProgram.cs で iOS のハンドラー登録を行い、NavigationPage にそのレンダラーを割り当てる
  3. 各ページで OnBackButtonPressed() を override して戻る挙動を制御する

ステップ:iOS に CustomNavigationPageRenderer を追加する

プロジェクトの Platforms/iOS 配下に CustomNavigationPageRenderer.cs を追加します。ここで iOS ネイティブの pop 処理をフックします。

using System.Linq;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Handlers.Compatibility;
using UIKit;

namespace YourApp.Platforms.iOS
{
    // NavigationPage 用のカスタムレンダラー(互換レンダラー)
    public class CustomNavigationPageRenderer : NavigationRenderer
    {
        public override UIViewController PopViewController(bool animated)
        {
            // Element は、このレンダラーに紐づいている NavigationPage
            if (Element is NavigationPage navigationPage)
            {
                // NavigationStack の末尾 = 現在表示中のページ、という前提で取りに行くのが安全です。
                var currentPage = navigationPage.Navigation?.NavigationStack?.LastOrDefault();

                if (currentPage != null)
                {
                    // ここが肝:ページへ「戻るが押された」ことを通知する
                    var handled = currentPage.SendBackButtonPressed();

                    if (handled)
                    {
                        // true = ページが戻るを処理した(=既定の戻るは不要)
                        // pop をキャンセル
                        return null;
                    }
                }
            }

            // 既定どおり pop
            return base.PopViewController(animated);
        }
    }
}

コードがやっていることを分解して理解する

「なぜこれで OnBackButtonPressed() が動くのか」を押さえておくと、後のトラブルシュートが楽になります。

処理意図結果
PopViewController を overrideiOS が戻る(pop)を実行する“入口”を捕まえる戻る前にアプリ側で判断できる
currentPage.SendBackButtonPressed()現在ページに「戻るが押された」を通知するページの OnBackButtonPressed() が呼ばれる
handled == true なら return nullページ側が戻るを処理済みなら既定の戻りは不要戻る(pop)がキャンセルされる
handled == false なら base.PopViewControllerページ側で処理しないなら既定動作に任せる通常どおり前のページへ戻る

実装ポイント:currentPage の選び方

「どのページに SendBackButtonPressed() を投げるか」は地味に重要です。単純に navigationPage.CurrentPage を使っても良いですが、ナビゲーションの状態によってはスタックの末尾が最も確実なことがあります。

取得方法例向いているケース
NavigationStack の末尾navigationPage.Navigation.NavigationStack.LastOrDefault()PushAsync/PopAsync の一般的な積み上げで安定
CurrentPagenavigationPage.CurrentPage単純構成で読みやすい

ステップ:MauiProgram.cs で iOS のハンドラー登録を行う

次に、iOS のときだけ NavigationPage に CustomNavigationPageRenderer を割り当てます。ConfigureMauiHandlers に追加するのが分かりやすいです。

using Microsoft.Maui.Controls;

namespace YourApp;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();

        builder
            .UseMauiApp<App>()
            .ConfigureMauiHandlers(handlers =>
            {
#if IOS
                handlers.AddHandler(typeof(NavigationPage), typeof(YourApp.Platforms.iOS.CustomNavigationPageRenderer));
#endif
            });

        return builder.Build();
    }
}

iOS 以外には影響させたくないので、必ず条件付きコンパイル(#if IOS)に入れておくのが安全です。iOS ビルド時にのみ NavigationPage の挙動が差し替わります。

影響範囲を絞りたい場合:NavigationPage 全体ではなく自前サブクラスだけに適用する

アプリ内で NavigationPage を複数使っていて「特定の NavigationPage だけ戻るフックしたい」というケースもあります。その場合は、空のサブクラスを作って、その型だけにハンドラーを登録すると安全です。

// 共有コード(任意の場所)
public class CustomNavigationPage : NavigationPage
{
    public CustomNavigationPage(Page root) : base(root) { }
}

// App 起動時
MainPage = new CustomNavigationPage(new MainPage());
// MauiProgram.cs(iOSのみ)
#if IOS
handlers.AddHandler(typeof(CustomNavigationPage), typeof(YourApp.Platforms.iOS.CustomNavigationPageRenderer));
#endif

こうしておけば、別の NavigationPage(例えばタブごとのナビゲーションなど)にまで一律に影響させずに済みます。

ステップ:各ページで OnBackButtonPressed() を override して制御する

ここまでで「戻るが押されたタイミングで OnBackButtonPressed() に到達する経路」が用意できました。あとは各ページで override して、戻るを許可するか、止めるか、独自処理にするかを決めます。

最小例:戻るを抑止したい(常に戻らない)

public class EditPage : ContentPage
{
    protected override bool OnBackButtonPressed()
    {
        // 例:編集内容が未保存のため戻らせたくない
        return true; // true = 既定の戻るをキャンセル
    }
}

実用例:未保存なら確認ダイアログを出してから戻る

OnBackButtonPressed() は同期メソッドなので、そのまま await できません。そこで「いったん戻るをキャンセル(true)」して、UI スレッド上で非同期に確認→OK なら PopAsync します。

ただし、このままだと PopAsync 時にも SendBackButtonPressed() が呼ばれて再度 OnBackButtonPressed() に入り、確認がループします。対策として、プログラムから戻すときだけ通すフラグを用意します。

public class EditPage : ContentPage
{
    private bool _allowPop;

    protected override bool OnBackButtonPressed()
    {
        // プログラム側が許可した pop は素通し
        if (_allowPop)
            return false;

        // ここから先は「ユーザー操作の戻る」想定:いったん戻るを止める
        Dispatcher.Dispatch(async () =>
        {
            bool ok = await DisplayAlert("確認", "変更を破棄して戻りますか?", "戻る", "キャンセル");
            if (ok)
            {
                _allowPop = true;
                try
                {
                    await Navigation.PopAsync();
                }
                finally
                {
                    _allowPop = false;
                }
            }
        });

        return true; // 既定の戻るはキャンセル
    }
}

このパターンにしておくと、ユーザーが戻るを押したときだけ確認が出て、OK ならスムーズに前のページへ戻せます。

PushAsync で開いたページでも動作する?

この方式は、Navigation.PushAsync(new MyPage()) のように NavigationStack に積まれる通常の ContentPage でも動作します。カスタムレンダラーでは NavigationStack の末尾(現在ページ)を取得して SendBackButtonPressed() を呼ぶため、PushAsync で表示したページでも同じように OnBackButtonPressed() が発火します。

ナビゲーション方法捕捉できる戻る備考
PushAsync / PopAsync(通常のスタック)○今回の主題。ナビゲーションバーの戻るを捕まえやすい
スワイプで戻る(エッジスワイプ)△pop が走るため多くの場合は捕まえられるが、UX的に止めたい場合は追加対応が必要
PushModalAsync / PopModalAsync(モーダル)△閉じる経路が別になることがある。必要ならモーダル側の構成も見直す

NavigationPage の前提を押さえる

NavigationPage は、ページを LIFO(後入れ先出し)のスタックとして管理し、PushAsync で積み、PopAsync で戻す階層ナビゲーションを提供します。今回の回避策は、この「スタックの末尾が現在ページになる」という前提を利用しています。

モーダル画面も “戻る” を捕まえたい場合の考え方

モーダル(PushModalAsync / PopModalAsync)は、iOS では「pop で戻る」というより「dismiss(閉じる)」として扱われることがあります。もしモーダルでも同じ UX(ナビゲーションバーの戻る)を提供したいなら、モーダルのルートを NavigationPage で包む設計が分かりやすいです。

// 例:モーダルでもナビゲーションバーを使いたい場合
await Navigation.PushModalAsync(new NavigationPage(new ModalRootPage()));

この場合、モーダル内での PushAsync/PopAsync は引き続き NavigationPage のスタックで動くため、今回のカスタムレンダラーが効きやすくなります。一方、ナビゲーションバーを持たないモーダルの「閉じる」を捕まえたい場合は別経路になることがあるため、OnDisappearing や独自の閉じるボタンなど、別の設計を検討してください。

スワイプで戻る(インタラクティブ Pop)を止めたいとき

確認ダイアログを出すページでは、エッジスワイプの戻るが UX 的に相性が悪い場合があります(スワイプ途中で戻る/戻らないが決まるなど)。必要なら、iOS 側でインタラクティブ Pop ジェスチャーを無効化するのも選択肢です。

public override void ViewDidLoad()
{
    base.ViewDidLoad();

    // 例:スワイプで戻るを禁止したい場合(ページ単位の制御は工夫が必要)
    if (NavigationController?.InteractivePopGestureRecognizer != null)
        NavigationController.InteractivePopGestureRecognizer.Enabled = false;
}

ただし、アプリ全体で無効化すると操作性が落ちることがあります。必要な画面だけに限定する、あるいは確認が必要な画面は戻るボタン自体を自前にするなど、要件に合わせて設計してください。

よくあるつまずきポイント

  • #if IOS の位置:AddHandler の登録が iOS ビルドに入っていないと、当然ながらフックされません。
  • 名前空間の不一致:typeof(YourApp.Platforms.iOS.CustomNavigationPageRenderer) が正しいか確認します。
  • using の不足:System.Linq を入れないと LastOrDefault() でコンパイルエラーになります。
  • DisplayAlert のループ:確認後に PopAsync すると再度 OnBackButtonPressed() が呼ばれるため、フラグで素通しにします。
  • 戻る処理の責務:戻るを止めるだけでなく「いつ・どうやって戻すか」をページ側で明確にしないと UX が破綻します。

まとめ

  • iOS + NavigationPage 構成では OnBackButtonPressed() が呼ばれない経路がある
  • iOS の NavigationRenderer で PopViewController を override し、SendBackButtonPressed() を呼ぶとページ側で戻るを制御できる
  • 確認ダイアログなど非同期処理を入れる場合は、戻るループを避けるためのフラグ制御が重要

この記事を書いた人

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

コメント

コメントする

目次