dnackのブログ

コード書く仕事の間にコード書いてる知命目前のおっさんです。

Prism/XamarinでAndroidアプリの作成(9):入力テキストのバリデーション

1週間も放置してましたね。まあ最初がおかしかっただけで更新頻度はこんなもんかと。

バリデーション

入力値のバリデーションがしたい気分のときがある。 数字だけしか受け付けたくないとか、ユーザ名とかパスワードのように半角英数字しか受け付けないようにしたいだとか。

パスワード

パスワードの話が出たがパスワードは実に簡単で、EntryにIsPasswordというプロパティがいるのでこいつをTrueにセットしてやればいい。

            <Entry 
                  IsPassword="True"
                  Placeholder="パスワード"
                  Text="{Binding Password.Value}"
                  />

これで、入力用のソフトキーボードは半角しか入力できない状態で表示され、しかもエントリに入力済みの文字はちゃんと*で伏せられる。

なんかもう楽すぎてユーザ名もこいつでいいかって思いたくなっちゃうんだけど、やっぱり入力した文字が見えないのはまずいよなぁ。 でもこれがこんなに簡単にできるんだから簡単にできるよねと思ったんだけど、残念ながらそんなに簡単でもなかった。

まずは動かしてみる

ValidationAttribute(検証属性)というのが使えるらしい。

docs.microsoft.com

一番簡単な例で、nullだったら怒られるバリデーション属性のRequairedAttributeというのを使ってみよう。 これは、C#のほうでバインドするプロパティを宣言するときにこう書ける。

        [Required]
        public ReactiveProperty<string> Username { get; }

これがちゃんと動いてるのを簡単な例でみてみる。

問題は、どうやってみえるようにするのが一番簡単かが問題なのだけれど…。

前回のエントリで調べた方法を使ってIObservable<bool>trueの時だけ押せるボタンを作ってみるか。 ってことでこんな感じ。

MainPage.cs

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="EntryTest.Views.MainPage"
             Title="{Binding Title}">

    <StackLayout HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand">
        <Entry Text="{Binding Username.Value}"
               Placeholder="ユーザー名"
               />
        <Button Text="Login" Command="{Binding ButtonLoginCommand}" />
    </StackLayout>
</ContentPage>

MainPageViewModel.cs

using Prism.Navigation;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System.Reactive.Disposables;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;

namespace EntryTest.ViewModels
{
    public class MainPageViewModel : ViewModelBase
    {
        [Required]
        public ReactiveProperty<string> Username { get; }
        public AsyncReactiveCommand ButtonLoginCommand { get; }

        private CompositeDisposable Disposable = new CompositeDisposable();

        public MainPageViewModel(INavigationService navigationService)
            : base(navigationService)
        {
            Title = "Main Page";
            Username = new ReactiveProperty<string>()
                .SetValidateAttribute(() => Username)
                .AddTo(Disposable);

            ButtonLoginCommand = Username.ObserveHasErrors
                .Inverse()
                .ToAsyncReactiveCommand()
                .WithSubscribe(AttemptLogin)
                .AddTo(Disposable);
        }

        public async Task AttemptLogin()
        {
            await Task.Delay(3000);
        }

        public override void Destroy()
        {
            base.Destroy();
            Disposable.Dispose();
        }

    }
}

基本的には前回のエントリでReactviPropertySlim<bool>型のプロパティからコマンドを作ったときと同じようにすればいいけれど、エラーの時にTrueになるプロパティをもとにしているので.Inverse()しなければいけないことには注意。

前回: Prism/XamarinでAndroidアプリの作成(8):ReactivePropertyを使って排他処理 - dnackのブログ

これで、確かに起動時に押せなかったボタンが、 エントリに何か入力すると押せるようになっているので、 入力値のバリデーションをする仕組みが働いていることがわかる。 f:id:dnack:20211003082549p:plain:h320 f:id:dnack:20211003082602p:plain:h320

正規表現は正義

このように便利な検証属性であるが、上のマイクロソフトのドキュメントのページにValidationAttributeの派生クラスとして記載されている検証属性の一覧は以下の11個のみ

CompareAttribute
CustomValidationAttribute
DataTypeAttribute
MaxLengthAttribute
MinLengthAttribute
RangeAttribute
RegularExpressionAttribute
RequiredAttribute
StringLengthAttribute
MembershipPasswordAttribute

個々の属性の細かい説明は本筋ではないので割愛するけれど、 とにかく半角英数字だけOK みたいな都合のいいのはない。 というか文字種で縛りをかけるものがそもそもない。 RegularExpressionAttributeがあるんだからいらねーだろって話なんだろうね。 ごもっとも。 ということで、ViewModelに[RegularExpression(@"[a-zA-Z0-9]+")]の一行を追加する。

なお、正規表現でもうまくいかんわという場合には、CustomValidationAttributeを使って自前でValidationのメソッドを書くことになる。

        [Required]
        [RegularExpression(@"[a-zA-Z0-9]+")]
        public ReactiveProperty<string> Username { get; }

これで全角だとボタンがEnableされないようになった。

f:id:dnack:20211003083012p:plain:h320

(わかりづらいけどエントリに入力しているのは全角”h”)

エラーメッセージを出してみる

人間というのは察しがよくないので、ボタンが押せないときに、なんで押せないかわからなくて途方にくれたりキレたりする。人間は愚か。

そして残念ながらUIというのは人間様を相手に想定しているわけなので、なんでボタンが押せないのか教えてやる必要があるだろう。

まず、Viewで、エントリとボタンの間にエラーメッセージ表示用のラベルを追加。

        <Label Text="{Binding UsernameErrorMessage.Value}"
               TextColor="Red"
               />

ViewModelではまずバインドするプロパティを宣言して(表示専用だからReadOnlyをつかうのがいい)

        public ReadOnlyReactiveProperty<string> UsernameErrorMessage { get; }

コンストラクタの中での初期化はこんな感じ。

            UsernameErrorMessage = Username.ObserveErrorChanged
                .Select(x => x?.Cast<string>().FirstOrDefault())
                .ToReadOnlyReactiveProperty()
                .AddTo(Disposable);

UserNameのObeserveErrrorChangedにバリデーションエラーがIEnumerableで入るので、 その中の先頭をとってきて、stringに解釈している。 FirstOrDefault()でとってきているので、IEnumerableがひとつも入っていない場合は stringのデフォルト値であるnullが入る。

さて、こうするとちゃんとエラーメッセージが出ている。

f:id:dnack:20211003083140p:plain:h320 f:id:dnack:20211003083150p:plain:h320

もしエラーメッセージが気に入らないときはこんな感じで好きなメッセージを設定できる。

        [Required(ErrorMessage = "ユーザ名を入力してください")]
        [RegularExpression(@"[a-zA-Z0-9]+", ErrorMessage = "半角英数字のみ入力できます")]

2つのエントリの両方ともエラーがない時だけ押せるボタン

今まではバリデーションする入力エントリが1つだったが、 ユーザ名・パスワードを入力してログインする場合だと、 両方とも有効でないとログインボタンを押せるようにする意味がない。 そこで、こんな感じにする。

            ButtonLoginCommand = Username.ObserveHasErrors
                .CombineLatest(Password.ObserveHasErrors, (x, y) => !x && !y)
                .ToAsyncReactiveCommand()
                .WithSubscribe(AttemptLogin)
                .AddTo(Disposable);

見ているのが一つの時は、Username.ObserveHasErrorsを反転するだけでよかったが、 Username.ObserveHasErrorsPassword.ObserveHasErrorsの両方を見ないといけないので、 それをCombineLatestで実現している。Rxマジで便利だな。なんもわからんけど。。 で、両方がfalseのときtrueを返すIObservable<bool>型を作れば、あとはいつも通り。

MainPage.xaml

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="EntryTest.Views.MainPage"
             Title="{Binding Title}">
    <StackLayout HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand">
        <Grid ColumnDefinitions="*,*"
              RowDefinitions="*,*" >
            <Entry Text="{Binding Username.Value}"
                   Placeholder="ユーザー名"
                   Grid.Row="0"
                   Grid.Column="0"
                   />
            <Label Text="{Binding UsernameErrorMessage.Value}"
                   TextColor="Red"
                   Grid.Row="0"
                   Grid.Column="1"
                   />
            <Entry Text="{Binding Password.Value}"
                   Placeholder="パスワード"
                   IsPassword="True"
                   Grid.Row="1"
                   Grid.Column="0"                 
                   />
            <Label Text="{Binding PasswordErrorMessage.Value}"
                   TextColor="Red"
                   Grid.Row="1"
                   Grid.Column="1"
                   />
        </Grid>
        <Button Text="Login" Command="{Binding ButtonLoginCommand}" />
    </StackLayout>
</ContentPage>

MainPageViewModel.cs

using Prism.Navigation;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System.Linq;
using System.Reactive.Disposables;
using System.ComponentModel.DataAnnotations;
using System.Reactive.Linq;
using System.Threading.Tasks;

namespace EntryTest.ViewModels
{
    public class MainPageViewModel : ViewModelBase
    {
        [Required(ErrorMessage = "ユーザ名を入力してください")]
        [RegularExpression(@"[a-zA-Z0-9]+", ErrorMessage = "半角英数字のみ入力できます")]
        public ReactiveProperty<string> Username { get; }

        [Required(ErrorMessage = "パスワードを入力してください")]
        [RegularExpression(@"[a-zA-Z0-9]+", ErrorMessage = "半角英数字のみ入力できます")]
        public ReactiveProperty<string> Password { get; }

        public ReadOnlyReactiveProperty<string> UsernameErrorMessage { get; }
        public ReadOnlyReactiveProperty<string> PasswordErrorMessage { get; }

        public AsyncReactiveCommand ButtonLoginCommand { get; }

        private CompositeDisposable Disposable = new CompositeDisposable();

        public MainPageViewModel(INavigationService navigationService)
            : base(navigationService)
        {
            Title = "Main Page";

            Username = new ReactiveProperty<string>()
                .SetValidateAttribute(() => Username)
                .AddTo(Disposable);
            Password = new ReactiveProperty<string>()
                .SetValidateAttribute(() => Password)
                .AddTo(Disposable);

            UsernameErrorMessage = Username.ObserveErrorChanged
                .Select(x => x?.Cast<string>().FirstOrDefault())
                .ToReadOnlyReactiveProperty()
                .AddTo(Disposable);

            PasswordErrorMessage = Password.ObserveErrorChanged
                .Select(x => x?.Cast<string>().FirstOrDefault())
                .ToReadOnlyReactiveProperty()
                .AddTo(Disposable);

            ButtonLoginCommand = Username.ObserveHasErrors
                .CombineLatest(Password.ObserveHasErrors, (x, y) => !x && !y)
                .ToAsyncReactiveCommand()
                .WithSubscribe(AttemptLogin)
                .AddTo(Disposable);
        }

        public async Task AttemptLogin()
        {
            await Task.Delay(3000);
        }

         public override void Destroy()
        {
            base.Destroy();
            Disposable.Dispose();
        }
    }
}

これで、両方のエントリがValidの時のみログインボタンが押せるコードが書けた。めでたしめでたし。

f:id:dnack:20211003083223p:plain:h320 f:id:dnack:20211003083231p:plain:h320 f:id:dnack:20211003083237p:plain:h320

宿題

このコードでは、ページができた状態ではエントリの中身がNullなので、当然エラーになっていて、 ページができた時からエラーメッセージが表示されている。 つまり何もしていないのにエラーが出ることになるので、これは少し気持ち悪いかもしれない。

これを抑制する仕組みがReactivePropertyにはあって、コンストラクタの引数に mode: ReactivePropertyMode.IgnoreInitialValidationErrorを渡せば、初期化時にはバリデーションを行わないようにできる。

しかし、起動時にバリデーションが行われないということはすなわち起動時にバリデーションエラーが出ないということので、本来なら「両方ともNullなので押せない」はずのログインボタンが押せるという判定になってしまっている。 したがってこの対応はちょっと微妙である。というかこの場合は使わないほうがよさそう。

となると、初期化時のReactiveProperty側のValidationの抑制を行うのではなく、エラーメッセージのほうだけを抑制する仕組みを何か考える必要がある。

できないことはないような気はするけれど、まあ、エラーメッセージくらい出ててもいいんじゃねーの?(ぶん投げた)

参考にしたURL

qiita.com

qiita.com