しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

WPF で Data Binding と Command を使ったアプリケーションをシンプルに書きたかった話

数年前から WinQuickLook という Windows アプリケーションを趣味で開発しているのですが、内部実装をガラッと変えた新バージョンの開発進捗が著しく悪いことに悩んでいました。現在 V4 というソリューションで絶賛開発中となっていますが、リリース日は未定という状態です。

このアプリケーションの開発を加速させるために、ViewModel を用意するより簡単な方法を求めていました。

初期バージョンは Windows Shell 周りの実装に力を入れていたので、UI 周りは Window が 1 つでボタンが数個ある程度だったため、大体はコードビハインドを使って書いていたのですが、V4 を機に MVVM で作ろうとしたところ余りにも面倒すぎて止まっているのが現状です。

Window が 1 つのアプリで ViewModel を分離して、更に MVVM フレームワークの導入とかそっちの方が手間ですし、もっとシンプルに書く方法があっても良いはずです。最近は Web フロントエンドでは Vue.js を触っているので、WPF の XAML + コードビハインドは Vue.js の SFC に近いと考えていました。

要するにコードビハインドを VM として扱ってしまえば、諸々楽になるのではという発想です。XAML には x:Name を追加するとフィールドを自動生成する機能がありますが、それは絶対に使わないことで疑似的に XAML = View、コードビハインド = ViewModel としてしまいます。

結構昔から考えていましたが、どうにも対応する余裕が無かったのですが、年末年始でインプットの時間が確保できたので、今一度 Vue.js の SFC 周りのドキュメントを読み込んで WPF に生かせないか検討しました。

Vue.js 周りのドキュメントを読み込んだ結果、2 つのクラスを実装するといい感じに WPF でも近いことが実現出来そうだったので、このブログを書いているという経緯です。

MVVM の人から言わせると意味不明な実装だと思いますが、WPF は Window クラスに必要なプロパティやメソッドが多いので、View と ViewModel を完全に分離するのは余りにも厳しすぎますね。View に依存した処理を呼び出すのに Messaging を使うより、該当メソッドの呼び出しで終わらせたいこともあります。

とまあ、経緯はこのくらいにして実際にサンプルコードを紹介していきます。

Data Binding を追加する

まずはよくある TextBox に入力したら TextBlock に自動で反映されるサンプルを実装していきます。

通常の WPF であれば DependencyProperty を使うか、INotifyPropertyChanged を実装したクラスを用意するかのどちらかだと思いますが、参考にした Vue.js ではプリミティブ型であれば ref を使ってラップしてから扱うので、同じような動作をする Ref<T> クラスを用意しました。

public class Ref<T> : INotifyPropertyChanged
{
    public Ref(T? value = default)
    {
        Value = value;
    }

    private T? _value;

    public T? Value
    {
        get => _value;
        set => SetField(ref _value, value);
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    private void SetField(ref T? field, T? value, [CallerMemberName] string? propertyName = null)
    {
        if (!EqualityComparer<T>.Default.Equals(field, value))
        {
            field = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

この Ref<T> だけ見ると ReactiveProperty と同じように見えると思いますが、今回は Rx の要素を求めておらず変更通知だけが必要なのでシンプルな実装にしています。

次に Ref<T> 型のプロパティを MainWindow のコードビハインド内で以下のように追加します。

public partial class MainWindow : Window
{
    public MainWindow() => InitializeComponent();

    public Ref<string> Message { get; } = new();
}

最後に XAML を書いて UI 要素とバインディングの設定を追加します。Vue.js の場合は View 内では自動的にアンラップされるので .value は必要ないのですが、WPF の世界では必要になります。

ここでのポイントは WindowDataContext に対して自分自身のインスタンスを設定していることです。

<Window x:Class="WpfApp5.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApp5"
        mc:Ignorable="d"
        Title="MainWindow" Height="200" Width="400" DataContext="{Binding RelativeSource={RelativeSource Self}}">

    <StackPanel Margin="10" VerticalAlignment="Center">
        <TextBox Text="{Binding Message.Value, UpdateSourceTrigger=PropertyChanged}" Margin="5" />
        <TextBlock Text="{Binding Message.Value, StringFormat=Input: {0}}" Margin="5" />
    </StackPanel>

</Window>

XAML エディターはこの書き方でも IntelliSense が動作するので、意図された使い方なのかもしれません。

このコードを実行してみると、以下のように TextBox に入力した内容が、リアルタイムに TextBlock に反映されていることが分かります。コードビハインドにプロパティを追加しただけです。

ViewModel を別に用意はしていませんが、非常にシンプルなコードで同じような動作が実現出来ました。

Command を追加する

単に双方向バインディングするだけなら別途 ViewModel を作ってもあまり手間は変わらないかも知れませんが、通常の Windows アプリケーションには何らかのボタンが押された時の処理を実装する必要があります。

MVVM なら RelayCommand や DelegateCommand を実装して、Button クラスの Command パラメータにバインドさせると思いますが、そのための Command を別途定義するのにも嫌気が差していました。

XAML から直接メソッドをバインド出来れば、そのあたりの不満は綺麗に解消すると考えていたので、今回はマークアップ拡張を作成して XAML から直接メソッドを Command にバインド出来るようにします。

[MarkupExtensionReturnType(typeof(ICommand))]
public class InvokeExtension : MarkupExtension
{
    public InvokeExtension(string methodName)
    {
        ArgumentNullException.ThrowIfNull(methodName);

        _methodName = methodName;
    }

    private readonly string _methodName;

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        ArgumentNullException.ThrowIfNull(serviceProvider);

        if (serviceProvider.GetService(typeof(IProvideValueTarget)) is not IProvideValueTarget provideValueTarget)
        {
            throw new InvalidOperationException();
        }

        var targetObject = (FrameworkElement)provideValueTarget.TargetObject;

        var methodInfo = targetObject.DataContext.GetType().GetMethod(_methodName);

        if (methodInfo is null)
        {
            throw new InvalidOperationException();
        }

        var parameters = methodInfo.GetParameters();

        if (parameters.Length > 1)
        {
            throw new InvalidOperationException();
        }

        return new InvokeMethodCommand(targetObject.DataContext, methodInfo, parameters.Length == 0);
    }

    private class InvokeMethodCommand : ICommand
    {
        public InvokeMethodCommand(object obj, MethodInfo methodInfo, bool parameterless)
        {
            _obj = obj;
            _methodInfo = methodInfo;
            _parameterless = parameterless;
        }

        private readonly object _obj;
        private readonly MethodInfo _methodInfo;
        private readonly bool _parameterless;

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

        public void Execute(object? parameter) => _methodInfo.Invoke(_obj, _parameterless ? null : new[] { parameter });

        public event EventHandler? CanExecuteChanged;
    }
}

ここでは実装について深く説明しませんし、実証できれば良いぐらいのレベルでしか実装していませんので、とりあえずメソッド名を渡せば自動的に Command 向けに変換してくれると考えておけば問題ないです。

実装としては先ほどのサンプルにボタンを追加して、クリックされるとメッセージダイアログを出すという処理を組み込みたいと思います。と言ってもコードビハインドに 1 つメソッドを追加するだけです。

public partial class MainWindow : Window
{
    public MainWindow() => InitializeComponent();

    public Ref<string> Message { get; } = new();

    public void Submit(string message) => MessageBox.Show(message);
}

追加したメソッドは引数を 1 つ取るようになっていますが、ここには自動的に CommandParameter へバインドした値が渡されます。当然ながら直接 Message プロパティを参照しても同じ結果となりますが、あえて CommandParameter から取るようにしました。

XAML 側の修正は Button を追加して、その Command に対してマークアップ拡張で実装した Invoke を参照するように書いただけです。忘れずに CommandParameter には Message.Value をバインドします。

<Window x:Class="WpfApp5.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApp5"
        mc:Ignorable="d"
        Title="MainWindow" Height="200" Width="400" DataContext="{Binding RelativeSource={RelativeSource Self}}">

    <StackPanel Margin="10" VerticalAlignment="Center">
        <TextBox Text="{Binding Message.Value, UpdateSourceTrigger=PropertyChanged}" Margin="5" />
        <TextBlock Text="{Binding Message.Value, StringFormat=Input: {0}}" Margin="5" />
        <Button Content="Submit" Command="{local:Invoke Submit}" CommandParameter="{Binding Message.Value}" Margin="5" />
    </StackPanel>

</Window>

これまで XAML でメソッドを参照することが無かったからか、残念ながらメソッド名の IntelliSense を有効化する方法は存在しないようですが、こればっかりはどうしようもない部分ですね。

このコードを実行すると、以下のようにボタンを押すと現在の入力内容がダイアログで表示されます。

Command 周りをマークアップ拡張で隠蔽しているので、C# と XAML の両方ともシンプルに書けています。個人的にはこのくらいのコード量で書けるのが理想かなと考えています。

一部のフレームワークでは Source Generator である程度自動生成も出来るようですが、XAML から参照しないといけないものを Source Generator で作るのは名前が変わるので分かり難そうです。

おまけ:よくあるカウンターのサンプル

最後におまけとして Vue.js や WPF のサンプルコードによくある、ボタンで数字を増やすやつを紹介します。コードビハインドにはカウンター用のプロパティと増減用のメソッドを用意します。

public partial class CounterWindow : Window
{
    public CounterWindow() => InitializeComponent();

    public Ref<int> Counter { get; } = new(0);

    public void Increment() => Counter.Value++;

    public void Decrement() => Counter.Value--;
}

XAML 側ではカウンター表示用の TextBox と増減用の Button を横並びに配置して、それぞれでプロパティとメソッドのバインディングを追加しています。

<Window x:Class="WpfApp5.CounterWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApp5"
        mc:Ignorable="d"
        Title="CounterWindow" Height="150" Width="300" DataContext="{Binding RelativeSource={RelativeSource Self}}" FontSize="16">

    <StackPanel Orientation="Horizontal" VerticalAlignment="Center" HorizontalAlignment="Center">
        <Button Content="-" Command="{local:Invoke Decrement}" />
        <TextBox Width="150" Text="{Binding Counter.Value}" TextAlignment="Center" />
        <Button Content="+" Command="{local:Invoke Increment}" />
    </StackPanel>

</Window>

このコードを実行すると、それぞれのボタンでカウンターの増減が出来るようになっています。

多くの画面を持つような大規模な WPF アプリケーションでは MVVM とフレームワークは非常に有用ですが、1,2 画面しかないようなアプリケーションではこのくらいのバインディングで十分なのではないかと。

現在進行形で WinQuickLook に組み込んで更なる検証を続けていますが、今のところは大幅に開発効率の向上を実感しています。興味がある方は GitHub からコードを直接確認出来ますし、ある程度機能が揃ったタイミングでライブラリとして切り出そうかと思っています。