Jake Pearson
Jake Pearson

Reputation: 27727

MVVM Sync Collections

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; }
}

Upvotes: 55

Views: 17855

Answers (11)

mark.monteiro
mark.monteiro

Reputation: 2941

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://learn.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);
    }
}

Upvotes: -1

The «Using MVVM to provide undo/redo. Part 2: Viewmodelling lists» article provides the MirrorCollection<V, D> class to achieve the view-model and model collections synchronization.

Additional references

  1. Original link (currently, it is not available): Notify Changed » Blog Archive » Using MVVM to provide undo/redo. Part 2: Viewmodelling lists.

Upvotes: 2

Hauke P.
Hauke P.

Reputation: 2843

While Sam Harwell's solution is pretty good already, it is subject to two problems:

  1. The event handler that is registered here this._source.CollectionChanged += OnSourceCollectionChanged is never unregistered, i.e. a this._source.CollectionChanged -= OnSourceCollectionChanged is missing.
  2. If event handlers are ever attached to events of view models generated by the viewModelFactory, there is no way of knowing when these event handlers may be detached again. (Or generally speaking: You cannot prepare the generated view models for "destruction".)

Therefore I propose a solution that fixes both (short) shortcomings of Sam Harwell's approach:

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Diagnostics.Contracts;
using System.Linq;

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

        public ObservableViewModelCollection(Func<TModel, TViewModel> viewModelFactory, Action<TViewModel> viewModelRemoveHandler = null)
        {
            Contract.Requires(viewModelFactory != null);

            _viewModelFactory = viewModelFactory;
            _viewModelRemoveHandler = viewModelRemoveHandler;
        }

        public ObservableCollection<TModel> Source
        {
            get { return _source; }
            set
            {
                if (_source == value)
                    return;

                this.ClearWithHandling();

                if (_source != null)
                    _source.CollectionChanged -= OnSourceCollectionChanged;

                _source = value;

                if (_source != null)
                {
                    foreach (var model in _source)
                    {
                        this.Add(CreateViewModel(model));
                    }
                    _source.CollectionChanged += OnSourceCollectionChanged;
                }
            }
        }

        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.RemoveAtWithHandling(e.OldStartingIndex);
                    break;

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

                    // add
                    goto case NotifyCollectionChangedAction.Add;

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

                default:
                    break;
            }
        }

        private void RemoveAtWithHandling(int index)
        {
            _viewModelRemoveHandler?.Invoke(this[index]);
            this.RemoveAt(index);
        }

        private void ClearWithHandling()
        {
            if (_viewModelRemoveHandler != null)
            {
                foreach (var item in this)
                {
                    _viewModelRemoveHandler(item);
                }
            }

            this.Clear();
        }

        private TViewModel CreateViewModel(TModel model)
        {
            return _viewModelFactory(model);
        }
    }
}

To deal with the first of the two problems, you can simply set Source to null in order to get rid of the CollectionChanged event handler.

To deal with the second of the two problems, you can simply add a viewModelRemoveHandler that allows to to "prepare your object for destruction", e.g. by removing any event handlers attached to it.

Upvotes: 1

MikeT
MikeT

Reputation: 5500

Resetting an collection to a default value or to match a target value is something i've hit quite frequently

i Wrote a small helper class of Miscilanious methods that includes

public static class Misc
    {
        public static void SyncCollection<TCol,TEnum>(ICollection<TCol> collection,IEnumerable<TEnum> source, Func<TCol,TEnum,bool> comparer, Func<TEnum, TCol> converter )
        {
            var missing = collection.Where(c => !source.Any(s => comparer(c, s))).ToArray();
            var added = source.Where(s => !collection.Any(c => comparer(c, s))).ToArray();

            foreach (var item in missing)
            {
                collection.Remove(item);
            }
            foreach (var item in added)
            {
                collection.Add(converter(item));
            }
        }
        public static void SyncCollection<T>(ICollection<T> collection, IEnumerable<T> source, EqualityComparer<T> comparer)
        {
            var missing = collection.Where(c=>!source.Any(s=>comparer.Equals(c,s))).ToArray();
            var added = source.Where(s => !collection.Any(c => comparer.Equals(c, s))).ToArray();

            foreach (var item in missing)
            {
                collection.Remove(item);
            }
            foreach (var item in added)
            {
                collection.Add(item);
            }
        }
        public static void SyncCollection<T>(ICollection<T> collection, IEnumerable<T> source)
        {
            SyncCollection(collection,source, EqualityComparer<T>.Default);
        }
    }

which covers most of my needs the first would probably be most applicable as your also converting types

note: this only Syncs the elements in the collection not the values inside them

Upvotes: 0

dFlat
dFlat

Reputation: 829

OK I have a nerd crush on this answer so I had to share this abstract factory I added to it to support my ctor injection.

using System;
using System.Collections.ObjectModel;

namespace MVVM
{
    public class ObservableVMCollectionFactory<TModel, TViewModel>
        : IVMCollectionFactory<TModel, TViewModel>
        where TModel : class
        where TViewModel : class
    {
        private readonly IVMFactory<TModel, TViewModel> _factory;

        public ObservableVMCollectionFactory( IVMFactory<TModel, TViewModel> factory )
        {
            this._factory = factory.CheckForNull();
        }

        public ObservableCollection<TViewModel> CreateVMCollectionFrom( ObservableCollection<TModel> models )
        {
            Func<TModel, TViewModel> viewModelCreator = model => this._factory.CreateVMFrom(model);
            return new ObservableVMCollection<TViewModel, TModel>(models, viewModelCreator);
        }
    }
}

Which builds off of this:

using System.Collections.ObjectModel;

namespace MVVM
{
    public interface IVMCollectionFactory<TModel, TViewModel>
        where TModel : class
        where TViewModel : class
    {
        ObservableCollection<TViewModel> CreateVMCollectionFrom( ObservableCollection<TModel> models );
    }
}

And this:

namespace MVVM
{
    public interface IVMFactory<TModel, TViewModel>
    {
        TViewModel CreateVMFrom( TModel model );
    }
}

And here is the null checker for completeness:

namespace System
{
    public static class Exceptions
    {
        /// <summary>
        /// Checks for null.
        /// </summary>
        /// <param name="thing">The thing.</param>
        /// <param name="message">The message.</param>
        public static T CheckForNull<T>( this T thing, string message )
        {
            if ( thing == null ) throw new NullReferenceException(message);
            return thing;
        }

        /// <summary>
        /// Checks for null.
        /// </summary>
        /// <param name="thing">The thing.</param>
        public static T CheckForNull<T>( this T thing )
        {
            if ( thing == null ) throw new NullReferenceException();
            return thing;
        }
    }
}

Upvotes: 1

bertvh
bertvh

Reputation: 831

I really like 280Z28's solution. Just one remark. Is it necessary to do the loops for each NotifyCollectionChangedAction? I know that the docs for the actions state "one or more items" but since ObservableCollection itself does not support adding or removing ranges, this can never happen I would think.

Upvotes: 0

Jonathan ANTOINE
Jonathan ANTOINE

Reputation: 9223

You can find an example (and explanations) here too : http://blog.lexique-du-net.com/index.php?post/2010/03/02/M-V-VM-How-to-keep-collections-of-ViewModel-and-Model-in-sync

Hope this help

Upvotes: 4

Aran Mulholland
Aran Mulholland

Reputation: 23945

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

Upvotes: 0

Sam Harwell
Sam Harwell

Reputation: 99959

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;
            }
        }
    }
}

Upvotes: 68

Dennis
Dennis

Reputation: 20571

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

Upvotes: 11

Charlie
Charlie

Reputation: 15247

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.

Upvotes: 4

Related Questions