かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

画面が表示された直後に入力値の​妥当性検証を行い画面にフィード​バックをする方法

MVVMパターンSilverlightでアプリケーションを組んでて画面が表示された時から入力値の妥当性検証をしておきたいという要望があるとします。簡単にできるだろうと思ってたら、結構実装に時間がかかったので備忘録がわりにメモしておきます。


ちなみにネタ元はMSDNフォーラムに以下の質問です。

色々質問されていますが、恐らくこのようなことがやりたいのだろうな〜と思ったので実装してみました。

ViewModelの基本クラスの定義

とりあえずSilverlightApplicationを作成してPrismをNuGetから入手して参照に追加します。そしてViewModelを定義します。

namespace PrismMVVMSample.ViewModels
{
    using System;
    using System.Collections.Generic;
    using System.ComponentModel;
    using System.ComponentModel.DataAnnotations;
    using Microsoft.Practices.Prism.ViewModel;

    /// <summary>
    /// ViewModelの基本クラス
    /// </summary>
    public class ViewModelBase : NotificationObject, INotifyDataErrorInfo
    {
        protected ErrorsContainer<ValidationResult> Errors { get; private set; }

        /// <summary>
        /// エラーに変更があったときに通知される。
        /// </summary>
        public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;

        public ViewModelBase()
        {
            this.Errors = new ErrorsContainer<ValidationResult>(this.RaiseErrorsChanged);
        }

        /// <summary>
        /// 指定したプロパティのエラー情報を取得する。
        /// </summary>
        /// <param name="propertyName">プロパティ名</param>
        /// <returns>エラー情報</returns>
        public System.Collections.IEnumerable GetErrors(string propertyName)
        {
            return this.Errors.GetErrors(propertyName);
        }

        /// <summary>
        /// エラーがある場合trueを返す。
        /// </summary>
        public bool HasErrors
        {
            get 
            { 
                return this.Errors.HasErrors;
            }
        }

        /// <summary>
        /// プロパティの検証と変更通知を行う。
        /// </summary>
        /// <param name="propertyName">プロパティ名</param>
        protected override void  RaisePropertyChanged(string propertyName)
        {
            this.ValidateProperty(propertyName);
 	        base.RaisePropertyChanged(propertyName);
        }

        /// <summary>
        /// 全プロパティの検証を行う
        /// </summary>
        public void ValidateProperties()
        {
            foreach (var p in this.GetType().GetProperties())
            {
                this.ValidateProperty(p.Name);
            }
        }

        /// <summary>
        /// 指定したプロパティの検証を行う
        /// </summary>
        /// <param name="propertyName">検証を行うプロパティ名</param>
        private void ValidateProperty(string propertyName)
        {
            var results = new List<ValidationResult>();
            if (Validator.TryValidateProperty(
                    this.GetType().GetProperty(propertyName).GetValue(this, null),
                    new ValidationContext(this, null, null)
                    {
                        MemberName = propertyName
                    },
                    results))
            {
                this.Errors.ClearErrors(propertyName);
            }
            else
            {
                this.Errors.SetErrors(propertyName, results);
            }
        }

        /// <summary>
        /// 指定したプロパティ名のエラーに変更があったことを通知する。
        /// </summary>
        /// <param name="propertyName">プロパティ名</param>
        protected virtual void RaiseErrorsChanged(string propertyName)
        {
            var h = this.ErrorsChanged;
            if (h != null)
            {
                h(this, new DataErrorsChangedEventArgs(propertyName));
            }
        }

    }
}

Prismのクラス総動員?ErrorsContainerはINotifyDataErrorInfoインターフェースを実装するときに楽するためにあるようなクラスなので許される状況ならガンガン使っていきましょう。ポイントは、ValidatePropertiesメソッドで、全プロパティに対して妥当性検証を行う処理を実装しているところです。これを後でViewから呼び出します。

適当なViewModelの定義

さて、ViewModelは何でもよかったのですが、入力を2つ受け取って出力を1つ出すというようなものにしようと思います。入力は2つとも必須入力項目であるということにしました。Executeメソッドで入力2つをModleに渡して結果を出力用プロパティに設定しています。
ということで以下のようなコードになりました。

namespace PrismMVVMSample.ViewModels
{
    using System.ComponentModel.DataAnnotations;
    using Microsoft.Practices.Prism.Interactivity.InteractionRequest;
    using PrismMVVMSample.Models;

    public class MainViewModel : ViewModelBase
    {
        private SampleModel model = new SampleModel();

        private string input;

        private string input2;

        private string output;

        public MainViewModel()
        {
            this.AlertRequest = new InteractionRequest<Notification>();
        }

        /// <summary>
        /// Viewにアラートウィンドウを出すためのリクエスト
        /// </summary>
        public InteractionRequest<Notification> AlertRequest { get; private set; }

        [Display(Name = "入力1")]
        [Required(ErrorMessage = "かならず入力してね")]
        public string Input
        {
            get
            {
                return this.input;
            }

            set
            {
                if (this.input == value)
                {
                    return;
                }

                this.input = value;
                this.RaisePropertyChanged(() => Input);
            }
        }

        [Display(Name = "入力2")]
        [Required(ErrorMessage = "かならず入力してね2")]
        public string Input2
        {
            get
            {
                return this.input2;
            }

            set
            {
                if (this.input2 == value)
                {
                    return;
                }

                this.input2 = value;
                this.RaisePropertyChanged(() => Input2);
            }
        }

        public string Output
        {
            get
            {
                return this.output;
            }

            set
            {
                if (this.output == value)
                {
                    return;
                }

                this.output = value;
                this.RaisePropertyChanged(() => Output);
            }
        }

        public void Execute()
        {
            if (this.InvalidStateAndAlert())
            {
                return;
            }

            this.Output = this.model.Execute(this.Input, this.Input2);
        }

        private bool InvalidStateAndAlert()
        {
            if (this.HasErrors)
            {
                this.AlertRequest.Raise(new Notification
                {
                    Title = "情報",
                    Content = "入力にエラーがあります"
                });
                return true;
            }

            return false;
        }
    }
}

Executeメソッドでは、InvalidStateAndAlertメソッドでエラーがあったらViewにアラートをお願いして何も処理をせずに終了してるのもポイントにはなるのかな。今回の目的とはあまり関係ないですが。

Modelの定義

Modelは適当です。文字連結だけしてます。

namespace PrismMVVMSample.Models
{
    /// <summary>
    /// 適当なモデル
    /// </summary>
    public class SampleModel
    {
        public string Execute(string arg1, string arg2)
        {
            return arg1 + " : " + arg2; 
        }
    }
}

MainPage.xamlの定義

まず、MainPage.xamlです。ここには、これから定義するMainViewとValidationSummaryだけを置いておきます。

<UserControl
    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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:PrismMVVMSample_Views="clr-namespace:PrismMVVMSample.Views" 
    xmlns:sdk="http://schemas.microsoft.com/winfx/2006/xaml/presentation/sdk" 
    x:Class="PrismMVVMSample.MainPage"
    mc:Ignorable="d"
    d:DesignHeight="300" 
    d:DesignWidth="400">

    <Grid x:Name="LayoutRoot" Background="White">
    	<Grid.RowDefinitions>
    		<RowDefinition/>
    		<RowDefinition Height="Auto"/>
    	</Grid.RowDefinitions>

    	<PrismMVVMSample_Views:MainView x:Name="mainView" Margin="0,0,0,16"/>

    	<sdk:ValidationSummary Grid.Row="1" />

    </Grid>
</UserControl>

MainViewの定義

いよいよ今回の肝のMainView.xamlです。最初はLoadedイベントでViewModelのValidatePropertiesを呼び出せば楽勝だと思ったのですが、それだとTextBoxには入力エラーを示すマーカーが出るのですがValidationSummaryには表示されませんでした。恐らくなのですが、LoadedのタイミングだとバリデーションエラーのイベントをValidationSummaryが、うまく拾えないのではないのかと考えられます。なので、今回はSizeChangedイベントで妥当性検証を呼び出すようにしました。毎回呼び出すのでは負荷がかかりそうなので、ViewModelのHasErrorsがfalseの場合だけ妥当性検証をするようにしてお茶を濁しています。

ここらへん、もうちょっとうまいやり方が無いか悩ましい所です。

<UserControl
    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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:PrismMVVMSample_ViewModels="clr-namespace:PrismMVVMSample.ViewModels" 
    xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity" 
    xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions" 
    xmlns:Custom="http://www.codeplex.com/prism" x:Name="userControl" 
    x:Class="PrismMVVMSample.Views.MainView"
    mc:Ignorable="d"
    d:DesignHeight="300" d:DesignWidth="400" TabNavigation="Cycle" xmlns:sdk="http://schemas.microsoft.com/winfx/2006/xaml/presentation/sdk">
    
	<UserControl.Resources>
		<PrismMVVMSample_ViewModels:MainViewModel x:Key="viewModel" d:IsDataSource="True"/>
	</UserControl.Resources>
	<UserControl.DataContext>
		<Binding Source="{StaticResource viewModel}"/>
	</UserControl.DataContext>
    
    <Grid x:Name="LayoutRoot" Background="White">
    	<Grid.ColumnDefinitions>
    		<ColumnDefinition Width="0.5*"/>
        </Grid.ColumnDefinitions>
    	<i:Interaction.Triggers>
            <!-- アラートウィンドウを出すためのTriggerAction -->
    		<Custom:InteractionRequestTrigger SourceObject="{Binding AlertRequest, Mode=OneWay}">
    			<Custom:PopupChildWindowAction>
    				<Custom:PopupChildWindowAction.ChildWindow>
    					<Custom:NotificationChildWindow/>
    				</Custom:PopupChildWindowAction.ChildWindow>
    			</Custom:PopupChildWindowAction>
    		</Custom:InteractionRequestTrigger>
            
            <!-- サイズ変更があって -->
    		<i:EventTrigger EventName="SizeChanged">
    			<i:Interaction.Behaviors>
                    <!-- ViewModelにエラーが無い場合に -->
    				<ei:ConditionBehavior>
    					<ei:ConditionalExpression>
    						<ei:ComparisonCondition LeftOperand="{Binding HasErrors, Mode=OneWay}" RightOperand="false"/>
    					</ei:ConditionalExpression>
    				</ei:ConditionBehavior>
    			</i:Interaction.Behaviors>
                <!-- ViewModelの全プロパティ検証エラーをやる -->
    			<ei:CallMethodAction MethodName="ValidateProperties" TargetObject="{Binding Mode=OneWay}"/>
    		</i:EventTrigger>
    	</i:Interaction.Triggers>
        <TextBox x:Name="textBox" Margin="68,8,0,0" TextWrapping="Wrap" Text="{Binding Input, Mode=TwoWay, NotifyOnValidationError=True, UpdateSourceTrigger=Default}" VerticalAlignment="Top" TabIndex="0" HorizontalAlignment="Left" Width="236">
            <!-- TextBoxのプロパティの変更があったときにBindingをUpdateする -->
            <i:Interaction.Behaviors>
                <Custom:UpdateTextBindingOnPropertyChanged/>
            </i:Interaction.Behaviors>
        </TextBox>
        <TextBox x:Name="textBox2" Margin="68,38,0,0" TextWrapping="Wrap" Text="{Binding Input2, Mode=TwoWay, NotifyOnValidationError=True, UpdateSourceTrigger=Default}" VerticalAlignment="Top" TabIndex="0" HorizontalAlignment="Left" Width="236">
            <!-- TextBoxのプロパティの変更があったときにBindingをUpdateする -->
            <i:Interaction.Behaviors>
                <Custom:UpdateTextBindingOnPropertyChanged/>
            </i:Interaction.Behaviors>
        </TextBox>
        <TextBlock Margin="8,96,8,0" TextWrapping="Wrap" Text="{Binding Output}" VerticalAlignment="Top" />
    	<Button Content="Execute" HorizontalAlignment="Left" Margin="8,68,0,0" VerticalAlignment="Top" Width="75" TabIndex="2">
    		<i:Interaction.Triggers>
                <!-- クリックイベントでViewModelのExecuteメソッドを呼びだす -->
    			<i:EventTrigger EventName="Click">
    				<ei:CallMethodAction MethodName="Execute" TargetObject="{Binding}"/>
    			</i:EventTrigger>
    		</i:Interaction.Triggers>
    	</Button>
        <sdk:Label HorizontalAlignment="Left" Margin="12,12,0,0" Name="label1" VerticalAlignment="Top" Target="{Binding Mode=OneWay}" PropertyPath="Input" />
        <sdk:Label HorizontalAlignment="Left" Margin="12,42,0,0" Name="label2" VerticalAlignment="Top" Target="{Binding Mode=OneWay}" PropertyPath="Input2" />
    </Grid>
</UserControl>

結構BehaviorやTriggerやTriggerActionのオンパレードなのでExpression Blendを使って書かないと手書きは結構大変だと思います。ポイントはGridのTriggersに登録されているSizeChangedのEventTriggerになります。ここに先ほど説明した内容がXAMLで定義されています。

実行してみると

実行してみると以下のように起動直後からバリデーションエラーが、うざいくらい表示されます。このUXをよしとするかしないかは要件しだいなのかな?割と、リアルタイムに妥当性検証のフィードバックをするようにしてみたら不評だったというお話も聞いたりするのですが、個人的には慣れの問題なのかな〜と思ったり思わなかったり・・・。悩ましいですね。

因みに、入力にエラーがある状態でボタンを押すとアラートが表示されます。

もちろん、入力をちゃんとやってればエラーも出ず処理が実行されます。

プロジェクトのダウンロード

コードレシピの以下のサイトからダウンロードできます。