.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
Popup
カスタムポップアップの作成が可能で値の受け渡しも可能。
以下のような機能を実装する。
ユーザー一覧画面 > ユーザーをタップ > 選択されたユーザーを編集するポップアップ画面を表示
ユーザーデータ
一覧、ポップアップ画面で使用する。前回作成済。
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が入力されている場合のみ「保存」を押下できるようにしている。
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を実装する。
<?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ではなくコードビハインドでバインド。
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を設定する。
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表示コマンドをバインド。
<?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ではなくコードビハインドでバインド。
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を登録。
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();
}
}
動作確認
注意
Animation
カスタマイズ可能なアニメーションクラスが用意されている。
FadeAnimation
Toolkitで用意されている。
コントロールのBehaviorsに設定するだけ。
<?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クラスを継承。
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で設定。
<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
タッチ操作またはマウス操作を使用して線を描画できる画面。
以下の手順では描画された線だけではなく、背景画像も一緒に保存する。
画像保存用のサービス
指定されたVisualElementの範囲をキャプチャして画像を作成するサービスを実装する。
MVVMに対応するため、コードビハインドではなくサービスを作成。
namespace MvvmMauiApp.Services;
/// <summary>
/// 指定されたVisualElementのキャプチャを取得し、保存するサービスインターフェース
/// </summary>
public interface ICaptureService
{
Task<bool> CaptureAndSaveAsync(VisualElement element, string filePath);
}
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とバインドするために用意しておく。
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が引数ありのコンストラクタなのでここでバインド。
using MvvmMauiApp.ViewModels;
namespace MvvmMauiApp.Views;
public partial class DrawingPage : ContentPage
{
public DrawingPage(DrawingPageViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
}
DIコンテナへの登録
サービスを使用するDrawingPage
はViewとViewModelを登録しておく。
...
builder.Services.AddTransient<DrawingPage>();
builder.Services.AddTransient<DrawingPageViewModel>();
builder.Services.AddSingleton<ICaptureService, CaptureService>();
...
完成
保存した画像
Discussion