🌊

.NET MAUI Community Toolkitを使ってMVVM実装(2)

に公開

はじめに

前回の記事に続いてMVVMで.NET MAUIアプリを実装する方法をメモ。
今回は「.NET MAUI Community Toolkit(CommunityToolkit.Maui)」の機能をいくつか使ってみる。

環境

Windows 11
Visual Studio 2022
.NET 8.0
CommunityToolkit.Mvvm 8.4.0
CommunityToolkit.Maui 9.1.1

カスタムポップアップの作成が可能で値の受け渡しも可能。
以下のような機能を実装する。
ユーザー一覧画面 > ユーザーをタップ > 選択されたユーザーを編集するポップアップ画面を表示

ユーザーデータ

一覧、ポップアップ画面で使用する。前回作成済。

User.cs
namespace MvvmMauiApp.Models
{
    public class User
    {
        public int UserId { get; set; }
        public string Name { get; set; } = string.Empty;
        public string Email { get; set; } = string.Empty;
        public bool IsAdmin { get; set; }
    }
}

Popupを作成

ViewModel
ToolkitのIPopupServiceをコンストラクタで受け取るようにしておくとDIコンテナが自動で注入してくれる。
PopupService.ClosePopupAsync()にUserを指定することで呼び出し元に結果を返す。
以下の例ではUser.Nameが入力されている場合のみ「保存」を押下できるようにしている。

UserPopupViewModel.cs
using CommunityToolkit.Maui.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MvvmMauiApp.Models;

namespace MvvmMauiApp.ViewModels
{
    public partial class UserPopupViewModel(IPopupService popupService) : ObservableObject
    {
        readonly IPopupService userPopupService = popupService;

        [ObservableProperty]
        private User user = new();

        [RelayCommand(CanExecute = nameof(CanSave))]
        public async Task Save()
        {
            await this.userPopupService.ClosePopupAsync(this.User);
        }

        [RelayCommand]
        public async Task Close()
        {
            await this.userPopupService.ClosePopupAsync();
        }

        bool CanSave() => string.IsNullOrWhiteSpace(User.Name) is false;
    }
}

View
toolkit:Popupを使用してカスタムPopupを実装する。

UserPopup.xaml
<?xml version="1.0" encoding="utf-8" ?>
<toolkit:Popup xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
            xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"   
            xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
            xmlns:vm="clr-namespace:MvvmMauiApp.ViewModels"  
            x:DataType="vm:UserPopupViewModel"
            x:Class="MvvmMauiApp.Views.UserPopup">

    <Frame BackgroundColor="White"
           HasShadow="True"
           Padding="20"
           HorizontalOptions="Center"
           VerticalOptions="Center">

        <VerticalStackLayout Padding="20"
                             Spacing="10"
                             MinimumHeightRequest="350"
                             MinimumWidthRequest="320"
                             VerticalOptions="Center">
            <Label 
            Text="{Binding User.UserId, StringFormat='ユーザーID:{0}'}"
            VerticalOptions="Center" 
            HorizontalOptions="Center" />

            <Entry
            Placeholder="ユーザー名"
            Text="{Binding User.Name}"
            VerticalOptions="Center" 
            HorizontalOptions="Fill"/>

            <Entry
            Placeholder="メール"
            Text="{Binding User.Email}"
            VerticalOptions="Center" 
            HorizontalOptions="Fill" />

            <HorizontalStackLayout HorizontalOptions="Center"
                               Spacing="20">
                <Button Text="閉じる" BackgroundColor="Transparent" BorderColor="Gray" BorderWidth="1" TextColor="Gray" Command="{Binding CloseCommand}" />
                <Button Text="保存" Command="{Binding SaveCommand}" />
            </HorizontalStackLayout>

        </VerticalStackLayout>
    </Frame>
</toolkit:Popup>

コードビハインド
ViewModelが引数ありなのでXAMLではなくコードビハインドでバインド。

UserPopup.xaml.cs
using CommunityToolkit.Maui.Views;
using MvvmMauiApp.ViewModels;

namespace MvvmMauiApp.Views;

public partial class UserPopup : Popup
{
	public UserPopup(UserPopupViewModel vm)
	{
		InitializeComponent();
		this.BindingContext = vm;
	}
}

呼び出し元

ViewModel
ToolkitのIPopupServiceをコンストラクタで受け取るようにしておく。
Popup表示の処理を実装。Popup画面のViewModelのUserに選択されたUserを設定する。

UserListPageViewModel.cs
using CommunityToolkit.Maui.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MvvmMauiApp.Models;
using System.Collections.ObjectModel;

namespace MvvmMauiApp.ViewModels
{
    public partial class UserListPageViewModel(IPopupService popupService) : ObservableObject
    {
        [ObservableProperty]
        private ObservableCollection<User> users = new();

        readonly IPopupService userPopupService = popupService;

        [RelayCommand]
        private async void OnPageLoaded()
        {
            // データ取得をシミュレートするために待機
            await Task.Delay(2000);
            Users = new ObservableCollection<User>
            {
                new User { UserId = 1, Name = "Alice", Email = "mail_Alice@mail.com", IsAdmin = true },
                new User { UserId = 2, Name = "Bob", Email = "mail_Bob@mail.com" },
                new User { UserId = 3, Name = "Charlie", Email = "mail_Charlie@mail.com" }
            };
        }

        [RelayCommand]
        public async Task DisplayPopup(User user)
        {
            var result = await this.userPopupService.ShowPopupAsync<UserPopupViewModel>(onPresenting: vm => vm.User = user);

            // 結果を一覧に反映
            if (result != null && result is User)
            {
                var index = Users.IndexOf(user);
                Users[index] = (User)result;
            }
        }
    }
}

View
CollectionViewの項目タップにPopup表示コマンドをバインド。

UserListPageViewModel.cs
<?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.UserListPage"  
            x:Name="Page"
            xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"   
            xmlns:vm="clr-namespace:MvvmMauiApp.ViewModels"  
            xmlns:models="clr-namespace:MvvmMauiApp.Models"  
            xmlns:behaviors="clr-namespace:MvvmMauiApp.Behaviors"  
            Title="UserListPage">  
    <!--ResourcesにConverterを設定-->
    <ContentPage.Resources>
        <x:String x:Key="IsAdminText">[Admin]</x:String>
        <x:String x:Key="NotAdminText"></x:String>
        <toolkit:BoolToObjectConverter x:Key="BoolToTextConverter"
                                       TrueObject="{StaticResource IsAdminText}"  
                                       FalseObject="{StaticResource NotAdminText}" />
    </ContentPage.Resources> 

    <!--BehaviorsでLoadedイベントにコマンドをバインド-->
    <ContentPage.Behaviors>
        <toolkit:EventToCommandBehavior Command="{Binding PageLoadedCommand}" EventName="Loaded" />  
     </ContentPage.Behaviors>  

    <VerticalStackLayout>  
        <!--Userの一覧-->
        <CollectionView x:Name="UserCollectionView" ItemsSource="{Binding Users}"
                        SelectionMode="Single">
            <CollectionView.ItemTemplate>  
                <DataTemplate>  
                    <Frame Padding="10" Margin="5" BorderColor="Gray" CornerRadius="5">  
                        <HorizontalStackLayout>  
                            <!--タップでPopup表示-->
                            <HorizontalStackLayout.GestureRecognizers>
                                <TapGestureRecognizer Command="{Binding BindingContext.DisplayPopupCommand,
                                                                Source={x:Reference UserCollectionView}}"
                                                      CommandParameter="{Binding .}">
                                </TapGestureRecognizer>
                            </HorizontalStackLayout.GestureRecognizers>
                            <Label Text="{Binding UserId, StringFormat='{}{0:D8}'}" Margin="0,0,20,0" />  
                            <Label Text="{Binding Name}" Margin="0,0,20,0" />  
                            <Label Text="{Binding Email}" Margin="0,0,20,0" />  
                            <Label Text="{Binding IsAdmin, Converter={StaticResource BoolToTextConverter}}" />  
                        </HorizontalStackLayout>  
                    </Frame>  
                </DataTemplate>  
            </CollectionView.ItemTemplate>  
        </CollectionView>  
    </VerticalStackLayout>  
</ContentPage>

コードビハインド
ViewModelが引数ありなのでXAMLではなくコードビハインドでバインド。

UserListPage.xaml.cs
using MvvmMauiApp.ViewModels;

namespace MvvmMauiApp.Views;

public partial class UserListPage : ContentPage
{
	public UserListPage(UserListPageViewModel vm)
	{
		InitializeComponent();
		this.BindingContext = vm;
	}
}

DIコンテナへの登録

IPopupServiceを使用する呼び出し元のViewとViewModelを登録。
Popup画面のViewとViewModelを登録。

MauiProgram.cs
using CommunityToolkit.Maui;
using Microsoft.Extensions.Logging;
using MvvmMauiApp.ViewModels;
using MvvmMauiApp.Views;

namespace MvvmMauiApp;

public static class MauiProgram
{
	public static MauiApp CreateMauiApp()
	{
		var builder = MauiApp.CreateBuilder();
		builder
			.UseMauiApp<App>()
			.UseMauiCommunityToolkit()
			.ConfigureFonts(fonts =>
			{
				fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
				fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
			});

		// DIコンテナ登録
        builder.Services.AddTransient<UserListPage>();
        builder.Services.AddTransient<UserListPageViewModel>();
        builder.Services.AddTransientPopup<UserPopup, UserPopupViewModel>();

#if DEBUG
        builder.Logging.AddDebug();
#endif

		return builder.Build();
	}
}

動作確認

注意

https://github.com/CommunityToolkit/Maui/wiki/Migrating-to-Popup-v2

Animation

カスタマイズ可能なアニメーションクラスが用意されている。
https://learn.microsoft.com/ja-jp/dotnet/communitytoolkit/maui/animations/
https://learn.microsoft.com/ja-jp/dotnet/communitytoolkit/maui/behaviors/animation-behavior

FadeAnimation

Toolkitで用意されている。
コントロールのBehaviorsに設定するだけ。

AnimationPage.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.AnimationPage"
            xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"   
             Title="AnimationPage">
    <VerticalStackLayout>
        <Button Text="FadeAnimation">
            <Button.Behaviors>
                <toolkit:AnimationBehavior EventName="Clicked">
                    <toolkit:AnimationBehavior.AnimationType>
                        <toolkit:FadeAnimation Opacity="0.5" />
                    </toolkit:AnimationBehavior.AnimationType>
                </toolkit:AnimationBehavior>
            </Button.Behaviors>
        </Button>
    </VerticalStackLayout>
</ContentPage>

カスタムAnimation

BaseAnimationクラスを継承。

ScaleToAnimation.cs
using CommunityToolkit.Maui.Animations;

namespace MvvmMauiApp.Behaviors
{
    public class ScaleToAnimation : BaseAnimation
    {
        public double Scale { get; set; }

        public override Task Animate(VisualElement view, CancellationToken token = default) => view.ScaleTo(Scale, Length, Easing);
    }
}

ボタンを押している間は大きく、離すと小さくなるようにXAMLで設定。

AnimationPage.xaml
        <Button Text="Custom Animation">
            <Button.Behaviors>
                <toolkit:AnimationBehavior EventName="Pressed">
                    <toolkit:AnimationBehavior.AnimationType>
                        <behaviors:ScaleToAnimation 
                            Easing="{x:Static Easing.Linear}"
                            Length="100"
                            Scale="1.05"/>
                    </toolkit:AnimationBehavior.AnimationType>
                </toolkit:AnimationBehavior>

                <toolkit:AnimationBehavior EventName="Released">
                    <toolkit:AnimationBehavior.AnimationType>
                        <behaviors:ScaleToAnimation 
                            Easing="{x:Static Easing.Linear}"
                            Length="100"
                            Scale="1"/>
                    </toolkit:AnimationBehavior.AnimationType>
                </toolkit:AnimationBehavior>
            </Button.Behaviors>
        </Button>

DrawingView

タッチ操作またはマウス操作を使用して線を描画できる画面。
以下の手順では描画された線だけではなく、背景画像も一緒に保存する。
https://learn.microsoft.com/ja-jp/dotnet/communitytoolkit/maui/views/drawingview

画像保存用のサービス

指定されたVisualElementの範囲をキャプチャして画像を作成するサービスを実装する。
MVVMに対応するため、コードビハインドではなくサービスを作成。

ICaptureService.cs
namespace MvvmMauiApp.Services;

/// <summary>
/// 指定されたVisualElementのキャプチャを取得し、保存するサービスインターフェース
/// </summary>
public interface ICaptureService
{
    Task<bool> CaptureAndSaveAsync(VisualElement element, string filePath);
}
CaptureService.cs
using Microsoft.Maui.Controls;

namespace MvvmMauiApp.Services;

/// <summary>
/// 指定した VisualElement のキャプチャを取得し、指定されたファイル名で保存するサービス
/// </summary>
public class CaptureService : ICaptureService
{
    public async Task<bool> CaptureAndSaveAsync(VisualElement element, string filePath)
    {
        if (element == null)
            return false;

        var screenshot = await element.CaptureAsync();
        if (screenshot == null)
            return false;

        using var stream = await screenshot.OpenReadAsync();
        using var fileStream = File.OpenWrite(filePath);
        await stream.CopyToAsync(fileStream);

        return true;
    }
}

画面

ViewModel

  • キャプチャサービスをコンストラクタで受け取る。
  • 背景画像は事前に用意しておく。
  • メッセージ、描画された線のコレクション、背景画像のパスをViewとバインドするために用意しておく。
DrawingPageViewModel.cs
using CommunityToolkit.Maui.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MvvmMauiApp.Services;
using System.Collections.ObjectModel;

namespace MvvmMauiApp.ViewModels
{
    public partial class DrawingPageViewModel(ICaptureService captureService) : ObservableObject
    {
        // デフォルトメッセージ
        private const string DefaultMessage = "サインしてください。";

        [ObservableProperty]
        private string message = DefaultMessage;

        [ObservableProperty]
        private ObservableCollection<IDrawingLine> lines = new();

        [ObservableProperty]
        private string backgroundImage = @"\path\to\Images\sign.png";

        private readonly ICaptureService captureService = captureService;

        [RelayCommand]
        public void Clear()
        {
            Lines.Clear();
            Message = DefaultMessage;
        }

        [RelayCommand]
        public async Task SaveCaptureAsync(VisualElement element)
        {
            // サービスを使ってキャプチャ
            var filePath = Path.Combine(FileSystem.AppDataDirectory, "drawing.png");
            var result = await captureService.CaptureAndSaveAsync(element, filePath);

            // キャプチャ結果に応じてメッセージを表示
            if (result)
            {
                Message = $"ファイルを保存しました: {filePath}";
            }
            else
            {
                Message = "キャプチャに失敗しました。";
            }
        }
    }
}

View

  • 画像とDrawingViewを重ねる。
  • SaveボタンのコマンドパラメータでキャプチャエリアのVisualElement(今回はGrid)を指定する。
<?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.DrawingPage"
             xmlns:vm="clr-namespace:MvvmMauiApp.ViewModels"
             xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
             Title="DrawingPage">
    <Grid ColumnDefinitions="*,*" RowDefinitions="Auto,*,Auto,*">
        <Grid BackgroundColor="AliceBlue"
                 Grid.Row="0" Grid.ColumnSpan="2" Padding="20,10,20,10">
            <HorizontalStackLayout HorizontalOptions="Center" Spacing="10">
                <Label Text="{Binding Message}" />
            </HorizontalStackLayout>
        </Grid>

        <Grid x:Name="CaptureArea" Grid.Row="1" Grid.ColumnSpan="2"
              BackgroundColor="White">
            <Image Source="{Binding BackgroundImage}" Aspect="AspectFit" Grid.ColumnSpan="2"/>
            <toolkit:DrawingView
                x:Name="SignDrawing"
                BackgroundColor="Transparent"
                Lines="{Binding Lines, Mode=TwoWay}"
                IsMultiLineModeEnabled="True" />
        </Grid>

		<Button
			Grid.Row="2"
			Text="Clear"
            Command="{Binding ClearCommand}"/>
		<Button
			Grid.Row="2"
			Grid.Column="1"
            Command="{Binding SaveCaptureCommand}"
            CommandParameter="{x:Reference CaptureArea}"
			Text="Save" />
    </Grid>
</ContentPage>

コードビハインド

  • VMが引数ありのコンストラクタなのでここでバインド。
DrawingPage.xaml.cs
using MvvmMauiApp.ViewModels;

namespace MvvmMauiApp.Views;

public partial class DrawingPage : ContentPage
{
    public DrawingPage(DrawingPageViewModel vm)
    {
        InitializeComponent();
        BindingContext = vm;
    }
}

DIコンテナへの登録

サービスを使用するDrawingPageはViewとViewModelを登録しておく。

MauiProgram.cs
    ...
    builder.Services.AddTransient<DrawingPage>();
    builder.Services.AddTransient<DrawingPageViewModel>();
    builder.Services.AddSingleton<ICaptureService, CaptureService>();
    ...

完成

保存した画像

Discussion