はじめに
今回の記事では MVVM 構成で実装された TextBox のバリデーションを行う方法についてまとめました。
作成したサンプルアプリケーションのイメージです。
WPF-SamplesのData Binding内のサンプルコードを参考にしています。
ViewModel の実装
データバインディングを実現するために ViewModel を作成します。また、ViewModel に継承しているプロパティ変更通知の基底クラスは以下の記事で解説している方法と同様の実装をしています。
namespace MVVM.TextBoxValidation.ViewModels
{
public sealed class MainWindowViewModel : BindableBase
{
public string ValidationRuleTextBox
{
get => _validationRuleTextBox;
set => SetProperty(ref _validationRuleTextBox, value);
}
private string _validationRuleTextBox;
public MainWindowViewModel()
{ }
}
}
XAML 側は以下のように実装します。
<Window x:Class="MVVM.TextBoxValidation.Views.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:local="clr-namespace:MVVM.TextBoxValidation"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="MainWindow"
Width="650"
Height="500"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<Grid Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="50" />
<RowDefinition Height="40" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Grid.ColumnSpan="2"
Margin="8"
FontSize="20"
Text="10以上の数値を入力したり、負の値や文字を入力するとエラーになります。" />
<Label Grid.Row="1"
Grid.Column="0"
Margin="2"
Content="ValidationRule:"
FontSize="15" />
<TextBox Grid.Row="1"
Grid.Column="1"
Margin="2"
Padding="5"
VerticalContentAlignment="Center"
FontSize="15"
Text="{Binding ValidationRuleTextBox, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
</Window>
設置した TextBox のバリデーション方法を見ていきます。
ValidationRule
How to: Implement Binding Validationのサンプルコードでもありますが、まずはValidationRule
クラスを使用する方法です。
これはValidationRule
クラスを継承した独自クラスを作成する方法で、コンバータークラスを作成する方法に似ています。
ValidationRules
というフォルダを作成しTextBoxRule
というクラスを作成します。
using System.Globalization;
using System.Windows.Controls;
namespace MVVM.TextBoxValidation.ValidationRules
{
public class TextBoxRule : ValidationRule
{
/// <summary>
/// XAML側から指定するプロパティ
/// </summary>
public int Max { get; set; }
public override ValidationResult Validate(object value, CultureInfo cultureInfo)
{
if(string.IsNullOrWhiteSpace(value as string))
{
return new ValidationResult(false, "入力必須です。");
}
if (int.TryParse(value as string, out int number))
{
if (number < 0) return new ValidationResult(false, "負の値は入力できません。");
if (number > Max) return new ValidationResult(false, "最大値を超えています。");
// 第一引数(bool): チェックした値が有効な値か判定する(true:有効な値と判定、false: 無効な値と判定)
// 第二引数(object): エラーだった場合のメッセージを指定
// 以下は値が有効だった場合に返す値の例
//return new ValidationResult(true, null);
// 上記と同様の戻り値(値が有効だった場合に返す)
return ValidationResult.ValidResult;
}
else
{
return new ValidationResult(false, "数値を入力してください。");
}
}
}
}
オーバーライドしたValidate
メソッドではValidationResult
インスタンスを返します。
この際、値を判定するロジックを書いて、無効な値と判定する場合はnew ValidationResult(false, "表示するメッセージ")
と指定します。
public int Max
プロパティは必須ではありませんが、今回は XAML 側から最大値を指定したいので実装しました。
このクラスを使用するために XAML 側の実装を以下のように修正します。
<Window x:Class="MVVM.TextBoxValidation.Views.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:local="clr-namespace:MVVM.TextBoxValidation"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:validRules="clr-namespace:MVVM.TextBoxValidation.ValidationRules"
Title="MainWindow"
Width="650"
Height="500"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<Grid Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="50" />
<RowDefinition Height="40" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Grid.ColumnSpan="2"
Margin="8"
FontSize="20"
Text="10以上の数値を入力したり、負の値や文字を入試力するとエラーになります。" />
<Label Grid.Row="1"
Grid.Column="0"
Margin="2"
VerticalContentAlignment="Center"
Content="ValidationRule:"
FontSize="15" />
<TextBox Grid.Row="1"
Grid.Column="1"
Margin="2"
Padding="5"
VerticalContentAlignment="Center"
FontSize="15">
+ <TextBox.Text>
+ <Binding Path="ValidationRuleTextBox"
+ TargetNullValue=""
+ UpdateSourceTrigger="PropertyChanged">
+ <Binding.ValidationRules>
+ <validRules:TextBoxRule Max="10" />
+ </Binding.ValidationRules>
+ </Binding>
+ </TextBox.Text>
+ </TextBox>
</Grid>
</Window>
今までの実装を確認します。
エラーとなる値を入力すると TextBox の外枠が赤くなっていることが分かります。
しかし、エラーメッセージが表示されていないためAdornedElementPlaceholder
を使用してエラーメッセージと、赤枠が装飾された TextBox を実装します。
AdornedElementPlaceholder
ControlTemplate
内でAdornedElementPlaceholder
を使用することで、元のコントロールの見た目や振る舞いが維持されたまま装飾を施した要素を実装することが出来ます。
今回の使い方では元の TextBox の位置とサイズを保持しつつ、エラーメッセージを表示することができます。
<Window x:Class="MVVM.TextBoxValidation.Views.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:local="clr-namespace:MVVM.TextBoxValidation"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:validRules="clr-namespace:MVVM.TextBoxValidation.ValidationRules"
Title="MainWindow"
Width="650"
Height="500"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
+ <Window.Resources>
+ <ControlTemplate x:Key="validationErrorTemplate">
+ <StackPanel>
+ <!-- 赤い枠の再現 -->
+ <Border BorderBrush="Red" BorderThickness="1">
+ <!-- 元の TextBox の表示 -->
+ <AdornedElementPlaceholder x:Name="adornedelem" />
+ </Border>
+ <!-- エラーメッセージの表示 -->
+ <TextBlock Foreground="Red" Text="{Binding AdornedElement.(Validation.Errors)[0].ErrorContent, ElementName=adornedelem}" />
+ </StackPanel>
+ </ControlTemplate>
+ </Window.Resources>
<Grid Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="50" />
<RowDefinition Height="40" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Grid.ColumnSpan="2"
Margin="8"
FontSize="20"
Text="10以上の数値を入力したり、負の値や文字を入試力するとエラーになります。" />
<Label Grid.Row="1"
Grid.Column="0"
Margin="2"
VerticalContentAlignment="Center"
Content="ValidationRule:"
FontSize="15" />
<TextBox Grid.Row="1"
Grid.Column="1"
Margin="2"
Padding="5"
VerticalContentAlignment="Center"
FontSize="15"
+ Validation.ErrorTemplate="{StaticResource validationErrorTemplate}">
<TextBox.Text>
<Binding Path="ValidationRuleTextBox" UpdateSourceTrigger="PropertyChanged">
<Binding.ValidationRules>
<validRules:TextBoxRule Max="10" />
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
</TextBox>
</Grid>
</Window>
追加した箇所を詳しく見ていきます。
<Window.Resources>
Window.Resources
では、このウィンドウ内で共通して使用するリソースを定義します。
ここで定義したリソース(スタイルやテンプレートなど)は、ウィンドウ内の他のコントロールで再利用できます。
<ControlTemplate x:Key="validationErrorTemplate">
ControlTemplate
は、コントロールの見た目を定義するものです。x:Key
にvalidationErrorTemplate
という名前を定義することで、このテンプレートを他のコントロールから参照できます。
<Border BorderBrush="Red" BorderThickness="1">
<AdornedElementPlaceholder x:Name="adornedelem" />
</Border>
<Border BorderBrush="Red" BorderThickness="1">
では赤枠の線を描画しています。
AdornedElementPlaceholder
は、このテンプレートが適用されたコントロールを表示します。このテンプレートの呼び出し元はTextBox
なので、TextBox
が表示されます。
x:Name="adornedelem"
でこの要素に名前を付けています。この名前は後ほどバインディングで使います。
<TextBlock Foreground="Red" Text="{Binding AdornedElement.(Validation.Errors)[0].ErrorContent, ElementName=adornedelem}" />
この実装ではAdornedElement
で発生したエラー(ここでは TextBox)のバリデーションエラーメッセージを取得し、それを TextBlock の Text に表示するためにバインディングしています。
ElementName=adornedelem
ではバインディングソースとなる要素を指定しています。この実装がないと、どの要素のバリデーションエラー情報を取得すべきかわからず以下のバインディングエラーが発生します。
重大度レベル カウント データ コンテキスト バインド パス ターゲット ターゲット型 説明 ファイル 行 プロジェクト
エラー 9 ValidationError AdornedElement TextBlock.Text String 型 ValidationError のオブジェクトに AdornedElement プロパティが見つかりません。
一旦ビルドして今までの実装を確認します。
バリデーションエラーのメッセージが表示されていることが確認できました。
What does AdornedElementPlaceholder exactly do when we use it in validation controlTemplate?
ValidatesOnExceptions
次にValidatesOnExceptions
を使用した方法を見ていきます。
これはデータバインディングした箇所にValidatesOnExceptions=True
を指定し、ViewModel 側のSetter
で発生させた例外をキャッチしてバリデーションエラーとして扱うことが出来ます。
XAML 側を以下のように修正して新しく TextBox を追加します。
<!-- 省略 -->
<Grid Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="50" />
<RowDefinition Height="40" />
+ <RowDefinition Height="20" />
+ <RowDefinition Height="40" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Grid.ColumnSpan="2"
Margin="8"
FontSize="20"
Text="10以上の数値を入力したり、負の値や文字を入試力するとエラーになります。" />
<Label Grid.Row="1"
Grid.Column="0"
Margin="2"
VerticalContentAlignment="Center"
Content="ValidationRule:"
FontSize="15" />
<TextBox Grid.Row="1"
Grid.Column="1"
Margin="2"
Padding="5"
VerticalContentAlignment="Center"
FontSize="15"
Validation.ErrorTemplate="{StaticResource validationErrorTemplate}">
<TextBox.Text>
<Binding Path="ValidationRuleTextBox"
TargetNullValue=""
UpdateSourceTrigger="PropertyChanged">
<Binding.ValidationRules>
<validRules:TextBoxRule Max="10" />
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
</TextBox>
+ <Label Grid.Row="3"
+ Grid.Column="0"
+ Margin="2"
+ VerticalContentAlignment="Center"
+ Content="ValidatesOnExceptions:"
+ FontSize="15" />
+ <TextBox Grid.Row="3"
+ Grid.Column="1"
+ Margin="2"
+ Padding="5"
+ VerticalContentAlignment="Center"
+ FontSize="15"
+ Text="{Binding ValidatesOnExceptionsTextBox,
+ TargetNullValue='',
+ UpdateSourceTrigger=PropertyChanged,
+ ValidatesOnExceptions=True}"
+ Validation.ErrorTemplate="{StaticResource validationErrorTemplate}" />
</Grid>
</Window>
ViewModel 側も以下のように修正します。
namespace MVVM.TextBoxValidation.ViewModels
{
public sealed class MainWindowViewModel : BindableBase
{
public string ValidationRuleTextBox
{
get => _validationRuleTextBox;
set => SetProperty(ref _validationRuleTextBox, value);
}
private string _validationRuleTextBox;
+ public string ValidatesOnExceptionsTextBox
+ {
+ get => _validatesOnExceptionsTextBox;
+ set
+ {
+ if(SetProperty(ref _validatesOnExceptionsTextBox, value))
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new ArgumentException("入力必須です。");
+ }
+ if (int.TryParse(value, out int number))
+ {
+ // int型の値に対するバリデーションを行う
+ if (number < 0)
+ throw new ArgumentException("負の値は入力できません。");
+ if (number > 10)
+ throw new ArgumentException("最大値を超えています。");
+ }
+ else
+ {
+ throw new ArgumentException("数値を入力してください。");
+ }
+ }
+ }
+ }
+ private string _validatesOnExceptionsTextBox;
public MainWindowViewModel()
{ }
}
}
バリデーションロジックなどはTextBoxRule.cs
と同様でそのままプロパティのSetter
に記述しています。
例外は返すのではなくスローするように変更しています。
またValidation.ErrorTemplate
プロパティに最初に作成したテンプレートを適応させることで同様にエラーメッセージを出力させることが出来ます。
ただ入力値によっては、バインディングされている値が変わる度に例外が発生するためか、パフォーマンスが悪くなる時がありました。
比較的実装は簡単ですが優先してこの方法を使用することは無いと思います。
INotifyDataErrorInfo
IDataErrorInfo
を使う方法もありますが、改良されたINotifyDataErrorInfo
を使用することが多いので今回は割愛します。
以下のように TextBox と Label を追加します。
<!-- 省略 -->
<Grid Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="50" />
<RowDefinition Height="40" />
<RowDefinition Height="20" />
<RowDefinition Height="40" />
+ <RowDefinition Height="20" />
+ <RowDefinition Height="40" />
</Grid.RowDefinitions>
<!-- 省略 -->
+ <Label Grid.Row="5"
+ Grid.Column="0"
+ Margin="2"
+ VerticalContentAlignment="Center"
+ Content="INotifyDataErrorInfo:"
+ FontSize="15" />
+ <TextBox Grid.Row="5"
+ Grid.Column="1"
+ Margin="2"
+ Padding="5"
+ VerticalContentAlignment="Center"
+ FontSize="15"
+ Text="{Binding INotifyDataErrorInfoTextBox, TargetNullValue='', UpdateSourceTrigger=PropertyChanged}"
+ Validation.ErrorTemplate="{StaticResource validationErrorTemplate}" />
</Grid>
</Window>
今回はINotifyDataErrorInfo
は直接 ViewModel に実装するのではなく、BindableBase
クラスに実装して共通化しておきます。
INotifyDataErrorInfo
を実装すると以下の 3 つが強制的に定義されます。
bool HasErrors
event EventHandler<DataErrorsChangedEventArgs>
IEnumerable GetErrors(string? propertyName)
またいくつかprotected
なメソッドを実装し、継承先の ViewModel から呼び出せるようにします。
using System.Collections;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace MVVM.TextBoxValidation.ViewModels
{
/// <summary>
/// プロパティ変更通知基底クラス
/// </summary>
+ public abstract class BindableBase : INotifyPropertyChanged, INotifyDataErrorInfo
{
#region 継承先で使用する実装
/// <summary>
/// エラーメッセージを格納するディクショナリ
/// </summary>
+ protected Dictionary<string, List<string>> _errors = new Dictionary<string, List<string>>();
/// <summary>
/// プロパティごとのバリデーションロジックを実装する
/// </summary>
/// <param name="propertyName"></param>
+ protected abstract void ValidateProperty(string propertyName);
/// <summary>
/// 指定されたプロパティ名とエラーメッセージをエラーリストに追加する
/// </summary>
/// <param name="propertyName">エラーを追加するプロパティの名前</param>
/// <param name="error">追加するエラーメッセージ</param>
+ protected void AddError(string propertyName, string error)
{
// 指定したキー(propertyName)に対応する値が存在するかチェック
// キーが存在すれば true を返し対応する値を out パラメータに割り当てる
// キーが無ければ false
// propertyName に対応するキーが無ければ新しくエラーリストを作成
if (!_errors.TryGetValue(propertyName, out var errorList))
{
errorList = new List<string>();
_errors[propertyName] = errorList;
}
// リストにエラーメッセージが含まれていなかったら場合はメッセージを追加しエラーイベントを通知
if (!errorList.Contains(error))
{
errorList.Add(error);
// エラーが追加されたことを通知する
OnErrorsChanged(propertyName);
}
}
/// <summary>
/// 指定したプロパティ名のエラーをクリアする
/// </summary>
/// <param name="propertyName">エラーをクリアするプロパティ名</param>
+ protected void ClearErrors(string propertyName)
{
if(_errors.TryGetValue(propertyName, out List<string>? errorList) && errorList != null)
{
errorList.Clear();
// エラーがクリアされたことを通知する
OnErrorsChanged(propertyName);
}
}
#endregion
/// <summary>
/// 指定したプロパティ名に関連するエラーが変更されたときに発生するイベントを発行する
/// </summary>
/// <param name="propertyName">エラーが変更されたプロパティの名前</param>
+ private void OnErrorsChanged(string propertyName)
{
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
}
#region INotifyDataErrorInfoインターフェース実装
/// <summary>
/// エラーが存在するかどうかを取得する
/// </summary>
/// <returns>エラーが存在する場合はtrue、存在しない場合はfalseを返す</returns>
+ public bool HasErrors => _errors.Any();
/// <summary>
/// イベントハンドラー
/// </summary>
+ public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
/// <summary>
/// 指定したプロパティ名に関するエラーを取得する
/// </summary>
/// <param name="propertyName">エラーを取得したいプロパティ名</param>
/// <returns>プロパティ名に関連するエラーのリスト</returns>
+ public IEnumerable GetErrors(string? propertyName)
{
if (string.IsNullOrWhiteSpace(propertyName)) return Enumerable.Empty<string>();
if (_errors.TryGetValue(propertyName, out List<string>? errorList))
{
return errorList ?? Enumerable.Empty<string>();
}
return Enumerable.Empty<string>();
}
#endregion
#region データバインディングに必要な処理
/// <summary>
/// プロパティ変更通知 イベント
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// 値が変更されていた場合にのみOnPropertyChangedを実行する
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="storage">変更前のプロパティの値</param>
/// <param name="value">変更後のプロパティの値</param>
/// <param name="propertyName">変更されたプロパティ名</param>
/// <returns>真偽値</returns>
+ protected virtual bool SetProperty<T>(ref T storage, T value, bool validate = false, [CallerMemberName] string? propertyName = null)
{
if (Equals(storage, value)) return false;
storage = value;
OnPropertyChanged(propertyName);
+ if(validate && propertyName != null)
+ {
+ ValidateProperty(propertyName);
+ }
return true;
}
/// <summary>
/// プロパティの変更を検知するメソッド
/// </summary>
/// <param name="property"></param>
protected void OnPropertyChanged([CallerMemberName] string? property = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(property));
}
#endregion
}
}
処理の内容についてはコメントを見て頂ければ分かると思いますが、いくつかポイントを絞って解説します。
SetProperty<T>
メソッドはもともとデータバインディングされているプロパティの値の変更通知を行うメソッドです。
このメソッドにbool validate = false
という引数を追加して、true
が渡ってきた場合に指定されたプロパティ名に対してValidateProperty
メソッドが実行されるように変更しました。
バリデーションを実行したいプロパティに対して以下のように記述します。
public string INotifyDataErrorInfoTextBox
{
get => _iNotifyDataErrorInfoTextBox;
set => SetProperty(ref _iNotifyDataErrorInfoTextBox, value, true); // true を追加する
}
private string _iNotifyDataErrorInfoTextBox;
また、ValidateProperty
メソッドはabstract
となっているため ViewModel で抽象メソッドの実装が強制されます。
protected override void ValidateProperty(string propertyName)
{
// 指定されたプロパティの既存のエラーを削除
ClearErrors(propertyName);
switch (propertyName)
{
case nameof(INotifyDataErrorInfoTextBox):
INotifyDataErrorInfoTextBoxValidate(INotifyDataErrorInfoTextBox, AddError);
break;
// 複数のプロパティのバリデーションを行いたい場合は追記する
default:
throw new ArgumentException("不正なプロパティ名です。");
}
}
INotifyDataErrorInfoTextBoxValidate
メソッドはバリデーションロジックを記述します。引数のAddError
メソッドは継承元のBindableBase
クラスに実装したprotected
なメソッドです。
private void INotifyDataErrorInfoTextBoxValidate(string value, Action<string, string> addError)
{
if (string.IsNullOrWhiteSpace(value))
{
addError(nameof(INotifyDataErrorInfoTextBox), "入力必 須です。");
return;
}
if (int.TryParse(value, out int number))
{
if (number < 0)
addError(nameof(INotifyDataErrorInfoTextBox), "負の値は入力できません。");
if (number > 10)
addError(nameof(INotifyDataErrorInfoTextBox), "最大値を超えています。");
}
else
{
addError(nameof(INotifyDataErrorInfoTextBox), "数値を入力してください。");
}
}
これまではバリデーションに引っかかった場合に例外を発生させていましたが、渡ってきたメソッド(定義済みデリゲート)を実行してプロパティ名に対するエラーメッセージを格納しています。
そしてAddError
メソッドでエラーメッセージが格納されるとOnErrorsChanged
が実行されてエラーメッセージが格納されたことが通知されます。
ビルドして今までの実装を確認します。
エラーメッセージが表示されていることが確認できました。
エラー発生時のスタイルを修正
今回の内容とは異なりますが、Window.Resources
に以下のスタイルを追加して、エラー発生時の TextBox をいい感じに装飾します。
<Style x:Key="ValidatedTextBoxStyle" TargetType="TextBox">
<Style.Triggers>
<Trigger Property="Validation.HasError" Value="True">
<Setter Property="BorderBrush" Value="#ff6366" />
<Setter Property="Background" Value="#ffe1df" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
</Trigger>
</Style.Triggers>
</Style>
各テキストボックスにStyle="{StaticResource ValidatedTextBoxStyle}"
を追加します。
今回作成したアプリのリポジトリはこちらです。