WPF で RelayCommand の CanExecute がボタンの有効・無効に反映されない問題の解決方法

自作 RelayCommand の CanExecute を変えてもボタンが更新されないのは CanExecuteChanged が発火されないため。CommandManager.RequerySuggested への委譲と手動発火の使い分けを整理する。

概要

WPF の MVVM では、ボタンを ViewModel の ICommand にバインドし、CanExecute の結果でボタンの有効・無効を切り替える。
ところが、CanExecute が参照する条件(入力の有無など)を変えてもボタンの状態が変わらない、という不具合が頻発する。
本記事では、この現象が ICommand.CanExecuteChanged イベントの発火漏れに起因することを説明し、CommandManager.RequerySuggested へ委譲する方式と、自前で CanExecuteChanged を発火する方式の実装・使い分けを整理する。


前提・対象環境


問題

Button.Command に ViewModel のコマンドをバインドし、CanExecute に入力状態を反映する構成を考える。
以下は、名前の入力有無で保存ボタンの有効・無効を切り替える意図のコードである。

public class RelayCommand : ICommand
{
    private readonly Action _execute;
    private readonly Func<bool> _canExecute;

    public RelayCommand(Action execute, Func<bool> canExecute)
    {
        _execute = execute;
        _canExecute = canExecute;
    }

    public bool CanExecute(object? parameter) => _canExecute();

    public void Execute(object? parameter) => _execute();

    // 誰も発火しないため、ボタンの状態は初回評価のまま固定される
    public event EventHandler? CanExecuteChanged;
}

CanExecute は初回バインド時に評価される。本来はその後 CanExecuteChanged の発火に応じて再評価されるが、この実装では一度も発火していないため、Name を入力しても保存ボタンは無効のまま変わらない。
ボタンが CanExecute を再評価する契機が無いことが原因である。

同じ文字列を入力した 2 組の入力欄とボタン。CanExecuteChanged を発火しない実装ではボタンが無効のまま、CommandManager.RequerySuggested へ委譲した実装ではボタンが有効になっている。
どちらも同じ条件(Name が空でなければ実行可能)で、同じ文字列を入力した状態。上は CanExecuteChanged を一度も発火しない実装で、入力してもボタンは無効のままである。下は CommandManager.RequerySuggested へ委譲した実装で、再問い合わせが走りボタンが有効になる。

原因・背景

コマンドソース(Button など)は、ICommand.CanExecuteChanged イベントを購読し、これが発火したときにだけ CanExecute を呼び直して自身の有効・無効を更新する。
公式ドキュメントも「コマンドソースは通常 CanExecuteChanged を購読し、発火時に CanExecute を呼んで、実行不可なら自身を無効化する」と記述している。
したがって CanExecuteChanged を発火しない限り、CanExecute の戻り値がいくら変化してもボタンには反映されない。

WPF 標準の RoutedCommand がこの問題を表面化させにくいのは、その CanExecuteChangedCommandManager.RequerySuggested に委譲されているためである。
CommandManager は、キーボードフォーカスの移動などコマンドの実行可否に影響し得る操作を検知すると RequerySuggested を発火し、バインドされた各コマンドに再評価を促す。
一方、自作の RelayCommand はこの仕組みに乗っていないため、CanExecuteChanged を自分で発火する責務がある。
さらに、CommandManager が検知するのはフォーカス変更などの UI 操作に限られ、ViewModel のプロパティ変更のような UI 非依存の条件変化は検知しない点にも注意が必要である。


解決方法

CanExecuteChanged を発火する方式は 2 つある。

前者は「WPF の再評価サイクルに相乗りする」方式、後者は「必要なときだけ自分で再評価させる」方式である。


実装例

CommandManager.RequerySuggested へ委譲する

CanExecuteChangedadd / removeCommandManager.RequerySuggested へ転送する。
これにより、フォーカス移動などの UI 操作のたびに CommandManager が再評価を促し、ボタンの状態が追従する。

public event EventHandler? CanExecuteChanged
{
    add    => CommandManager.RequerySuggested += value;
    remove => CommandManager.RequerySuggested -= value;
}

UI 操作を伴わない条件変化(タイマー・非同期処理の完了など)では、次のように明示的に再評価を促す。
InvalidateRequerySuggestedRequerySuggested を発火し、これに接続されたコマンドソース(標準の RoutedCommand や委譲方式の RelayCommand を購読するボタン等)に CanExecute の再問い合わせを促す。

// 条件が変わったが UI 操作が伴わない場合に呼ぶ
CommandManager.InvalidateRequerySuggested();

この呼び出しは即座に評価するのではなく、RequerySuggested を発火して接続中のコマンドソースに CanExecute の再問い合わせを促す。
そのため、後述のとおり RequerySuggested に接続された各コマンドソースを再評価させるコストを伴う。

自前で CanExecuteChanged を発火する

冒頭の RelayCommandCanExecuteChanged を独自イベントに変え、再評価が必要な時点で発火するメソッドを追加する。

public class RelayCommand : ICommand
{
    private readonly Action _execute;
    private readonly Func<bool> _canExecute;

    public RelayCommand(Action execute, Func<bool> canExecute)
    {
        _execute = execute;
        _canExecute = canExecute;
    }

    public bool CanExecute(object? parameter) => _canExecute();
    public void Execute(object? parameter) => _execute();

    public event EventHandler? CanExecuteChanged;

    public void RaiseCanExecuteChanged()
        => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

ViewModel 側では SaveCommand を初期化し、CanExecute が参照するプロパティ(Name)を更新した時点で RaiseCanExecuteChanged を呼ぶ。
以下は保存ボタンの有効条件(Name の入力有無)が変わるたびに再評価させる、コンパイル可能な最小構成である。

public class SaveViewModel
{
    public RelayCommand SaveCommand { get; }

    public SaveViewModel()
    {
        // Name が空でなければ実行可能
        SaveCommand = new RelayCommand(Save, () => !string.IsNullOrEmpty(Name));
    }

    private string _name = string.Empty;
    public string Name
    {
        get => _name;
        set
        {
            if (_name == value) return;
            _name = value;
            // 入力状態が変わったので保存コマンドを再評価させる
            SaveCommand.RaiseCanExecuteChanged();
        }
    }

    private void Save() { /* 保存処理を実装する */ }
}

この方式では、再評価されるのは SaveCommand だけであり、発火のタイミングも明確である。
CommunityToolkit.MvvmRelayCommand はこの方式を採用しており、NotifyCanExecuteChanged() メソッドや [NotifyCanExecuteChangedFor] 属性で同等の発火を行う。


注意点


代替案・比較

方式 メリット デメリット 適するケース
RequerySuggested へ委譲 実装が少なく UI 操作に自動追従 RequerySuggested 接続分を広く再評価・発火契機が不透明・弱参照の考慮が要る 実行可否が主に UI 操作(フォーカス・選択)に連動する
自前で CanExecuteChanged を発火 対象コマンドのみ再評価・契機が明確 条件変化ごとに発火の記述が必要 ViewModel のプロパティ変化で可否が決まる
InvalidateRequerySuggested を都度呼ぶ 委譲方式のまま任意契機で再評価できる RequerySuggested 接続分の再評価コスト・呼び忘れ 委譲方式で UI 非依存の条件変化を反映したい

まとめ

ボタンの有効・無効が更新されないのは、CanExecute の結果ではなく CanExecuteChanged の発火漏れが原因である。
選択基準は次のとおりである。

いずれの発火も UI スレッドで行い、CanExecute は軽量に保つことが、応答性を損なわない前提となる。