12
12
はじめての記事投稿
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.1.13 1.2.4
  • R3Extensions.WPF - 1.1.13 1.2.4
  • ObservableCollections - 2.1.3 2.1.4

前提

R3にはReactiveCollectionが内包されていないため、Cysharp/ObservableCollectionsを使用します。
また、リポジトリのReadmeにある内容は省略しています。

複数のBindableReactivePropertyのHasErrorsがfalseのとき活性化するボタンを作りたい

runceel/ReactivePropertyではIObservable<bool> ObserveHasErrorsプロパティがありましたが、
R3にはbool HasErrorsプロパティしかないため、これを監視できるようにします。
また、ReactivePropertyにあったCombineLatestValuesAreAllFalse()は参考にして似たものを作ります。

2024/07/17 編集
_rp*ObserveHasErrorsはReadOnlyReactiveProperty<bool>を使用していましたが、
ReactivePropertyである必要はなかったため、Observable<bool>に修正しました。

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
{
    [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<Unit> 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);
}

a.gif

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

ObservableCollectionsでフィルターを適用するにはISynchronizedViewFilterを使用します。
まずはItemとそのフィルターを定義します。

Item.cs
public class Item
{
    public required int Id { get; set; }
    public required string Name { get; set; }
}

public class ItemsFilter : ISynchronizedViewFilter<Item, Item>
{
    public int? IdFilterText { get; set; }
    public string NameFilterText { get; set; }
    
    public bool IsMatch(Item value, Item view)
    {
        if (!string.IsNullOrEmpty(NameFilterText) && !value.Name.Contains(NameFilterText, StringComparison.CurrentCultureIgnoreCase))
        {
            return false;
        }
        if (IdFilterText is not null && !value.Id.Equals(IdFilterText))
        {
            return false;
        }

        return true;
    }

    public void OnCollectionChanged(in SynchronizedViewChangedEventArgs<Item, Item> eventArgs)
    {
    }

    public void WhenFalse(Item value, Item view)
    {
    }

    public void WhenTrue(Item value, Item view)
    {
    }
}

ViewModelでは、基になるObservableListとISynchronizedView、上記のFilter、View(画面)にバインドするINotifyCollectionChangedSynchronizedViewを定義します。
このとき、INotifyCollectionChangedSynchronizedViewはINotifyPropertyChangedを実装したプロパティにしないと画面が更新されませんでした。

2024/08/12 編集
ObservableCollections v2.1.4にて
IColllectionEventDispatcherを受け取るToNotifyCollectionChanged()が追加されたため、
BindingOperations.EnableCollectionSynchronization()は不要になりました

MainWindowViewModel.cs
public class MainWindowViewModel : BindableBase, IDisposable
{
    private readonly IDisposable _disposable;
    
    public BindableReactiveProperty<int?> IdFilterText { get; } = new();
    public BindableReactiveProperty<string> NameFilterText { get; } = new();
    
    public ObservableList<Item> SourceList { get; set; }

    private readonly ItemsFilter _filter = new();
    private readonly ISynchronizedView<Item, Item> _synchronizedView;

    private INotifyCollectionChangedSynchronizedView<Item> _filteredView;
    public INotifyCollectionChangedSynchronizedView<Item> FilteredView
    {
        get => _filteredView;
        set => SetProperty(ref _filteredView, value);
    }
    
    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);
        _synchronizedView.AttachFilter(_filter);
        
        //ToNotifyCollectionChanged()でView(画面)にバインドするビューを作成する
        //SourceListがUIスレッドでのみ変更される場合は、引数なしでも可
        FilteredView = _synchronizedView.ToNotifyCollectionChanged(SynchronizationContextCollectionEventDispatcher.Current);

        //テキストボックス入力後、300ms以内に発生したイベントは溜めて流す
        IdFilterText.Debounce(TimeSpan.FromMilliseconds(300))
                    .Skip(1)
                    .Subscribe(_ => RefreshView())
                    .AddTo(ref d);

        NameFilterText.Debounce(TimeSpan.FromMilliseconds(300))
                      .Skip(1)
                      .Subscribe(_ => RefreshView())
                      .AddTo(ref d);

        d.Add(_synchronizedView);
        d.Add(FilteredView);
        _disposable = d.Build();
    }

    public void RefreshView()
    {
        _filter.IdFilterText = IdFilterText.Value;
        _filter.NameFilterText = NameFilterText.Value;
        FilteredView = _synchronizedView.ToNotifyCollectionChanged(SynchronizationContextCollectionEventDispatcher.Current);
    }

    public void Dispose()
    {
        _disposable.Dispose();
        GC.SuppressFinalize(this);
    }
}

ItemsFilterにより、Idは完全一致、Nameは大文字小文字を問わない部分一致でフィルタリングができました。
文字入力の度、ToNotifyCollectionChanged()内でnew()されるため、この使い方で合ってる?とも思いますが。
b.gif

DataGrid内でObservableListを編集したい

ObservableListはGenericsでないIListを実装していません。(Readme見る限りパフォーマンスの都合上?)
そのため、セルをダブルクリックした時点で「System.InvalidOperationException: ''EditItem' は、このビューに対して許可されていません。'」という例外が発生します。
参考リンク:DataGrid(WPF) の ItemsSource には IList が必要

私にはどうすることもできなかったので、行末にえんぴつマークのボタンを設け、
押すと編集ダイアログが開きそこで編集、という形をとりました;)

おわりに

「躓いたところ」の記事のためR3のいいところを全く紹介できていませんが、
R3最高! とても便利に使っています。
neueccさん、Cysharpさんすごい!(語彙力)
R3は新時代のハイパフォーマンスRx、みなさんもぜひ使ってみてはいかがでしょうか。

12
12
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
12
12