かずきのBlog@hatena

日本マイクロソフトに勤めています。XAML + C#の組み合わせをメインに、たまにASP.NETやJavaなどの.NET系以外のことも書いています。掲載内容は個人の見解であり、所属する企業を代表するものではありません。

ReactivePropertyでModelに紐づくViewModelの作り方のパターン

データの入れ物のModelに対するViewModelは大体こんな感じになるよねっていう感じのものを作ってみました。

とりあえず以下のようなイメージです。

  • ModelはINotifyPropertyChangedを実装した値の入れ物のクラス
  • ViewModelはそれをラップしてVからの入力値を保持するクラス
  • ViewModelでは入力値の検証を行う
  • ViewModelではModelのプロパティが変わったらそれを直ちに反映する
  • ViewModelではVからの入力値にエラーがない状態でCommitメソッドを呼ばれたらModelに値を書き戻す

まず、値の入れ物のクラスを作ります。

class Person : BindableBase
{
    private int age;

    public int Age
    {
        get { return this.age; }
        set { this.SetProperty(ref this.age, value); }
    }

    private string name;

    public string Name
    {
        get { return this.name; }
        set { this.SetProperty(ref this.name, value); }
    }
}

ViewModelはこんな感じ。

class PersonViewModel : IDisposable
{
    private CompositeDisposable Disposable { get; } = new CompositeDisposable();

    public Person Model { get; }

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

    [Required]
    [RegularExpression("[0-9]+")]
    [Range(0, 100)]
    public ReactiveProperty<string> Age { get; }

    private ReactiveProperty<bool> hasErrors;
    public ReactiveProperty<bool> HasErrors =>
        hasErrors ?? (hasErrors = Observable.CombineLatest(
            this.Name.ObserveHasErrors,
            this.Age.ObserveHasErrors)
            .Select(x => x.Any(y => y))
            .ToReactiveProperty());

    private Subject<Unit> CommitTrigger { get; } = new Subject<Unit>();

    private IObservable<Unit> CommitAsObservable =>
        this.CommitTrigger
            .Where(_ => !this.HasErrors.Value);

    public PersonViewModel(Person model)
    {
        this.Model = model;

        // Name property
        this.Name = this.Model
            .ObserveProperty(x => x.Name)
            .ToReactiveProperty()
            .SetValidateAttribute(() => this.Name)
            .AddTo(this.Disposable);
        this.CommitAsObservable
            .Select(_ => this.Name.Value)
            .Subscribe(x => this.Model.Name = x)
            .AddTo(this.Disposable);

        // Age property
        this.Age = this.Model
            .ObserveProperty(x => x.Age)
            .Select(x => x.ToString())
            .ToReactiveProperty()
            .SetValidateAttribute(() => this.Age)
            .AddTo(this.Disposable);
        this.CommitAsObservable
            .Select(_ => this.Age.Value)
            .Select(x => int.Parse(x))
            .Subscribe(x => this.Model.Age = x)
            .AddTo(this.Disposable);
    }

    public void Commit()
    {
        this.CommitTrigger.OnNext(Unit.Default);
    }

    public void Dispose()
    {
        this.Disposable.Dispose();
    }
}

M -> VMのプロパティの書き換えはObserveProperty -> ToReactivePropertyでやっています。基本ですね。SetValudateAttributeを使ってDataAnnotationsのデータ検証を有効化しています。

次にVMのReactivePropertyにエラーがあるかどうかを返すHasErrorsというReactivePropertyを定義しています。これは、各プロパティのObserveHasErrorsをCombineLatestで結合して1つでもTrueがあったらTrueになるようにしています。

private ReactiveProperty<bool> hasErrors;
public ReactiveProperty<bool> HasErrors =>
    hasErrors ?? (hasErrors = Observable.CombineLatest(
        this.Name.ObserveHasErrors,
        this.Age.ObserveHasErrors)
        .Select(x => x.Any(y => y))
        .ToReactiveProperty());

次に、値をVMからMに書き戻すためのきっかけとなるタイミングを制御するIObservableを作ります。これは以下のようにSubjectで発火したものをエラーが無いときだけ素通りさせるという感じでいきます。

private Subject<Unit> CommitTrigger { get; } = new Subject<Unit>();

private IObservable<Unit> CommitAsObservable =>
    this.CommitTrigger
        .Where(_ => !this.HasErrors.Value);

このCommitAsObservableに値が流れてきたときにVM -> Mへの値の書き戻し処理を書きます。例としてAgeプロパティを。

// Age property
this.Age = this.Model
    .ObserveProperty(x => x.Age)
    .Select(x => x.ToString())
    .ToReactiveProperty()
    .SetValidateAttribute(() => this.Age)
    .AddTo(this.Disposable);
this.CommitAsObservable
    .Select(_ => this.Age.Value)
    .Select(x => int.Parse(x))
    .Subscribe(x => this.Model.Age = x)
    .AddTo(this.Disposable);

こんな感じでM -> VMとVM -> Mの値の移し替え処理を近いところに定義できるので個人的には書き忘れ起きにくいと思ってるので気に入ってます。

最後に、CommitでCommitTriggerに値を流します。

public void Commit()
{
    this.CommitTrigger.OnNext(Unit.Default);
}

これで、Commitを呼ぶとエラーのないときだけ、VM -> Mへの値の書き戻し処理が走るようになります。

まだちょっとめんどいかな。