[WPF]ReactivePropertyからR3,ObservableCollectionsへの移行 

# はじめに
WPFにおいて、[runceel/ReactiveProperty](https://github.com/runceel/ReactiveProperty)から[Cysharp/R3](https://github.com/Cysharp/R3)へ移行するときに躓いた点を備忘録としてまとめました。

間違ったことを書いている可能性が十分にあります。
より良い方法などがあればコメントでご指摘いただけるとうれしいです。

今回の記事は下記環境で行っています。
* .NET8
* R3 - 1.2.9
* R3Extensions.WPF - 1.2.9
* ObservableCollections - 3.3.1

:::note
2024/11/09編集
ObservableCollections v3.3.1の公開とともに、記事を大幅に書き直しました。
:::
# 前提
R3には`ReactiveCollection`が内包されていないため、[Cysharp/ObservableCollections](https://github.com/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()`は参考にして似たものを作ります。

```C#:R3Extensions
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));
    }
}
```

```C#:MainWindowViewModel
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);
}
```
<details><summary>MainWindow.xaml</summary>

```xml: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>
```
</details>

![a.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/157544/c7031bc2-87af-a928-5f5c-9d45cc6b902a.gif)

### ライブラリの公開

毎回これらを書くのが大変、ということでライブラリを作成＆公開しました。
[R3Utility](https://github.com/zerodev1200/R3Utility)
まだまだ超ミニマムライブラリですが、これを使えば上記のコードはこう書けます！
```C#:MainWindowViewModel
using R3Utility;

ReactiveCommand = ReactiveValidationHelper.CreateCanExecuteSource(RP1, RP2, RP3)
                                          .ToReactiveCommand(async _ => await DoAsync());
```
`_rp*ObserveHasErrors`も`RP*.ForceNotify()`もいらない！かんたん！やったね！

## フィルタリングできるViewを作成したい
Readmeにある図がすべてを表しているので引用させてもらいます！
![363874407-b5590bb8-16d6-4f9c-be07-1288a6801e68.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/157544/7ea32177-e74a-1af3-b393-7bc2b28d854a.png)

### ObservableCollectionsによるデータフィルタリングの仕組み

ObservableCollectionsでは、データの流れは大きく3つの層に分かれています：

1. **ソースデータ層** (`ObservableList<T>`)
   - 元となるデータのコレクション
&nbsp;
2. **変換層** (`ISynchronizedView<T, TView>`)
   - `CreateView()` メソッドで作成
   - `AttachFilter()` でフィルタリング条件を適用
&nbsp;
3. **表示層** (`NotifyCollectionChangedSynchronizedViewList<T>`)
   - `ToNotifyCollectionChanged()` メソッドで作成
   - WPF/UIとの連携に必要な`INotifyCollectionChanged`を実装

### ポイント

- フィルタリングを行うためには、必ず`CreateView()`で`ISynchronizedView`を作成する必要があります
- フィルターなしで単純に表示用のViewを作成したい場合は、`ToNotifyCollectionChanged()`を直接使用できます
- `AttachFilter()`には`Func<T, bool>`を渡すことで、柔軟なフィルタリング条件を設定できます

```C#:MainWindowViewModel.cs
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; }
}
```
<details><summary>MainWindow.xaml</summary>

```xml: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>
```
</details>

ポイントでも触れましたが`AttachFilter()`は`Func\<T, bool>`を受け取れます。
`MatchesFilter()`で条件を設定し、
それを渡すことにより、Idは完全一致、Nameは大文字小文字を問わない部分一致でフィルタリングができました。
![b.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/157544/941bcf3a-f2e4-aef4-13d2-7afb3b46dfab.gif)



# DataGrid内でObservableListを編集したい
ObservableCollections v3.3.1以降では`CreateWritableView()`および`ToWritableNotifyCollectionChanged()`を使用することで
セルをダブルクリックして内容の編集ができます。
runceel/ReactivePropertyの`FilteredReadOnlyObservableCollection`ではその名の通りReadOnlyでしたが、ObservableCollectionsでは**Writable and Filtable**が実現しています！

# ToReactivePropertyAsSynchronized()がない！
R3のReactivePropertyはミニマムなコア要素だけを提供([*](https://github.com/Cysharp/R3/issues/227#issuecomment-2185424369))してくださってるので、
ここは[R3Utility](https://github.com/zerodev1200/R3Utility)でカバーしました。
`INotifyPropertyChanged`を実装したクラスに`ToTwoWayBindableReactiveProperty()`すれば実現可能です。
```csharp
// 双方向バインドされた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、みなさんもぜひ使ってみてはいかがでしょうか。