.NET MAUI ShellのTabからJSONファイル名を渡して読み込む実装ガイド

.NET MAUI の Shell タブを切り替えたときに、タブごとに別の JSON ファイル(例: VARHaute.json / SiouBlanc.json など)を読み分けたいのに、クエリパラメータが MainPage で受け取れない…という悩みはとてもよくあります。本記事では、.NET MAUI Shell の仕組みを整理しながら、タブから JSON ファイル名を安全かつシンプルに渡す実装パターンを丁寧に解説します。

目次

.NET MAUI Shell のタブから JSON を選んで読み込むシナリオ

今回の前提となるシナリオを整理しておきます。

  • 画面上部には AppShell.xaml の <Tab> / <ShellContent> を使ったタブナビゲーションがある。
  • 各タブは「地域ごとのハイキングコース」などを表していて、押したタブに応じて別々の JSON を読み込みたい。

例:

  • VARHaute タブ → VARHaute.json
  • SiouBlanc タブ → SiouBlanc.json
  • Alpes de Haute Provence タブ → AlpesdeHauteProvence.json

そして、よくある実装イメージは次のようなものです。

  • AppShell.xaml の ShellContent に Route="MainPage?MyData=VARHaute" のようなクエリを付ける。
  • MainPage 側で MyData を受け取って、RandoService に渡して JSON を読み込む。

ところが、実際には MainPage 側で MyData を受け取れない、という問題につまずきがちです。本記事では、次の順番で解決方法を解説していきます。

  • なぜ ShellContent のクエリが MainPage に届かないのか
  • Tab にクエリを付ける正しいやり方
  • IQueryAttributable / QueryProperty の正しい使い方
  • JSON 読み込みサービス(RandoService)のリファクタリング
  • どうしても ShellContent にクエリを付けたいときの代替案

なぜ ShellContent にクエリを書いても MainPage で受け取れないのか

まずは原因から。Shell ナビゲーションの仕組みを簡単に整理しておきます。

Shell の階層イメージ

.NET MAUI Shell は、ざっくり以下のような階層で構成されています。

FlyoutItem
  └ Tab
      └ ShellContent (ContentTemplate でページを指す)

ナビゲーションの「ルート(Route)」は基本的に Tab や ShellContent などのナビゲーション要素に対して設定されますが、

  • ShellContent は “下位ナビゲーション” 用である
  • クエリ文字列を付けても、そのままページ側に渡らないケースがある

という落とし穴があります。質問のように

&lt;ShellContent
    Route="MainPage?MyData=VARHaute"
    ContentTemplate="{DataTemplate views:MainPage}" /&gt;

と書いても MainPage で MyData が受け取れない原因はここにあります。

そこで、本記事でおすすめするのは

  • クエリは Tab に付ける(Route="MainPage?MyData=VARHaute" は Tab 側に書く)
  • ページ側は IQueryAttributable または QueryProperty で受け取る

という構成です。

解決策の全体像:Tab にクエリを付けて IQueryAttributable で受け取る

ここからは、実際のコードを交えながら「最もシンプルで安全な」実装例を見ていきます。

AppShell.xaml.cs でルートを登録する

まず、Shell ナビゲーションでページ名(MainPage)を使って遷移できるように、ルート登録を行います。

public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();
        // ページ名でナビゲーションできるようにルート登録
        Routing.RegisterRoute(nameof(MainPage), typeof(MainPage));
    }
}

これをやっておくことで、

  • Shell.Current.GoToAsync(nameof(MainPage))
  • Tab の Route="MainPage?MyData=..."

のような書き方がシンプルに使えるようになります。

AppShell.xaml:Tab にクエリ付きルートを設定する

次に、AppShell.xaml 側で 各 Tab の Route にクエリ文字列を付けて、JSON ファイル名を渡します。

&lt;FlyoutItem Title="Randos"&gt;

  &lt;!-- VARHaute タブ --&gt;
  &lt;Tab Title="VARHaute" Route="MainPage?MyData=VARHaute"&gt;
    &lt;ShellContent
        Title="一覧"
        ContentTemplate="{DataTemplate views:MainPage}" /&gt;
  &lt;/Tab&gt;

  &lt;!-- SiouBlanc タブ --&gt;
  &lt;Tab Title="SiouBlanc" Route="MainPage?MyData=SiouBlanc"&gt;
    &lt;ShellContent
        Title="一覧"
        ContentTemplate="{DataTemplate views:MainPage}" /&gt;
  &lt;/Tab&gt;

  &lt;!-- Alpes de Haute Provence タブ --&gt;
  &lt;Tab Title="Alpes de Haute Provence"
       Route="MainPage?MyData=AlpesdeHauteProvence"&gt;
    &lt;ShellContent
        Title="一覧"
        ContentTemplate="{DataTemplate views:MainPage}" /&gt;
  &lt;/Tab&gt;

&lt;/FlyoutItem&gt;

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

  • クエリは Tab の Route に付ける(ShellContent ではない)
  • ShellContent 側は ContentTemplate で MainPage を指すだけでOK

これで「どのタブが押されたか」に応じて MyData に別々の値が設定されるようになります。

MainPage で IQueryAttributable を使ってパラメータを受け取る

続いて、MainPage 側でクエリパラメータを受け取ります。おすすめは IQueryAttributable を実装する方法です。

using Microsoft.Maui.Controls;
using System.Collections.Generic;

public partial class MainPage : ContentPage, IQueryAttributable
{
    private readonly RandosViewModel _vm;

    public MainPage(RandosViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = _vm = viewModel;
    }

    // タブ選択で遷移したとき、ここにクエリが渡ってくる
    public async void ApplyQueryAttributes(IDictionary&lt;string, object&gt; query)
    {
        if (query.TryGetValue("MyData", out var value) 
            &amp;&amp; value is string fileName 
            &amp;&amp; !string.IsNullOrWhiteSpace(fileName))
        {
            await _vm.LoadAsync(fileName);   // ViewModel 経由でサービスを呼ぶ
        }
    }
}

ApplyQueryAttributes は ページが表示される直前にフレームワークから呼ばれるメソッドです。ここでクエリを取り出して ViewModel に渡せば、「タブごとに異なる JSON を読み込む」動きが実現できます。

QueryProperty を使う場合の注意点

IQueryAttributable ではなく、属性ベースの QueryProperty を使うこともできます。ただし、下記の条件を満たさないと値が入ってこないので注意が必要です。

[QueryProperty(nameof(MyData), "MyData")]
public partial class MainPage : ContentPage
{
    public string MyData { get; set; }  // &lt;= public set が必須

    public MainPage(RandosViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }

    protected override async void OnAppearing()
    {
        base.OnAppearing();

        if (!string.IsNullOrWhiteSpace(MyData))
        {
            await ((RandosViewModel)BindingContext).LoadAsync(MyData);
        }
    }
}
ポイント必要条件よくあるミス
プロパティの setterpublic string MyData { get; set; }private set; や init; だと値が入らない
キー名属性の第2引数とクエリ文字列のキーを完全一致させる"mydata" と "MyData" の大小違いでハマる
タイミングOnAppearing など表示後のタイミングで使うコンストラクタ内で使おうとしてまだ値が入っていない

トラブルが多い場合は、IQueryAttributable を使った方が挙動が見えやすく、デバッグしやすい傾向があります。

ViewModel とサービスで JSON を読み込む構成

クエリパラメータを受け取れたら、次は「どうやって JSON を読み込むか」です。ここでは MVVM を前提に、RandosViewModel と RandoService に責務を分ける構成を紹介します。

RandosViewModel:ファイル名を受け取って読み込みを依頼する

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

public partial class RandosViewModel : ObservableObject
{
    private readonly RandoService _service;

    public RandosViewModel(RandoService service)
    {
        _service = service;
    }

    [ObservableProperty]
    private List&lt;Rando&gt; items;

    [RelayCommand]
    public async Task LoadAsync(string fileName)
    {
        try
        {
            Items = await _service.GetRandos(fileName);
        }
        catch (Exception ex)
        {
            // TODO: ログ出力やユーザー通知など
            System.Diagnostics.Debug.WriteLine(ex);
            Items = new List&lt;Rando&gt;();
        }
    }
}

ポイントは、

  • ViewModel は「どの JSON を読み込むか」(fileName)を受け取るだけ
  • 実際の HTTP/JSON 処理はサービスに全て委譲する

という責務分割です。テストもしやすくなります。

RandoService:ファイル名から URL を組み立てて JSON を取得

public class RandoService
{
    private readonly HttpClient _httpClient;

    public RandoService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task&lt;List&lt;Rando&gt;&gt; GetRandos(string fileName)
    {
        if (string.IsNullOrWhiteSpace(fileName))
        {
            return new List&lt;Rando&gt;();
        }

        // ファイル名のエンコード(URL セーフにする)
        var safe = Uri.EscapeDataString(fileName);

        // 必要に応じてベース URL は設定ファイルや定数クラスから取得
        var url = $"https://randopro.org/MyUploads/Data/{safe}.json";

        using var response = await _httpClient.GetAsync(url);
        response.EnsureSuccessStatusCode();

        var data = await response.Content.ReadFromJsonAsync&lt;List&lt;Rando&gt;&gt;();

        return data ?? new List&lt;Rando&gt;();
    }
}

ここでの注意点は次の通りです。

  • ファイル名にスペースや日本語が含まれていても問題ないように Uri.EscapeDataString でエンコードする。
  • EnsureSuccessStatusCode() で HTTP エラーを例外として検知する。
  • ReadFromJsonAsync<List<Rando>>() の戻り値が null の場合を考慮して、空リストを返す。

MauiProgram.cs でサービスとページを DI 登録する

最後に、MauiProgram.cs で DI コンテナにサービスや ViewModel を登録します。

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

        builder
            .UseMauiApp&lt;App&gt;()
            .ConfigureFonts(fonts =&gt;
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        // HttpClient を使うサービス
        builder.Services.AddHttpClient&lt;RandoService&gt;();

        // ViewModel / Page の登録
        builder.Services.AddTransient&lt;RandosViewModel&gt;();
        builder.Services.AddTransient&lt;MainPage&gt;();

        return builder.Build();
    }
}

これで、

  • AppShell → MainPage(DI による生成)
  • MainPage → RandosViewModel
  • RandosViewModel → RandoService

という依存関係が自動的に解決されるようになり、テストもしやすく、拡張性の高い構成になります。

ShellContent にクエリを付けたいときの代替案:Messenger パターン

ここまで紹介したように「タブの Route にクエリを付ける」のが最もシンプルでおすすめですが、プロジェクト都合などで

  • どうしても ShellContent 側にクエリを書きたい
  • あるいはすでに Route 設計が固まっていて Tab を触りたくない

というケースもあると思います。その場合は、Shell のナビゲーションイベントで現在の Location を拾い、Messenger 経由でページ側に知らせるというアプローチが現実的です。

AppShell.xaml.cs:OnNavigated で現在のルートをメッセージ送信

using CommunityToolkit.Mvvm.Messaging;

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

    protected override void OnNavigated(ShellNavigatedEventArgs args)
    {
        base.OnNavigated(args);

        if (args.Current is not null)
        {
            // 例: "MainPage?MyData=VARHaute" といった文字列が入る
            var route = args.Current.Location.ToString();
            WeakReferenceMessenger.Default.Send(new NavigationPathChangedMessage(route));
        }
    }
}

// メッセージ定義
public record NavigationPathChangedMessage(string Route);

ここで route には "MainPage?MyData=VARHaute" のような文字列が入ります。

MainPage でメッセージを受信して JSON を読み込む

ページ側では、メッセージを購読してクエリ文字列から MyData の値を取り出し、ViewModel のロードを呼び出します。

using CommunityToolkit.Mvvm.Messaging;

public partial class MainPage : ContentPage
{
    private readonly RandosViewModel _vm;

    public MainPage(RandosViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = _vm = viewModel;

        // メッセージ購読
        WeakReferenceMessenger.Default.Register&lt;NavigationPathChangedMessage&gt;(this, OnNavigationPathChanged);
    }

    private async void OnNavigationPathChanged(object recipient, NavigationPathChangedMessage message)
    {
        // Route は "MainPage?MyData=VARHaute" など。
        // Uri として扱うために仮のスキームを付ける
        var uri = new Uri("app://" + message.Route);

        var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
        var file = query["MyData"];

        if (!string.IsNullOrWhiteSpace(file))
        {
            await _vm.LoadAsync(file);
        }
    }
}

ここで重要なのは、

  • 文字列を無理に Substring で切り刻まない
  • Uri 型とクエリパーサー(ParseQueryString)を使って安全に取り出す

という点です。末尾スラッシュの有無や複数クエリ(?a=1&b=2)などを考えると、文字列操作で頑張るより、標準の URL パーサーに任せた方がバグを防げます。

Tab ルート方式 vs Messenger 方式の比較

項目Tab にクエリを付ける方式Messenger 方式
実装のシンプルさ◎ とてもシンプル△ やや複雑(メッセージ定義・購読が必要)
既存設計への影響Tab の Route を変更できる必要あり◎ 既存 Route 設計をあまり変えなくて済む
保守性シンプルで読みやすく保守しやすいナビゲーションとページがメッセンジャーで結合されるので追いにくい場合あり
学習コストIQueryAttributable さえ覚えればOKCommunityToolkit.Mvvm.Messaging の理解が必要

特別な事情がなければ、まずは Tab の Route にクエリを付ける方式をおすすめします。

よくあるハマりどころとデバッグポイント

今回のような Shell ナビゲーション+クエリのシナリオで陥りがちなポイントをまとめておきます。

症状原因対処法
MyData が常に null になるQueryProperty のプロパティが private set; になっているpublic string MyData { get; set; } に変更する
クエリが届かないクエリを ShellContent に付けていて、Tab には付いていないTab の Route に ?MyData=... を付ける
クエリのキー名が違うMyData vs mydata のように大文字小文字が違う属性・クエリ文字列・辞書キーを完全一致させる
ナビゲーション時に例外が出るRouting.RegisterRoute をしていない、または Route 名が食い違っているRouting.RegisterRoute(nameof(MainPage), typeof(MainPage)); を確認
URL がおかしくて JSON が 404 になるファイル名にスペースや特殊文字が含まれているが、エンコードしていないUri.EscapeDataString(fileName) でエンコードしてから URL を生成する
JSON 読み込みで落ちるが理由が分からない例外を握りつぶしている、またはログを出していないtry/catch で例外をログに出し、EnsureSuccessStatusCode() を使う

デバッグ時のおすすめ手順は次の通りです。

  1. ApplyQueryAttributes(または OnAppearing)にブレークポイントを置く。
  2. 実際にタブを押して、ブレークポイントに止まるか確認する。
  3. query 辞書(または MyData プロパティ)の中身をウォッチする。
  4. RandoService の GetRandos に入るかどうかを追う。
  5. URL(url 変数)をそのままブラウザに貼って、JSON が返ってくるか確認する。

ここまでやれば、ほとんどの問題は原因が特定できるはずです。

仕上げチェックリスト

実装が一通りできたら、次のチェックリストを上から順に確認してみてください。

  • Tab の Route に ?MyData=... を付けているか
  • MainPage が IQueryAttributable を実装している、または QueryProperty のキー名が合っているか
  • プロパティの setter が public になっているか
  • RandoService.GetRandos(string fileName) のように、ファイル名を引数で受け取るようリファクタできているか
  • URL 生成時に Uri.EscapeDataString を使っているか
  • 例外発生時にログが出るようにしているか
  • MauiProgram.cs でサービスと ViewModel とページが DI 登録されているか

これらを満たしていれば、タブごとに異なる JSON を安全かつ安定して読み分けられるはずです。

応用:コードから明示的に遷移して JSON を切り替える場合

最後に、タブ操作ではなく「ボタンを押したら特定の JSON を読み込むページに遷移する」ようなケースにもよく使う書き方を紹介しておきます。

await Shell.Current.GoToAsync(
    nameof(MainPage),
    new Dictionary&lt;string, object&gt;
    {
        ["MyData"] = "VARHaute"
    });

このように GoToAsync の第2引数に辞書を渡すと、IQueryAttributable / QueryProperty の仕組みでページ側にパラメータが渡されます。

つまり、

  • タブから遷移するとき:Tab の Route="MainPage?MyData=..."
  • コードから遷移するとき:GoToAsync(..., new Dictionary<string, object> { ["MyData"] = "..." })

という2通りの書き方を持ちつつ、ページ側の受け取りロジックは共通にしておくことができます。これにより、タブ遷移でもボタン遷移でも同じ JSON 読み込み処理を使いまわせるようになります。

まとめ:タブごとに JSON を切り替える .NET MAUI Shell 実装パターン

本記事では、.NET MAUI Shell の Tab から MainPage に JSON ファイル名を渡し、RandoService で動的に JSON を読み込む方法を解説しました。要点をあらためて整理すると次の通りです。

  • クエリは ShellContent ではなく Tab の Route に付ける
  • MainPage は IQueryAttributable でクエリを受ける(または QueryProperty を正しく使う)
  • JSON 読み込みは RandoService に集約し、GetRandos(string fileName) のようにファイル名をパラメータで受ける
  • ViewModel(RandosViewModel)はサービス呼び出しとプロパティ更新に専念する
  • MauiProgram.cs で HttpClient/サービス/ViewModel/ページを DI 登録して依存関係を整理する

この構成にしておくと、

  • JSON ファイルの追加・差し替え時にアプリを再ビルドせずに済む
  • タブ数が増えても、Tab の Route を追加するだけで対応できる
  • テストや保守がしやすいシンプルな構造になる

.NET MAUI Shell は最初は少しクセがありますが、一度パターンを押さえてしまえば、タブナビゲーションやパラメータ付き遷移をとてもシンプルに実装できます。ぜひ本記事のパターンをベースに、あなたのアプリに最適な JSON 読み込みロジックを組み立ててみてください。

この記事を書いた人

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

コメント

コメントする

目次