redwarrior’s diary

C#, ASP.NET, WEB, G*などの雑多な情報

PasswordBoxのバリデーションをMVVMアーキテクチャで実装する

前提のはなし

WPFでマスクのついたテキストボックスを作成するには、PasswordBoxというコントロールが用意されている。

PasswordBoxに入力した値は、string型のPasswordプロパティから取得できるが、セキュリティ上はSecureString型のSecurePasswordプロパティから取得した方が良いという話になっていた。

.NET Coreでは方針が変更されて、SecurePasswordプロパティは推奨されなくなった。それならばと、Passwordプロパティを使うことにした。

Passwordプロパティは、コードビハインドで使用するならば特に障害は無いが、MVVMアーキテクチャバインディングを利用しようとすると、バインディングできるように設計されていないので、単純にバインディングする事は出来ない。

バインディングできるようにヘルパークラスを作って公開している人がいるため、それを利用させてもらう。詳しくは、以下で説明されている。

mseeeen.msen.jp

garafu.blogspot.com

本題

PasswordBoxに入力した値をViewModelのプロパティにバインディング出来るようになったので、ViewModelにバリデーションを実装した。

上記サイトとあまり変わらないが、以下に作成したヘルパークラス(PasswordBoxHelper.cs)、XAMLの使用箇所と、バリデーションを実装したViewModel(CreateUserViewModel.cs)をのせておく。

PasswordBoxHelper.cs

internal class PasswordBoxHelper : DependencyObject
{
    public static readonly DependencyProperty AttachmentProperty
        = DependencyProperty.RegisterAttached(
            "Attachment",
            typeof(bool),
            typeof(PasswordBoxHelper),
            new FrameworkPropertyMetadata(false, AttachmentProperty_Changed));

    public static readonly DependencyProperty PasswordProperty
        = DependencyProperty.RegisterAttached(
            "Password",
            typeof(string),
            typeof(PasswordBoxHelper),
            new FrameworkPropertyMetadata(default(string), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

    public static bool GetAttachment(DependencyObject dp)
    {
        return (bool)dp.GetValue(AttachmentProperty);
    }

    public static void SetAttachment(DependencyObject dp, bool value)
    {
        dp.SetValue(AttachmentProperty, value);
    }

    public static string GetPassword(DependencyObject dp)
    {
        return (string)dp.GetValue(PasswordProperty);
    }

    public static void SetPassword(DependencyObject dp, string value)
    {
        dp.SetValue(PasswordProperty, value);
    }

    private static void AttachmentProperty_Changed(DependencyObject sender, DependencyPropertyChangedEventArgs e)
    {
        if (!(sender is PasswordBox passwordBox)) return;

        if ((bool)e.NewValue)
        {
            passwordBox.PasswordChanged += PasswordBox_PasswordChanged;
        }
    }

    private static void PasswordBox_PasswordChanged(object sender, RoutedEventArgs e)
    {
        var passwordBox = (PasswordBox)sender;
        SetPassword(passwordBox, passwordBox.Password);
    }
}

XAML抜粋

<UserControl x:Class="MainMenu.Views.CreateUser"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"
             xmlns:helper="clr-namespace:MainMenu.Utilities"
             prism:ViewModelLocator.AutoWireViewModel="True"
             Height="350" Width="500">
--省略
<PasswordBox Grid.Row="4" Grid.Column="1" VerticalAlignment="Top" HorizontalAlignment="Left"
         Width="200" Height="24"
         Validation.ErrorTemplate="{StaticResource ErrorTemplate}"
         helper:PasswordBoxHelper.Attachment="True"
         helper:PasswordBoxHelper.Password="{Binding Password, UpdateSourceTrigger=PropertyChanged}" />
--省略

CreateUserViewModel.cs

using Prism.Mvvm;
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Runtime.CompilerServices;

namespace MainMenu.ViewModels
{
    public class CreateUserViewModel : BindableBase, INotifyDataErrorInfo
    {
        private readonly ErrorsContainer<string> _errors;

        private string _password;

        [Required(ErrorMessage = "パスワードを入力してください")]
        public string Password
        {
            get { return _password; }
            set
            {
                if (SetProperty(ref _password, value))
                {
                    ValidateProperty(value);
                }
            }
        }

        public CreateUserViewModel()
        {
            _errors = new ErrorsContainer<string>(RaiseErrorsChanged);
        }

        protected void ValidateProperty(object value, [CallerMemberName] string propertyName = null)
        {
            var context = new ValidationContext(this) { MemberName = propertyName };
            var validationErrors = new List<ValidationResult>();
            if (!Validator.TryValidateProperty(value, context, validationErrors))
            {
                _errors.SetErrors(propertyName, validationErrors.Select(error => error.ErrorMessage));
            }
            else
            {
                _errors.ClearErrors(propertyName);
            }
        }

        private void RaiseErrorsChanged([CallerMemberName] string propertyName = null)
        {
            ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
        }

        public IEnumerable GetErrors(string propertyName) => _errors.GetErrors(propertyName);

        public bool HasErrors => _errors.HasErrors;

        public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
    }
}

解説

XAML)PasswordBoxコントロールのPasswordプロパティは依存関係プロパティではないので、ヘルパークラスで添付プロパティ(Password)を作成して、VIewModelのプロパティ(Password)とバインディングする。

(PasswordBoxHelper.cs)PasswordBoxのPasswordChangedイベントに処理を追加して、PasswordBoxの値が変わったら、ヘルパークラスの添付プロパティに同じ値を入力する。

XAML)ヘルパークラスの添付プロパティと、VIewModelのプロパティがバインディングされているので、ViewModelのプロパティ(Password)が更新される。

バリデーションをしない場合は、PasswordBoxの値がバインディングしたViewModelのプロパティから取得できれば良い、つまり画面からソースへの反映ができれば良く、ViewModelのプロパティをPasswordBoxに反映する、つまりソースから画面への反映は必要無いので、バインドモードはOneWayToSourceでも良いはず。

バリデーションはTextBoxコントロールみたいに、Validation.ErrorTemplateを使用した。

バリデーションを使用した場合は、ViewModelのバリデーション結果によって、画面を更新する(メッセージの表示等)ため、バインドモードはTwoWayを指定する必要があった。指定しないとバリデーションを設定しても行われなかった。

動作環境

  • .NET Core 3.1
  • Prism 7.2

以上