rksoftware

Visual Studio とか C# とかが好きです

まだまだ現役! Windows フォームアプリの新機能を確認する

※ この記事は以前に 7 記事の連続記事として書いたものを再構成して 1 記事にしたものです。

先日、.NET Blog に興味深い記事が公開されました。 devblogs.microsoft.com

最近あまり面白い話題が少ない感じでしたが興味深いのがついに来ました。みんな大好き Windows フォームアプリのお話です。
↑ のブログ記事。見ていきましょう!

※ 現在 WPF でなく WinForms を愛用している日本の IT 技術者が WinForms アプリを MVVM で作りたいと思っているかは別として。

■ 背景?

最初に WinForms でデータバインディングが新しいものではないと語っています。少なくとも日本では大人気の DataGrid と型付き DataSet もそうですし、私は知らなかったのですが VB6 の時代には既に一般的だったそうです。
しかし、まあ多くはイベントハンドラとコードビハインドでコードが書かれ、ロジックと UI 操作が混在した難解なコードが多く書かれていました。これでは、テストも困難ですしロジックを他のものに再利用することもできないことが言及されています。

■ INotifyPropertyChanged

WPF で MVVM が出てみな MVVM で書くようになったことになっています。実際位はそうはならなかったことを私たちは知っていますが、ポジション的にはそう言わざるを得ません。
で、記事では WPF の MVVM に触れ INotifyPropertyChanged の実装の実例コードが書かれています。このコード、日本語のぼろ具などで実際にはあまり見ることはないかもしれません。私も例としてこのようなコードを書くことはあまりしてきませんでした。
※コメントや改行、ネームスペースについては私の好みに書き換えました。

public class NotifyPropertyChangeDemo: System.ComponentModel.INotifyPropertyChanged
{
    public event PropertyChangedEventHandler ? PropertyChanged;

    private string ? _lastName;
    private string ? _firstName;

    public string ? LastName { get => _lastName; set { if (_lastName == value) { return; } _lastName = value; OnPropertyChanged(); } }
    public string ? FirstName { get => _firstName; set { if (_firstName == value) { return; } _firstName = value; OnPropertyChanged(); } }

    private void OnPropertyChanged([CallerMemberName] string propertyName = "") => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

INotifyPropertyChanged と言えば私は良く BindableBase クラスを作ってしまうやり方を書いていましたね。

public class BindableBase: System.ComponentModel.INotifyPropertyChanged
{
    public event PropertyChangedEventHandler ? PropertyChanged;

    protected bool SetProperty<T>(ref T property, T value, [CallerMemberName] string propertyName = "")
    {
        if(Object.Equals(property, value)) return false;
        property = value;
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        return true;
    }
}

public class NotifyPropertyChangeDemo: BindableBase
{
    private string ? _lastName;
    private string ? _firstName;

    public string ? LastName { get => _lastName; set => SetProperty(ref _lastName, value); }
    public string ? FirstName { get => _firstName; set => SetProperty(ref _firstName, value); }
}

最近書いていなかったから懐かしいです。

■ なぜ WinForms を使うのか

だいぶ見出しタイトルを解釈を大きくした意訳しましたが、WinForms を使う理由にも触れています。
新規でアプリを作るなら WinUI を使えばいいが、巨大な既存 WinForms アプリを捨てられないし巨大すぎて一気につくりかえることもできないでしょう? 改修しながら MVVM に書き換えていこう! そうしたら一部分の機能は MAUI でモバイルでも、という事もできるようになっていくのですよ、という事のようですね。

■ サンプルソリューション

問うわけでサンプルのコードが紹介されています。ViewModel をプロジェクトに切り出して、テストプロジェクトも作って、MAUI と WinForms で ViewModel を共有するサンプルですね。
github.com

ここまでは背景説明

ここまでは背景説明ですね。ここから .NET 7 で追加された WinForms のバインディング機能の紹介になっていくようです。長くなるのでまずはここまで。次回からゆっくりと .NET 7 でもまだまだ現役! WinForms の新機能!! を見ていきましょう。

■ .NET 7 での WinForms の Command バインドの新機能

WinForms のボタンとツールバーアイテムに ICommand をバインドするためのアップデートがあるそうです。
アップデートされた要素を一つずつ確認して行きたいのですが、その前に気になったところが。ICommand の実装例も掲載されています。この中に気になるコードがありました。

※コメントや改行は私の好みに書き換えています。

public class RelayCommand : ICommand
{
    public event EventHandler? CanExecuteChanged;

    private readonly Action _commandAction;
    private readonly Func<bool>? _canExecuteCommandAction;

    public RelayCommand(Action commandAction, Func<bool>? canExecuteCommandAction = null)
    {
        ArgumentNullException.ThrowIfNull(commandAction, nameof(commandAction));

        _commandAction = commandAction;
        _canExecuteCommandAction = canExecuteCommandAction;
    }

    bool ICommand.CanExecute(object? parameter) => _canExecuteCommandAction is null || _canExecuteCommandAction.Invoke();
    void ICommand.Execute(object? parameter) => _commandAction.Invoke();
    public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

この中のここです。

_canExecuteCommandAction is null || _canExecuteCommandAction.Invoke();

なるほど! そういうのもあるのか! という感じでした。普段何も考えずに

_canExecuteCommandAction?.Invoke() ?? true;

と書いていました。覚えておこう。

■ ButtonBase クラスの Comand プロパティ

ButtonBase クラスに Comand プロパティが追加されました。
ButtonBase は .NET Framework 1.1 からある由緒正しい老舗クラスですね。 learn.microsoft.com

Comand プロパティは .NET 7 から。 learn.microsoft.com

記事にも書かれていますが、ButtonBase の派生クラスである System.Windows.Forms.Button、System.Windows.Forms.CheckBox、System.Windows.Forms.RadioButton で使えるはずです。試してみましょう。

■ プレビュー機能

Command プロパティを使おうと思ったらエラーで怒られが発生しました、

エラー    CA2252  'Command' を使用するには、プレビュー機能を選択する必要があります。詳細については、「https://aka.ms/dotnet-warnings/preview-features」を参照してください。

対処として .csproj ファイルへ記述を追加します。

変更前

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net7.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <UseWindowsForms>true</UseWindowsForms>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

</Project>

追加する内容

<EnablePreviewFeatures>True</EnablePreviewFeatures>

変更後

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net7.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <UseWindowsForms>true</UseWindowsForms>
    <ImplicitUsings>enable</ImplicitUsings>
    <EnablePreviewFeatures>True</EnablePreviewFeatures>
  </PropertyGroup>

</Project>

■ ソースコード

namespace WinFormsApp1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            this.components = new System.ComponentModel.Container();
            InitializeComponent();

            var viewModel = new ViewModel();
            var bindingSource = new System.Windows.Forms.BindingSource(components);
            bindingSource.DataSource = viewModel;

            var button = new System.Windows.Forms.Button
            {
                AutoSize = true,
                Location = new System.Drawing.Point(16, 32),
                Size = new System.Drawing.Size(94, 29),
                Text = "button1",
                UseVisualStyleBackColor = true,
            };
            var checkBox = new System.Windows.Forms.CheckBox
            {
                AutoSize = true,
                Location = new System.Drawing.Point(16, 76),
                Size = new System.Drawing.Size(101, 24),
                Text = "checkBox1",
                UseVisualStyleBackColor = true,
            };
            var radioButton = new System.Windows.Forms.RadioButton
            {
                AutoSize = true,
                Location = new System.Drawing.Point(16, 108),
                Size = new System.Drawing.Size(117, 24),
                TabStop = true,
                Text = "radioButton1",
                UseVisualStyleBackColor = true,
            };
            var enabledChange = new System.Windows.Forms.Button
            {
                AutoSize = true,
                Location = new System.Drawing.Point(16, 140),
                Size = new System.Drawing.Size(94, 29),
                Text = "EnabledChange",
                UseVisualStyleBackColor = true,
            };
            var label = new System.Windows.Forms.Label
            {
                AutoSize = true,
                Location = new System.Drawing.Point(20, 172),
                Size = new System.Drawing.Size(50, 20),
                Text = "label1",
            };
            button.DataBindings.Add(new System.Windows.Forms.Binding("Command", bindingSource, "ButtonCommand", true));
            checkBox.DataBindings.Add(new System.Windows.Forms.Binding("Command", bindingSource, "CheckBoxCommand", true));
            radioButton.DataBindings.Add(new System.Windows.Forms.Binding("Command", bindingSource, "RadioButtonCommand", true));
            enabledChange.DataBindings.Add(new System.Windows.Forms.Binding("Command", bindingSource, "EnabledChangeCommand", true));
            label.DataBindings.Add(new System.Windows.Forms.Binding("Text", bindingSource, "Text", true));
            this.Controls.Add(button);
            this.Controls.Add(checkBox);
            this.Controls.Add(radioButton);
            this.Controls.Add(enabledChange);
            this.Controls.Add(label);
        }
    }
}
public class BindableBase : System.ComponentModel.INotifyPropertyChanged
{
    public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;

    protected bool SetProperty<T>(ref T property, T value, [System.Runtime.CompilerServices.CallerMemberName] string propertyName = "")
    {
        if (Object.Equals(property, value)) return false;
        property = value;
        PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));
        return true;
    }
}
class Command : System.Windows.Input.ICommand
{
    public event EventHandler? CanExecuteChanged;

    private readonly Action _commandAction;
    private readonly Func<bool>? _canExecuteCommandAction;

    public Command(Action commandAction, Func<bool>? canExecuteCommandAction = null)
    {
        _commandAction = commandAction;
        _canExecuteCommandAction = canExecuteCommandAction;
    }

    public bool CanExecute(object? parameter) => _canExecuteCommandAction?.Invoke() ?? true;
    public void Execute(object? parameter) => _commandAction.Invoke();
    public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
public class ViewModel : BindableBase
{
    bool _enabled = true;
    private string _text;
    public string Text { get => _text; private set => SetProperty(ref _text, value); }
    public System.Windows.Input.ICommand ButtonCommand { get; init; }
    public System.Windows.Input.ICommand CheckBoxCommand { get; init; }
    public System.Windows.Input.ICommand RadioButtonCommand { get; init; }
    public System.Windows.Input.ICommand EnabledChangeCommand { get; init; }

    public ViewModel()
    {
        ButtonCommand = new Command((() => Text += $"Button clicked\n"), canExecute);
        CheckBoxCommand = new Command((() => Text += $"CheckBox clicked\n"), canExecute);
        RadioButtonCommand = new Command((() => Text += $"RadioButton clicked\n"), canExecute);
        EnabledChangeCommand = new Command(enabledChange, null);

        bool canExecute() => _enabled;
        void enabledChange()
        {
            _enabled = !_enabled;
            Text += $"EnabledChangeButton clicked\n";
            ((Command)ButtonCommand).NotifyCanExecuteChanged();
            ((Command)CheckBoxCommand).NotifyCanExecuteChanged();
            ((Command)RadioButtonCommand).NotifyCanExecuteChanged();
        };
    }
}

実行結果

上から順にボタンをクリックしていきました。計算通り、バインディングされています。

■ コードの要点

↑ のコードでバインディングしているところは

var viewModel = new ViewModel();
var bindingSource = new System.Windows.Forms.BindingSource(components);
bindingSource.DataSource = viewModel;

button.DataBindings.Add(new System.Windows.Forms.Binding("Command", bindingSource, "ButtonCommand", true));
checkBox.DataBindings.Add(new System.Windows.Forms.Binding("Command", bindingSource, "CheckBoxCommand", true));
radioButton.DataBindings.Add(new System.Windows.Forms.Binding("Command", bindingSource, "RadioButtonCommand", true));
enabledChange.DataBindings.Add(new System.Windows.Forms.Binding("Command", bindingSource, "EnabledChangeCommand", true));
label.DataBindings.Add(new System.Windows.Forms.Binding("Text", bindingSource, "Text", true));

です。

■ BindableComponent クラス

BindableComponent クラスが追加されたとのことです。
確かに、バージョン 7 からとなっています。
learn.microsoft.com

で、このクラスは何者でしょう?

プロパティとして

  • BindingContext
  • DataBindings

を持っているので、このためのクラスなのでしょう。
そして、この BindableComponent クラス。 learn.microsoft.com そしてさらにそこから派生するのが、

  • System.Windows.Forms.ToolStripButton
  • System.Windows.Forms.ToolStripControlHost
  • System.Windows.Forms.ToolStripDropDownItem
  • System.Windows.Forms.ToolStripLabel
  • System.Windows.Forms.ToolStripSeparator

さらに先まで派生をみると

  • System.Windows.Forms.ToolStripComboBox
  • System.Windows.Forms.ToolStripProgressBar
  • System.Windows.Forms.ToolStripTextBox
  • System.Windows.Forms.ToolStripDropDownButton
  • System.Windows.Forms.ToolStripMenuItem
  • System.Windows.Forms.ToolStripSplitButton
  • System.Windows.Forms.ToolStripStatusLabel

こららの画面コントロールがバインディングに対応したという事なのでしょう。つまりツールバーメニューに Command をバインドできるという事ですね。
ちょっと、コントロールが多いので代表して ToolStripButton を試してみます。

やってみましょう。

■ 検証コード

public class BindableBase : System.ComponentModel.INotifyPropertyChanged
{
    public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;

    protected bool SetProperty<T>(ref T property, T value, [System.Runtime.CompilerServices.CallerMemberName] string propertyName = "")
    {
        if (Object.Equals(property, value)) return false;
        property = value;
        PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));
        return true;
    }
}

class Command : System.Windows.Input.ICommand
{
    public event EventHandler? CanExecuteChanged;

    private readonly Action _commandAction;
    private readonly Func<bool>? _canExecuteCommandAction;

    public Command(Action commandAction, Func<bool>? canExecuteCommandAction = null)
    {
        _commandAction = commandAction;
        _canExecuteCommandAction = canExecuteCommandAction;
    }

    public bool CanExecute(object? parameter) => _canExecuteCommandAction?.Invoke() ?? true;
    public void Execute(object? parameter) => _commandAction.Invoke();
    public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

public class ViewModel : BindableBase
{
    bool _enabled = true;
    private string _text;
    public string Text { get => _text; private set => SetProperty(ref _text, value); }
    public System.Windows.Input.ICommand ButtonCommand { get; init; }

    public ViewModel()
    {
        ButtonCommand = new Command((() => Text += $"Button clicked\n"), canExecute);

        bool canExecute() => _enabled;
    }
}
namespace WinFormsApp1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            this.components = new System.ComponentModel.Container();
            InitializeComponent();

            var viewModel = new ViewModel();
            var bindingSource = new System.Windows.Forms.BindingSource(components);
            bindingSource.DataSource = viewModel;

            var bitmap = new System.Drawing.Bitmap(24, 24);
            System.Drawing.Graphics.FromImage(bitmap).FillRectangle(System.Drawing.Brushes.Aqua, new System.Drawing.Rectangle(0, 0, 24, 24));
            var toolStripButton1 = new System.Windows.Forms.ToolStripButton
            {
                Image = bitmap,
                Size = new System.Drawing.Size(29, 24),
            };
            var toolStrip1 = new System.Windows.Forms.ToolStrip
            {
                ImageScalingSize = new System.Drawing.Size(20, 20),
                Location = new System.Drawing.Point(0, 0),
                Size = new System.Drawing.Size(800, 27)
            };
            var label = new System.Windows.Forms.Label
            {
                AutoSize = true,
                Location = new System.Drawing.Point(20, 172),
                Size = new System.Drawing.Size(50, 20),
                Text = "label1",
            };

            toolStripButton1.DataBindings.Add(new System.Windows.Forms.Binding("Command", bindingSource, "ButtonCommand", true));
            label.DataBindings.Add(new System.Windows.Forms.Binding("Text", bindingSource, "Text", true));

            toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { toolStripButton1 });
            this.Controls.Add(toolStrip1);
            this.Controls.Add(label);
        }

    }
}

実行結果

計算通り! 画面上部のツールバーのボタン (aqua 色の四角) をクリックするとテキストが更新されました。

■ コードの要点

バインディングしているところ実際前回と同じなので今回少し手こずったところ。

var bitmap = new System.Drawing.Bitmap(24, 24);
System.Drawing.Graphics.FromImage(bitmap).FillRectangle(System.Drawing.Brushes.Aqua, new System.Drawing.Rectangle(0, 0, 24, 24));
var toolStripButton1 = new System.Windows.Forms.ToolStripButton
{
    Image = bitmap,
    Size = new System.Drawing.Size(29, 24),
};

ToolStripButton は見た目として画像を設定するコントロールでした。検証コードでプロジェクトに画像を追加して、というのはやりたくなかったので古式ゆかしい GDI+ です。GDI+、太古の技術すぎてコードを忘れていました。しかし大事なことです。GDI+ なしには WinForms は語れません。思い出せてよかったです。

■ CommandParameter プロパティ

ButtonBase クラスと ToolStripItem クラスに CommandParameter プロパティが追加されたとのことです。
ButtonBase クラス、確かに、バージョン 7 からとなっています。
learn.microsoft.com

ToolStrigItem クラスには見当たりません......。 learn.microsoft.com しかし Visual Studio で見ると確かにいますね。

試してみましょう。

■ 検証コード

public class BindableBase : System.ComponentModel.INotifyPropertyChanged
{
    public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;

    protected bool SetProperty<T>(ref T property, T value, [System.Runtime.CompilerServices.CallerMemberName] string propertyName = "")
    {
        if (Object.Equals(property, value)) return false;
        property = value;
        PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));
        return true;
    }
}

class Command : System.Windows.Input.ICommand
{
    public event EventHandler? CanExecuteChanged;

    private readonly Action<object?> _commandAction;
    private readonly Func<bool>? _canExecuteCommandAction;

    public Command(Action<object?> commandAction, Func<bool>? canExecuteCommandAction = null)
    {
        _commandAction = commandAction;
        _canExecuteCommandAction = canExecuteCommandAction;
    }

    public bool CanExecute(object? parameter) => _canExecuteCommandAction?.Invoke() ?? true;
    public void Execute(object? parameter) => _commandAction.Invoke(parameter);
    public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

public class ViewModel : BindableBase
{
    bool _enabled = true;
    private string _text;
    public string Text { get => _text; private set => SetProperty(ref _text, value); }
    public System.Windows.Input.ICommand ButtonCommand { get; init; }

    public ViewModel()
    {
        ButtonCommand = new Command(((parameter) => Text += $"Button clicked: {parameter?.ToString()}\n"), canExecute);

        bool canExecute() => _enabled;
    }
}
using System.Windows.Forms;

namespace WinFormsApp1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            this.components = new System.ComponentModel.Container();
            InitializeComponent();

            var viewModel = new ViewModel();
            var bindingSource = new System.Windows.Forms.BindingSource(components);
            bindingSource.DataSource = viewModel;

            var button = new System.Windows.Forms.Button
            {
                AutoSize = true,
                Location = new System.Drawing.Point(16, 32),
                Size = new System.Drawing.Size(94, 29),
                Text = "button1",
                UseVisualStyleBackColor = true,
            };
            var bitmap = new System.Drawing.Bitmap(24, 24);
            System.Drawing.Graphics.FromImage(bitmap).FillRectangle(System.Drawing.Brushes.Aqua, new System.Drawing.Rectangle(0, 0, 24, 24));
            var toolStripButton = new System.Windows.Forms.ToolStripButton
            {
                Image = bitmap,
                Size = new System.Drawing.Size(29, 24),
            };
            var toolStrip = new System.Windows.Forms.ToolStrip
            {
                ImageScalingSize = new System.Drawing.Size(20, 20),
                Location = new System.Drawing.Point(0, 0),
                Size = new System.Drawing.Size(800, 27)
            };
            var label = new System.Windows.Forms.Label
            {
                AutoSize = true,
                Location = new System.Drawing.Point(20, 172),
                Size = new System.Drawing.Size(50, 20),
                Text = "label1",
            };
            button.DataBindings.Add(new System.Windows.Forms.Binding("Command", bindingSource, "ButtonCommand", true));
            toolStripButton.DataBindings.Add(new System.Windows.Forms.Binding("Command", bindingSource, "ButtonCommand", true));
            label.DataBindings.Add(new System.Windows.Forms.Binding("Text", bindingSource, "Text", true));

            button.CommandParameter = "saitama is best!";
            toolStripButton.CommandParameter = "gunma is nice!";

            this.Controls.Add(button);
            toolStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { toolStripButton });
            this.Controls.Add(toolStrip);
            this.Controls.Add(label);
        }

    }
}

実行結果

計算通り! 画面上部の普通のボタンとツールバーのボタン (aqua 色の四角) をクリックすると CommandParameter の内容を含んでテキストが更新されました。

■ コードの要点

フォームのコントロールを作っているところで普通に文字列を設定しています。Command の Execute の引数に確かに渡されています。

button.CommandParameter = "saitama is best!";
toolStripButton.CommandParameter = "gunma is nice!";

■ CommandParameter へのバインディング

CommandParameter へのバインディングもできます。

button.DataBindings.Add(new System.Windows.Forms.Binding("CommandParameter", bindingSource, "Text", true));
toolStripButton.DataBindings.Add(new System.Windows.Forms.Binding("CommandParameter", bindingSource, "Text", true));


ボタンを押すたびに CommandParameter の値が追記される ViewModel の Text プロパティをバインドしてみたので、ボタンを押すたびに加速しながら文字列が増えていくようになりました。

■ OnRequestCommandExecute メソッド

ButtonBase クラスと ToolStripItem クラスに OnRequestCommandExecute メソッドが追加されたとのことです。
ButtonBase クラス、確かに、バージョン 7 からとなっています。
learn.microsoft.com

ToolStrigItem クラスには見当たりません......。 learn.microsoft.com しかし Visual Studio で見ると確かにいますね。

このメソッドを override することで、Command の呼び出しを制御できるようですね。 試してみましょう。

■ 検証コード

public class BindableBase : System.ComponentModel.INotifyPropertyChanged
{
    public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;

    protected bool SetProperty<T>(ref T property, T value, [System.Runtime.CompilerServices.CallerMemberName] string propertyName = "")
    {
        if (Object.Equals(property, value)) return false;
        property = value;
        PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));
        return true;
    }
}

class Command : System.Windows.Input.ICommand
{
    public event EventHandler? CanExecuteChanged;

    private readonly Action<object?> _commandAction;
    private readonly Func<bool>? _canExecuteCommandAction;

    public Command(Action<object?> commandAction, Func<bool>? canExecuteCommandAction = null)
    {
        _commandAction = commandAction;
        _canExecuteCommandAction = canExecuteCommandAction;
    }

    public bool CanExecute(object? parameter) => _canExecuteCommandAction?.Invoke() ?? true;
    public void Execute(object? parameter) => _commandAction.Invoke(parameter);
    public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

public class ViewModel : BindableBase
{
    bool _enabled = true;
    private string _text;
    public string Text { get => _text; private set => SetProperty(ref _text, value); }
    public System.Windows.Input.ICommand ButtonCommand { get; init; }

    public ViewModel()
    {
        ButtonCommand = new Command(((parameter) => Text += $"Button clicked: {parameter?.ToString()}\n"), canExecute);

        bool canExecute() => _enabled;
    }
}
namespace WinFormsApp1
{
    public partial class Form1 : Form
    {
        class MyButton : System.Windows.Forms.Button
        {
            public bool CanRequestExecute { get; set; } = true;
            protected override void OnRequestCommandExecute(EventArgs e) { if (CanRequestExecute) base.OnRequestCommandExecute(e); }
        }

        public Form1()
        {
            this.components = new System.ComponentModel.Container();
            InitializeComponent();

            var viewModel = new ViewModel();
            var bindingSource = new System.Windows.Forms.BindingSource(components);
            bindingSource.DataSource = viewModel;

            var button = new MyButton
            {
                AutoSize = true,
                Location = new System.Drawing.Point(16, 32),
                Size = new System.Drawing.Size(94, 29),
                Text = "button1",
                UseVisualStyleBackColor = true,
            };
            var checkBox = new System.Windows.Forms.CheckBox
            {
                AutoSize = true,
                Location = new System.Drawing.Point(16, 76),
                Size = new System.Drawing.Size(101, 24),
                Text = "checkBox1",
                UseVisualStyleBackColor = true,
                Checked = true,
            };
            var label = new System.Windows.Forms.Label
            {
                AutoSize = true,
                Location = new System.Drawing.Point(20, 172),
                Size = new System.Drawing.Size(50, 20),
                Text = "label1",
            };
            button.DataBindings.Add(new System.Windows.Forms.Binding("Command", bindingSource, "ButtonCommand", true));
            label.DataBindings.Add(new System.Windows.Forms.Binding("Text", bindingSource, "Text", true));
            this.Controls.Add(button);
            this.Controls.Add(checkBox);
            this.Controls.Add(label);

            checkBox.CheckedChanged += (object? sender, EventArgs e) => button.CanRequestExecute = ((CheckBox?)sender)?.Checked ?? true;
        }
    }
}

実行結果

計算通り! CheckBox の ON/OFF を切り替えるとボタンの Command が実行されたりされなかったりします。画像ではわからないですけど。

■ コードの要点

ボタンの派生クラスを作って OnRequestCommandExecute メソッドを override しています。

class MyButton : System.Windows.Forms.Button
{
    public bool CanRequestExecute { get; set; } = true;
    protected override void OnRequestCommandExecute(EventArgs e) { if (CanRequestExecute) base.OnRequestCommandExecute(e); }
}

■ DataContext プロパティ

Control クラスに DataContext プロパティと DataContextChange イベントが追加されたとのことです。
確かに、バージョン 7 からとなっています。
learn.microsoft.com learn.microsoft.com

このプロパティ、バインディングには影響がないそうですが、コントロールの親から子へと伝播して親子で同じ値になってくれるとのことです。その際、イベントが親子すべてで発生すると。

やってみましょう。

■ 検証コード

namespace WinFormsApp1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            this.components = new System.ComponentModel.Container();
            InitializeComponent();

            var button = new System.Windows.Forms.Button
            {
                AutoSize = true,
                Location = new System.Drawing.Point(16, 32),
                Size = new System.Drawing.Size(94, 29),
                Text = "button1",
                UseVisualStyleBackColor = true,
            };
            var label = new System.Windows.Forms.Label
            {
                AutoSize = true,
                Location = new System.Drawing.Point(20, 172),
                Size = new System.Drawing.Size(50, 20),
                Text = "label1",
            };
            this.Controls.Add(button);
            this.Controls.Add(label);

            button.Click += (object? sender, EventArgs e) => this.DataContext = new object();

            this.DataContextChanged += (object? sender, EventArgs e) =>label.Text += "Form::DataContextChanged\n";
            button.DataContextChanged += (object? sender, EventArgs e) => label.Text += "Button::DataContextChanged\n";
            label.DataContextChanged += (object? sender, EventArgs e) => label.Text += "Label::DataContextChanged\n";
        }
    }
}

実行結果

計算通り! ボタンを押すたびに、各コントロールの DataContextChanged が呼ばれています。

■ DataContext の変更に応じてバインディングする ViewModel を変える

こんな感じでしょうか?

public class BindableBase : System.ComponentModel.INotifyPropertyChanged
{
    public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;

    protected bool SetProperty<T>(ref T property, T value, [System.Runtime.CompilerServices.CallerMemberName] string propertyName = "")
    {
        if (Object.Equals(property, value)) return false;
        property = value;
        PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));
        return true;
    }
}

class Command : System.Windows.Input.ICommand
{
    public event EventHandler? CanExecuteChanged;

    private readonly Action<object?> _commandAction;
    private readonly Func<bool>? _canExecuteCommandAction;

    public Command(Action<object?> commandAction, Func<bool>? canExecuteCommandAction = null)
    {
        _commandAction = commandAction;
        _canExecuteCommandAction = canExecuteCommandAction;
    }

    public bool CanExecute(object? parameter) => _canExecuteCommandAction?.Invoke() ?? true;
    public void Execute(object? parameter) => _commandAction.Invoke(parameter);
    public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

public class ViewModel1 : BindableBase
{
    private string _text;
    public string Text { get => _text; private set => SetProperty(ref _text, value); }
    public System.Windows.Input.ICommand ButtonCommand { get; init; }

    public ViewModel1()
    {
        ButtonCommand = new Command(((parameter) => Text += $"1:Button clicked: {parameter?.ToString()}\n"));
    }
}

public class ViewModel2 : BindableBase
{
    private string _text;
    public string Text { get => _text; private set => SetProperty(ref _text, value); }
    public System.Windows.Input.ICommand ButtonCommand { get; init; }

    public ViewModel2()
    {
        ButtonCommand = new Command(((parameter) => Text += $"2:Button clicked: {parameter?.ToString()}\n"));
    }
}
namespace WinFormsApp1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            this.components = new System.ComponentModel.Container();
            InitializeComponent();

            var viewModel1 = new ViewModel1();
            var viewModel2 = new ViewModel2();
            var bindingSource = new System.Windows.Forms.BindingSource(components);
            bindingSource.DataSource = viewModel1;

            var button = new Button
            {
                AutoSize = true,
                Location = new System.Drawing.Point(16, 32),
                Size = new System.Drawing.Size(94, 29),
                Text = "button1",
                UseVisualStyleBackColor = true,
            };
            var button2 = new Button
            {
                AutoSize = true,
                Location = new System.Drawing.Point(16, 64),
                Size = new System.Drawing.Size(94, 29),
                Text = "button2",
                UseVisualStyleBackColor = true,
            };
            var label = new System.Windows.Forms.Label
            {
                AutoSize = true,
                Location = new System.Drawing.Point(20, 172),
                Size = new System.Drawing.Size(50, 20),
                Text = "label1",
            };
            button.DataBindings.Add(new System.Windows.Forms.Binding("Command", bindingSource, "ButtonCommand", true));
            label.DataBindings.Add(new System.Windows.Forms.Binding("Text", bindingSource, "Text", true));
            this.Controls.Add(button);
            this.Controls.Add(button2);
            this.Controls.Add(label);

            button2.Click += (object? sender, EventArgs e) => this.DataContext = this.DataContext == viewModel2 ? viewModel1 : viewModel2;
            this.DataContextChanged += (object? sender, EventArgs e) => bindingSource.DataSource = ((System.Windows.Forms.Control?)sender)?.DataContext;
        }
    }
}

実行結果

button2 をクリックするたびに ViewModel が切り替わります。

■ コードの要点

この部分ですね。

button2.Click += (object? sender, EventArgs e) => this.DataContext = this.DataContext == viewModel2 ? viewModel1 : viewModel2;
this.DataContextChanged += (object? sender, EventArgs e) => bindingSource.DataSource = ((System.Windows.Forms.Control?)sender)?.DataContext;

しかしこのコード、DataContext プロパティの伝播を使っていません。BindingSource の DataSource プロパティが実質 DataContext ですし。
子コントロールにごっつい自作クラスにして、BindingSource を独自に持つ形なら活かせますかね? 今後に期待、なのでしょうか。

ここでついにデザイナ

次回は皆さんお待ちかね! UI デザイナを見ていきましょう。WinForms と言えば UI デザイナ! UI デザイナあってこその WinForms! UI デザイナなしに WinForms のコードを書く人はおそらく正直、変態と言われても仕方がないレベルだと思います。
UI デザイナ。期待しましょう!

■ デザイナでバインディング設定

ついにこの時が来ました。デザイナでバインディングを設定していきましょう!
デザイナがなければ WinForms とは言えません。デザイナこそが WinForms かもしれません。
※ 私の想像の中の WinFormser の価値観です。

やってみましょう。

■ ViewModel のコード

こんな ViewModel でやっていきます。BindableBase クラス、Command クラスは省略します。以前の記事を見てください。

public class ViewModel2 : BindableBase
{
    private string _text;
    public string Text { get => _text; private set => SetProperty(ref _text, value); }
    public System.Windows.Input.ICommand ButtonCommand { get; init; }

    public ViewModel2()
    {
        ButtonCommand = new Command(((parameter) => Text += $"2:Button clicked: {parameter?.ToString()}\n"));
    }
}

■ デザイナでデータソースを追加

まずは、ボタンの Command プロパティへバインディングしようとします。
最初は選択肢が出てこないので、 [ オブジェクト データソースを追加します ] をクリック。

クラスの一覧が出てくるので、ViewModel のクラスを選択します。ここには public なクラスが並ぶようです。

選択して [ OK ] で何か生まれました。

<?xml version="1.0" encoding="utf-8"?>
<!--
    This file is automatically generated by Visual Studio. It is 
    used to store generic object data source configuration information.  
    Renaming the file extension or editing the content of this file may   
    cause the file to be unrecognizable by the program.
-->
<GenericObjectDataSource DisplayName="ViewModel2" Version="1.0" xmlns="urn:schemas-microsoft-com:xml-msdatasource">
  <TypeInfo>ViewModel2, WinFormsApp1, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null</TypeInfo>
</GenericObjectDataSource>

■ いよいよバインディング


変わりません!


別のプロパティでは出てくるので先の手順はあっていそうです。とりあえず困ったときにすること、そうリビルドです。リビルドしてみます。


でてきました。設定しています。

続けて、Text もバインドします。

■ CommandParameter

ボタンの CommandParameter にはリテラル値は設定できないようです。怒られが発生しました。

バインドにしてみます。

設定できました。

■ 実行

実行します。

ボタンを押しても何も起きません!

実は、一行だけコードを書く必要があるようです。

public partial class Form2 : Form
{
    public Form2()
    {
        InitializeComponent();
        viewModel2BindingSource.DataSource = new ViewModel2();
    }
}

この部分を追加しました。なるほど。

viewModel2BindingSource.DataSource = new ViewModel2();

確かに、実際のインスタンスの設定がデザイナ上でできているのか不安に思ったのですが、できないようですね。

■ もう一度、実行


計画通り! バインディングできました。

■ 生まれたコード

コードはこんな感じになっていました。

namespace WinFormsApp1
{
    partial class Form2
    {
        /// <summary>
        /// Required designer variable.
        /// </summary>
        private System.ComponentModel.IContainer components = null;

        /// <summary>
        /// Clean up any resources being used.
        /// </summary>
        /// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
        protected override void Dispose(bool disposing)
        {
            if (disposing && (components != null))
            {
                components.Dispose();
            }
            base.Dispose(disposing);
        }

        #region Windows Form Designer generated code

        /// <summary>
        /// Required method for Designer support - do not modify
        /// the contents of this method with the code editor.
        /// </summary>
        private void InitializeComponent()
        {
            this.components = new System.ComponentModel.Container();
            this.button1 = new System.Windows.Forms.Button();
            this.viewModel2BindingSource = new System.Windows.Forms.BindingSource(this.components);
            this.label1 = new System.Windows.Forms.Label();
            ((System.ComponentModel.ISupportInitialize)(this.viewModel2BindingSource)).BeginInit();
            this.SuspendLayout();
            // 
            // button1
            // 
            this.button1.DataBindings.Add(new System.Windows.Forms.Binding("Command", this.viewModel2BindingSource, "ButtonCommand", true));
            this.button1.DataBindings.Add(new System.Windows.Forms.Binding("CommandParameter", this.viewModel2BindingSource, "Text", true));
            this.button1.Location = new System.Drawing.Point(9, 14);
            this.button1.Name = "button1";
            this.button1.Size = new System.Drawing.Size(94, 29);
            this.button1.TabIndex = 0;
            this.button1.Text = "button1";
            this.button1.UseVisualStyleBackColor = true;
            // 
            // viewModel2BindingSource
            // 
            this.viewModel2BindingSource.DataSource = typeof(ViewModel2);
            // 
            // label1
            // 
            this.label1.AutoSize = true;
            this.label1.DataBindings.Add(new System.Windows.Forms.Binding("Text", this.viewModel2BindingSource, "Text", true));
            this.label1.Location = new System.Drawing.Point(16, 60);
            this.label1.Name = "label1";
            this.label1.Size = new System.Drawing.Size(50, 20);
            this.label1.TabIndex = 1;
            this.label1.Text = "label1";
            // 
            // Form2
            // 
            this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 20F);
            this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
            this.ClientSize = new System.Drawing.Size(800, 450);
            this.Controls.Add(this.label1);
            this.Controls.Add(this.button1);
            this.Name = "Form2";
            this.Text = "Form2";
            ((System.ComponentModel.ISupportInitialize)(this.viewModel2BindingSource)).EndInit();
            this.ResumeLayout(false);
            this.PerformLayout();

        }

        #endregion

        private Button button1;
        private Label label1;
        private BindingSource viewModel2BindingSource;
    }
}

■ コードの要点

この部分ですね。

this.components = new System.ComponentModel.Container();

this.viewModel2BindingSource = new System.Windows.Forms.BindingSource(this.components);
this.button1.DataBindings.Add(new System.Windows.Forms.Binding("Command", this.viewModel2BindingSource, "ButtonCommand", true));
this.button1.DataBindings.Add(new System.Windows.Forms.Binding("CommandParameter", this.viewModel2BindingSource, "Text", true));
this.label1.DataBindings.Add(new System.Windows.Forms.Binding("Text", this.viewModel2BindingSource, "Text", true));
private BindingSource viewModel2BindingSource;

■ 気になる点

this.viewModel2BindingSource.DataSource = typeof(ViewModel2);

このコードはちょっと気になりますね。このコードあるのに、手で追加したコードは必要なのか、と。

Windows フォームアプリでも MVVM

これからは Windows フォームアプリでも心置きなく MVVM が使える時代です!
日本 1 億 2 千万人の WinFormser の皆さん! 安心してください。これからはなんでも全部 MVVM にしていきましょう!