はじめに
WPFにおいて、runceel/ReactivePropertyからCysharp/R3へ移行するときに躓いた点を備忘録としてまとめました。
間違ったことを書いている可能性が十分にあります。
より良い方法などがあればコメントでご指摘いただけるとうれしいです。
今回の記事は下記環境で行っています。
- .NET8
- R3 - 1.2.9
- R3Extensions.WPF - 1.2.9
- ObservableCollections - 3.3.1
2024/11/09編集
ObservableCollections v3.3.1の公開とともに、記事を大幅に書き直しました。
前提
R3にはReactiveCollection
が内包されていないため、Cysharp/ObservableCollectionsを使用します。
また、リポジトリのReadmeにある内容は省略しています。
ReactiveProperty vs BindableReactivePropaerty
runceel/ReactivePropertyではReactiveProperty
は全部入りでした。
R3ではReactiveProperty
がプレーンな実装、
BindableReactiveProperty
がINotifyPropertyChangedや値のバリデーションを搭載したものになります。
R3+WPFの場合、BindableReactiveProperty
を使うといいでしょう。
そのほかにもReadOnly(Bindable)ReactiveProperty
や、スレッドセーフなSynchronizedReactiveProperty
があります。
用途に合わせて選定しましょう。
複数のBindableReactivePropertyのHasErrorsがfalseのとき活性化するボタンを作りたい
runceel/ReactivePropertyではIObservable<bool> ObserveHasErrors
プロパティがありましたが、
R3にはbool HasErrors
プロパティしかないため、これを監視できるようにします。
また、ReactivePropertyにあったCombineLatestValuesAreAllFalse()
は参考にして似たものを作ります。
internal static class R3Extensions
{
internal static Observable<bool> CombineLatestValuesAreAllFalse(this IEnumerable<Observable<bool>> sources)
{
return Observable.CombineLatest(sources).Select(xs => xs.All(x => !x));
}
internal static Observable<bool> CombineLatestValuesAreAllFalse(params Observable<bool>[] sources)
{
return Observable.CombineLatest(sources).Select(xs => xs.All(x => !x));
}
}
public class MainWindowViewModel
{
//EnableValidation()を忘れないこと
[Required]
public BindableReactiveProperty<string> RP1 { get; } = new BindableReactiveProperty<string>().EnableValidation<MainWindowViewModel>();
[Required, Range(1, 99)]
public BindableReactiveProperty<int?> RP2 { get; } = new BindableReactiveProperty<int?>().EnableValidation<MainWindowViewModel>();
[Required]
public BindableReactiveProperty<string> RP3 { get; } = new BindableReactiveProperty<string>("").EnableValidation<MainWindowViewModel>();
private readonly Observable<bool> _rp1ObserveHasErrors;
private readonly Observable<bool> _rp2ObserveHasErrors;
private readonly Observable<bool> _rp3ObserveHasErrors;
public ReactiveCommand ReactiveCommand { get; }
public MainWindowViewModel()
{
RP1.ForceNotify();
RP2.ForceNotify();
RP3.ForceNotify();
_rp1ObserveHasErrors = Observable.EveryValueChanged(RP1, x => x.HasErrors);
_rp2ObserveHasErrors = Observable.EveryValueChanged(RP2, x => x.HasErrors);
_rp3ObserveHasErrors = Observable.EveryValueChanged(RP3, x => x.HasErrors);
ReactiveCommand = R3Extensions.CombineLatestValuesAreAllFalse(_rp1ObserveHasErrors, _rp2ObserveHasErrors, _rp3ObserveHasErrors)
.ToReactiveCommand(async _ => await DoAsync());
//もしくは
ReactiveCommand = new[] { _rp1ObserveHasErrors, _rp2ObserveHasErrors, _rp3ObserveHasErrors }
.CombineLatestValuesAreAllFalse()
.ToReactiveCommand(async _ => await DoAsync());
}
private async Task DoAsync() => await Task.Delay(1000);
}
MainWindow.xaml
<Window x:Class="Qiita.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Qiita"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800"
d:DataContext="{d:DesignInstance local:MainWindowViewModel, IsDesignTimeCreatable=True}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="50"/>
<RowDefinition Height="100"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal">
<TextBox Text ="{Binding RP1.Value, UpdateSourceTrigger=PropertyChanged}" Width="50" Margin="10"/>
<TextBox Text ="{Binding RP2.Value, UpdateSourceTrigger=PropertyChanged, TargetNullValue=''}" Width="50" Margin="10" />
<TextBox Text ="{Binding RP3.Value, UpdateSourceTrigger=PropertyChanged}" Width="50" Margin="10" />
</StackPanel>
<Button Grid.Row="1" Command="{Binding ReactiveCommand}" Content="Button" Height="30" Width="100"/>
</Grid>
</Window>
ライブラリの公開
毎回これらを書くのが大変、ということでライブラリを作成&公開しました。
R3Utility
まだまだ超ミニマムライブラリですが、これを使えば上記のコードはこう書けます!
using R3Utility;
ReactiveCommand = ReactiveValidationHelper.CreateCanExecuteSource(RP1, RP2, RP3)
.ToReactiveCommand(async _ => await DoAsync());
_rp*ObserveHasErrors
もRP*.ForceNotify()
もいらない!かんたん!やったね!
フィルタリングできるViewを作成したい
Readmeにある図がすべてを表しているので引用させてもらいます!
ObservableCollectionsによるデータフィルタリングの仕組み
ObservableCollectionsでは、データの流れは大きく3つの層に分かれています:
-
ソースデータ層 (
ObservableList<T>
)- 元となるデータのコレクション
- 元となるデータのコレクション
-
変換層 (
ISynchronizedView<T, TView>
)-
CreateView()
メソッドで作成 -
AttachFilter()
でフィルタリング条件を適用
-
-
表示層 (
NotifyCollectionChangedSynchronizedViewList<T>
)-
ToNotifyCollectionChanged()
メソッドで作成 - WPF/UIとの連携に必要な
INotifyCollectionChanged
を実装
-
ポイント
- フィルタリングを行うためには、必ず
CreateView()
でISynchronizedView
を作成する必要があります - フィルターなしで単純に表示用のViewを作成したい場合は、
ToNotifyCollectionChanged()
を直接使用できます -
AttachFilter()
にはFunc<T, bool>
を渡すことで、柔軟なフィルタリング条件を設定できます
public class MainWindowViewModel : BindableBase, IDisposable
{
private readonly IDisposable _disposable;
private readonly ObservableList<Item> _sourceList;
private readonly ISynchronizedView<Item, Item> _synchronizedView;
public NotifyCollectionChangedSynchronizedViewList<Item> FilteredView { get; }
public BindableReactiveProperty<int?> IdFilterText { get; } = new();
public BindableReactiveProperty<string> NameFilterText { get; } = new();
public MainWindowViewModel()
{
var d = Disposable.CreateBuilder();
_sourceList = [
new(){Id = 101,Name = "Apple"},
new(){Id = 102,Name = "Banana"},
new(){Id = 103,Name = "Car"},
new(){Id = 201,Name = "Dog"},
new(){Id = 301,Name = "Eagle"},
new(){Id = 310,Name = "Fire"},
];
//フィルターが適用可能なビューを作成する
_synchronizedView = _sourceList.CreateView(x => x);
//ToNotifyCollectionChanged()でView(画面)にバインドするビューを作成する
//_sourceListがUIスレッドでのみ変更される場合は、引数なしでも可
FilteredView = _synchronizedView.ToNotifyCollectionChanged(SynchronizationContextCollectionEventDispatcher.Current);
//テキストボックス入力後、300ms以内に発生したイベントは溜めて流す
//Skip(1)はWindow起動時に流れてくるイベントをスキップするため
IdFilterText.Skip(1)
.Debounce(TimeSpan.FromMilliseconds(300))
.Subscribe(_ => RefreshView())
.AddTo(ref d);
NameFilterText.Skip(1)
.Debounce(TimeSpan.FromMilliseconds(300))
.Subscribe(_ => RefreshView())
.AddTo(ref d);
d.Add(_synchronizedView);
d.Add(FilteredView);
_disposable = d.Build();
}
private void RefreshView()
{
_synchronizedView.AttachFilter(MatchesFilter);
}
private bool MatchesFilter(Item item)
{
if (!string.IsNullOrEmpty(NameFilterText.Value) && item.Name?.Contains(NameFilterText.Value, StringComparison.CurrentCultureIgnoreCase) == false)
{
return false;
}
if (IdFilterText.Value is not null && !item.Id.Equals(IdFilterText.Value))
{
return false;
}
return true;
}
public void Dispose()
{
_disposable.Dispose();
GC.SuppressFinalize(this);
}
}
public class Item
{
public required int Id { get; set; }
public required string Name { get; set; }
}
MainWindow.xaml
<Window x:Class="Qiita.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Qiita"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800"
d:DataContext="{d:DesignInstance local:MainWindowViewModel, IsDesignTimeCreatable=True}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="50" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Idフィルタ" Margin="5 5 5 5"/>
<TextBox Text="{Binding IdFilterText.Value, UpdateSourceTrigger=PropertyChanged, TargetNullValue=''}" Width="150" Margin="5 5 5 5"/>
<TextBlock Text="Nameフィルタ" Margin="25 5 5 5"/>
<TextBox Text="{Binding NameFilterText.Value, UpdateSourceTrigger=PropertyChanged}" Width="150" Margin="5 5 5 5"/>
</StackPanel>
<ListView Grid.Row="1" ItemsSource="{Binding FilteredView}" >
<ListView.View>
<GridView>
<GridViewColumn Header="Id" Width="50" DisplayMemberBinding="{Binding Id}"/>
<GridViewColumn Header="Name" Width="100" DisplayMemberBinding="{Binding Name}"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
</Window>
ポイントでも触れましたがAttachFilter()
はFunc\<T, bool>
を受け取れます。
MatchesFilter()
で条件を設定し、
それを渡すことにより、Idは完全一致、Nameは大文字小文字を問わない部分一致でフィルタリングができました。
DataGrid内でObservableListを編集したい
ObservableCollections v3.3.1以降ではCreateWritableView()
およびToWritableNotifyCollectionChanged()
を使用することで
セルをダブルクリックして内容の編集ができます。
runceel/ReactivePropertyのFilteredReadOnlyObservableCollection
ではその名の通りReadOnlyでしたが、ObservableCollectionsではWritable and Filtableが実現しています!
ToReactivePropertyAsSynchronized()がない!
R3のReactivePropertyはミニマムなコア要素だけを提供(*)してくださってるので、
ここはR3Utilityでカバーしました。
INotifyPropertyChanged
を実装したクラスにToTwoWayBindableReactiveProperty()
すれば実現可能です。
// 双方向バインドされたBindableReactiveProperty<T>を作成
BindableReactiveProperty<string> name = item.ToTwoWayBindableReactiveProperty(x => x.Name);
// BindableReactivePropertyを更新するとソースも更新される
name.Value = "X"; //item.Name = "X"
// ソースを更新するとBindableReactivePropertyの値も更新される
// (通常のObservePropertyChangedと同じ)
item.Name = "Y"; //name.Value = "Y"
// ObservePropertyChanged()と同じように3階層まで可能
BindableReactiveProperty<string> nestedProperty = viewModel.ToTwoWayBindableReactiveProperty(
x => x.User,
x => x.Profile,
x => x.DisplayName
);
おわりに
「躓いたところ」の記事のためR3のいいところを全く紹介できていませんが、
R3最高! とても便利に使っています。
neueccさん、Cysharpさんすごい!(語彙力)
R3は新時代のハイパフォーマンスRx、みなさんもぜひ使ってみてはいかがでしょうか。