🛠️
.NET MAUI 一覧→モーダル編集→一覧に反映 (Shell)
はじめに
.NET Maui の Shellによる画面遷移で 一覧画面→モーダル編集→編集内容を一覧に反映 の流れを確認する。
環境
Windows 11
Visual Studio 2022
.NET 8.0
CommunityToolkit.Mvvm 8.4.0
1. 表示用データ
UserAcount.cs
namespace MvvmMauiApp.Models;
public class UserAcount
{
public int UserId { get; set; }
public string AccountName { get; set; } = string.Empty;
public string Note { get; set; } = string.Empty;
// サンプルデータ作成用
public static UserAcount GetData(int userId)
{
return new UserAcount
{
UserId = userId,
AccountName = $"Account {userId}",
Note = $"Note for account {userId}",
};
}
}
2. 画面遷移用サービス
- Shellによる画面遷移をViewModelから使いやすいようにサービス化し、DI登録しておく。
INavigationService.cs
namespace MvvmMauiApp.Services;
public interface INavigationService
{
Task NavigateToAsync(string route, ShellNavigationQueryParameters? parameters = null);
Task PopAsync();
}
public class NavigationService : INavigationService
{
public Task NavigateToAsync(string route, ShellNavigationQueryParameters? parameters)
{
return
parameters == null ?
Shell.Current.GoToAsync(route, animate: false) :
Shell.Current.GoToAsync(route, animate: false, parameters: parameters);
}
public Task PopAsync()
{
return Shell.Current.Navigation.PopAsync();
}
}
- サービス取得を簡略化するためのクラス
ServiceLocator.cs
namespace MvvmMauiApp.Services;
public static class ServiceLocator
{
private static IServiceProvider? _serviceProvider;
public static void Initialize(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public static T GetService<T>() where T : class
{
return _serviceProvider?.GetRequiredService<T>()
?? throw new InvalidOperationException("ServiceLocator not initialized");
}
}
- ServiceLocator を初期化。
App.xaml.cs
using MvvmMauiApp.Services;
namespace MvvmMauiApp
{
public partial class App : Application
{
public App(IServiceProvider serviceProvider)
{
InitializeComponent();
ServiceLocator.Initialize(serviceProvider);
MainPage = new AppShell();
}
}
}
- これで以下のようにサービズを取得できるようになる。
var navigationService = ServiceLocator.GetService<INavigationService>();
3. 一覧画面
下記の例は「ページ + ページ用VM → 内部のカスタムView +カスタムView専用VM → アイテムVM」 という 多層ViewModel構造 を取った MVVM 構成。
View(XAML)はBindingContext を切り替えながら必要な部分にVMをバインドしている。
UserAcountListPage.xaml (Page)
└─ [BindingContext] → UserAcountListPageViewModel (DIで自動注入)
└─ ListViewModel : UserAcountListViewModel
└─ (BindingContext を渡す) → UserAcountListView.xaml (ContentView)
└─ [BindingContext] → UserAcountListViewModel
└─ Items : ObservableCollection<UserAcountListItemViewModel>
├─ [Item] UserAcountListItemViewModel
│ └─ Data : UserAcount (Model)
├─ [Item] UserAcountListItemViewModel
│ └─ Data : UserAcount (Model)
└─ …(以下同様)
UserAcountListPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MvvmMauiApp.Views.UserAcountListPage"
xmlns:vm="clr-namespace:MvvmMauiApp.ViewModels"
xmlns:view="clr-namespace:MvvmMauiApp.Views"
x:DataType="vm:UserAcountListPageViewModel"
Title="UserAcountListPage">
<VerticalStackLayout>
<view:UserAcountListView BindingContext="{Binding ListViewModel}" />
</VerticalStackLayout>
</ContentPage>
UserAcountListPageViewModel
using CommunityToolkit.Mvvm.ComponentModel;
namespace MvvmMauiApp.ViewModels;
public partial class UserAcountListPageViewModel : ObservableObject
{
[ObservableProperty]
private UserAcountListViewModel listViewModel;
public UserAcountListPageViewModel()
{
this.ListViewModel = new UserAcountListViewModel();
}
}
UserAcountListView.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:MvvmMauiApp.ViewModels"
xmlns:view="clr-namespace:MvvmMauiApp.Views"
x:DataType="vm:UserAcountListViewModel"
x:Class="MvvmMauiApp.Views.UserAcountListView">
<VerticalStackLayout>
<CollectionView ItemsSource="{Binding Items}">
<CollectionView.ItemsLayout>
<LinearItemsLayout Orientation="Vertical" ItemSpacing="5"/>
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="vm:UserAcountListItemViewModel">
<Frame Padding="10" Margin="5" BorderColor="Gray" CornerRadius="5">
<Frame.GestureRecognizers>
<TapGestureRecognizer Command="{Binding NavigateToDetailModalCommand}" />
</Frame.GestureRecognizers>
<HorizontalStackLayout Spacing="10">
<Label Text="{Binding Data.UserId}" />
<Label Text="{Binding Data.AccountName}" />
<Label Text="{Binding Data.Note}" />
</HorizontalStackLayout>
</Frame>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</VerticalStackLayout>
</ContentView>
UserAcountListViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using System.Collections.ObjectModel;
namespace MvvmMauiApp.ViewModels;
public partial class UserAcountListViewModel : ObservableObject
{
[ObservableProperty]
private ObservableCollection<UserAcountListItemViewModel> items;
public UserAcountListViewModel()
{
this.Items = new ObservableCollection<UserAcountListItemViewModel>();
foreach (var item in new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 })
{
this.Items.Add(new UserAcountListItemViewModel(item));
}
}
}
- モーダル編集画面にパラメータ(
ShellNavigationQueryParameters)を渡して遷移
UserAcountListItemViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MvvmMauiApp.Models;
using MvvmMauiApp.Services;
using MvvmMauiApp.Views;
namespace MvvmMauiApp.ViewModels;
public partial class UserAcountListItemViewModel : ObservableObject
{
public int UserId { get; set; }
public UserAcount Data { get; private set; }
public UserAcountListItemViewModel(int userId)
{
this.UserId = userId;
this.Data = UserAcount.GetData(this.UserId);
}
[RelayCommand]
public async Task NavigateToDetailModal()
{
var parameters = new ShellNavigationQueryParameters
{
{ nameof(this.UserId), this.UserId },
{ nameof(this.Data.AccountName), this.Data.AccountName },
{ nameof(this.Data.Note), this.Data.Note},
};
var navigationService = ServiceLocator.GetService<INavigationService>();
await navigationService.NavigateToAsync(nameof(UserAcountDetailsPage), parameters);
}
}
4. モーダル編集画面
-
Shell.PresentationMode="Modal"でモーダル表示。 - ContentPageの背景色と、Gridのサイズ調整でダイアログのような表示にしている。
- 下記の例では
Noteのみ編集可能項目としている。
UserAcountDetailsPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MvvmMauiApp.Views.UserAcountDetailsPage"
BackgroundColor="#aa000000"
xmlns:vm="clr-namespace:MvvmMauiApp.ViewModels"
x:DataType="vm:UserAcountDetailsPageViewModel"
Shell.PresentationMode="Modal">
<Grid
WidthRequest="300"
HeightRequest="200"
RowDefinitions="auto,auto,auto,auto"
RowSpacing="10"
Padding="20" Background="White">
<Label
Grid.Row="0"
Text="{Binding UserId, StringFormat='UserId: {0}'}" />
<Label
Grid.Row="1"
Text="{Binding AccountName, StringFormat='UserName: {0}'}" />
<Entry
Grid.Row="2"
Text="{Binding Note}" />
<HorizontalStackLayout
Grid.Row="3"
Spacing="10"
HorizontalOptions="Center">
<Button Text="Cancel" Command="{Binding BackCommand}" Style="{StaticResource CancelButtonStyle}" />
<Button Text="Save" Command="{Binding SaveCommand}" Style="{StaticResource SaveButtonStyle}" />
</HorizontalStackLayout>
</Grid>
</ContentPage>
- ViewModel が
IQueryAttributableを実装することで、Shell ナビゲーション時にクエリパラメータを受け取ることが可能になる。
UserAcountDetailsPageViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MvvmMauiApp.Services;
namespace MvvmMauiApp.ViewModels;
public partial class UserAcountDetailsPageViewModel : ObservableObject, IQueryAttributable
{
[ObservableProperty]
private int userId;
[ObservableProperty]
private string accountName;
[ObservableProperty]
private string note;
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue(nameof(UserId), out var id))
{
this.UserId = (int)id;
}
if (query.TryGetValue(nameof(AccountName), out var name))
{
this.AccountName = name?.ToString() ?? "";
}
if (query.TryGetValue(nameof(Note), out var note))
{
this.Note = note?.ToString() ?? "";
}
}
[RelayCommand]
public async Task BackAsync()
{
var navigationService = ServiceLocator.GetService<INavigationService>();
await navigationService.PopAsync();
}
[RelayCommand]
public async Task SaveAsync()
{
// 一旦戻るだけ
var navigationService = ServiceLocator.GetService<INavigationService>();
await navigationService.PopAsync();
}
}
- Page と ViewModel を自動的にバインドし、Shell ナビゲーションで使えるようにするためにDI登録。
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
...(略)...
builder.Services.RegisterForNavigation<UserAcountListPage, UserAcountListPageViewModel>();
builder.Services.RegisterForNavigation<UserAcountDetailsPage, UserAcountDetailsPageViewModel>();
...(略)...
builder.Services.AddTransient<INavigationService, NavigationService>();
以下のような画面になる。
| 一覧 | 編集 |
|---|---|
![]() |
![]() |
5. 一覧画面に編集内容を反映
Noteを編集し、一覧に反映する。
本記事では編集画面から一覧画面に編集した内容を渡す方法を2つ紹介する。
A 画面遷移時に反映用のコールバックを渡す
この方法では、編集画面に遷移する際に「編集内容を反映するためのコールバック関数」を渡す。編集画面でデータが更新された際に、このコールバック関数を呼び出すことで、一覧画面のデータを直接更新。
UserAcountListItemViewModel.cs
public partial class UserAcountListItemViewModel : ObservableObject
{
...(略)...
// 更新用のコールバック
private void OnSaveCallback(string note)
{
this.Data.Note = note;
OnPropertyChanged(nameof(this.Data));
}
[RelayCommand]
public async Task NavigateToDetailModal()
{
var parameters = new ShellNavigationQueryParameters
{
{ nameof(this.UserId), this.UserId },
{ nameof(this.Data.AccountName), this.Data.AccountName },
{ nameof(this.Data.Note), this.Data.Note},
{ "OnSaveCallback", new Action<string>(x => this.OnSaveCallback(x)) }, // ★コールバックをパラメータとして渡す
};
var navigationService = ServiceLocator.GetService<INavigationService>();
await navigationService.NavigateToAsync(nameof(UserAcountDetailsPage), parameters);
}
}
UserAcountDetailsPageViewModel.cs
public partial class UserAcountDetailsPageViewModel : ObservableObject, IQueryAttributable
{
...(略)...
// ★コールバックを保持するフィールド
private Action<string>? onSaveCallback = null;
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue(nameof(UserId), out var id))
{
this.UserId = (int)id;
}
if (query.TryGetValue(nameof(AccountName), out var name))
{
this.AccountName = name?.ToString() ?? "";
}
if (query.TryGetValue(nameof(Note), out var note))
{
this.Note = note?.ToString() ?? "";
}
// ★コールバックの受け取り
if (query.TryGetValue("OnSaveCallback", out var callback) && callback is Action<string> action)
{
this.onSaveCallback = action;
}
}
[RelayCommand]
public async Task BackAsync()
{
var navigationService = ServiceLocator.GetService<INavigationService>();
await navigationService.PopAsync();
}
[RelayCommand]
public async Task SaveAsync()
{
// ★コールバックを実行する
this.onSaveCallback?.Invoke(this.Note);
var navigationService = ServiceLocator.GetService<INavigationService>();
await navigationService.PopAsync();
}
}
| 編集 | 確定 |
|---|---|
![]() |
![]() |
B Publish/Subscribe パターン
ComunityToolkit のWeakReferenceMessangerを使用してPublish/Subscribeパターンを実現。
- Messageクラス
UserAcountUpdatedMessage
namespace MvvmMauiApp.Message;
public class UserAccountUpdatedMessage
{
public Guid MessageId { get; set; }
public string Note { get; set; }
public UserAccountUpdatedMessage(Guid messageId, string note)
{
MessageId = messageId;
Note = note;
}
}
UserAcountListItemViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using MvvmMauiApp.Message;
using MvvmMauiApp.Models;
using MvvmMauiApp.Services;
using MvvmMauiApp.Views;
namespace MvvmMauiApp.ViewModels;
public partial class UserAcountListItemViewModel : ObservableObject
{
public int UserId { get; set; }
public UserAcount Data { get; private set; }
// ★自身を示すID
public Guid MessageId { get; } = Guid.NewGuid();
public UserAcountListItemViewModel(int userId)
{
this.UserId = userId;
this.Data = UserAcount.GetData(this.UserId);
// ★メッセージの受信を購読
// 第2引数としてMassageHandlerを指定することで、受信時にActionを実行
WeakReferenceMessenger.Default.Register<UserAccountUpdatedMessage>(this, (recipient, message) =>
{
new Action<Guid, string>((x, y) => OnNoteUpdated(x, y)).Invoke(message.MessageId, message.Note);
});
}
// モーダル画面で編集が確定された時に実行する処理
private void OnNoteUpdated(Guid messageId, string note)
{
if (this.MessageId == messageId)
{
this.Data.Note = note;
OnPropertyChanged(nameof(Data));
}
}
[RelayCommand]
public async Task NavigateToDetailModal()
{
var parameters = new ShellNavigationQueryParameters
{
{ nameof(this.UserId), this.UserId },
{ nameof(this.Data.AccountName), this.Data.AccountName },
{ nameof(this.Data.Note), this.Data.Note},
{ nameof(this.MessageId), this.MessageId }, // ★自身を示すIDを渡す
};
var navigationService = ServiceLocator.GetService<INavigationService>();
await navigationService.NavigateToAsync(nameof(UserAcountDetailsPage), parameters);
}
// ★購読の終了
~UserAcountListItemViewModel()
{
WeakReferenceMessenger.Default.UnregisterAll(this);
}
}
UserAcountDetailsPageViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using MvvmMauiApp.Message;
using MvvmMauiApp.Services;
namespace MvvmMauiApp.ViewModels;
public partial class UserAcountDetailsPageViewModel : ObservableObject, IQueryAttributable
{
[ObservableProperty]
private int userId;
[ObservableProperty]
private string accountName;
[ObservableProperty]
private string note;
// ★送信先のID
private Guid? MessageId = null;
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue(nameof(UserId), out var id))
{
this.UserId = (int)id;
}
if (query.TryGetValue(nameof(AccountName), out var name))
{
this.AccountName = name?.ToString() ?? "";
}
if (query.TryGetValue(nameof(Note), out var note))
{
this.Note = note?.ToString() ?? "";
}
// 送信先のIDを取得
if (query.TryGetValue("MessageId", out var messageId) && messageId is Guid guid)
{
this.MessageId = guid;
}
}
[RelayCommand]
public async Task BackAsync()
{
var navigationService = ServiceLocator.GetService<INavigationService>();
await navigationService.PopAsync();
}
[RelayCommand]
public async Task SaveAsync()
{
// ★メッセージを送信
if (this.MessageId.HasValue)
{
WeakReferenceMessenger.Default.Send(new UserAccountUpdatedMessage(this.MessageId.Value, this.Note));
}
var navigationService = ServiceLocator.GetService<INavigationService>();
await navigationService.PopAsync();
}
}
| 編集 | 確定 |
|---|---|
![]() |
![]() |






Discussion