🛠️

.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 ナビゲーション時にクエリパラメータを受け取ることが可能になる。

https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.maui.controls.iqueryattributable?view=net-maui-9.0&viewFallbackFrom=net-maui-8.0

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パターンを実現。

https://learn.microsoft.com/ja-jp/dotnet/communitytoolkit/mvvm/messenger

  • 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