.NET MAUI Shell Navigationで一覧ページのハンバーガーメニューを常に表示する方法|戻る矢印になる原因とGoToAsync(//)の解決策

.NET MAUI の Shell Navigation で「一覧ページでは常にハンバーガーメニューを表示したいのに、詳細画面を経由すると戻る矢印になってしまう」問題は、ルート(Flyout のトップ)扱いが崩れているのが原因です。本記事では原因の見分け方と、MVVM でも安全に直せる実装手順をまとめます。

目次

現象:.NET MAUI Shell Navigationで一覧ページなのにハンバーガーメニューが戻る矢印に変わる

たとえば建物管理アプリを .NET MAUI(MVVM)で作っていて、Shell の Flyout(ハンバーガーメニュー)を使っているとき、次のような挙動に悩むことがあります。

  • Flyout から BuildingOverviewPage(建物一覧)へ移動した直後は、左上がハンバーガーのまま
  • 一覧から ViewEditBuildingPage(表示/追加/編集)へ遷移
  • そこから「一覧へ戻る」を押すと、BuildingOverviewPage が表示されるのに左上が戻る矢印になっている
  • 戻る矢印を押すと、直前の View/Edit/Add に戻ってしまい、ユーザー体験が不自然になる

一覧ページはアプリの“入口”として扱いたいことが多いので、ここで戻る矢印が出るのはかなりストレスです。特に「一覧=常に Flyout のトップ」という設計のアプリでは、ハンバーガーメニューが出続けること自体が要件になります。

結論:一覧ページをFlyoutのルートとして扱わせ、ハンバーガーメニューを常に表示する

この問題の本質は、Shell が BuildingOverviewPage を「Flyout のルート(トップ)」ではなく「ナビゲーションスタック上の1ページ」と認識してしまっていることです。解決の要点は次の2つに集約できます。

  1. 一覧ページのルート定義を一本化する(“二重登録”をやめる)
  2. 一覧へ戻すときは絶対ルート(//)で開き直して、スタックをリセットする

この2点を押さえるだけで、一覧ページは常に Flyout のトップとして扱われ、左上はハンバーガー表示が維持されます。

なぜ戻る矢印になるのか:Shell Navigationの「スタック」と「ルート」

.NET MAUI の Shell Navigation は、ざっくり言うと「見えている画面の階層(FlyoutItem / Tab / Section など)」と「その中で積み上がるナビゲーションスタック」が合体した仕組みです。左上のアイコンは、Shell が現在地をどう解釈しているかで切り替わります。

Shellの判断左上に出るものよくある状況
現在ページがセクションのルート(トップ)ハンバーガー(Flyout ボタン)Flyout からトップページへ切り替えた直後
現在ページがスタック上(戻れるページがある)戻る矢印(Back ボタン)詳細・編集などへ遷移した後/スタックに“積んだ”戻り方をした後

つまり、一覧ページで戻る矢印が出るのは「一覧ページがルートではない」か「一覧ページがルートに見えても、実は“積まれた一覧”になっている」どちらかです。今回の症状(戻る矢印を押すと View/Edit/Add に戻ってしまう)は、ほぼ間違いなく後者、つまり一覧へ戻る操作が“戻る”ではなく“一覧をもう一枚積む”動きになっているパターンです。

ありがちな原因:一覧ページをルーティングで二重登録している

よくあるのが次の状態です。

  • AppShell.xaml に <ShellContent Route="BuildingOverviewPage" ... /> を定義している
  • さらに AppShell.xaml.cs で Routing.RegisterRoute("buildingoverview", typeof(BuildingOverviewPage)); を書いている

これだと一覧ページへの入口が複数できてしまい、意図せず「Flyout の切り替え」ではなく「スタックにプッシュする遷移」で一覧へ戻ってしまうケースが増えます。結果として一覧ページの左上が戻る矢印になり、押すと直前ページへ戻ってしまいます。

まずはここから:戻る矢印問題の切り分けチェックリスト

修正に入る前に、現状のコードがどのパターンかを短時間で判断できるチェックリストを用意しました。

チェック項目YESの場合に起こりやすいこと対処の方向性
一覧ページを ShellContent として定義している本来は Flyout のトップになれる戻り方(URL)を正すだけで直る可能性大
一覧ページを Routing.RegisterRoute でも登録している一覧へ戻るときに“積む”遷移をしがち一覧の RegisterRoute を削除して一本化
詳細→一覧の遷移が GoToAsync("BuildingOverviewPage") など相対/通常ルート一覧を新規にプッシュしてしまい戻る矢印が出るGoToAsync("//BuildingOverviewPage") に置換
詳細→一覧が GoToAsync("..") なのに矢印が残るそもそも一覧がルートになっていない可能性Shell の構造(FlyoutItem/Section)から見直す

多くのケースは「一覧を RegisterRoute している」か「一覧に戻すときに // を使っていない」のどちらか、または両方です。

解決策:BuildingOverviewPageのRouteを一本化し、“二重登録”をやめる

一覧ページ(Flyout から到達する“トップ扱いの画面”)は、ShellContent の Route だけで管理するのが一番安全です。逆に、詳細・編集などの“スタックに積む画面”だけを Routing.RegisterRoute で登録する、という役割分担にすると迷いが減ります。

推奨:ShellContent に Route を付ける(一覧=トップ)

AppShell.xaml の例です。実際の構成に合わせて FlyoutItem や Title は調整してください。

&lt;?xml version="1.0" encoding="utf-8" ?&gt;
&lt;Shell
    x:Class="MyApp.AppShell"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:views="clr-namespace:MyApp.Views"&gt;

    &lt;FlyoutItem Title="建物" Route="buildings"&gt;
        &lt;ShellContent
            Title="一覧"
            Route="BuildingOverviewPage"
            ContentTemplate="{DataTemplate views:BuildingOverviewPage}" /&gt;
    &lt;/FlyoutItem&gt;

&lt;/Shell&gt;

ポイントは Route="BuildingOverviewPage" を明示して、一覧ページを Shell の階層に組み込むことです。これで Flyout から一覧へ移動したときは、一覧がトップとして扱われ、ハンバーガーメニューが出ます。

詳細ページは RegisterRoute(詳細=スタックに積む)

AppShell.xaml.cs では、詳細ページ(View/Edit/Add)だけを登録します。一覧ページを登録しないのがコツです。

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

        // 詳細・編集など「積む」画面だけを登録
        Routing.RegisterRoute(nameof(ViewEditBuildingPage), typeof(ViewEditBuildingPage));

        // 一覧(BuildingOverviewPage)は ShellContent の Route で定義済みなので RegisterRoute しない
        // Routing.RegisterRoute("buildingoverview", typeof(BuildingOverviewPage)); // ← これは削除
    }
}

この役割分担ができると、ナビゲーション設計がかなりシンプルになります。ルートページ(Flyout の入口)に戻すときは Shell の階層へ、詳細へ入るときは RegisterRoute へ、という考え方です。

解決策:一覧へ戻すときは // の絶対ルートで GoToAsync してスタックをリセットする

次に「戻り方」を直します。GoToAsync の書き方で、Shell が「戻る」なのか「積む」なのかを判断するためです。結論は、一覧へ戻したいときは絶対ルート(//)で開き直すのが最も確実です。

GoToAsync の書き方と挙動の違い

書き方意味スタックへの影響一覧での見た目おすすめ用途
await Shell.Current.GoToAsync(nameof(ViewEditBuildingPage));登録ルートへ遷移(プッシュ)スタックに積む戻る矢印になりやすい一覧→詳細、一覧→編集
await Shell.Current.GoToAsync("..");ひとつ戻る(ポップ)スタックから外すルートに戻ればハンバーガー純粋に直前へ戻す
await Shell.Current.GoToAsync("//BuildingOverviewPage");ルートから開き直す(絶対ルート)スタックをリセットハンバーガーメニューが維持される保存/完了後に必ず一覧へ戻す

今回の要件は「一覧ページでは常にハンバーガーメニューを表示したい」なので、詳細・編集がどういう遷移で来たかに関係なく、戻り先を一覧のトップに固定するのが正解です。そのために //BuildingOverviewPage を使います。

実装例:保存・キャンセル後は必ず一覧のトップへ

たとえば ViewEditBuildingPage 側で「保存」「キャンセル」「削除」などの完了後、一覧へ戻すときは次のようにします。

// 保存完了後など
await Shell.Current.GoToAsync("//BuildingOverviewPage");

これでナビゲーションスタックがリセットされ、BuildingOverviewPage は Flyout のトップとして扱われます。結果として、左上はハンバーガーメニューになり、押せば Flyout を開ける状態が維持されます。

ルートが見つからない場合は「完全なルート」を使う

アプリが複数の FlyoutItem / Tab を持つ場合、Route 名が重複したり、Shell の階層が深くなったりして、//BuildingOverviewPage だけだと意図した場所に行かないことがあります。その場合は、FlyoutItem の Route を含めた“完全なルート”に寄せると安定します。

// FlyoutItem に Route="buildings" を付けている場合の例
await Shell.Current.GoToAsync("//buildings/BuildingOverviewPage");

「どの Route をどう付けるべきか」で迷ったら、Route 名をユニークにする、そして戻すときは完全なルートで指定する、の2つが安全策です。

MVVM で迷子にならないための実装パターン

MVVM で Shell Navigation を扱うとき、ルート文字列が散らばるとメンテナンスが一気に難しくなります。ここでは、実務で事故が減りやすい構成を紹介します。

ルート文字列は定数に集約する

まずは「どこへ遷移するか」を 1 か所にまとめます。文字列の打ち間違いや、後から Route 名を変えたときの漏れを防げます。

public static class AppRoutes
{
    // ルート(トップ)
    public const string BuildingOverviewRoot = "//BuildingOverviewPage";

    // 積む画面
    public const string ViewEditBuilding = nameof(ViewEditBuildingPage);
}

ViewModel からは NavigationService を経由する

ViewModel が直接 Shell.Current に触れるかどうかはチーム方針によりますが、「テストしやすさ」「責務の分離」を考えると、薄い NavigationService を用意しておくと便利です。

public interface INavigationService
{
    Task GoToAsync(string route);
    Task GoToAsync(string route, IDictionary<string, object> parameters);
}

public sealed class ShellNavigationService : INavigationService
{
    public Task GoToAsync(string route)
        => Shell.Current.GoToAsync(route);

    public Task GoToAsync(string route, IDictionary<string, object> parameters)
        => Shell.Current.GoToAsync(route, parameters);
}

そして ViewModel では「保存後は必ず一覧トップへ戻す」という意図を、コードで明確に表現できます。

public sealed class ViewEditBuildingViewModel
{
    private readonly INavigationService _nav;

    public ViewEditBuildingViewModel(INavigationService nav)
    {
        _nav = nav;
    }

    public async Task SaveAsync()
    {
        // 保存処理(省略)

        // 完了後は必ず一覧トップへ
        await _nav.GoToAsync(AppRoutes.BuildingOverviewRoot);
    }

    public async Task CancelAsync()
    {
        // キャンセルも一覧トップへ固定するなら同じ
        await _nav.GoToAsync(AppRoutes.BuildingOverviewRoot);
    }
}

「戻る=スタックを戻る」ではなく、「完了=一覧へ着地する」という業務フローをそのままルーティングに落とし込めるのが、絶対ルート遷移の強みです。

詳細画面への遷移:パラメータの渡し方も整理しておく

一覧→詳細(表示/追加/編集)では、建物IDなどのパラメータを渡すことが多いです。Shell Navigation では、Dictionary で渡す方法が分かりやすく、型安全な実装にも寄せやすいです。

// 一覧から選択した建物の詳細へ
await Shell.Current.GoToAsync(
    AppRoutes.ViewEditBuilding,
    new Dictionary<string, object>
    {
        ["BuildingId"] = selectedBuilding.Id,
        ["Mode"] = "Edit"
    });

受け取り側は IQueryAttributable を使うか、[QueryProperty] で受け取ります。どちらでもよいですが、複数パラメータや変換が絡むなら IQueryAttributable の方が拡張しやすいです。

public partial class ViewEditBuildingPage : ContentPage, IQueryAttributable
{
    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("BuildingId", out var idObj) && idObj is int id)
        {
            // ViewModel にセットするなど
        }
    }
}

ここを整理しておくと「保存後は必ず一覧へ」などの遷移ルールを統一しやすくなります。

よくある落とし穴:戻り先が“一覧に見えて一覧ではない”

戻る矢印問題が再発しやすいのは、次のような実装が混ざったときです。

落とし穴:詳細から一覧へ GoToAsync("BuildingOverviewPage") している

// が付いていない通常の遷移は、状況によって「一覧をスタックに積む」動きになり得ます。見た目は一覧へ戻ったようでも、内部的には「詳細の上に一覧が乗っている」状態なので、左上は戻る矢印になります。

落とし穴:一覧を RegisterRoute して、その Route を使って戻っている

Routing.RegisterRoute で登録したページは、基本的に“スタックに積むための入口”として使うのが前提です。ここで一覧を登録してしまうと、一覧がトップのはずなのに、戻る矢印の対象になってしまいます。

落とし穴:一覧ページが複数の場所に存在し、Route 名が衝突している

複数モジュールを持つアプリだと、同名の一覧ページ(たとえば「一覧」)が複数あって Route 名が重複しがちです。Shell は Route 名で解決するため、意図せず別セクションの一覧へ飛んだり、そもそも見つからなかったりします。Route 名はユニークにし、可能なら //flyoutRoute/shellContentRoute のように完全指定するのが安全です。

応急処置:アイコンだけハンバーガーに“見せる”方法(非推奨)

どうしてもナビゲーションスタックを残したまま、見た目だけハンバーガーにしたい場合、BackButtonBehavior のアイコンを上書きしてコマンドを差し替える方法があります。ただし、これは「戻れる状態なのに戻れないUI」になりやすく、UX的に危険なので、基本はおすすめしません。

// BuildingOverviewPage のコンストラクタなどで
Shell.SetBackButtonBehavior(this, new BackButtonBehavior
{
    IconOverride = "hamburger.png",
    Command = new Command(() => Shell.Current.FlyoutIsPresented = true)
});

この方法だと、内部スタックは残ったままです。ユーザーが「戻りたい」と思ったときに戻れない、あるいは端末の戻るジェスチャーだけ効いてしまうなど、挙動が分裂します。「一覧はトップ」なら、見た目ではなくスタックを正す方が長期的に安全です。

デバッグのコツ:現在地(Route)をログに出して原因を確定する

修正しても戻る矢印が消えない場合は、現在の Route をログに出すと原因特定が早くなります。Shell には現在地を表す情報があり、遷移後に確認できます。

var location = Shell.Current.CurrentState.Location.OriginalString;
System.Diagnostics.Debug.WriteLine($"Current Route: {location}");

一覧へ戻ったつもりなのに、ログ上は .../ViewEditBuildingPage/BuildingOverviewPage のような形になっているなら「一覧を積んでいる」確率が高いです。戻り処理を // に寄せる、一覧の RegisterRoute を消す、Route 名の衝突を解消する、の順で確認していきます。

補足:Add/Edit 後の戻り先を統一すると運用が楽になる

現場でよくあるのが「キャンセルは .. で戻すけど、保存後は一覧へ戻したい」「追加後は別のページへ遷移したい」など、戻り先がケースごとにバラけるパターンです。ここでルールが曖昧だと、いつの間にか戻る矢印問題が再発します。

要件が「一覧ページでは常にハンバーガーメニューを表示する」なら、Add/Edit の完了系アクションは基本的に //BuildingOverviewPage に揃えてしまうのが実装も運用もシンプルです。もし“直前に戻したい”が要件なら .. を使い、一覧がトップであることを保証する設計(一覧を積まない)を徹底します。

まとめ:一覧ページをトップ扱いにすると、ハンバーガーメニューとShell Navigationが安定する

.NET MAUI の Shell Navigation で、一覧ページのハンバーガーメニューが戻る矢印に変わってしまう問題は、Shell の仕様として「スタックに積まれたページには戻る矢印が出る」ことが原因です。解決するには、一覧ページを Flyout のルートとして扱わせる実装に揃えるのが最短です。

  • 一覧ページは ShellContent の Route だけで定義し、Routing.RegisterRoute での二重登録を避ける
  • 詳細・編集から一覧へ戻すときは await Shell.Current.GoToAsync("//BuildingOverviewPage"); でルートから開き直す
  • MVVM ではルート定数と NavigationService で遷移ルールを一元管理すると再発しにくい

この形にしておくと、一覧ページの左上は常にハンバーガーメニューになり、ユーザーは「どこにいても一覧からメニューへ戻れる」安心感のあるUIを体験できます。

この記事を書いた人

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

コメント

コメントする

目次