追記・修正
前提
ReactivePropertyの基礎的なことが分かっている人向けです。分からない人は検索すれば良質な記事がいろいろと出てくるのでそちらを参照。
何がしたいのか
例えばこんな感じのモデルクラスがあったとして、
//INotifyPropertyChangedの実装は省略
internal class Item
{
public ReactivePropertySlim<bool> IsOk { get; } = new ReactivePropertySlim<bool>();
public ReactivePropertySlim<int> Index { get; } = new ReactivePropertySlim<int>();
public Item(int index, bool isOk)
{
IsOk.Value = isOk;
Index.Value = index;
}
}
このモデルを保持するコレクションにAnyを使いたいときがあります。例えばコレクションに有効な要素があるときのみ押せるボタンを作りたい、とか。
//ItemsにIsOkがTureな要素が一つでもあるときだけAnyOkItemsCommandが有効になって押せるボタン
<Button Command="{Binding AnyOkItemsCommand}">Any OK Items</Button>
そんな時は下のようにAnyの戻り値をToReactiveCommandしたくなるんですが、残念ながらこのコードはコンパイルエラーになります。なぜならObservableCollectionが実装しているAnyの戻り値が素のboolだから。
internal class MainWindowViewModel
{
public ReactiveCollection<Item> Items { get; } = new ReactiveCollection<Item>();
public ReactiveCommand AnyOkItemsCommand{ get; }
public MainWindowViewModel()
{
AnyOkItemsCommand = Items.Any(x => x.IsOk.Value).ToReactiveCommand(); //コンパイルエラー
}
}
…どうすりゃいいの?
解決編
まずはObserveElementObservablePropertyとSelectを使う
と思ったら答えはここにありました。ReactiveProperty使ってれば必ずと言っていいほどお世話になるかずきさんのTwitter。今回のモデル向けに多少改変すると、こんな感じ。
AnyOkItemsCommand = Items.ObserveElementObservableProperty(x => x.IsOk)
.Select(_ => this.Items.Any(x => x.IsOk.Value))
.ToReactiveCommand();
ObserveElementObservablePropertyでIsOkを監視して、変更があったら「IsOkがtrue」を条件にしたAnyを取ってToReactiveCommand。先ほとは違ってSelectの中でAnyをしているので無事にIObservable<bool>が得られます。やったね。
実際にこのIObservable<bool>が機能していることを確認してみましょう。AnyOkItemsCommandを設定したボタンを配置した適当な画面を作ります。Items表示用のDataGridも置いて実行。Itemsは空なので当然ボタンは無効に…
あれー?
何が足りない?
これ別に機能していないというわけではないんです。その証拠に、IsOkがfalseなItemを追加してやればちゃんとボタンが無効になります。
ではなぜ起動直後にボタンが無効にならなかったのかというと、ObserveElementObservablePropertyの発火タイミングが原因。ObserveElementObservablePropertyはあくまでも指定したプロパティの変更を受けて発火するので、コレクションのインスタンスが生成された時点では何の値も発行されません。だからボタンも初期状態のまま。(その理屈でいうとAdd時も別にItemのプロパティが変更されたわけではないのだから値は発行されないのでは?とは思うんですが、そのあたりはちゃんと調べてないです。とにかく発行されるからヨシ!)
改良
画面表示時にはもうItemsに要素があるよ!という場合は上のやり方でいいんですが、初期状態では空にしておきたいときは少々困ります。じゃあどうするかというと、値が発行されないなら発行してやればいい。
//2022/04/25 修正後
AnyOkItemsCommand = Items.ObserveElementObservableProperty(x => x.IsOk)
.Select(_ => this.Items.Any(x => x.IsOk.Value))
.ToReadOnlyReactivePropertySlim() //追加。slimにない機能が必要なら非slim版でも可
.ToReactiveCommand();
//2022/04/25 修正前
AnyOkItemsCommand = Items.ObserveElementObservableProperty(x => x.IsOk)
.Select(_ => false) //追加 この値は後続で使わないので適当にfalse
.StartWith(false) //追加 同上
.Select(_ => this.Items.Any(x => x.IsOk.Value))
.ToReactiveCommand();
というわけでStartWithです。こいつは渡されたシーケンスの先頭に指定した値をくっつけてくれるやつです(ちゃんとした説明は他をあたってください)。これで先頭にbool値を足してやればコマンドの生成時にその値が発行されて ReactivePropertyは初期値が通知されるので、一度ReactivePorpertyにしてやれば無事に起動時にボタンが無効化されます。今度こそやったね。
さらに改良
…と思いきや、これでもまだ足りないところがあります。それはRemoveやClearへの対応。ObserveElementObservablePropertyはAdd時には発火しますが、RemoveやClearには反応してくれません。なので一度OKなItemが追加されてボタンが有効化されてしまうと、RemoveやClearでOKなItemがゼロになってもボタンは有効なままです。そこをケアするとこうなります。
//2022/04/25 修正後
AnyOkItemsCommand = Items.ObserveElementObservableProperty(x => x.IsOk)
.CombineLatest(Items.CollectionChangedAsObservable()) //追加
.Select(_ => this.Items.Any(x => x.IsOk.Value))
.ToReadOnlyReactivePropertySlim()
.ToReactiveCommand();
//2022/04/25 修正前
AnyOkItemsCommand = Items.ObserveElementObservableProperty(x => x.IsOk)
.CombineLatest(Items.CollectionChangedAsObservable()) //追加
.Select(_ => false)
.StartWith(false)
.Select(_ => this.Items.Any(x => x.IsOk.Value))
.ToReactiveCommand();
CollectionChangedAsObservableを監視してやれば、RemoveやClearでもボタンが無効化されます。今度こそ本当にやったね。
拡張メソッド
毎回書くには長いので拡張メソッドにしました。 IObservable<bool> ReadOnlyReactivePropertySlim<bool>を返すので受け取った側でToReactiveCommand等して使います。
//2022/04/25 修正。長くなるので修正前のコードは略。
public static class Extension
{
//対象プロパティがIObservable<bool>
public static ReadOnlyReactivePropertySlim<bool> Any<T>(this ObservableCollection<T> colletion, Expression<Func<T, IObservable<bool>>> propertySelector) where T : class
{
var func = propertySelector.Compile();
return colletion.ObserveElementObservableProperty(propertySelector)
.CombineLatest(colletion.CollectionChangedAsObservable())
.ToReadOnlyReactivePropertySlim()
.Select(_ => colletion.Any(x=> func.Invoke(x).MostRecent(false).First()));
}
//対象プロパティがbool
public static ReadOnlyReactivePropertySlim<bool> Any<T, TProperty>(this ObservableCollection<T> collection, Expression<Func<T, bool>> propertySelector) where T:class, INotifyPropertyChanged
{
var func = propertySelector.Compile();
return collection.ObserveElementProperty(propertySelector)
.CombineLatest(collection.CollectionChangedAsObservable())
.ToReadOnlyReactivePropertySlim()
.Select(_ => ((IEnumerable<T>)collection).Any(x => func(x)));
}
//対象プロパティがIObservableな任意の型
public static ReadOnlyReactivePropertySlim<bool> Any<T,TProperty>(this ObservableCollection<T> collection, Expression<Func<T, IObservable<TProperty>>> propertySelector, Func<T, bool> predicate) where T : class
{
return collection.ObserveElementObservableProperty(propertySelector)
.CombineLatest(collection.CollectionChangedAsObservable())
.ToReadOnlyReactivePropertySlim()
.Select(_ => collection.Any(predicate));
}
//対象プロパティが任意の型
public static ReadOnlyReactivePropertySlim<bool> Any<T, TProperty>(this ObservableCollection<T> collection, Expression<Func<T, TProperty>> propertySelector, Func<T, bool> predicate) where T : class, INotifyPropertyChanged
{
return collection.ObserveElementProperty(propertySelector)
.CombineLatest(collection.CollectionChangedAsObservable())
.ToReadOnlyReactivePropertySlim()
.Select(_ => collection.Any(predicate));
}
//プロパティを指定しない
public static IObservable<bool> Any<T>(this ObservableCollection<T> collection)
{
return collection.ObserveProperty(x => x.Count).Select(x => x != 0);
}
}
それぞれの使い方はこんな感じ。
//対象プロパティ(IsOk)がIObservable<bool>もしくはboolのとき。見かけ上は同じになる。
AnyOkItemsCommand = Items.Any(x => x.IsOk).ToReactiveCommand();
//対象プロパティ(Index)がIObservableなとき(ここではReactivePropertを想定)。
AnyOkItemsCommand = Items.Any(x => x.Index, x => x.Index.Value == 1).ToReactiveCommand();
//対象プロパティ(Index)がIObservableじゃないとき。
AnyOkItemsCommand = Items.Any(x => x.Index, x => x.Index == 1).ToReactiveCommand();
//コレクションが空かどうかだけ見たいとき。
AnyItemsCommand = Items.Any().ToReactiveCommand();
ちなみにこの拡張メソッドを実装すると、素のboolを返すAnyを使いたいときはIEnumerable<T>へのキャストが必要になります。不都合があるときはメソッド名を変えてください。
2022/04/25追記)
ToReactiveCommand()の戻り値をDisposeするだけでいいの?という点に関しては正直よく分からないです(コメント欄参照)。実際に使うときはAnyの戻り値を使いまわせるようにプロパティなりなんなりに保持すると思うので、エラーが出るようならそちらも併せてDisposeしてみてください(解決する保証はないですが)。