.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 は “下位ナビゲーション” 用である
- クエリ文字列を付けても、そのままページ側に渡らないケースがある
という落とし穴があります。質問のように
<ShellContent
Route="MainPage?MyData=VARHaute"
ContentTemplate="{DataTemplate views:MainPage}" />
と書いても 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 ファイル名を渡します。
<FlyoutItem Title="Randos">
<!-- VARHaute タブ -->
<Tab Title="VARHaute" Route="MainPage?MyData=VARHaute">
<ShellContent
Title="一覧"
ContentTemplate="{DataTemplate views:MainPage}" />
</Tab>
<!-- SiouBlanc タブ -->
<Tab Title="SiouBlanc" Route="MainPage?MyData=SiouBlanc">
<ShellContent
Title="一覧"
ContentTemplate="{DataTemplate views:MainPage}" />
</Tab>
<!-- Alpes de Haute Provence タブ -->
<Tab Title="Alpes de Haute Provence"
Route="MainPage?MyData=AlpesdeHauteProvence">
<ShellContent
Title="一覧"
ContentTemplate="{DataTemplate views:MainPage}" />
</Tab>
</FlyoutItem>
ポイントは次の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<string, object> query)
{
if (query.TryGetValue("MyData", out var value)
&& value is string fileName
&& !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; } // <= 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);
}
}
}
| ポイント | 必要条件 | よくあるミス |
|---|---|---|
| プロパティの setter | public 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<Rando> 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<Rando>();
}
}
}
ポイントは、
- ViewModel は「どの JSON を読み込むか」(
fileName)を受け取るだけ - 実際の HTTP/JSON 処理はサービスに全て委譲する
という責務分割です。テストもしやすくなります。
RandoService:ファイル名から URL を組み立てて JSON を取得
public class RandoService
{
private readonly HttpClient _httpClient;
public RandoService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<List<Rando>> GetRandos(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return new List<Rando>();
}
// ファイル名のエンコード(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<List<Rando>>();
return data ?? new List<Rando>();
}
}
ここでの注意点は次の通りです。
- ファイル名にスペースや日本語が含まれていても問題ないように
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<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// HttpClient を使うサービス
builder.Services.AddHttpClient<RandoService>();
// ViewModel / Page の登録
builder.Services.AddTransient<RandosViewModel>();
builder.Services.AddTransient<MainPage>();
return builder.Build();
}
}
これで、
AppShell→MainPage(DI による生成)MainPage→RandosViewModelRandosViewModel→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<NavigationPathChangedMessage>(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 さえ覚えればOK | CommunityToolkit.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() を使う |
デバッグ時のおすすめ手順は次の通りです。
ApplyQueryAttributes(またはOnAppearing)にブレークポイントを置く。- 実際にタブを押して、ブレークポイントに止まるか確認する。
query辞書(またはMyDataプロパティ)の中身をウォッチする。- RandoService の
GetRandosに入るかどうかを追う。 - 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<string, object>
{
["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 読み込みロジックを組み立ててみてください。

コメント