MVVM Sync Collections

Viewed 16938

Is there a standardized way to sync a collection of Model objects with a collection of matching ModelView objects in C# and WPF? I'm looking for some kind of class that would keep the following two collections synced up assuming I only have a few apples and I can keep them all in memory.

Another way to say it, I want to make sure if I add an Apple to the Apples collection I would like to have an AppleModelView added to the AppleModelViews collection. I could write my own by listening to each collections' CollectionChanged event. This seems like a common scenario that someone smarter than me has defined "the right way" to do it.

public class BasketModel
{
    public ObservableCollection<Apple> Apples { get; }
}

public class BasketModelView
{
    public ObservableCollection<AppleModelView> AppleModelViews { get; }
}
11 Answers

I use lazily constructed, auto-updating collections:

public class BasketModelView
{
    private readonly Lazy<ObservableCollection<AppleModelView>> _appleViews;

    public BasketModelView(BasketModel basket)
    {
        Func<AppleModel, AppleModelView> viewModelCreator = model => new AppleModelView(model);
        Func<ObservableCollection<AppleModelView>> collectionCreator =
            () => new ObservableViewModelCollection<AppleModelView, AppleModel>(basket.Apples, viewModelCreator);

        _appleViews = new Lazy<ObservableCollection<AppleModelView>>(collectionCreator);
    }

    public ObservableCollection<AppleModelView> Apples
    {
        get
        {
            return _appleViews.Value;
        }
    }
}

Using the following ObservableViewModelCollection<TViewModel, TModel>:

namespace Client.UI
{
    using System;
    using System.Collections.Generic;
    using System.Collections.ObjectModel;
    using System.Collections.Specialized;
    using System.Diagnostics.Contracts;
    using System.Linq;

    public class ObservableViewModelCollection<TViewModel, TModel> : ObservableCollection<TViewModel>
    {
        private readonly ObservableCollection<TModel> _source;
        private readonly Func<TModel, TViewModel> _viewModelFactory;

        public ObservableViewModelCollection(ObservableCollection<TModel> source, Func<TModel, TViewModel> viewModelFactory)
            : base(source.Select(model => viewModelFactory(model)))
        {
            Contract.Requires(source != null);
            Contract.Requires(viewModelFactory != null);

            this._source = source;
            this._viewModelFactory = viewModelFactory;
            this._source.CollectionChanged += OnSourceCollectionChanged;
        }

        protected virtual TViewModel CreateViewModel(TModel model)
        {
            return _viewModelFactory(model);
        }

        private void OnSourceCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
            switch (e.Action)
            {
            case NotifyCollectionChangedAction.Add:
                for (int i = 0; i < e.NewItems.Count; i++)
                {
                    this.Insert(e.NewStartingIndex + i, CreateViewModel((TModel)e.NewItems[i]));
                }
                break;

            case NotifyCollectionChangedAction.Move:
                if (e.OldItems.Count == 1)
                {
                    this.Move(e.OldStartingIndex, e.NewStartingIndex);
                }
                else
                {
                    List<TViewModel> items = this.Skip(e.OldStartingIndex).Take(e.OldItems.Count).ToList();
                    for (int i = 0; i < e.OldItems.Count; i++)
                        this.RemoveAt(e.OldStartingIndex);

                    for (int i = 0; i < items.Count; i++)
                        this.Insert(e.NewStartingIndex + i, items[i]);
                }
                break;

            case NotifyCollectionChangedAction.Remove:
                for (int i = 0; i < e.OldItems.Count; i++)
                    this.RemoveAt(e.OldStartingIndex);
                break;

            case NotifyCollectionChangedAction.Replace:
                // remove
                for (int i = 0; i < e.OldItems.Count; i++)
                    this.RemoveAt(e.OldStartingIndex);

                // add
                goto case NotifyCollectionChangedAction.Add;

            case NotifyCollectionChangedAction.Reset:
                Clear();
                for (int i = 0; i < e.NewItems.Count; i++)
                    this.Add(CreateViewModel((TModel)e.NewItems[i]));
                break;

            default:
                break;
            }
        }
    }
}

I may not exactly understand your requirements however the way I have handled a similar situation is to use CollectionChanged event on the ObservableCollection and simply create/destroy the view models as required.

void OnApplesCollection_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{    
  // Only add/remove items if already populated. 
  if (!IsPopulated)
    return;

  Apple apple;

  switch (e.Action)
  {
    case NotifyCollectionChangedAction.Add:
      apple = e.NewItems[0] as Apple;
      if (apple != null)
        AddViewModel(asset);
      break;
    case NotifyCollectionChangedAction.Remove:
      apple = e.OldItems[0] as Apple;
      if (apple != null)
        RemoveViewModel(apple);
      break;
  }

}

There can be some performance issues when you add/remove a lot of items in a ListView.

We have solved this by: Extending the ObservableCollection to have an AddRange, RemoveRange, BinaryInsert methods and adding events that notify others the collection is being changed. Together with an extended CollectionViewSource that temporary disconnects the source when the collection is changed it works nicely.

HTH,

Dennis

Well first of all, I don't think there is a single "right way" to do this. It depends entirely on your application. There are more correct ways and less correct ways.

That much being said, I am wondering why you would need to keep these collections "in sync." What scenario are you considering that would make them go out of sync? If you look at the sample code from Josh Smith's MSDN article on M-V-VM, you will see that the majority of the time, the Models are kept in sync with the ViewModels simply because every time a Model is created, a ViewModel is also created. Like this:

void CreateNewCustomer()
{
    Customer newCustomer = Customer.CreateNewCustomer();
    CustomerViewModel workspace = new CustomerViewModel(newCustomer, _customerRepository);
    this.Workspaces.Add(workspace);
    this.SetActiveWorkspace(workspace);
}

I am wondering, what prevents you from creating an AppleModelView every time you create an Apple? That seems to me to be the easiest way of keeping these collections "in sync," unless I have misunderstood your question.

I've written some helper classes for wrapping observable collections of business objects in their View Model counterparts here

This is a slight variation on Sam Harwell's answer, implementing IReadOnlyCollection<> and INotifyCollectionChanged instead of inheriting from ObservableCollection<> directly. This prevents consumers from modifying the collection, which wouldn't generally be desired in this scenario.

This implementation also uses CollectionChangedEventManager to attach the event handler to the source collection to avoid a memory leak if the source collection is not disposed at the same time as the mirrored collection.

/// <summary>
/// A collection that mirrors an <see cref="ObservableCollection{T}"/> source collection 
/// with a transform function to create it's own elements.
/// </summary>
/// <typeparam name="TSource">The type of elements in the source collection.</typeparam>
/// <typeparam name="TDest">The type of elements in this collection.</typeparam>
public class MappedObservableCollection<TSource, TDest>
    : IReadOnlyCollection<TDest>, INotifyCollectionChanged
{
    /// <inheritdoc/>
    public int Count => _mappedCollection.Count;

    /// <inheritdoc/>
    public event NotifyCollectionChangedEventHandler CollectionChanged {
        add { _mappedCollection.CollectionChanged += value; }
        remove { _mappedCollection.CollectionChanged -= value; }
    }

    private readonly Func<TSource, TDest> _elementMapper;
    private readonly ObservableCollection<TDest> _mappedCollection;

    /// <summary>
    /// Initializes a new instance of the <see cref="MappedObservableCollection{TSource, TDest}"/> class.
    /// </summary>
    /// <param name="sourceCollection">The source collection whose elements should be mapped into this collection.</param>
    /// <param name="elementMapper">Function to map elements from the source collection to this collection.</param>
    public MappedObservableCollection(ObservableCollection<TSource> sourceCollection, Func<TSource, TDest> elementMapper)
    {
        if (sourceCollection == null) throw new ArgumentNullException(nameof(sourceCollection));
        _mappedCollection = new ObservableCollection<TDest>(sourceCollection.Select(elementMapper));

        _elementMapper = elementMapper ?? throw new ArgumentNullException(nameof(elementMapper));

        // Update the mapped collection whenever the source collection changes
        // NOTE: Use the weak event pattern here to avoid a memory leak
        // See: https://docs.microsoft.com/en-us/dotnet/framework/wpf/advanced/weak-event-patterns
        CollectionChangedEventManager.AddHandler(sourceCollection, OnSourceCollectionChanged);
    }

    /// <inheritdoc/>
    IEnumerator<TDest> IEnumerable<TDest>.GetEnumerator()
        => _mappedCollection.GetEnumerator();

    /// <inheritdoc/>
    IEnumerator IEnumerable.GetEnumerator()
        => _mappedCollection.GetEnumerator();

    /// <summary>
    /// Mirror a change event in the source collection into the internal mapped collection.
    /// </summary>
    private void OnSourceCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        switch (e.Action) {
            case NotifyCollectionChangedAction.Add:
                InsertItems(e.NewItems, e.NewStartingIndex);
                break;
            case NotifyCollectionChangedAction.Remove:
                RemoveItems(e.OldItems, e.OldStartingIndex);
                break;
            case NotifyCollectionChangedAction.Replace:
                RemoveItems(e.OldItems, e.OldStartingIndex);
                InsertItems(e.NewItems, e.NewStartingIndex);
                break;
            case NotifyCollectionChangedAction.Reset:
                _mappedCollection.Clear();
                InsertItems(e.NewItems, 0);
                break;
            case NotifyCollectionChangedAction.Move:
                if (e.OldItems.Count == 1) {
                    _mappedCollection.Move(e.OldStartingIndex, e.NewStartingIndex);
                } else {
                    RemoveItems(e.OldItems, e.OldStartingIndex);

                    var movedItems = _mappedCollection.Skip(e.OldStartingIndex).Take(e.OldItems.Count).GetEnumerator();
                    for (int i = 0; i < e.OldItems.Count; i++) {
                        _mappedCollection.Insert(e.NewStartingIndex + i, movedItems.Current);
                        movedItems.MoveNext();
                    }
                }

                break;
        }
    }

    private void InsertItems(IList newItems, int newStartingIndex)
    {
        for (int i = 0; i < newItems.Count; i++)
            _mappedCollection.Insert(newStartingIndex + i, _elementMapper((TSource)newItems[i]));
    }

    private void RemoveItems(IList oldItems, int oldStartingIndex)
    {
        for (int i = 0; i < oldItems.Count; i++)
            _mappedCollection.RemoveAt(oldStartingIndex);
    }
}
Related