15
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめての記事投稿
Qiita Engineer Festa20242024年7月17日まで開催中!

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

Last updated at Posted at 2024-07-08

はじめに

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()は参考にして似たものを作ります。

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));
    }
}
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);
}
MainWindow.xaml
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>

a.gif

ライブラリの公開

毎回これらを書くのが大変、ということでライブラリを作成&公開しました。
R3Utility
まだまだ超ミニマムライブラリですが、これを使えば上記のコードはこう書けます!

MainWindowViewModel
using R3Utility;

ReactiveCommand = ReactiveValidationHelper.CreateCanExecuteSource(RP1, RP2, RP3)
                                          .ToReactiveCommand(async _ => await DoAsync());

_rp*ObserveHasErrorsRP*.ForceNotify()もいらない!かんたん!やったね!

フィルタリングできるViewを作成したい

Readmeにある図がすべてを表しているので引用させてもらいます!
363874407-b5590bb8-16d6-4f9c-be07-1288a6801e68.png

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

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

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

ポイント

  • フィルタリングを行うためには、必ずCreateView()ISynchronizedViewを作成する必要があります
  • フィルターなしで単純に表示用のViewを作成したい場合は、ToNotifyCollectionChanged()を直接使用できます
  • AttachFilter()にはFunc<T, bool>を渡すことで、柔軟なフィルタリング条件を設定できます
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; }
}
MainWindow.xaml
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は大文字小文字を問わない部分一致でフィルタリングができました。
b.gif

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、みなさんもぜひ使ってみてはいかがでしょうか。

15
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?