◆ INotifyPropertyChanged, INotifyDataErrorInfo を自動でやってくれる
◆ checkValidity メソッドを override してエラーチェック
◆ プロパティ定義を簡単に
◆ まとめてエラーチェックする対象を 属性で指定できる
◆ メソッドを自動で Command に設定してくれる

ViewModel に当たる部分ですね

正確に ViewModel というとあれしないとだめ これしてはいけないとか 面倒な制限がいろいろあるのであんまり ViewModel といいたくないところですが 言いやすい言葉がないのでここでは VM (ViewModel) と DataContext に設定するクラスのことを呼びます

私は 別に ViewModel で View を見ようが MessageBox 出そうがいいと思うのですけどねー
考え方の一つなわけですし 作る人がやりやすいように作るのが一番です
一般的にこれやったらダメだから といってやれば簡単にできることをあえてしないで遠回りな事するほうがムダに思えます

一般的に言われてることがホントに正しければ自分で何度も改良しながら作っていくうちに同じものになるはずです
ならないなら正しくないか自分には合わないものなのですよ

とまぁ 私はフレームワークはこうあるべきだーとか○○パターンとかいうのばかり言ってるのはキライです ということで本題に戻します

変更通知

WPF の VM は Binding したデータを入れておくもので VM の中だけを書き換えて Binding によって View (見た目) を更新させるものです
Binding はソースとターゲットがあって ElementName とか RelativeSource で設定しない限り DataContext (VM) がソースになります
TextBox などのコントロールのプロパティがターゲットです

ターゲットは DependencyProperty というもので書き換えると設定に応じて自動で通知してくれるのでプロパティの値が変わったタイミングや TextBox のフォーカスが外れたときなどに VM の値が更新されます

ですが ソースの方は VM のプロパティを書き換えてもそれだけだとターゲットに通知されないので画面表示は変化なしです
通知するには VM に INotifyPropertyChanged インターフェースを継承して実装します

そこまで書くことは多くないのですが毎回となると少し面倒です

エラー通知

また 入力のエラーチェックもできます

WPF だとエラーチェックはすべて Binding を通して Binding の機能でエラーチェックするようです

VM をほとんど使わないものだと ValidationRules を設定する方法が XAML にルールも書けて見やすいと思います

単純に例外があるとエラーにしたいだけなら ValidatesOnExceptions を true にしておきます

VM を使ってやるなら VM クラスに INotifyDataErrorInfo インターフェースを継承して実装します
これの通知をエラー扱いにするには ValidatesOnNotifyDataErrors を true にするのですが デフォルトで true なので Binding オプションは特に何もしないで VM 側で実装して通知するだけでできます

昔のもので ValidatesOnDataErrors を true にして VM 側では IDataErrorInfo インターフェースを継承して実装する方法もあります
機能少なめで今後こっちを使う必要はないと思うので 忘れてもいいと思います
メリットは機能少ない分実装の手間も少ないくらい?


とこんな種類があるのですが 一番扱いやすいのは VM で INotifyDataErrorInfo を実装する方法です
VM 側でチェック処理をするので他のプロパティを見て 場合によってルールを変えたりもできますし 自由度が高いです
XAML にルール書くのだと BindingGroup とか作らないとまとめてチェックできなかったり何かと不便も多かった気がします


ただ 便利ではあるのですがプロパティ変更を通知するだけの INotifyPropertyChanged と比べて書く量が多いです
毎回書くなら少しとはいえない程度に面倒です

プロパティ定義

また プロパティを定義するときも set のタイミングで通知するのでひとつひとつが長くなります
setter を自分で定義するので自動プロパティも使えませんし 書くだけじゃなくて見るときも見づらくなります
private string _str = "";

public string str
{
    get
    {
        return this._str;
    }
    set
    {
        if(this._str == value)
        {
            return;
        }

        this._str = value;
        // 変更通知
        // エラーチェック
        // エラー更新・通知
    }
}

長くなるので下の方コメントで済ませてますが ひとつのプロパティでこんなに長いです
プロパティが 20 個くらいあったら それだけで……

BindingDataBase

と言う感じで 自分で毎回 1 から VM を作るのは大変すぎるんです
楽に書けるようなベースクラスを作っておきます

使うときはこれを継承するだけで簡単に書けるようにします

先に 内部で使うクラスを準備します

値がなければデフォルト値を自動で取り出してセットする Dictionary を Map という名前で作っておきます
ちょっと長いですけど map["key"] で "key" がデータになくてもエラーにせずにデフォルト値をセットするだけのものです
デフォルト値は Func<TValue> 型で設定します
参照型の場合デフォルト値が共有になってしまうので仕方なくです

[Map.cs]
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace liblib
{
    public class Map<TKey, TValue> : Dictionary<TKey, TValue>
    {
        Func<TValue> default_value_generator { get; }
        bool create_value_if_not_exist { get; }

        public Map(bool create_value_if_not_exist = true)
        {
            this.default_value_generator = () => default(TValue);
            this.create_value_if_not_exist = create_value_if_not_exist;
        }

        public Map(Func<TValue> default_value_generator, bool set_with_get = true)
        {
            this.default_value_generator = default_value_generator;
            this.create_value_if_not_exist = create_value_if_not_exist;
        }

        public new TValue this[TKey key]
        {
            get
            {
                if (key == null || !this.ContainsKey(key))
                {
                    var value = this.default_value_generator();

                    if (key != null && this.create_value_if_not_exist)
                    {
                        base[key] = value;
                    }

                    return value;
                }
                else
                {
                    return base[key];
                }
            }
            set
            {
                base[key] = value;
            }
        }
    }
}

次にメインのベースクラスです
[BindingDataBase.cs]
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace liblib.wpf
{
    public abstract class BindingDataBase : INotifyPropertyChanged, INotifyDataErrorInfo
    {
        /// <summary>
        /// エラー管理
        /// </summary>
        protected Map<string, HashSet<string>> errors { get; }
            = new Map<string, HashSet<string>>(() => new HashSet<string>());

        /// <summary>
        /// プロパティの実体
        /// </summary>
        protected Dictionary<string, object> entities { get; }
            = new Dictionary<string, object>();

        /// <summary>
        /// エラーを含むか
        /// </summary>
        public bool HasErrors => this.errors.Any(e => e.Value.Count > 0);

        public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
        public event PropertyChangedEventHandler PropertyChanged;

        /// <summary>
        /// 指定プロパティのエラー取得
        /// </summary>
        /// <param name="propertyName"></param>
        /// <returns></returns>
        public IEnumerable GetErrors(string propertyName) => this.errors[propertyName];

        /// <summary>
        /// プロパティの変更を通知する
        /// </summary>
        /// <param name="property_name"></param>
        protected void notifyPropertyChanged(string property_name)
            => this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(property_name));

        /// <summary>
        /// エラーの変更を通知する
        /// </summary>
        /// <param name="property_name"></param>
        protected void notifyErrorsChanged(string property_name)
            => this.ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(property_name));

        /// <summary>
        /// 指定プロパティ名の実体を返す
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="property_name"></param>
        /// <param name="default_value"></param>
        /// <returns></returns>
        protected T getValue<T>(string property_name, T default_value = default(T))
        {
            if (!this.entities.ContainsKey(property_name))
            {
                this.entities[property_name] = default_value;
            }

            return (T)entities[property_name];
        }

        /// <summary>
        /// 指定プロパティ名に値を保存
        /// </summary>
        /// <param name="property_name"></param>
        /// <param name="value"></param>
        protected void setValue(string property_name, object value)
        {
            object old_value;
            if (this.entities.TryGetValue(property_name, out old_value) && Equals(old_value, value))
            {
                return;
            }

            object correct_value;
            HashSet<string> errors = new HashSet<string>();
            if (this.checkValidity(property_name, value, old_value, out correct_value, ref errors))
            {
                // valid

                this.entities[property_name] = correct_value;
                this.errors[property_name].Clear();
                this.notifyPropertyChanged(property_name);
                this.notifyErrorsChanged(property_name);
            }
            else
            {
                // invalid

                this.entities[property_name] = correct_value;
                this.errors[property_name] = errors;
                this.notifyPropertyChanged(property_name);
                this.notifyErrorsChanged(property_name);
            }
        }

        /// <summary>
        /// データのチェック
        /// </summary>
        /// <param name="property_name"></param>
        /// <param name="value"></param>
        /// <param name="corrected_value"></param>
        /// <param name="errors"></param>
        /// <returns></returns>
        protected virtual bool checkValidity(string property_name, object value, object old_value, out object correct_value, ref HashSet<string> errors)
        {
            correct_value = value;
            return true;
        }
    }
}

Gist

サンプル


XAML
<Window.Resources>
    <Style TargetType="TextBox">
        <Style.Triggers>
            <Trigger Property="Validation.HasError" Value="True">
                <Setter Property="ToolTip" Value="{Binding (Validation.Errors).CurrentItem.ErrorContent,RelativeSource={RelativeSource Self}}" />
            </Trigger>
        </Style.Triggers>
    </Style>
</Window.Resources>
<UniformGrid Margin="10" Columns="2">
    <TextBox Text="{Binding str1}"/>
    <Label Content="{Binding str1}" />
    <TextBox Text="{Binding str2}"/>
    <Label Content="{Binding str2}" />
</UniformGrid>

cs
private class TestBindingData : BindingDataBase
{
    public string str1
    {
        get { return this.getValue(nameof(str1), ""); }
        set { this.setValue(nameof(str1), value); }
    }

    public string str2
    {
        get { return this.getValue(nameof(str2), ""); }
        set { this.setValue(nameof(str2), value); }
    }

    public string str3
    {
        get { return this.getValue(nameof(str3), ""); }
        set
        {
            this.setValue(nameof(str3), value);
            this.str3l = this.str3;
        }
    }

    public string str3l
    {
        get { return this.getValue(nameof(str3l), ""); }
        set { this.setValue(nameof(str3l), value); }
    }

    protected override bool checkValidity(string property_name, object value, object old_value, out object correct_value, ref HashSet<string> errors)
    {
        switch (property_name)
        {
            case nameof(str1):
            case nameof(str3):
                {
                    var str = (string)value;
                    correct_value = value;

                    if (str.Length > 5)
                    {
                        errors.Add("5文字まで");
                        return false;
                    }
                    return true;
                }

            case nameof(str2):
                {
                    var str = (string)value;

                    if (str.Length > 5)
                    {
                        correct_value = str.Substring(0, 5);
                    }
                    else
                    {
                        correct_value = value;
                    }
                    return true;
                }

            default:
                correct_value = value;
                return true;
        }
    }
}

public MainWindow()
{
    this.DataContext = new TestBindingData();
}

wpfsmp19


プロパティ定義がすごくシンプルになってますよね
短すぎる分 コピペしたときの nameof の変え忘れに注意です
変え忘れて変なところ変わるバグが頻発してます

nameof すら使わないで
MethodBase.GetCurrentMethod().Name.Substring(4)

で プロパティ名持ってくることもできるのですが getter/setter なので頻繁に実行されるコードですし パフォーマンス的にあんまり使わないほうがいいかなと思ってここでは nameof にしてます


checkValidity メソッドでエラーチェックです
メソッド名は JavaScript からです
override して プロパティごとにチェックします

エラーがあるなら errors にエラーメッセージを入れて false を返します
エラーがないなら true を返します

false を返しても errors が空ならエラーなし扱いになります

correct_value には修正した値を入れます
修正しないならそのまま value を入れればおっけいです

エラーがあった場合に correct_value に修正した値をいれて エラーはなしという扱いにすると 自動補正になります
correct_value を設定したのに errors 設定して false を返すと修正後の値がエラーに扱われます



サンプルでは TextBox に 5 文字以上の入力はエラーになるようにしてます (MaxLength 使えばいいとか言わない)
左側が TextBox で 右側が Label です

1 つめと 3 つめはエラーを返してるので赤色になります
2 つめは 5 文字以上のときは 5 文字までに強制的に修正するのでフォーカスはずすと 6 文字目以降が消えます

UpdateSourceTrigger を PropertyChanged にしてるときに correct_value を設定する場合は注意が必要です
文字を打つごとに修正されるので 「3 文字以下はエラーで空文字に修正する」 という場合は 1 文字打つごとに空文字にされてしまって いつまでたっても 3 文字入力できません

3 つめは Label 側のエラーをなくしています
INotifyDataErrorInfo では プロパティ名に対してエラーを紐付けるようになっています

なので同じプロパティで Binding すると入力できない Label の方もエラーになります
Label の方はエラー表示しないように 別プロパティを Binding して setter で TextBox の値と同じようにしています

XAML でエラーでも赤枠つけないテンプレートにすることもできますけど テンプレート変更はもっと大変なのでプロパティ増やす方法にしてます

ちょっと拡張

1: 全部のプロパティをエラーチェックしたい

更新があったときにはエラーチェックされるのですが 更新がないとチェックされません
更新ないとチェックする意味ないように思えますけど初期値だけ例外です

未入力の空文字はエラーにしたくても一度も変更されていないとチェックしてくれません

なので最終チェックのときに全部のプロパティにエラーチェックする必要があります
最終チェック用のメソッドを作ってプロパティ名一覧書いていってもいいですけど別のところに書くと変更時に漏れやすいですし 見やすくもないです

C# はプロパティなどに属性というメタデータをつけれるのでそれを使って自動でチェックする対象かを設定できるようにしてみます


[AutoValidateTarget.cs]
using System;

namespace liblib.wpf
{
    [AttributeUsage(AttributeTargets.Property)]
    public class AutoValidateTarget : Attribute
    {
    }
}

[BindingDataBase.cs +]
/// <summary>
/// AutoValidateAttribute 付きのプロパティをまとめてエラーチェック
/// </summary>
/// <returns></returns>
protected bool reportValidity()
{
    var valid = true;
    foreach (var property_info in this.GetType().GetProperties())
    {
        if (Attribute.GetCustomAttribute(property_info, typeof(AutoValidateTarget)) != null)
        {
            var value = property_info.GetValue(this);

            object correct_value;
            HashSet<string> errors = new HashSet<string>();
            var name = property_info.Name;
            if (this.checkValidity(name, value, value, out correct_value, ref errors))
            {
                // valid

                this.entities[name] = correct_value;
                this.errors[name].Clear();
                this.notifyPropertyChanged(name);
                this.notifyErrorsChanged(name);
            }
            else
            {
                // invalid

                this.entities[name] = correct_value;
                this.errors[name] = errors;
                this.notifyPropertyChanged(name);
                this.notifyErrorsChanged(name);

                valid = false;
            }
        }
    }
    return valid;
}

使い方は
[AutoValidateTarget]
public string str1
{
    get { return this.getValue(nameof(str1), ""); }
    set { this.setValue(nameof(str1), value); }
}

のようにプロパティに属性をつけるだけ

reportValidity メソッドを呼び出すと AutoValidateTarget 属性付きのプロパティのエラーチェックが行われます

2: コマンドを自動で設定

VM には Command という機能があります
なにかアクションが起きたときに処理をする部分で Click リスナにメソッド設定するような感じです

Click リスナで Window のメソッドが実行され そこから VM のメソッド呼ぶということをしなくても ボタン押したときに直接 VM の関数を呼び出すようにできます

XAML にこう書いて
<Button Command="{Binding click_command}">button</Button>

コードの方では VM の click_command プロパティに ICommand を継承してる型を設定します
ICommand では CanExecute メソッドと Execute メソッドがあって CanExecute メソッドが true を返すとコマンドが実行でき ボタンなら disabled/enabled が切り替わります
Execute メソッドにはそのコマンドの処理内容を書いておきます

……が それだと Click リスナのようなメソッドひとつのために ICommand を継承して実装したクラスを 1 つ作ることになってすごく手間です
これだと Click から VM のメソッド呼ぶ方が楽かも ってくらいです

なので らくーにする方法で Execute と CanExecute を後から delegate (ラムダ式) で設定するというのがよく使われてます
コンストラクタで関数を受け取って設定するシンプルなものです

delegate を受け取って処理を決めてるからか DelegateCommand と呼ばれることが多いみたいです
RelayCommand という宗派もあるようですが 個人的に DelegateCommand のほうがよく見るしなんとなく名前が気に入ってるので DelegateCommand にします


[DelegateCommand.cs]
using System;
using System.Windows.Input;

namespace liblib.wpf
{
    public class DelegateCommand : ICommand
    {
        private Action<object> execute { get; set; }
        private Func<object, bool> canExecute { get; set; }
        public event EventHandler CanExecuteChanged
        {
            add { CommandManager.RequerySuggested += value; }
            remove { CommandManager.RequerySuggested -= value; }
        }
        public DelegateCommand(Action<object> execute, Func<object, bool> canExecute = null)
        {
            this.execute = execute;
            this.canExecute = canExecute;
        }
        public bool CanExecute(object parameter)
        {
            return this.canExecute?.Invoke(parameter) ?? true;
        }
        public void Execute(object parameter)
        {
            this.execute(parameter);
        }
    }
}

CanExecuteChanged はこのコードにあるように設定するのが決まりみたいになってます
フォーカス変わるなど CanExecute が変わりそうなことが起こる度に自動で CanExecute を再実行して更新するためのものです

この設定だと けっこう頻繁に呼び出されるので CanExecute の更新が必要なタイミングは限られていて CanExecute の処理が外部のデータベースやウェブページなど結果を取得するのに時間かかるものに依存してる場合は自分で書き換えるほうがいいかもしれません



これを作っても コンストラクタや VM を設定する外部から DelegateCommand 型のプロパティに初期値としてインスタンスを作って生成する必要があります
public BindingData()
{
    this.click_command = new DelegateCommand(this.click_action, _ => true);
}
とか
public MainWindow()
{
    this.DataContext = new BindingData
    {
        close_command = new DelegateCommand(_ => this.Close());
    };
}

みたいなの

外部からのときはともかく内部のときでコンストラクタにいろいろ書くのは見づらくなりますし 多いと邪魔です


なので 決められた法則で名前がつけられていれば自動でインスタンス作って入れてくれるメソッドを作りました

[BindingDataBase.cs +]
/// <summary>
/// 規則に沿った名前のコマンドは自動でコマンド生成する
/// </summary>
protected void initCommands()
{
    foreach (var property in this.GetType().GetProperties())
    {
        var lower_name = property.Name.ToLower();
        if (lower_name.EndsWith("_command") && property.PropertyType == typeof(DelegateCommand))
        {
            var binding_flag = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase;
            var method = this.GetType().GetMethod(lower_name.Replace("_command", "_Execute"), binding_flag);
            if (method == null)
            {
                continue;
            }
            var method_delegate = (Action<object>)Delegate.CreateDelegate(typeof(Action<object>), this, method);
            var check_method = this.GetType().GetMethod(lower_name.Replace("_command", "_CanExecute"), binding_flag);
            if (check_method == null)
            {
                property.SetValue(this, new DelegateCommand(method_delegate));
            }
            else
            {
                var check_method_delegate = (Func<object, bool>)Delegate.CreateDelegate(typeof(Func<object, bool>), this, check_method);
                property.SetValue(this, new DelegateCommand(method_delegate, check_method_delegate));
            }
        }
    }
}

プロパティ名が "_command" で終わる DelegateCommand 型のプロパティは "_command" の部分を "_Execute" と "_CanExecute" にしたメソッドが関連付けられます

BindingDataBase のコンストラクタでは initCommands を実行しないので 必要なら VM クラスのコンストラクタで行います
やってほしくないこともありそうなので自動で実行はしないようになってます

さいごに

いまさらだけど

BindingData / Base

なのに

Binding / DataBase

に見えなくもない微妙なネーミングです……

データベースは全く関係ないのに