kamulog

xamarin.formsのネタなど

Xamarin.FormsでReactivePropertyとDataAnnotationsを使ってバリデーションを行うサンプル(独自の検証ルールも作ってみる)

あまりXamarin.Formsでのバリデーションに関する記事が見当たらなかったので、いつも使ってるReactivePropertyとDataAnnotationsを使ったバリデーション方法をご紹介します。

作成したもの

github.com

下準備

ターゲットフレームワーク変更

PCLのターゲットフレームワークをプロファイルを44に変更します。

f:id:kamusoft:20170530202732p:plain

このプロファイルでDataAnnotationsを使えるReactivePropertyがインストールできます。 他のプロファイルでもWindowsPhoneないやつはいけると思いますが、私はいつもこれでやっていますので参考までに。 .net standardは私の環境ではどうやってもできませんでした…

ReactivePropertyをインストー

nugetでReactiveProperty3.2.0をPCl+各プラットフォームにインストール。 これ以降のバージョンは上記プロファイルではインストールできない。

f:id:kamusoft:20170530202758p:plain

Androidのプロジェクトの参照からSystem.Runtime.InteropService.WindowsRuntimeを削除

f:id:kamusoft:20170530202814p:plain

参考

ReactiveProperty/HowToUseXF-ja.md at master · runceel/ReactiveProperty · GitHub

コーディングの手順

  1. 入力用のReactivePropertyを定義
  2. プロパティにValidationAttributeをつける
  3. プロパティにルールをセットする(必要ならmodelとシンクロ)
  4. エラー用のReactivePropertyを定義
  5. エラー用プロパティに入力用プロパティのエラーが流れるように設定する
  6. (必要なら)全体のエラーとCommandを結びつける

コード

ViewModel

public class MainPageViewModel : BindableBase
{
    //TODO: 言語リソースからメッセージを取得する場合はこれ
    //[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(Lang))] 
    [Required(ErrorMessage = "必須")]
    [StringLength(20, ErrorMessage = "20文字以内で入力してください")]
    public ReactiveProperty<string> Title { get; }
    public ReadOnlyReactiveProperty<string> TitleError { get; }
    
    [StringLength(140, ErrorMessage = "140文字以内で入力してください")]
    public ReactiveProperty<string> Description { get; }
    public ReadOnlyReactiveProperty<string> DescriptionError { get; }
    
    //[Range(0,999,ErrorMessage = "0-999の範囲で入力してください")]
    [RegularExpression(@"[0-9]{1,3}", ErrorMessage = "3桁以内の整数で入力してください")]
    public ReactiveProperty<string> Priority { get; }
    public ReadOnlyReactiveProperty<string> PriorityError { get; }

    public ReactiveProperty<DateTime> DateTo { get; }
    public ReactiveProperty<DateTime> DateFrom { get; }

    [CombineCompareAsDate(Mode.LessThanOrEqual, ErrorMessage = "終了日は開始日以降にしてください")]
    public ReactiveProperty<IList<DateTime>> CombineDate { get; }
    public ReadOnlyReactiveProperty<string> DateError { get; }

    public AsyncReactiveCommand OKCommand { get; set; }

    public MainPageViewModel(IPageDialogService pageDialog, ToDo todo)
    {

        Title = todo.ToReactivePropertyAsSynchronized(x => x.Title, ignoreValidationErrorValue: true) //ModelのTitleとシンクロして、エラー以降の値はModelにセットしない
                    .SetValidateAttribute(() => this.Title);    //Validationセット

        Description = todo.ToReactivePropertyAsSynchronized(x => x.Descriptions, ignoreValidationErrorValue: true)
                          .SetValidateAttribute(() => this.Description);

        Priority = todo.ToReactivePropertyAsSynchronized(
                            x => x.Priority,
                            x => x.ToString(),  // M->VMは文字列変換
                            x => {
                                // VM->Mは数値変換
                                int ret;
                                int.TryParse(x, out ret);
                                return ret;
                            },
                         ignoreValidationErrorValue: true
                        )
                       .SetValidateAttribute(() => this.Priority);

        TitleError = Title.ObserveErrorChanged
                          .Select(x => x?.Cast<string>()?.FirstOrDefault()) //発生したエラーの最初の値を文字列として取得
                          .ToReadOnlyReactiveProperty();


        DescriptionError = Description.ObserveErrorChanged
                                      .Select(x => x?.Cast<string>()?.FirstOrDefault())
                                      .ToReadOnlyReactiveProperty();

        PriorityError = Priority.ObserveErrorChanged
                                      .Select(x => x?.Cast<string>()?.FirstOrDefault())
                                      .ToReadOnlyReactiveProperty();

        DateTo = todo.ToReactivePropertyAsSynchronized(x => x.DateTo, ignoreValidationErrorValue: true);
        DateFrom = todo.ToReactivePropertyAsSynchronized(x => x.DateFrom, ignoreValidationErrorValue: true);

        CombineDate = new[]{
            DateFrom,
            DateTo
        }.CombineLatest()   //開始日、終了日をドッキングして
         .ToReactiveProperty() //ReactiveProperty化して
         .SetValidateAttribute(() => this.CombineDate); //カスタムルールを適用

        //それをエラーに流す
        DateError = CombineDate.ObserveErrorChanged.Select(x => x?.Cast<string>()?.FirstOrDefault()).ToReadOnlyReactiveProperty();

        OKCommand = new[]{
            Title.ObserveHasErrors,
            Description.ObserveHasErrors,
            Priority.ObserveHasErrors,
            CombineDate.ObserveHasErrors
        }.CombineLatest(x => x.All(y => !y)).ToAsyncReactiveCommand();  //エラーをまとめてCommand化


        OKCommand.Subscribe(async _ => {
            await pageDialog.DisplayAlertAsync("", "Done", "OK");
        });
    }
}

View

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms" 
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
    xmlns:ef="clr-namespace:AiForms.Effects;assembly=AiForms.Effects"
    x:Class="Sample.Views.MainPage" Title="ToDo Entry"
    BackgroundColor="#F7F0E4">
    <ContentPage.Resources>
        <ResourceDictionary>
            <Style x:Key="BaseView" TargetType="View">
                <Setter Property="ef:AddText.On" Value="true" />
                <Setter Property="ef:AddText.Margin" Value="3" />
                <Setter Property="ef:AddText.Padding" Value="2" />
                <Setter Property="ef:AddText.BackgroundColor" Value="#A0F0F0E0" />
                <Setter Property="ef:AddText.FontSize" Value="8" />
            </Style>
            <Style TargetType="Entry" BasedOn="{StaticResource BaseView}" />
            <Style TargetType="Editor" BasedOn="{StaticResource BaseView}" />
            <Style TargetType="DatePicker" BasedOn="{StaticResource BaseView}" />
        </ResourceDictionary>
    </ContentPage.Resources>
    
    <StackLayout Padding="10">
        <Label Text="Title" />
        <Entry Text="{Binding Title.Value}" BackgroundColor="White" HeightRequest="46" ef:AddText.Text="{Binding TitleError.Value}" />

        <Label Text="Description" />
        <Editor Text="{Binding Description.Value}" BackgroundColor="White" HeightRequest="120" ef:AddText.Text="{Binding DescriptionError.Value}" />

        <Label Text="Priority" />
        <Entry Text="{Binding Priority.Value}" Placeholder="0-999" PlaceholderColor="Silver" BackgroundColor="White" HeightRequest="46" ef:AddText.Text="{Binding PriorityError.Value}" />
        
        <Label Text="Span" />
        <DatePicker Date="{Binding DateFrom.Value}"  ef:AddText.Text="{Binding DateError.Value}" />
        <DatePicker Date="{Binding DateTo.Value}" />

        <Button Text="OK" Command="{Binding OKCommand}" />
    </StackLayout>
</ContentPage>

検証に対応したカスタムViewを作るというのも一つの方法だと思うのですが、個人的にはViewに依存したものは使いにくそうなので、View側にはErrorを表示する場所を提供するものだけを用意するようにしています。 ここではエラーメッセージの表示にはAiForms.EffectsのAddTextを使用しています(宣伝)。

属性について

ルール属性は重ねて付与できるので、複数のルールを適用できます。 またエラーメッセージを言語リソースからとってくる場合はコメントにあるとおり

[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(Lang))] 

こんな感じてキーと言語リソースの型を指定してあげればOKです。

ValidationAttributeの種類

他にもあると思いますが、だいたい以下の感じです。

  • Required
    • 必須チェック
  • StringLength
    • 文字数チェック
  • Range
    • 範囲チェック
  • RegularExpression

気をつけないといけないのがそれぞれの属性は対象となるソースの型が決まっているものがあることです。 例えばRangeのソースにstring型を指定したり、RegularExpressionのソースにstring以外を指定したりすると検証が発動しません。Rangeはint型を、RegularExpressionはstring型を指定するようにしましょう。

定義済みのルールが少ないかもしれませんが、正規表現があればきっとなんとでもなると思いますw

Reactivepropertyの設定

ほとんど以下のオーバービュー通りにしてるだけです。

MVVMをリアクティブプログラミングで快適にReactivePropertyオーバービュー - かずきのBlog@hatena

Property設定からCommand設定まですると簡単に2度押し防止でエラーなしでCanExecuteなボタンが作成できて本当に便利です。

注意すべきはmodelとシンクロして、ソースはint型なのにValidationAttribute用にstringを使わなきゃいけないというような場合です。 その場合はToReactivePropertyAsSynchronizedでModel->ViewModelとViewModel->Modelの変換処理を設定しないといけません。 このサンプルではPriorityのところです。ちょっとここは辛いですね。

独自の検証属性を作成する

正規表現でもカバーしきれないようなルールがある場合は、独自の検証属性を作成することができます。 このサンプルでは2つの日付項目の大小チェックをカスタム属性を作成して対応しています。

2つの項目を対象とする場合、それぞれに検証属性をつけて無理やりやろうとすると検証の無限ループでStackOverflowになりがちなので、ここはReactivePropertyの力を借ります。

検証対象の2つの項目をCombineLatestしたものに適用するようにすれば、その1つだけに属性を付与してあげれば良いようにできます。

独自の検証属性の作成方法は以下の記事を参考にしています。

WPFでの入力値検証・その7 ~独自の検証ロジックを作成する~ - SourceChord

ValidationAttributeを継承した〇〇Attributeというクラスを作ってIsValidをoverrideしてそこに検証の処理を書きます。 検証で問題なければValidationResult.Successを返し、問題あればValidationResult(ErrorMessage)でエラーメッセージを返すようにします。

この検証属性のIsValidの処理ですが、CombineLatestでまとめたものがvalueに入ってくるのでそれを比較しているだけです。

public class CombineCompareAsDateAttribute : ValidationAttribute
{
    Mode CompareOption;

    public CombineCompareAsDateAttribute(Mode compareOption)
    {
        this.CompareOption = compareOption;
    }

    public CombineCompareAsDateAttribute(Mode compareOption, string errorMessage) : this(compareOption)
    {
        this.CompareOption = compareOption;
        this.ErrorMessage = errorMessage;
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        var v = value as IList;
        if (v == null)
            return ValidationResult.Success;    //想定外のは成功とする(他のルールに任せる)

        if (v.Count > 2) {
            return ValidationResult.Success;
        }

        var dateA = v[0] as DateTime?;
        var dateB = v[1] as DateTime?;

        if (dateA == null || dateB == null) {
            return ValidationResult.Success;
        }

        bool ret = true;
        switch (CompareOption) {
            case Mode.LessThan:
                ret = dateA >= dateB;
                break;
            case Mode.LessThanOrEqual:
                ret = dateA > dateB;
                break;
            case Mode.GreaterThan:
                ret = dateA >= dateB;
                break;
            case Mode.GreaterThanOrEqual:
                ret = dateA > dateB;
                break;
        }

        return ret ? new ValidationResult(ErrorMessage) : ValidationResult.Success;
    }
}

public enum Mode
{
    LessThan, LessThanOrEqual, GreaterThan, GreaterThanOrEqual,
}

終わりに

Xamarin.Formsのバリデーションに関してはあんまり情報もなく、定番のライブラリなんかも特に見当たらなかったので、私は基本的にこの方法で検証しています。そしてこれで必要十分だと思います。何かの参考になれば幸いです。