📖

WPF使いのためのWinUI 3:CommandとIsEnabledを両方バインドすると問題が起きるので、CanExecuteを使おう

に公開

概要

Button を押したときの処理は、 MVVM であれば Command へバインドすることが多いと思います。さらに、その Button の有効・無効状態を変更しようとして IsEnabled へプロパティをバインドすると、WPFでは起きなかった問題がWinUI 3では起きた。という話です。

最初に結論まとめ

結論としては、 MVVM で Command に処理をバインドしたコントロールの有効・無効状態を変更するのであれば IsEnabled ではなく CanExecute を使うのが定石なので、きちんとそちらを使えば良いというだけの話です。そうしましょう。

この記事は、実際に IsEnabled にバインドしてみると WPF では問題が起きないが WinUI 3 では問題が起きる事が分かったので、同じ事をやってしまったときに原因不明で混乱しないように、起きる問題の情報を書いておくものです。

説明

起きた問題

次のように、 IsEnabled をバインドした上で、ボタンを押すと1秒待って終了するだけのコードを書きます。このコードでボタンを押すとどうなるか。1秒経ったらボタンが有効になると思いますよね?しかし、ボタンはずっと無効のままになります。これが今回起きた問題です。

<Button Command="{x:Bind ViewModel.TestActionCommand}"
        Content="テスト" 
        IsEnabled="{x:Bind ViewModel.IsEnableTestAction, Mode=TwoWay}"
        />
[ObservableProperty]
private bool _isEnableTestAction = true;

[RelayCommand]
private async Task OnTestActionAsync(){
    await Task.Delay(TimeSpan.FromSeconds(1));
}

ちなみに、メソッドの中(この場合 OnTestActionAsync() の中)でawaitをしなければこの問題は起きません。

原因

問題の発生条件を上のコードまで絞り込むのが大変だったんですが、絞り込んでしまえば同じ話を議論しているフォーラムスレッドなどもありました。

https://stackoverflow.com/questions/28874883/button-isenabled-binding-not-working-properly

原因を特定した会話はなさそうですが、 Command を使うときに IsEnabled を併用すること自体が良くないというのが結論のようです。

動きや断片的な情報から見ての推測ですが・・・ まず WinUI 3 の Button には元々、 Command へ割り当てたメソッドが非同期処理に入って Task を返した場合に、 Task が終わるまでボタンを無効化しておく動きがあります。この動きの実現に WinUI 3 では内部的に IsEnabled を使っているために、 IsEnabled のバインドの処理とぶつかって動きがおかしくなるのかもしれません。

対処方法

上のコードでは ICommand.Execute だけしか使っていませんが、元々 MVVM の ICommand は Execute, CanExecute, CanExecuteChanged のセットで構成されています。 Button の Command を使用する場合は、 CanExecute でボタンの有効・無効を操作できるので、 IsEnabled のバインディングと同じ事が実現できます。そちらの方が ICommand の用途とも合っているので、そちらに変更すればOKです。この辺りは、 MS Learn でも説明されています。

https://learn.microsoft.com/ja-jp/dotnet/maui/xaml/fundamentals/mvvm?view=net-maui-9.0?wt.mc_id=MVP_452751#commanding

これを反映するには、次のようにコードを変更します。

  1. IsEnabled のバインディングは削除する
  2. ICommand.Execute のメソッド側に RelayCommand 属性で、 CanExecute のメソッド(この場合はプロパティ)を設定する
  3. CanExecute を担当するプロパティに、 NotifyCanExecuteChangedFor で、値を変更したときに通知する ICommand.Execute のメソッドを指定する

コードは次のようになります。

<Button Command="{x:Bind ViewModel.TestActionCommand}"
        Content="テスト" 
        />
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(TestActionCommand))]
private bool _isEnableTestAction = true;

[RelayCommand(CanExecute = nameof(IsEnableTestAction))]
private async Task OnTestActionAsync(){
    await Task.Delay(TimeSpan.FromSeconds(1));
}

Execute のメソッドと CanExecute のメソッドでお互いに相手を指定するのが冗長な気もしますが、これは必須のようです。 ICommand の実装として CanExecuteChanged の呼び出しは自動ではなく明示的に必要なタイミングで呼び出すものなので、 CanExecute を割り当てる属性指定に加えて CanExecute 側から CanExecuteChanged を呼び出すための属性指定も明示で必要になるということだと思います。

まとめ

発端としてはそもそも ICommand と IsEnabled の使い方が設計上良くないという問題が有ったものの、結果として「ボタンの処理が終わっても有効状態に戻らない」という妙な動きになったために解決に手間取りました。同じ問題で時間を無駄にしないように気をつけましょう!

Discussion