Search code examples
system.reactiveinotifypropertychangedreactiveuiinotifycollectionchanged

How to bind ObservableCollection<T> of mutable model to ReadOnlyObservableCollection<T> of viewmodel using ReactiveUI and DynamicData


I'm using ReactiveUI and DynamicData in my C# project. However, domain model classes still rely on C# events, INotifyPropertyChanged and INotifyCollectionChanged interfaces.

There are Model and ViewModel classes:

public class Model
{
    public ObservableCollection<int> Collection { get; } = new ObservableCollection<int>();
}

public class ViewModel : ReactiveObject, IDisposable
{
    private readonly CompositeDisposable _cleanUp;
    private readonly SourceList<int> _collectionForCurrentBModel = new SourceList<int>();
    private Model _model = new Model();
    private IDisposable _tempCleanUp = Disposable.Empty;

    public ViewModel()
    {
        _cleanUp = new CompositeDisposable();
        _collectionForCurrentBModel.Connect()
            .Bind(out var aModelsForCurrentBModel)
            .Subscribe(Console.WriteLine)
            .DisposeWith(_cleanUp);
        CollectionForCurrentBModel = aModelsForCurrentBModel;

        this.WhenAnyValue(x => x.Model.Collection) // Every time Model in ViewModel changes:
            .Subscribe(collection =>
            {
                // we dispose previous subscription:
                _tempCleanUp.Dispose();
                // then we manually reset SourceList<int> to match new collection:
                _collectionForCurrentBModel.Edit(x =>
                {
                    x.Clear();
                    x.AddRange(collection);
                });
                // finally, we manually subscribe to ObservableCollection<int>'s events to synchronize SourceList<int>.
                _tempCleanUp = collection.ObserveCollectionChanges().Subscribe(pattern =>
                {
                    switch (pattern.EventArgs.Action)
                    {
                        case NotifyCollectionChangedAction.Add:
                            _collectionForCurrentBModel.AddRange(pattern.EventArgs.NewItems.Cast<int>());
                            break;
                        case NotifyCollectionChangedAction.Remove:
                            _collectionForCurrentBModel.RemoveRange(pattern.EventArgs.OldStartingIndex,
                            pattern.EventArgs.OldItems.Count);
                            break;
                        case NotifyCollectionChangedAction.Replace:
                            for (var i = 0; i < pattern.EventArgs.NewItems.Count; i++)
                                _collectionForCurrentBModel.Replace((int) pattern.EventArgs.OldItems[i],
                                (int) pattern.EventArgs.NewItems[i]);
                            break;
                        case NotifyCollectionChangedAction.Move:
                            break;
                        case NotifyCollectionChangedAction.Reset:
                            break;
                        default:
                            throw new ArgumentOutOfRangeException();
                    }
                });
            });
    }

    public ReadOnlyObservableCollection<int> CollectionForCurrentBModel { get; }

    public Model Model
    {
        get => _model;
        set => this.RaiseAndSetIfChanged(ref _model, value);
    }

    public void Dispose()
    {
        _cleanUp.Dispose();
    }
}

So, ViewModel has Model property. Current Model can be changed to another one. ViewModel also has CollectionForCurrentModel property, which is basically equal to its' source (Model.Collection) in this example (however, there is supposed to be some sorting, filtering, etc.). The CollectionForCurrentModel property should be read-only. The code below works as intended:

private static void Main(string[] args)
{
    using var viewModel = new ViewModel();
    // viewModel.Collection: {}
    viewModel.Model.Collection.Add(0);
    // viewModel.Collection: {0}
    viewModel.Model.Collection.Add(1);
    // viewModel.Collection: {0, 1}
    var oldModel = viewModel.Model;
    viewModel.Model = new Model();
    // viewModel.Collection: {}
    viewModel.Model.Collection.Add(2);
    // viewModel.Collection: {2}
    oldModel.Collection.Add(3);
    // viewModel.Collection: {2}
}

However, adding new field to ViewModel for storing latest subscription, manually unsubscribing from it and manually synchronizing collections seems quite ugly. Is there another way to subscribe to:

IObservable<IObservable<IChangeSet<T>>>
\\ is result of
this.WhenAnyValue(x => x.ObservableCollection, selector: collection => collection.ToObservableChangeSet();

? Can DynamicData automatically manage inner subscriptions to bind the observable collection in mutable property to other collection?


Solution

  • This should be easy. You can do something like this:

    this.WhenAnyValue(x => x.Model.Collection)
        .Select(collection => collection.ToObservableChangeSet())
        .Switch() //this is the dynamic data overload of rx.Switch() 
        .Bind(out var aModelsForCurrentBModel)
        .Subscribe();
    

    The select statement returns an observable of observable change sets which on it's own is not very useful. That is why the switch statement is required. When ever the collection has been set it clears the existing items from it's cache and loads the items from the new observable collection. After that you can simply bind to the target observable collection.

    Using this technique means there is no need to manually maintain a source list.