Prism/XamarinでAndroidアプリの作成(9):入力テキストのバリデーション
1週間も放置してましたね。まあ最初がおかしかっただけで更新頻度はこんなもんかと。
バリデーション
入力値のバリデーションがしたい気分のときがある。 数字だけしか受け付けたくないとか、ユーザ名とかパスワードのように半角英数字しか受け付けないようにしたいだとか。
パスワード
パスワードの話が出たがパスワードは実に簡単で、EntryにIsPasswordというプロパティがいるのでこいつをTrueにセットしてやればいい。
<Entry IsPassword="True" Placeholder="パスワード" Text="{Binding Password.Value}" />
これで、入力用のソフトキーボードは半角しか入力できない状態で表示され、しかもエントリに入力済みの文字はちゃんと*で伏せられる。
なんかもう楽すぎてユーザ名もこいつでいいかって思いたくなっちゃうんだけど、やっぱり入力した文字が見えないのはまずいよなぁ。 でもこれがこんなに簡単にできるんだから簡単にできるよねと思ったんだけど、残念ながらそんなに簡単でもなかった。
まずは動かしてみる
ValidationAttribute(検証属性)というのが使えるらしい。
一番簡単な例で、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のブログ
これで、確かに起動時に押せなかったボタンが、 エントリに何か入力すると押せるようになっているので、 入力値のバリデーションをする仕組みが働いていることがわかる。
正規表現は正義
このように便利な検証属性であるが、上のマイクロソフトのドキュメントのページに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されないようになった。
(わかりづらいけどエントリに入力しているのは全角”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
が入る。
さて、こうするとちゃんとエラーメッセージが出ている。
もしエラーメッセージが気に入らないときはこんな感じで好きなメッセージを設定できる。
[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.ObserveHasErrors
とPassword.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の時のみログインボタンが押せるコードが書けた。めでたしめでたし。
宿題
このコードでは、ページができた状態ではエントリの中身がNullなので、当然エラーになっていて、 ページができた時からエラーメッセージが表示されている。 つまり何もしていないのにエラーが出ることになるので、これは少し気持ち悪いかもしれない。
これを抑制する仕組みがReactivePropertyにはあって、コンストラクタの引数に mode: ReactivePropertyMode.IgnoreInitialValidationError
を渡せば、初期化時にはバリデーションを行わないようにできる。
しかし、起動時にバリデーションが行われないということはすなわち起動時にバリデーションエラーが出ないということので、本来なら「両方ともNullなので押せない」はずのログインボタンが押せるという判定になってしまっている。 したがってこの対応はちょっと微妙である。というかこの場合は使わないほうがよさそう。
となると、初期化時のReactiveProperty側のValidationの抑制を行うのではなく、エラーメッセージのほうだけを抑制する仕組みを何か考える必要がある。
できないことはないような気はするけれど、まあ、エラーメッセージくらい出ててもいいんじゃねーの?(ぶん投げた)
参考にしたURL