この記事は、WPF / .NET MAUIでMVVMを書き始めた人向けに、ボタンを押したときの処理をどこに書くかを整理する記事です。
前回の記事では、CommunityToolkit.Mvvmを使って ObservableObject や ObservableProperty の基本を整理しました。
今回はその続きとして、保存ボタンのような操作をViewModelに移す流れを見ていきます。
連載全体はこちらです。
この記事では、Commandの型名を細かく覚えることよりも、
- ボタンを押したときの処理をどこに書くか
- Clickイベントに書いていた処理をどうViewModelへ移すか
- ボタン連打や入力不足をどう防ぐか
を中心に整理します。
この記事で扱うこと
この記事では、MVVMでボタン操作を書くときの基本を扱います。
- Clickイベントに処理を書き続けると何が困るか
- ボタンを押したときの処理をViewModelへ移す考え方
-
Button.CommandからViewModelの処理を呼ぶ流れ - CommunityToolkit.Mvvmを使ったCommandの書き方
- ボタンの有効・無効をViewModelで管理する方法
- 保存中の二重押しを防ぐ方法
- Commandが動かないときに見るところ
画面遷移、DI、Prism、ReactiveProperty、複雑な入力検証までは深く扱いません。
まずは、保存ボタンを例にして、ClickイベントからViewModelへ処理を移す流れを見ます。
最初に結論
MVVMでは、ボタンを押したときの業務処理は基本的にViewModelに書きます。
画面側のClickイベントには、なるべく処理を書きません。
ただし、すべてをViewModelに寄せればよいわけではありません。
判断基準はこのようになります。
| 処理 | 書く場所 |
|---|---|
| 入力値を保存する | ViewModel |
| APIを呼び出す | ViewModel |
| DBに登録する | ViewModel |
| 入力チェックをする | ViewModel |
| ボタンの有効・無効を切り替える | ViewModel |
| 画面のアニメーションを動かす | View |
| フォーカスを移動する | View |
| スクロール位置を調整する | View |
| ダイアログを表示する | View、またはサービス経由 |
大事なのは、ボタンを押したあとの「意味のある処理」をViewModelに置くことです。
保存する、検索する、削除する、送信する、といった処理はViewModel側に置いた方が扱いやすくなります。
今回作る画面
今回作るのは、名前を入力して保存するだけの小さな画面です。
名前
[ ]
[ 保存 ]
保存しました。
この画面では、次の3つをViewModelにつなぎます。
| 画面側 | ViewModel側 | 役割 |
|---|---|---|
TextBox.Text / Entry.Text
|
UserName |
入力された名前 |
Button.Command |
SaveCommand |
保存ボタンの処理 |
TextBlock.Text / Label.Text
|
Message |
結果メッセージ |
MVVMでは、このつながりを Binding と Command で作ります。
WPFなら次のような形です。
<TextBox Text="{Binding UserName}" />
<Button Command="{Binding SaveCommand}" />
<TextBlock Text="{Binding Message}" />
.NET MAUIなら次のような形です。
<Entry Text="{Binding UserName}" />
<Button Command="{Binding SaveCommand}" />
<Label Text="{Binding Message}" />
ポイントは、ボタンのClickイベントに保存処理を書かないことです。
画面側は Command を呼び、処理の中身はViewModelに置きます。
Clickイベントに書くとどうなるか
最初は、ボタンのClickイベントに処理を書きたくなります。
たとえばWPFなら、このような書き方です。
<Button Content="保存" Click="SaveButton_Click" />
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
if (string.IsNullOrWhiteSpace(NameTextBox.Text))
{
MessageTextBlock.Text = "名前を入力してください。";
return;
}
Save(NameTextBox.Text);
MessageTextBlock.Text = "保存しました。";
}
小さい画面なら、これでも動きます。
ただ、この書き方を続けると、画面側のコードが少しずつ重くなります。
たとえば、次のような状態になりやすいです。
- TextBoxの値を画面側から直接読む
- 入力チェックが画面側に増える
- 保存処理が画面側に入る
- 処理中の二重押し防止も画面側に入る
- エラー表示の制御も画面側に入る
こうなると、画面の見た目と処理の中身が混ざります。
最初は問題なくても、入力項目が増えたり、保存処理が複雑になったりすると、どこを直せばよいか分かりにくくなります。
Clickイベントに書くこと自体が、すべて悪いわけではありません。
問題になりやすいのは、Clickイベントの中に入力チェック、保存処理、通信処理、DB更新などが増えていくことです。
ViewModelに移すと何が変わるか
MVVMでは、画面で入力された値をViewModelのプロパティに持たせます。
そして、ボタンを押したときの処理もViewModelに置きます。
画面は「ボタンが押された」という操作をViewModelへ渡すだけです。
このときに使うのがCommandです。
Commandは、ボタンなどの操作とViewModelの処理をつなぐための仕組みです。
ざっくり言うと、Clickイベントの代わりに使うものです。
Button.Command
↓ Binding
SaveCommand
↓
Save()
↓
Message更新
↓
Bindingで画面へ反映
この流れが見えると、MVVMで「どこからどこへつながっているか」を追いやすくなります。
CommunityToolkit.Mvvmを使う
この記事では、CommunityToolkit.Mvvmを使います。
NuGetで次のパッケージを追加します。
CommunityToolkit.Mvvm
ViewModelは次のように書けます。
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private string? userName;
[ObservableProperty]
private string? message;
[RelayCommand]
private void Save()
{
if (string.IsNullOrWhiteSpace(UserName))
{
Message = "名前を入力してください。";
return;
}
// ここに保存処理を書く
Message = "保存しました。";
}
}
このコードでは、UserName、Message、SaveCommand が使えるようになります。
SaveCommand は、Save メソッドから自動生成されます。
つまり、ボタン側からは SaveCommand を呼び出せばよいです。
CommunityToolkit.Mvvmの属性を使う場合、ViewModelは partial class にします。
partial を付け忘れると、生成コードとうまくつながらず、プロパティやCommandが使えない原因になります。
WPFの場合
WPFでは、画面のDataContextにViewModelを設定します。
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
}
}
XAMLでは、TextBoxとButtonをViewModelにバインドします。
<StackPanel Margin="20">
<TextBox
Text="{Binding UserName, UpdateSourceTrigger=PropertyChanged}" />
<Button
Content="保存"
Command="{Binding SaveCommand}"
Margin="0,8,0,0" />
<TextBlock
Text="{Binding Message}"
Margin="0,8,0,0" />
</StackPanel>
これで、ボタンを押すとViewModelの Save メソッドが動きます。
画面側のClickイベントは不要です。
.NET MAUIの場合
.NET MAUIでも考え方は同じです。
違うのは、WPFのDataContextにあたるものがBindingContextになる点です。
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
BindingContext = new MainViewModel();
}
}
XAMLは次のようになります。
<VerticalStackLayout Padding="20">
<Entry
Text="{Binding UserName}" />
<Button
Text="保存"
Command="{Binding SaveCommand}" />
<Label
Text="{Binding Message}" />
</VerticalStackLayout>
WPFでも.NET MAUIでも、考え方は同じです。
画面側はCommandを呼び出すだけです。
処理の中身はViewModelに置きます。
WPF : DataContext
.NET MAUI : BindingContext
名前は違いますが、どちらも「この画面はどのViewModelを見るか」を設定するものです。
ボタンを押せる条件をViewModelで管理する
保存ボタンは、いつでも押せればよいとは限りません。
たとえば、名前が空なら押せないようにしたい場合があります。
その場合は、Commandの実行可否をViewModel側で管理します。
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string? userName;
[ObservableProperty]
private string? message;
private bool CanSave()
{
return !string.IsNullOrWhiteSpace(UserName);
}
[RelayCommand(CanExecute = nameof(CanSave))]
private void Save()
{
Message = "保存しました。";
}
}
この例では、UserName が空の場合、保存ボタンは押せません。
UserName に値が入ると、保存ボタンを押せるようになります。
ポイントは、ボタンを押せるかどうかもViewModelに書いていることです。
画面側でTextBoxを見て、ボタンの有効・無効を切り替える必要はありません。
UserName が変わったときにボタンの有効・無効も更新したい場合は、[NotifyCanExecuteChangedFor(nameof(SaveCommand))] を付けます。
これを忘れると、入力値を変えてもボタンの状態が変わらないことがあります。
非同期処理ならasyncにする
保存処理がすぐ終わるとは限りません。
APIを呼ぶ、ファイルを読み込む、DBに登録する、といった処理は時間がかかることがあります。
その場合は、非同期処理にします。
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string? userName;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private bool isBusy;
[ObservableProperty]
private string? message;
private bool CanSave()
{
return !IsBusy && !string.IsNullOrWhiteSpace(UserName);
}
[RelayCommand(CanExecute = nameof(CanSave))]
private async Task SaveAsync()
{
if (!CanSave())
{
return;
}
try
{
IsBusy = true;
Message = "保存しています...";
// 実際の処理ではAPI呼び出しやファイル保存などを行う
await Task.Delay(1000);
Message = "保存しました。";
}
catch (Exception ex)
{
Message = $"保存に失敗しました。{ex.Message}";
}
finally
{
IsBusy = false;
}
}
}
SaveAsync メソッドからは、SaveCommand が生成されます。
ボタン側の書き方は変わりません。
WPFなら次のように書きます。
<Button
Content="保存"
Command="{Binding SaveCommand}" />
.NET MAUIなら次のように書きます。
<Button
Text="保存"
Command="{Binding SaveCommand}" />
メソッド名が SaveAsync でも、生成されるCommand名は基本的に SaveCommand です。
XAML側で SaveAsyncCommand と書かないように注意します。
二重押しを防ぐ
保存処理でよくある問題が、ボタンの二重押しです。
保存中にもう一度ボタンを押せてしまうと、同じデータが二重登録されることがあります。
そのため、処理中はボタンを押せないようにします。
先ほどのコードでは、IsBusy を使って二重押しを防いでいます。
private bool CanSave()
{
return !IsBusy && !string.IsNullOrWhiteSpace(UserName);
}
IsBusy が true の間は、保存ボタンを押せません。
try
{
IsBusy = true;
await Task.Delay(1000);
}
finally
{
IsBusy = false;
}
finally で IsBusy = false に戻しているのは、途中で例外が発生しても処理中状態を解除するためです。
この形にしておくと、成功しても失敗してもボタンが戻ります。
DB登録、注文確定、ファイル書き込みのような処理では、二重押し対策を入れた方が安全です。
「保存中にもう一度押せる状態」は、実務では不具合につながりやすいです。
async voidは避ける
Clickイベントで非同期処理を書くと、次のようなコードになりがちです。
private async void SaveButton_Click(object sender, RoutedEventArgs e)
{
await SaveAsync();
}
イベントハンドラなので async void 自体は書けます。
ただ、保存処理や検索処理の本体を async void に寄せていくと、例外処理やテストがしにくくなります。
ViewModelでは、基本的に async Task で書きます。
[RelayCommand]
private async Task SaveAsync()
{
await Task.Delay(1000);
}
この方が、非同期処理として扱いやすくなります。
Commandが動かないときに見るところ
Commandが動かないときは、次の順番で確認すると切り分けやすいです。
DataContextまたはBindingContextが設定されているか
WPFならDataContextです。
DataContext = new MainViewModel();
.NET MAUIならBindingContextです。
BindingContext = new MainViewModel();
ここが設定されていないと、XAMLのBinding先が見つかりません。
Command名が合っているか
Save メソッドに [RelayCommand] を付けると、生成される名前は SaveCommand です。
[RelayCommand]
private void Save()
{
}
XAMLでは次のように書きます。
<Button Command="{Binding SaveCommand}" />
Save ではなく、SaveCommand です。
ViewModelがpartial classになっているか
CommunityToolkit.Mvvmの属性を使う場合、ViewModelは partial にします。
public partial class MainViewModel : ObservableObject
{
}
partial がないと、生成コードとうまくつながりません。
ObservableObjectを継承しているか
画面更新を通知するには、ViewModelが ObservableObject を継承している必要があります。
public partial class MainViewModel : ObservableObject
{
}
入力値が更新されているか
WPFのTextBoxでは、必要に応じて UpdateSourceTrigger=PropertyChanged を指定します。
<TextBox Text="{Binding UserName, UpdateSourceTrigger=PropertyChanged}" />
これがないと、入力中の値がすぐにViewModelへ反映されないことがあります。
Commandが動かないときは、いきなりCommandの中身を疑うより、先に次の3つを見ると早いです。
- ViewModelが画面に設定されているか
- XAMLのCommand名が合っているか
- ViewModelが
partialになっているか
ViewModelに書かない方がよい処理
ボタンを押したときの処理をViewModelに書くといっても、画面に強く依存する処理まで全部ViewModelに入れる必要はありません。
たとえば、次のような処理です。
- TextBoxにフォーカスを当てる
- スクロール位置を変える
- アニメーションを開始する
- 画面固有の表示状態を細かく切り替える
これらは画面の都合なので、View側に残してもよいです。
ViewModelに置きたいのは、画面が変わっても意味が変わらない処理です。
保存、検索、削除、入力チェック、処理中判定などはViewModelに置く候補になります。
実務での分け方
迷ったときは、次の基準で考えると分けやすいです。
| 判断 | 書く場所 |
|---|---|
| 画面が変わっても同じ意味を持つ処理 | ViewModel |
| 入力値から結果を判断する処理 | ViewModel |
| 保存、検索、削除などの操作 | ViewModel |
| ボタンを押せるかどうかの条件 | ViewModel |
| 見た目だけの制御 | View |
| フォーカス、スクロール、アニメーション | View |
| OSや画面部品に強く依存する処理 | View、または専用サービス |
最初から完璧に分けようとしなくても大丈夫です。
まずは、保存、検索、削除のような処理をClickイベントからViewModelへ移すだけでも、コードの見通しはかなり変わります。
最小構成のサンプル
最後に、保存ボタンの最小構成をまとめます。
ViewModelです。
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string? userName;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private bool isBusy;
[ObservableProperty]
private string? message;
private bool CanSave()
{
return !IsBusy && !string.IsNullOrWhiteSpace(UserName);
}
[RelayCommand(CanExecute = nameof(CanSave))]
private async Task SaveAsync()
{
if (!CanSave())
{
return;
}
try
{
IsBusy = true;
Message = "保存しています...";
await Task.Delay(1000);
Message = "保存しました。";
}
catch (Exception ex)
{
Message = $"保存に失敗しました。{ex.Message}";
}
finally
{
IsBusy = false;
}
}
}
WPFのXAMLです。
<StackPanel Margin="20">
<TextBox
Text="{Binding UserName, UpdateSourceTrigger=PropertyChanged}" />
<Button
Content="保存"
Command="{Binding SaveCommand}"
Margin="0,8,0,0" />
<TextBlock
Text="{Binding Message}"
Margin="0,8,0,0" />
</StackPanel>
.NET MAUIのXAMLです。
<VerticalStackLayout Padding="20">
<Entry
Text="{Binding UserName}" />
<Button
Text="保存"
Command="{Binding SaveCommand}" />
<Label
Text="{Binding Message}" />
</VerticalStackLayout>
このサンプルで見てほしいポイントは、画面側にClickイベントを書いていないことです。
ボタンはCommandを呼び、処理の中身はViewModelに置いています。
まとめ
MVVMでは、ボタンを押したときの業務処理はViewModelに書きます。
Clickイベントに直接書くと、画面のコードに入力チェックや保存処理が集まりやすくなります。
小さい画面では問題になりにくいですが、機能が増えるほど修正しにくくなります。
基本の考え方は次のとおりです。
| やりたいこと | 書き方 |
|---|---|
| ボタンを押したときに処理を実行したい | Commandを使う |
| 入力値をViewModelに持たせたい | ObservablePropertyを使う |
| ボタンの有効・無効を切り替えたい | CanExecuteを使う |
| 非同期処理を書きたい | async Taskで書く |
| 二重押しを防ぎたい | IsBusyとCanExecuteを組み合わせる |
最初は、Clickイベントをすべてなくすことを目標にしなくてもよいです。
まずは、保存、検索、削除のような意味のある処理からViewModelへ移すと、MVVMの形が分かりやすくなります。
関連記事
MVVMの全体像はこちらです。
XAMLの読み方を先に整理する場合はこちらです。
WPF側の基本実装はこちらです。
.NET MAUI側の基本実装はこちらです。
連載Indexはこちらです。
C#達人への道 連載Index|読む順・公開記事一覧