sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

C# CommunityToolkit.Mvvm の学習5 ObservableValidator

ObservableValidator

ObservableValidator は INotifyDataErrorInfo インターフェースを実装した base クラスで、他のアプリケーションモジュールに公開されたプロパティの検証をサポートします。

ObservableObject も継承しています。INotifyPropertyChanged も INotifyPropertyChanging も実装されています。プロパティ変更通知とプロパティ検証の両方をサポートする必要がある、あらゆる種類のオブジェクトの起点として使用できます。

APIs:

  • ObservableValidator
  • ObservableObject

どのように機能するか (How it works)

ObservableValidator の主な特徴はつぎのとおりです:

  • INotifyDataErrorInfo の基本実装を提供して、ErrorsChanged イベントとその他の必要な API を公開します。
  • (ベースとなる ObservableObject クラスによって提供されるものの上に)SetProperty の overload を提供して、プロパティを自動的に検証し、値を更新する前に必要なイベントを発生させる機能を提供します。
  • TrySetProperty の overload は SetProperty に似ています。しかし、バリデーションが成功した場合のみ、プロパティを更新し、さらに検査するために(もし発生していたら)生成されたエラーを返す機能を備えています。
  • ValidateProperty メソッドを公開しています。これは特定のプロパティの値が更新されていないにも関わらず、そのバリデーションは(そのプロパティの)代わりに更新された別のプロパティの値に依存して、マニュアルで更新をトリガーしたいときに便利です。
  • ValidateAllProperties メソッドを公開しています。このメソッドは、現在のインスタンスに存在するすべての public プロパティ(少なくとも 1つの [ValidationAttribute] が適用されている)の検証を自動的に実行します。
  • ユーザーが再入力するようなフォームに binding されたモデルをリセットするときのために ClearAllErrors メソッドを公開しています。
  • プロパティのバリデーションに仕様する ValidationContext を初期化するために、さまざまなパラメーターを渡すことができます。これは、正しく動作させるために追加のサービス/オプション を必要とするような、カスタム バリデーションを使用したい場合に有用です。

単純な例

以下は、変更通知と検証の両方に対応するプロパティを実装する方法の例です:

public class RegistrationForm : ObservableValidator
{
    private string name;

    [Required]
    [MinLength(2)]
    [MaxLength(100)]
    public string Name
    {
        get => name;
        set => SetProperty(ref name, value, true);
    }
}

ObservableValidator クラスによって公開されている SetProperty メソッドを呼び出しています。(従来の SetProprty から)追加されている true に設定されている bool 値のパラメーターは、値が更新されたときにプロパティも検証するかどうかを示す値です。

ObservableValidator は、プロパティに適用されたすべてのチェック項目(の属性)を、新しい値に対して自動的に検証を実行します。ErrorsChanged を登録すれば GetErrors(string) メソッドを使用して、変更された各プロパティのエラーのリストを取得できます。

Sample

public partial class ValidationFormWidgetViewModel : ObservableValidator
{
    private readonly IDialogService DialogService;

    public ValidationFormWidgetViewModel(IDialogService dialogService)
    {
        DialogService = dialogService;
    }

    public event EventHandler? FormSubmissionCompleted;
    public event EventHandler? FormSubmissionFailed;

    [ObservableProperty]
    [Required]
    [MinLength(2)]
    [MaxLength(100)]
    private string? firstName;

    [ObservableProperty]
    [Required]
    [MinLength(2)]
    [MaxLength(100)]
    private string? lastName;

    [ObservableProperty]
    [Required]
    [EmailAddress]
    private string? email;

    [ObservableProperty]
    [Required]
    [Phone]
    private string? phoneNumber;

    [RelayCommand]
    private void Submit()
    {
        ValidateAllProperties();

        if (HasErrors)
        {
            FormSubmissionFailed?.Invoke(this, EventArgs.Empty);
        }
        else
        {
            FormSubmissionCompleted?.Invoke(this, EventArgs.Empty);
        }
    }

    [RelayCommand]
    private void ShowErrors()
    {
        string message = string.Join(Environment.NewLine, GetErrors().Select(e => e.ErrorMessage));

        _ = DialogService.ShowMessageDialogAsync("Validation errors", message);
    }
}
<StackPanel Spacing="16">

    <!--  Text forms  -->
    <controls:ValidationTextBox
        HeaderText="Enter your first:"
        PlaceholderText="First name"
        PropertyName="FirstName"
        Text="{x:Bind ViewModel.FirstName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
    <controls:ValidationTextBox
        HeaderText="Enter your last name:"
        PlaceholderText="Last name"
        PropertyName="LastName"
        Text="{x:Bind ViewModel.LastName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
    <controls:ValidationTextBox
        HeaderText="Enter your email address:"
        PlaceholderText="Email"
        PropertyName="Email"
        Text="{x:Bind ViewModel.Email, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
    <controls:ValidationTextBox
        HeaderText="Enter your phone number:"
        PlaceholderText="Phone number"
        PropertyName="PhoneNumber"
        Text="{x:Bind ViewModel.PhoneNumber, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

    <!--  Submit command  -->
    <Button Command="{x:Bind ViewModel.SubmitCommand}" Content="Submit" />

    <!--  Popups  -->
    <Grid>
        <muxc:InfoBar
            x:Name="SuccessInfoBar"
            Title="Success"
            Message="The form was filled in correctly."
            Severity="Success">
            <interactivity:Interaction.Behaviors>
                <interactions:EventTriggerBehavior EventName="FormSubmissionCompleted" SourceObject="{x:Bind ViewModel}">
                    <interactions:ChangePropertyAction
                        PropertyName="IsOpen"
                        TargetObject="{x:Bind SuccessInfoBar}"
                        Value="True" />
                    <interactions:ChangePropertyAction
                        PropertyName="IsOpen"
                        TargetObject="{x:Bind FailureInfoBar}"
                        Value="False" />
                </interactions:EventTriggerBehavior>
            </interactivity:Interaction.Behaviors>
        </muxc:InfoBar>
        <muxc:InfoBar
            x:Name="FailureInfoBar"
            Title="Error"
            Message="The form was filled in with some errors."
            Severity="Error">
            <muxc:InfoBar.ActionButton>
                <Button Command="{x:Bind ViewModel.ShowErrorsCommand}" Content="Show errors" />
            </muxc:InfoBar.ActionButton>
            <interactivity:Interaction.Behaviors>
                <interactions:EventTriggerBehavior EventName="FormSubmissionFailed" SourceObject="{x:Bind ViewModel}">
                    <interactions:ChangePropertyAction
                        PropertyName="IsOpen"
                        TargetObject="{x:Bind SuccessInfoBar}"
                        Value="False" />
                    <interactions:ChangePropertyAction
                        PropertyName="IsOpen"
                        TargetObject="{x:Bind FailureInfoBar}"
                        Value="True" />
                </interactions:EventTriggerBehavior>
            </interactivity:Interaction.Behaviors>
        </muxc:InfoBar>
    </Grid>
</StackPanel>

カスタム検証のメソッド (Custom validation methods)

プロパティを検証する際に、viewmodel が追加のサービス、データ、その他 API にアクセスする場合があります。シナリオと必要とされる柔軟性のレベルに応じて、プロパティにカスタムバリデーションを追加する様々な方法があります。

ここでは CustomValidationAttribute 型を使用して、プロパティの追加検証を実行するために特定のメソッドを呼び出す方法の例を示します:

public class RegistrationForm : ObservableValidator
{
    private readonly IFancyService service;

    public RegistrationForm(IFancyService service)
    {
        this.service = service;
    }

    private string name;

    [Required]
    [MinLength(2)]
    [MaxLength(100)]
    [CustomValidation(typeof(RegistrationForm), nameof(ValidateName))]
    public string Name
    {
        get => this.name;
        set => SetProperty(ref this.name, value, true);
    }

    public static ValidationResult ValidateName(string name, ValidationContext context)
    {
        RegistrationForm instance = (RegistrationForm)context.ObjectInstance;
        bool isValid = instance.service.Validate(name);

        if (isValid)
        {
            return ValidationResult.Success;
        }

        return new("The name was not validated by the fancy service");
    }
}

この例の場合、static な ValidateName メソッドを作成して viewmodel に挿入されたサービスを通じて Name プロパティの検証を行います。

このメソッドは(引数で)Name プロパティの値と ValidationContext インスタンスを受け取ります。この ValidationContext には、viewmodel のインスタンス、検証対象のプロパティの名前、オプションで使用中 or 設定可能なサービスプロバイダ、カスタムフラグなどが含まれます。

この例の場合、RegistrationForm インスタンスを(引数の)ValidationContext から取得して、そこから必要なサービスを使用してプロパティを検証しています。このカスタム検証は、他の属性で指定された検証の後に実行される点に注意してください。

なので、カスタム検証メソッドとと既存の検証の属性を自由に組み合わせることが可能です。

カスタム検証の属性 (Custom validation attributes)

カスタム検証をするもうひとつの方法は、カスタムした ValidationAttribute を実装することです。(この実装は)override した IsValid メソッドに検証のロジックを挿入することです。

この方法では、同じ属性を複数の(コード上の)場所で再利用することがとても簡単になるので、上記のアプローチと比べて柔軟性が増します。例として、同じ viewmodel 内の別のプロパティに対してプロパティの検証をしてみることにします。最初のステップは、カスタムした GreaterThanAttribute を定義することです。

public sealed class GreaterThanAttribute : ValidationAttribute
{
    public GreaterThanAttribute(string propertyName)
    {
        PropertyName = propertyName;
    }

    public string PropertyName { get; }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        object
            instance = validationContext.ObjectInstance,
            otherValue = instance.GetType().GetProperty(PropertyName).GetValue(instance);

        if (((IComparable)value).CompareTo(otherValue) > 0)
        {
            return ValidationResult.Success;
        }

        return new("The current value is smaller than the other one");
    }
}

つぎは、作成したカスタム属性を viewmodel に追加します:

public class ComparableModel : ObservableValidator
{
    private int a;

    [Range(10, 100)]
    [GreaterThan(nameof(B))]
    public int A
    {
        get => this.a;
        set => SetProperty(ref this.a, value, true);
    }

    private int b;

    [Range(20, 80)]
    public int B
    {
        get => this.b;
        set
        {
            SetProperty(ref this.b, value, true);
            ValidateProperty(A, nameof(A));
        }
    }
}

この例の場合だと、(比較をする)2つの値は、数値としての特性があり、お互いに特定の関係になければならない。(AはBより大きくなければならない)A プロパティに GreaterThanAttribute を追加して、 B の Setter に ValidateProperty メソッドの呼び出しを追加しています。

再利用可能なカスタム検証の属性を持つことで、アプリケーションの他の viewmodel でも役に立つというメリットを得ることができます。検証ロジックは viewmodel の定義自体から完全に切り離されるため、このアプローチはコードのモジュール化としても役に立ちます。

参考