WPF Prism episode: 9 ~ ReactiveProperty の Validation は DataAnnotation じゃないと思った? ~
前回は Prism の RequestNavigate に設定したパラメータを遷移先画面で受け取り、パラメータから取り出したデータを ReactiveProperty で View とバインドする方法を紹介しました。
その際、VM ⇔ View 間だけでなく Model ⇔ VM 間も双方向でバインドすることで、View で入力した値が Model までシームレスに伝播する方法も併せて紹介しましたが、episode: 8 のままでは反映されたくない値まで Model へ伝播されてしまいます。
そのため今回は不正な値を Model に反映されるのを防ぐ方法と、View の入力エラーをチェックするための Validation を ReactiveProperty へ設定する方法を紹介します。
今回は WPF 標準の Validation を ReactiveProperty に設定する方法の紹介がメインですが、ReactiveProperty を使用せず Prism の BindableBase 等で実装する場合でもほとんど同じですし、何かの参考にはなるとは思うので読んでもらえると嬉しいです。
このエントリは以前 episode: 9、9′ に分けて公開していましたが、リニューアルして 1 エントリに統合しました。
リニューアル前に episode: 9 で紹介していた Extended WPF Toolkit™ については 新規エントリとして『WPF Prism extra: 4 ~ Extended WPF Toolkit™ で数値と日付を入力 ~』のタイトルで extra シリーズに分割したのでお手数ですが extra: 4 へ移動してください。
尚、この記事は Visual Studio 2017 Community Edition で .NET Framework 4.7.2 以上 と C# + Prism 7.1 + ReactiveProperty を使用して 、WPF アプリケーションを MVVM パターンで作成するのが目的で、C# での基本的なコーディング知識を持っている人が対象です。
WPF の Validation
今までのエントリではあえて触れていませんでしたが、アプリには必須と言える Validation について紹介します。
WPF の Validation は歴史的な経緯から何通りかの方法があって『WPFでの入力値検証・まとめ – SourceChord』が、日本語で書かれた情報としては最もまとまっていると思うので、一通り目を通しておくと WPF の Validation についての概要が把握できると思います。
何通りかある Validation の内、このエントリで紹介するのは DataAnnotations だけなので『WPFでの入力値検証・まとめ – SourceChord』 の中でも特に『WPFでの入力値検証・その3 ~DataAnnotationsを使う~ – SourceChord』だけでも読んでおくとここで紹介する内容も理解し易いと思います。
DataAnnotations を使用した Validation
DataAnnotations は元々 ASP.NET のために用意された機能のようですが、WPF でも ASP.NET と同じように使用できます。
【System.ComponentModel.DataAnnotations 名前空間】配下には DataAnnotations に使用するためのクラスが多数用意されていますが、以下の 2 つのクラスでほとんどカバーできると管理人個人的には考えています。
- RegularExpression 属性
- Required 属性
中でも特に有用なのが【RegularExpression 属性】で、項目の単体チェックであればこの属性だけでほぼ全てカバーできると言えます。
正規表現が苦手な人や面倒な人には英語サイトですが Regular Expression Library のようなサイトもあるので、似たパターンを探して少し改変すれば整数・小数桁数チェックまで含めた数値チェック等は簡単に設定できると思います。
src. 1 はサンプルアプリの身体測定データ編集 View に DataAnnotations を適用した例です。
測定日は日付型なので後回しにして、まずは数値項目のみに Validation を追加しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | using System.ComponentModel.DataAnnotations; using Prism.Mvvm; using Prism.Regions; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace WpfTestApp.ViewModels { /// <summary> 身体測定データの編集画面を表します。 </summary> public class PhysicalEditorViewModel : BindableBase, IDisposable, INavigationAware { #region "プロパティ" /// <summary>測定日を取得・設定します。</summary> public ReactiveProperty<DateTime?> MeasurementDate { get; set; } /// <summary>身長を取得・設定します。</summary> [RegularExpression(@"^\d{1,3}(\.\d{1,2})?$", ErrorMessage ="身長は整数3桁 少数2桁の範囲で入力してください。")] public ReactiveProperty<double> Height { get; set; } /// <summary>体重を取得・設定します。</summary> [RegularExpression(@"^\d{1,3}(\.\d{1,2})?$", ErrorMessage = "体重は整数3桁 少数2桁の範囲で入力してください。")] public ReactiveProperty<double> Weight { get; set; } /// <summary>BMIを取得します。</summary> public ReadOnlyReactivePropertySlim<double> Bmi { get; private set; } #endregion ~ 略 ~ private PhysicalInformation physical = null; private System.Reactive.Disposables.CompositeDisposable disposables = new System.Reactive.Disposables.CompositeDisposable(); /// <summary>Viewを表示した後呼び出されます。</summary> /// <param name="navigationContext">Navigation Requestの情報を表すNavigationContext。</param> void INavigationAware.OnNavigatedTo(NavigationContext navigationContext) { if (this.physical != null) return; this.physical = this.getPhysicalData(navigationContext); this.MeasurementDate = this.physical .ToReactivePropertyAsSynchronized(x => x.MeasurementDate) .AddTo(this.disposables); this.Height = this.physical .ToReactivePropertyAsSynchronized(x => x.Height, ignoreValidationErrorValue: true) .SetValidateAttribute(() => this.Height) .AddTo(this.disposables); this.Weight = this.physical .ToReactivePropertyAsSynchronized(x => x.Weight, ignoreValidationErrorValue: true) .SetValidateAttribute(() => this.Weight) .AddTo(this.disposables); this.Bmi = this.physical.ObserveProperty(x => x.Bmi) .ToReadOnlyReactivePropertySlim() .AddTo(this.disposables); this.RaisePropertyChanged(null); } ~ 略 ~ } } |
DataAnnotations とは src. 1 のように Validation を設定したいプロパティへ追加する属性を指し、適用するにはまず【System.ComponentModel.DataAnnotations】を using します。
名前空間を追加する時に IntelliSense へ候補が表示されない場合はプロジェクトへアセンブリ参照の追加も必要です。
管理人の場合、参照を自分では追加していませんが using 記述時に候補へ表示されたので ReactiveProperty 等が裏で参照しているからだと思います。
src. 1 では Validation を設定したい Height・Weight プロパティに RegularExpression 属性を追加して整数部 3 桁・小数部 2 桁の正の数値のみが有効になる正規表現パターンを指定しています。
この正規表現も先ほど紹介した Regular Expression Library で見つけたものを流用しています。
RegularExpression 属性の追加と併せて ReactiveProperty の初期化時に【SetValidateAttribute メソッド】も追加することで Validation が有効になります。(50、54 行目)
※ SetValidateAttribute のパラメータのラムダ式へ対象のプロパティを渡します
併せて ToReactivePropertyAsSynchronized メソッドの省略可能第 3 パラメータ【ignoreValidationErrorValue】に true を設定すると Validation でエラーになった場合は値が Model へ反映されなくなります。
又、以前紹介した ReactivePropertySlim は Validation の機能が省かれているため Validation を設定するプロパティには使用できません。
そのため、Validation を設定するプロパティは Slim 無しの無印 ReactiveProperty で宣言する必要があります。
続いて src. 2 は src. 1 の VM とバインドする XAML 側です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | <UserControl x:Class="WpfTestApp.Views.PhysicalEditor" ~ 略 ~ prism:ViewModelLocator.AutoWireViewModel="True"> <Grid> ~ 略 ~ <Grid Grid.Column="1" Grid.Row="1" Margin="10,0,0,0"> ~ 略 ~ <Grid Grid.Column="0" Grid.Row="1"> <Grid.ColumnDefinitions> <ColumnDefinition Width="0.8*"/> <ColumnDefinition Width="0.2*"/> </Grid.ColumnDefinitions> <TextBox Grid.Column="0" Text="{Binding Height.Value, UpdateSourceTrigger=PropertyChanged}" MaxLength="6" VerticalContentAlignment="Center" HorizontalContentAlignment="Right" /> <TextBlock Grid.Column="1" VerticalAlignment="Center" Text="cm" /> </Grid> <Grid Grid.Column="1" Grid.Row="1"> <Grid.ColumnDefinitions> <ColumnDefinition Width="0.8*"/> <ColumnDefinition Width="0.2*"/> </Grid.ColumnDefinitions> <TextBox Grid.Column="0" Text="{Binding Weight.Value, UpdateSourceTrigger=PropertyChanged}" MaxLength="6" TextAlignment="Right" VerticalContentAlignment="Center" /> <TextBlock Grid.Column="1" VerticalAlignment="Center" Text="kg" /> </Grid> <TextBox Grid.Column="3" Grid.Row="1" Text="{Binding Bmi.Value, Mode=OneWay}" TextAlignment="Right" IsReadOnly="True" VerticalContentAlignment="Center" /> </Grid> </Grid> </Grid> </UserControl> |
Validation の設定に XAML 側の変更が必須ではありませんが、バインディングのパラメータに『UpdateSourceTrigger=PropertyChanged』を追加して、1 文字単位で Validation を実行するように変更しています。
このパラメータを設定しない場合は【LostFocus】のタイミングで Validation が実行されるので必須の設定ではありませんが、1 文字毎リアルタイムで Validation を実行したい場合に設定します。
ここまでで実行すると下の fig.1 のように不正データを入力するとエラーを示す赤枠で囲まれるようになります。
src. 1 では DataAnnotation に設定するエラーメッセージを直接 VM のソースコードに埋め込んでいますが、実際のアプリではリテラルな文字列をソースコードへ直接埋め込むより、多言語対応のプロジェクトとして作成してエラーメッセージ部分をリソースに切り出す方が何かと取り回しやすいと思います。
このエントリでは多言語への対応方法等の紹介はしないので、必要な場合は『WPFでの入力値検証・その9 ~エラー表示のローカライズ~ – SourceChord』を参考にしてください。
数値型プロパティを TextBox へバインド
Height プロパティのような数値型で定義されたプロパティをバインドした TextBox へ数値以外の文字を入力すると、TextBox 等の WPF コントロール内部で自動的に Parse してエラーにするようで、VM まで値が渡ってきませんしイミディエイトウィンドウにスタックトレースは出力されますが、例外は Throw されません。
そのため、上で紹介した ReactiveProperty の初期化時の ToReactivePropertyAsSynchronized へ【ignoreValidationErrorValue: true】を指定する・しないに関わらず Model へ数値以外がセットされる事は無いようですが、Model と双方向で接続していて Validation を設定するプロパティには【ignoreValidationErrorValue: true】を常に指定するべきだと思います。
又、double や decimal 等のような小数部のある数値を TextBox とバインドしている場合、小数点の入力に問題がありますが、次回の記事で紹介します。
日付型プロパティを DatePicker へバインド
続いて測定日(日付型)の Validation ですが、測定日に使用している DatePicker コントロールは、TextBox と多少動作が違います。
まず、DatePicker に UpdateSourceTrigger = PropertyChanged を設定しても動作は UpdateSourceTrigger = LostFocus と変わらないので UpdateSourceTrigger を設定する意味はありません。
又、DatePicker へ RegularExpression 属性を設定した場合の動作も TextBox とは違います。
試しに src. 3 のように RegularExpression 属性を設定します。
1 2 3 4 | /// <summary>測定日を取得・設定します。</summary> [RegularExpression(@"^([2][0]\d{2}\/([0]\d|[1][0-2])\/([0-2]\d|[3][0-1]))$|^([2][0]\d{2}\/([0]\d|[1][0-2])\/([0-2]\d|[3][0-1])$", ErrorMessage ="不正な日付です。")] public ReactiveProperty<DateTime?> MeasurementDate { get; set; } |
src. 3 のように Validation を設定しても fig. 3 のような動作になります。
RegularExpression 属性で入力形式を制限しているにもかかわらずエラーを示す赤枠も表示されませんし、直前に設定した値に戻っています。
DatePicker は LostFocus 前に不正な入力を切り捨てて、直前に設定した値に戻るようなので RegularExpression 属性で入力文字種チェックを設定する意味は無いようです。
又、RegularExpression 属性だけでなく Required 属性も無視されるため DatePicker に DataAnnotations は設定できないと言えます。
そして、この連載で紹介しているサンプルアプリで『身体測定データ.測定日』と『試験結果データ.試験日』の 2 項目は『それぞれのリスト内で一意』となる表示上のキー項目と言う仕様なので、リスト内の他メンバの値を参照する必要があります。
今まで紹介した Validation は単項目の Validation でしたが、以降では複数のプロパティが絡む Validationを設定する方法を紹介します。
複数のプロパティが絡む Validation
結論から言うと、ReactiveProperty に複数のプロパティが絡む Validation を 設定したい場合に DataAnnotations は使用できません。
『WPFでの入力値検証・その7 ~独自の検証ロジックを作成する~ – SourceChord』や『WPFでの入力値検証・その8 ~複数のプロパティが絡むバリデーションを作成する~ – SourceChord』に書かれている方法で ReactiveProperty へ複数のプロパティが絡む Validation を設定しようとしても想定通りの動作をしません。
※ Prism の BindableBase 等で定義したプロパティであれば動作するはずです(管理人は未確認)
上記 SourceChord のエントリに書かれている【ValidationAttribute 派生クラス】、【CustomValidation 属性】の両方で想定通りの動作をしないのは Validation メソッド内で VM が参照できないことが原因です。
ReactiveProperty に CustomValidation 属性を設定した場合
CustomValidation 属性を使用する場合のコンストラクタは以下のように指定します。
CustomValidation コンストラクタの第 1 パラメータは Validation メソッドを定義したクラスの type 、第 2 パラメータには Validation メソッド名を指定します。
又、第 2 パラメータに指定する CustomValidation メソッドは以下のシグネチャで作成します。
CustomValidation メソッド内にブレークポイントを設定して実行すると、第 2 パラメータ: ValidationContext.ObjectInstance には null が渡ってくることが確認できます。
又、CustomValidation に指定するメソッドは static で定義する必要があるため、どんなデータでも渡せるとは限りません。
以上の状況から CustomValidation は複数項目が関係する Validation に使用するのは難しいと考えられます。
ReactiveProperty に ValidationAttribute から派生したクラスを設定した場合
ValidationAttribute 派生クラスを設定する場合も同様で、override する IsValid メソッドの第 2 パラメータ: ValidationContext.ObjectInstance は ReactiveProperty に設定している型(ここでは Nullable<DateTime>)が渡ってくるため CustomValidation の場合と同じく VM が取得できません。
Prism の BindableBase 等で定義すれば、ValidationContext.ObjectInstance プロパティから VM が取得できる(管理人は未確認)ようですが、ReactiveProperty で定義した場合、ValidationAttribute 派生クラスと CustomValidation 属性のどちらを使用しても VM のインスタンスを取得出来ないため複数プロパティが絡む Validation には使用出来ないと言えます。
項目単体の Validation であれば RegularExpression 属性で大半をカバーできるため、ReactiveProperty で定義したプロパティに ValidationAttribute 派生クラスや CustomValidation 属性を使用する意味は無いと言えます。
DataAnnotation を使用した Validation を設定する場合、通常は VM へ INotifyDataErrorInfo インタフェースを実装する必要がありますが、ReactiveProperty の場合は ReactiveProperty自身が INotifyDataErrorInfo インタフェースを実装している為、このような動作になっていると考えられます。
そのため、ReactiveProperty へ複数プロパティが絡む Validation を設定するには ReactiveProperty に含まれる SetValidateNotifyError メソッドを呼び出す必要があります。
ReactiveProperty.SetValidateNotifyError で複数項目の Validation を実行する
ReactiveProperty へ複数プロパティが絡む Validation を設定するには ReactiveProperty に含まれる【SetValidateNotifyError】拡張メソッドを使用しますが、 ReactiveProperty は DataAnnotations と SetValidateNotifyError 拡張メソッドを同時に設定できないため、複数プロパティが絡む Validation も項目単位の Validation も SetValidateNotifyError に指定したメソッド内で処理します。
初期の ReactiveProperty では DataAnnotation と SetValidateNotifyError を同時に設定できたようですが、最新バージョンではできないようです。
SetValidateNotifyError 拡張メソッドを src. 4 のように呼び出します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | using System; using System.Linq; using System.Collections.Generic; using System.Threading.Tasks; using System.ComponentModel.DataAnnotations; using Prism.Mvvm; using Prism.Regions; using Reactive.Bindings; using Reactive.Bindings.Extensions; namespace WpfTestApp.ViewModels { /// <summary> 身体測定データの編集画面を表します。 </summary> public class PhysicalEditorViewModel : BindableBase, IDisposable, INavigationAware { #region "プロパティ" /// <summary>測定日を取得・設定します。</summary> public ReactiveProperty<DateTime?> MeasurementDate { get; set; } ~ 略 ~ /// <summary>Viewを表示した後呼び出されます。</summary> /// <param name="navigationContext">Navigation Requestの情報を表すNavigationContext。</param> void INavigationAware.OnNavigatedTo(NavigationContext navigationContext) { if (this.physical != null) return; this.physical = this.getPhysicalData(navigationContext); // 測定日 this.MeasurementDate = this.physical .ToReactivePropertyAsSynchronized(x => x.MeasurementDate, ignoreValidationErrorValue: true) .SetValidateNotifyError(v => this.getMeasurementDateError(v))) .AddTo(this.disposables); // 身長 this.Height = this.physical .ToReactivePropertyAsSynchronized(x => x.Height, ignoreValidationErrorValue: true) .SetValidateAttribute(() => this.Height) .AddTo(this.disposables); ~ 略 ~ } ~ 略 ~ /// <summary>測定日のエラー文字列を取得します。</summary> /// <param name="value">View で入力されたDateTime?。</param> /// <returns>測定日のエラー文字列</returns> private string getMeasurementDateError(DateTime? value) { if (!value.HasValue) return "必須入力です。"; if (this.appData.HasPhysicalKey(value, this.physical)) return "既に同一の測定日が存在するため、別の日付を設定してください。"; else return null; } /// <summary>アプリデータ本体を表します。</summary> private WpfTestAppData appData = null; /// <summary>コンストラクタ。</summary> /// <param name="data">アプリのデータオブジェクト(Unity からインジェクション)</param> public PhysicalEditorViewModel(WpfTestAppData data) { this.appData = data; } ~ 略 ~ } } |
この Validation には身体測定データリスト自体が必要なので、あらかじめデータオブジェクトのインスタンスを Unity からコンストラクタへインジェクションしてもらっています。(68行目)
そして、測定日 ReactiveProperty の初期化時に SetValidateNotifyError() メソッドを呼び出して Validation を実行するデリゲート先を設定しています。
src. 4 ではデリゲート先を指定していますが、ラムダ式で直接記述することもできます。
SetValidateNotifyError メソッドでは戻り値に null 又は string.Empty を返すとエラー無しと判定されるので、正常値の場合は null を返すよう記述します。
又、Validation 内で呼び出している HasPhysicalKey は src. 5 のようにデータオブジェクトへ追加した重複チェックメソッドです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | using System.Linq; namespace WpfTestApp { /// <summary>サンプルアプリのデータコンテナを表します。</summary> public class WpfTestAppData { ~ 略 ~ /// <summary>指定した日付と同日の身体測定データ(自分自身は除く)が /// 存在するかを返します。</summary> /// <param name="value">存在をチェックする日付を表すDateTime?。</param> /// <param name="target">チェックで除外する身体測定データを表すPhysicalInformation。</param> /// <returns>指定した日付と同日の身体測定データが存在するかを表すbool。</returns> public bool HasPhysicalKey(DateTime? value, PhysicalInformation target) { if (value.HasValue) return this.Physicals .Where(p => p.MeasurementDate.HasValue) .FirstOrDefault((p) => p.MeasurementDate.Value.Date == value.Value.Date && p.Id != target.Id) != null; else return this.Physicals.FirstOrDefault(p => !p.MeasurementDate.HasValue) != null; } } } |
実行して身体測定結果編集 View を表示します。
身体測定結果編集 View を表示しただけでエラーを表す赤枠が表示されています。
Validation メソッドの中で『null はエラー』としているので当然ですが、初回起動時でいきなりエラーが表示されているのは UX 的に問題です。
このような初期値エラーを無視するには ReactiveProperty の初期化時に指定している ToReactivePropertyAsSynchronized 拡張メソッドの省略可能第 2 パラメータ:mode へ src. 6 のように初回 Validation エラーを無視する指定を追加します。
1 2 3 4 5 6 7 | var mode = ReactivePropertyMode.Default | ReactivePropertyMode.IgnoreInitialValidationError; // 測定日 this.MeasurementDate = this.physical .ToReactivePropertyAsSynchronized(x => x.MeasurementDate, mode, true) .SetValidateNotifyError(v => this.getMeasurementDateError(v))) .AddTo(this.disposables); |
src. 6 のように第 2 パラメータの mode をセットすると、初期値エラーの赤枠は表示されなくなります。
尚、身長・体重の両プロパティは初期値としてエラーにならない値をセットしているので、測定日プロパティのように第 2 パラメータをセットしなくても初回表示時にエラーは表示されません。
これで、身体測定データ編集 View の全項目へ Validation を設定しましたが、DataAnnotations で指定しているエラーメッセージが表示されていません。
これは WPF 標準のエラーテンプレートがエラーメッセージを表示するようになっていないからなので、エラーメッセージが表示されるように XAML を編集する必要がありますが、今回はここまでにします。
又、前回のエントリで Prism の IConfirmNavigationRequest インタフェースを紹介すると宣言したのに、結局次回以降へ持ち越すことになってしまいました。
そして次回は、Validation の ErrorTemplate を紹介する予定です。
尚、いつもの通り今回のソースコードも GitHub リポジトリ にアップしています。
次回記事「ErrorTemplate は Resources タグ、時々、ResourceDictionary ファイルのなか。【episode: 10 WPF Prism】」