I have had the problem of observable collection synchronization for quite a while, and always got around it somehow, but never really had the time to create a fully functional and satisafactory solution. Now the time has come :)
So lets suppose, we have a WorkspaceViewModel, that contains a PageManager which is responsible for handling the pages and setting the active one. The pagemanager has a Pages property that is an ObservableCollection
, but we need on the WorkspaceViewModel a Pages property that has the same property with a different type, lets say IPageViewModel.
The classes can be seen below.
The framework classes, that we can use, but dont want to modify:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace BITStuff.Wpf.Interfaces
{
public interface IPage
{
}
}
namespace BITStuff.Wpf.BaseClasses
{
public class PageManager
{
#region properties
///
/// the collection of pages
///
protected System.Collections.ObjectModel.ObservableCollection<IPage> _Pages = new System.Collections.ObjectModel.ObservableCollection<IPage>();
#endregion properties
public System.Collections.ObjectModel.ObservableCollection<IPage> Pages
{
get { return _Pages; }
}
}
}
The application classes, where we have slightly different requirements:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace UserInterface.Interfaces.ViewModels
{
public interface IPageViewModel : BITStuff.Wpf.Interfaces.IPage
{
string Title { get; }
}
}
namespace UserInterface.BaseClasses
{
public abstract class WorkspaceViewModel
{
public ObservableCollection<IPageViewModel> Pages { get; }
protected PageManager PageManager { get; set; }
}
}
And now i want to expose the PageManager's Pages collection as the WorkspaveViewModel's Page collection.
A WRONG approach is to create a new ObservableCollection in the getter:
public ObservableCollection<IPageViewModel> Pages { get { return new ObservableCollection<IPageViewModel>(PageManager.Pages.OfType<IPageViewModel>()); }
This is WRONG because like this all change notifications will go to an independent object, the lastly created ObservableCollection, which is obviosly not what we would want.
So what our requirement is actually to create a bond between the two collections, that if one of them changes, then the other does as well.
As you might already know, Microsoft provided the INotifyCollectionChanged interface, which exposes the CollectionChanged event to notify interested parties, that the collection has changed. We will use this interface, which ObservableCollection already implements.
The following code uses the MemberInfo helper class, which is explained in
this blog post. It also uses the WithParams extension method on strings, which is simply a more fluent way of string.Format having the following code:
public static string WithParams(this string text, params object[] parameters)
{
return string.Format(text, parameters);
}
I also use the Moq framework for the unit tests. You can get it at
google codeplex here
Other requirements might be to be able to stop the synchronization and to restart it after a time.
The synchronization token, that actually represents the synchronization itself will get in its constructor the source and destination collections, and two converter functions between the tpes. The second converter function should be allowed to be null, which means that the synchronization shall be only one way.
Now a very important thing is the way we convert items. The class should check for potential errors while for example removing items from the second collection. If we have a one-way synchronization, and the second collection has different elements than the synchronization source, then we should probably throw an error, then to remove another element just by index.
!!!IMPORTANT!!!
The comparisions are done using the .Equals() method, so the converter functions should make sure that convert(x).Equals(convert(x)), and that convertBack(x).Equals(convertBack). This is especially important, because for example when wrapping a type into another, then you cannot simply create a new wrapped type on the convert, because the object equality will fail. You should in this case override the .Equals() function, to return for example equality on the wrapped object.
Do not overlook this, because it can cause you a lot of headache to figure out why the code throws errors or has unexpected results!!
Lets start by writing a unit test for the functionality.
------------- Test -------------
First we create a unit test calss.
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace CollectionSyncronizationTokenTest
{
[TestClass]
public class CollectionSyncronizationTokenTest
{
[TestMethod]
public void TestMethod1()
{
}
}
}
Now we dont need TestMethod1(). We can remove it.
We will write the tests against an int and a string collection. We will convert with the .ToString() method, and we will convert back trying to parse, and return 0 if failing. In the tests we dont test for the correctness of the convert functions, and we will not pass in wrong results for the convertBack method.
private ObservableCollection<int> sourceCollection = new ObservableCollection<int>();
private ObservableCollection<string> destCollection = new ObservableCollection<string>();
private Func<int, string> converterFunc;
private Func<string, int> convertBackFunc;
CollectionSyncronizationToken<int, string> subject;
[TestInitialize]
public void Init()
{
this.converterFunc = (i) =>
{
return i.ToString();
};
this.convertBackFunc = (s) =>
{
int temp;
if (Int32.TryParse(s, out temp))
{
return temp;
}
return 0;
};
}
Now we should create some test methods, that will make sure that our requirements are met. The NotifyCollectionChangedAction enum is the action representing what has changed in the source collection has 5 possile values. We will make a test for each of these actions. Since we want to test the synchronization both ways, we will make these tests generic, so they can be invked on any of the collections.
First we start Adding items. We simply make sure the initial count is ok, and then add an elem from the exampleList (randomly), and make sure that the item was added to the other collection as well. We need the example list, because of the generic argument, as i cannot just create a new() object of TSource, since might for example in the case of test not be different enough in subsequential runs.
/// <summary>
/// checks if adding is syncronized
/// </summary>
/// <typeparam name="TSource"></typeparam>
/// <typeparam name="TDestination"></typeparam>
/// <param name="source"></param>
/// <param name="destination"></param>
private void IsAddSynced<TSource, TDestination>(ObservableCollection<TSource> source, ObservableCollection<TDestination> destination, Func<TSource, TDestination> converter, IEnumerable<TSource> exampleList)
{
Assert.AreEqual(source.Count, destination.Count);
var list = exampleList.ToList();
var testItem = list[new Random().Next(list.Count)];
var convertedItem = converter(testItem);
source.Add(testItem);
Assert.AreEqual(source.Count, destination.Count);
Assert.AreEqual(convertedItem, destination.Last());
}
Then we test remove and removeAt.
/// <summary>
/// checks if removing is synchronized
/// </summary>
/// <typeparam name="TSource"></typeparam>
/// <typeparam name="TDestination"></typeparam>
/// <param name="source"></param>
/// <param name="destination"></param>
private void IsRemoveSynced<TSource, TDestination>(ObservableCollection<TSource> source, ObservableCollection<TDestination> destination, Func<TSource, TDestination> converter)
{
Assert.AreEqual(source.Count, destination.Count);
Assert.IsTrue(source.Count > 1);
// test remove
var testItem = source[new Random().Next(source.Count)];
var convertedItem = converter(testItem);
var index = source.IndexOf(testItem);
source.Remove(testItem);
Assert.AreEqual(source.Count, destination.Count);
// test removeat
testItem = source[new Random().Next(source.Count)];
convertedItem = converter(testItem);
index = source.IndexOf(testItem);
source.RemoveAt(index);
Assert.AreEqual(source.Count, destination.Count);
}
The next is a move, which is supported by ObservableCollection.
/// <summary>
/// checks if moving is synchronized
/// </summary>
/// <typeparam name="TSource"></typeparam>
/// <typeparam name="TDestination"></typeparam>
/// <param name="source"></param>
/// <param name="destination"></param>
private void IsMoveSynced<TSource, TDestination>(ObservableCollection<TSource> source, ObservableCollection<TDestination> destination, Func<TSource, TDestination> converter)
{
Assert.AreEqual(source.Count, destination.Count);
Assert.IsTrue(source.Count > 1);
// get the 2 items to move
var idx1 = new Random().Next(source.Count);
var idx2 = new Random().Next(source.Count - 1);
idx2 = idx2 >= idx1 ? idx2 + 1 : idx2;
var item = source[idx1];
var convertedItem = converter(item);
source.Move(idx1, idx2);
Assert.AreEqual(source.Count, destination.Count);
Assert.AreEqual(convertedItem, destination[idx2]);
// test removeat
}
Now check the replace.
/// <summary>
/// checks if relacing is synchronized
/// </summary>
/// <typeparam name="TSource"></typeparam>
/// <typeparam name="TDestination"></typeparam>
/// <param name="source"></param>
/// <param name="destination"></param>
private void IsReplaceSynced<TSource, TDestination>(ObservableCollection<TSource> source, ObservableCollection<TDestination> destination, Func<TSource, TDestination> converter, IEnumerable<TSource> testItems)
{
Assert.AreEqual(source.Count, destination.Count);
Assert.IsTrue(source.Count > 0);
var list = testItems.ToList();
var item = list[new Random().Next(list.Count)];
var convertedItem = converter(item);
var idx = new Random().Next(source.Count);
source[idx] = item;
Assert.AreEqual(source.Count, destination.Count);
Assert.AreEqual(convertedItem, destination[idx]);
}
And finally the clear method.
/// <summary>
/// checks if clear is synchronized
/// </summary>
/// <typeparam name="TSource"></typeparam>
/// <typeparam name="TDestination"></typeparam>
/// <param name="source"></param>
/// <param name="destination"></param>
private void IsClearSynced<TSource, TDestination>(ObservableCollection<TSource> source, ObservableCollection<TDestination> destination)
{
Assert.AreEqual(source.Count, destination.Count);
Assert.IsTrue(source.Count > 0);
source.Clear();
Assert.AreEqual(0, destination.Count);
}
I am not aware of any other functions, but you may add them if you find. Please let me know if I missed something.
Now we also need a helper method, that tests that 2 collections are atm synchronized. (The .ForEach() method simply takes an action with the item, and its index)
/// <summary>
/// checks if collections are syncronized
/// </summary>
/// <typeparam name="TSource"></typeparam>
/// <typeparam name="TDestination"></typeparam>
/// <param name="source"></param>
/// <param name="destination"></param>
private void IsSynced<TSource, TDestination>(ObservableCollection<TSource> source, ObservableCollection<TDestination> destination, Func<TSource, TDestination> converter)
{
Assert.AreEqual(source.Count, destination.Count);
source.ForEach((item, idx) =>
{
Assert.AreEqual(converter(item), destination[idx]);
});
}
We will create also one to make sure that a collection is not syncronized with another. Here we just test for all operations.
/// <summary>
/// checks if 2 collections are not synchronized
/// </summary>
/// <typeparam name="TSource"></typeparam>
/// <typeparam name="TDestination"></typeparam>
/// <param name="source"></param>
/// <param name="destination"></param>
private void IsNotSynced<TSource, TDestination>(ObservableCollection<TSource> source, ObservableCollection<TDestination> destination, IEnumerable<TSource> testItems)
{
var iscalled = false;
NotifyCollectionChangedEventHandler handler = (s, e) =>
{
iscalled = true;
};
destination.CollectionChanged += handler;
var list = testItems.ToList();
Func<TSource> getNext = () => list[new Random().Next(list.Count)];
source.Add(getNext());
Assert.IsFalse(iscalled);
source.Add(getNext());
source.Add(getNext());
source.Add(getNext());
source.Remove(source.Skip(2).FirstOrDefault());
Assert.IsFalse(iscalled);
source.RemoveAt(new Random().Next(source.Count));
Assert.IsFalse(iscalled);
// get the 2 items to move
var idx1 = new Random().Next(source.Count);
var idx2 = new Random().Next(source.Count - 1);
idx2 = idx2 >= idx1 ? idx2 + 1 : idx2;
source.Move(idx1, idx2);
Assert.IsFalse(iscalled);
source[new Random().Next(source.Count)] = getNext();
Assert.IsFalse(iscalled);
source.Clear();
Assert.IsFalse(iscalled);
destCollection.CollectionChanged -= handler;
}
Great. So now that we have some helper methods, lets write the tests for the string and int collections. We have 6 test case. First we want to check if the oneway synchronization works, so that it creates an initial synchronization, and then changes are propagated. The to see if stopping the synch results in no more change propagation. The we test if after restarting the functionality is back.
The other 3 tests are the same, but with 2 way binding.
The code is as follows:
[TestMethod]
public void TestSyncWith()
{
var collection1 = this.sourceCollection;
var collection2 = this.destCollection;
var converter = this.converterFunc;
var convertBack = this.convertBackFunc;
collection1.Add(2);
collection1.Add(5);
var token = this.subject = new CollectionSyncronizationToken<int, string>(collection1, collection2, converter, convertBack);
IsSynced(collection1, collection2, converter);
IsAddSynced(collection1, collection2, converter, Enumerable.Range(10, 20));
IsAddSynced(collection2, collection1, convertBack, Enumerable.Range(10, 20).Select(x => x.ToString()));
// remove removes 2 items, so just add 2 more
collection1.Add(22);
collection1.Add(33);
IsRemoveSynced(collection1, collection2, converter);
IsRemoveSynced(collection2, collection1, convertBack);
IsMoveSynced(collection1, collection2, converter);
IsMoveSynced(collection2, collection1, convertBack);
IsReplaceSynced(collection1, collection2, converter, Enumerable.Range(10, 20));
IsReplaceSynced(collection2, collection1, convertBack, Enumerable.Range(10, 20).Select(x => x.ToString()));
IsClearSynced(collection1, collection2);
collection1.Add(44);
collection1.Add(55);
IsClearSynced(collection2, collection1);
}
[TestMethod]
public void TestStopSync()
{
this.sourceCollection.Add(1);
this.sourceCollection.Add(22);
this.sourceCollection.Add(33);
this.sourceCollection.Add(44);
var token = this.subject = new CollectionSyncronizationToken<int, string>(this.sourceCollection, this.destCollection, this.converterFunc, this.convertBackFunc);
token.StopSync();
IsNotSynced(this.sourceCollection, this.destCollection, Enumerable.Range(10, 20));
IsNotSynced(this.destCollection, this.sourceCollection, Enumerable.Range(10, 20).Select(x => x.ToString()));
}
[TestMethod]
public void TestRestartSync()
{
var collection1 = this.sourceCollection;
var collection2 = this.destCollection;
var converter = this.converterFunc;
var convertBack = this.convertBackFunc;
collection1.Add(2);
collection1.Add(5);
var token = this.subject = new CollectionSyncronizationToken<int, string>(this.sourceCollection, this.destCollection, this.converterFunc, this.convertBackFunc);
token.StopSync();
collection1.Add(102);
collection1.Add(105);
token.RestartSync();
IsSynced(collection1, collection2, converter);
IsAddSynced(collection1, collection2, converter, Enumerable.Range(10, 20));
IsAddSynced(collection2, collection1, convertBack, Enumerable.Range(10, 20).Select(x => x.ToString()));
// remove removes 2 items, so just add 2 more
collection1.Add(22);
collection1.Add(33);
IsRemoveSynced(collection1, collection2, converter);
IsRemoveSynced(collection2, collection1, convertBack);
IsMoveSynced(collection1, collection2, converter);
IsMoveSynced(collection2, collection1, convertBack);
IsReplaceSynced(collection1, collection2, converter, Enumerable.Range(10, 20));
IsReplaceSynced(collection2, collection1, convertBack, Enumerable.Range(10, 20).Select(x => x.ToString()));
IsClearSynced(collection1, collection2);
collection1.Add(44);
collection1.Add(55);
IsClearSynced(collection2, collection1);
}
[TestMethod]
public void TestSyncOneWay()
{
this.sourceCollection.Add(33);
this.sourceCollection.Add(44);
this.subject = new CollectionSyncronizationToken<int, string>(this.sourceCollection, this.destCollection, this.converterFunc);
var collection1 = this.sourceCollection;
var collection2 = this.destCollection;
var converter = this.converterFunc;
IsSynced(collection1, collection2, converter);
IsAddSynced(collection1, collection2, converter, Enumerable.Range(10, 20));
// remove removes 2 items, so just add 2 more
collection1.Add(22);
collection1.Add(33);
IsRemoveSynced(collection1, collection2, converter);
IsMoveSynced(collection1, collection2, converter);
IsReplaceSynced(collection1, collection2, converter, Enumerable.Range(10, 20));
IsClearSynced(collection1, collection2);
IsNotSynced(collection2, collection1, Enumerable.Range(10, 20).Select(x => x.ToString()));
}
[TestMethod]
public void TestStopSyncOneWay()
{
this.sourceCollection.Add(1);
this.sourceCollection.Add(22);
this.sourceCollection.Add(33);
this.sourceCollection.Add(44);
var token = this.subject = new CollectionSyncronizationToken<int, string>(this.sourceCollection, this.destCollection, this.converterFunc);
token.StopSync();
IsNotSynced(this.sourceCollection, this.destCollection, Enumerable.Range(10, 20));
IsNotSynced(this.destCollection, this.sourceCollection, Enumerable.Range(10, 20).Select(x => x.ToString()));
}
[TestMethod]
public void TestSyncOneWayRestart()
{
this.sourceCollection.Add(33);
this.sourceCollection.Add(44);
this.subject = new CollectionSyncronizationToken<int, string>(this.sourceCollection, this.destCollection, this.converterFunc);
var collection1 = this.sourceCollection;
var collection2 = this.destCollection;
var converter = this.converterFunc;
this.subject.StopSync();
collection1.Add(102);
collection1.Add(105);
this.subject.RestartSync();
IsSynced(collection1, collection2, converter);
IsAddSynced(collection1, collection2, converter, Enumerable.Range(10, 20));
// remove removes 2 items, so just add 2 more
collection1.Add(22);
collection1.Add(33);
IsRemoveSynced(collection1, collection2, converter);
IsMoveSynced(collection1, collection2, converter);
IsReplaceSynced(collection1, collection2, converter, Enumerable.Range(10, 20));
IsClearSynced(collection1, collection2);
IsNotSynced(collection2, collection1, Enumerable.Range(10, 20).Select(x => x.ToString()));
}
Hehe, quite a long test class, but we are done with the worse. We have the tests, now its just the development of the class. Lets create the class now.
------------- Class -------------
We start off by creating our classm and createing the default constructor. We save all dependencies in private fields. We also add an IsSynchronizing readonly property, and handle it internally later. We also define an isActive prvate property. The goal of this property is to avoid cycles. When we subscribe a two way notification, then change in collecrtion 1 would raise a change in collection 2 which in turn would do the same to collection 1, and so on. To avoid these cycles, we will use this field.
We also added the public methods StopSync() and RestartSync(). With all this code, your unittests should finally compile, and fail. (well, since there is no code yet, the stop methods actually already work, wohooo :) )
using CollectionSyncronizationToken.HelperClasses;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CollectionSyncronizationToken
{
public class CollectionSynchronizationToken<TSource, TDestination>
{
#region properties
/// <summary>
/// whether the syncronization is turned on
/// </summary>
public bool IsSyncing { get { return _IsSyncing; } }
#endregion properties
#region fields
private bool _IsSyncing = true;
private Func<TSource, TDestination> _converter;
private Func<TDestination, TSource> _backConverter;
private ObservableCollection<TSource> _sourceCollection;
private ObservableCollection<TDestination> _destinationCollection;
/// <summary>
/// this field turns the effective syncronization off, so that the handlers do not start calling each other in an unlimited way
/// </summary>
private bool isActive = true;
#endregion fields
#region ctor
public CollectionSynchronizationToken(ObservableCollection<TSource> sourceCollection, ObservableCollection<TDestination> destinationCollection, Func<TSource, TDestination> converter, Func<TDestination, TSource> backConverter = null)
{
#region parameter check
if (sourceCollection == null)
throw new ArgumentNullException(MemberName.Param(() => sourceCollection));
if (destinationCollection == null)
throw new ArgumentNullException(MemberName.Param(() => destinationCollection));
if (converter == null)
throw new ArgumentNullException(MemberName.Param(() => converter));
#endregion parameter check
this._sourceCollection = sourceCollection;
this._destinationCollection = destinationCollection;
this._converter = converter;
this._backConverter = backConverter;
InitDestinationCollectionValues();
AddHandlers();
}
#endregion ctor
#region public methods
///
/// stops the syncronization
///
public void StopSync()
{
}
///
/// starts again the syncronization
///
public void RestartSync()
{
}
#endregion public methods
}
}
Now start to fill the gaps. The object starts with a synch state, so we need to do an initial synchronization, and create the event handlers and attach them to the collection. On stop we just remove the handlers, and on restart we reattach them.
So we call in the constructor and the restart the same, and in the stop remove the handlers
public CollectionSyncronizationToken(ObservableCollection<TSource> sourceCollection, ObservableCollection<TDestination> destinationCollection, Func<TSource, TDestination> converter, Func<TDestination, TSource> backConverter = null)
{
//.. previous code here
InitDestinationCollectionValues();
AddHandlers();
}
#region public methods
///
/// stops the syncronization
///
public void StopSync()
{
if (_IsSyncing)
{
RemoveHandlers();
}
}
///
/// starts again the syncronization
///
public void RestartSync()
{
if (!_IsSyncing)
{
InitDestinationCollectionValues();
AddHandlers();
}
}
#endregion public methods
Ok, we just created some work for ourselves, but lets see the units.
Lets create quickly a Convert and a ConvertBack method. These are good apart, cause this allows you to do any error handling, if you want, on the conversions (which is a good idea, since those are delegates passed from calling code.
private TDestination Convert(TSource item)
{
return this._converter(item);
}
private TSource ConvertBack(TDestination item)
{
return this._backConverter(item);
}
The InitDestinationCollectionValues() should just update the destination collection with values from the first collection.
///
/// sets the correct values on the destination collection to begin a syncronized state
///
private void InitDestinationCollectionValues()
{
this._sourceCollection.ForEach((item, index) =>
{
var convertedItem = this.Convert(item);
if (this._destinationCollection.Count > index)
{
if (!convertedItem.Equals(this._destinationCollection[index]))
{
this._destinationCollection[index] = convertedItem;
}
}
else // we just add the item, as the index should be right
{
this._destinationCollection.Add(convertedItem);
}
});
}
Great. Now we need to implement the add and remove handlers.
The handlers will be simple handler functions in this token.
///
/// adds the collection change handlers to the collections
///
private void AddHandlers()
{
this._sourceCollection.CollectionChanged += _sourceCollection_CollectionChanged;
if (this._backConverter != null)
{
this._destinationCollection.CollectionChanged += _destinationCollection_CollectionChanged;
}
this._IsSyncing = true;
}
///
/// removes the handlers
///
private void RemoveHandlers()
{
this._sourceCollection.CollectionChanged -= _sourceCollection_CollectionChanged;
if (this._backConverter != null)
{
this._destinationCollection.CollectionChanged -= _destinationCollection_CollectionChanged;
}
this._IsSyncing = false;
}
#region handlers
void _destinationCollection_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
}
void _sourceCollection_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
}
#endregion handlers
Cool. So all that is left, is the handling of the collection changed. Now essentially whether we are synchronizing from the first, or the second collection, there should be not much difference. So lets create a helper method, that will handle the synchronization. It will recieve the collection that raised the event, the collection where the change has to be propagated, and string names for the source and destination collections, that will be included in the errors.
Fisrt lets create a helper class, that will format our exceptions. Obviously if you prefer, you can use any other method of error handling.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using CollectionSyncronizationToken.Extenders;
namespace CollectionSyncronizationToken.HelperClasses
{
public class ExceptionHelper
{
/// <summary>
/// creates a collection sync conversion exception
/// </summary>
/// <typeparam name="TSource"></typeparam>
/// <typeparam name="TResult"></typeparam>
/// <param name="ex"></param>
/// <param name="sourceCollection"></param>
/// <param name="destinationCollection"></param>
/// <param name="isNormalDirection"></param>
/// <returns></returns>
public static Exception CollectionSyncronizationException<TSource, TResult>(string message, Exception ex, ObservableCollection<TSource> sourceCollection, ObservableCollection<TResult> destinationCollection)
{
return new Exception("An error occured while trying to syncronize two collections of type {0} and {1}: {2}".WithParams(typeof(TSource).Name, typeof(TResult).Name, message), ex);
}
/// <summary>
/// creates a collection sync conversion exception
/// </summary>
/// <typeparam name="TSource"></typeparam>
/// <typeparam name="TResult"></typeparam>
/// <param name="ex"></param>
/// <param name="sourceCollection"></param>
/// <param name="destinationCollection"></param>
/// <param name="isNormalDirection"></param>
/// <returns></returns>
public static Exception CollectionSyncronizationConvertException<TSource, TResult>(Exception ex, ObservableCollection<TSource> sourceCollection, ObservableCollection<TResult> destinationCollection, bool isNormalDirection)
{
return new Exception(
"An error occured while trying to syncronize two collections of type {0} and {1}. The conversion from {2} to {3} failed."
.WithParams(typeof(TSource).Name, typeof(TResult).Name,
isNormalDirection ? typeof(TSource).Name : typeof(TResult).Name,
isNormalDirection ? typeof(TResult).Name : typeof(TSource).Name),
ex);
}
}
}
Ok, and now comes the hard part. Lets create the handling method, and start a switch statement over what we have to do (its in the eventArgs.Action field).
/// <summary>
/// handles the collection changed event
/// </summary>
/// <typeparam name="TItemSource"></typeparam>
/// <typeparam name="TItemDest"></typeparam>
/// <param name="sourceCollectionName"></param>
/// <param name="destinationCollectionName"></param>
/// <param name="sourceCollection"></param>
/// <param name="destinationCollection"></param>
private void HandleCollectionChanged<TItemSource, TItemDest>(NotifyCollectionChangedEventArgs e, string sourceCollectionName, string destinationCollectionName,
ObservableCollection<TItemSource> sourceCollection, ObservableCollection<TItemDest> destinationCollection, Func<TItemSource, TItemDest> converter)
{
switch (e.Action)
{
case System.Collections.Specialized.NotifyCollectionChangedAction.Add:
break;
case System.Collections.Specialized.NotifyCollectionChangedAction.Move:
break;
case System.Collections.Specialized.NotifyCollectionChangedAction.Remove:
break;
case System.Collections.Specialized.NotifyCollectionChangedAction.Replace:
break;
case System.Collections.Specialized.NotifyCollectionChangedAction.Reset:
break;
default:
throw ExceptionHelper.CollectionSyncronizationException("The {0} collection raised an {1} action which was not understood (oldItemCount:{2}, olditemIndex:{3}, newItemCount:{4}, newItemIndex{5})".WithParams(sourceCollectionName, e.Action, e.OldItems.Count, e.OldStartingIndex, e.NewItems.Count, e.NewStartingIndex), null, _sourceCollection, _destinationCollection);
}
}
On NotifyCollectionChangedAction.Add the collection should have aquired new items in the given position of the collection, so we just add the given items there after some error checking.
case System.Collections.Specialized.NotifyCollectionChangedAction.Add:
// we can move only one item
if (e.NewItems.Count + e.NewStartingIndex > sourceCollection.Count)
{
throw ExceptionHelper.CollectionSyncronizationException("The {3} collection of {0} elements raised an add of {1} elements from position {2}, which is not possible".WithParams(sourceCollection.Count, e.NewItems.Count, e.NewStartingIndex, sourceCollectionName), null, this._sourceCollection, this._destinationCollection);
}
if(e.NewStartingIndex > destinationCollection.Count)
{
throw ExceptionHelper.CollectionSyncronizationException("The {3} collection of {0} elements raised an add of {1} elements from position {2}, but the {4} collection has only {5} elmenents so inserting at the given index is not possible".WithParams(sourceCollection.Count, e.NewItems.Count, e.NewStartingIndex, sourceCollectionName, destinationCollectionName, destinationCollection.Count), null, this._sourceCollection, this._destinationCollection);
}
e.NewItems.OfType<TItemSource>().ForEach((item, idx) =>
{
destinationCollection.Insert(e.NewStartingIndex + idx, converter(item));
});
break;
When we NotifyCollectionChangedAction.Move an item, it should happen with the .Move command of the ObservableCollection. It should have exactly 1 item in the NewItems collection, and the Old and new starting index should specify from where to where move them. So we do some error checking, and if all is ok, we do the same method call on the destination collection
case System.Collections.Specialized.NotifyCollectionChangedAction.Move:
// we can move only one item
if (e.NewItems.Count != 1)
{
throw ExceptionHelper.CollectionSyncronizationException("Not exactly one({0}) item moved in the {1} collection".WithParams(e.NewItems.Count, sourceCollectionName), null, this._sourceCollection, this._destinationCollection);
}
if (e.NewStartingIndex >= destinationCollection.Count || e.OldStartingIndex >= destinationCollection.Count)
{
throw ExceptionHelper.CollectionSyncronizationException("The oldIndex({0}) or the newIndex({1}) are larger then the {3} collection size({2}) during move".WithParams(e.OldStartingIndex, e.NewStartingIndex, destinationCollection.Count, destinationCollectionName), null, _sourceCollection, _destinationCollection);
}
if (e.NewStartingIndex >= sourceCollection.Count || e.OldStartingIndex >= sourceCollection.Count)
{
throw ExceptionHelper.CollectionSyncronizationException("The oldIndex({0}) or the newIndex({1}) are larger then the {3} collection size({2}) during move".WithParams(e.OldStartingIndex, e.NewStartingIndex, sourceCollection.Count, sourceCollectionName), null, this._sourceCollection, this._destinationCollection);
}
if (e.NewStartingIndex != e.OldStartingIndex)
{
// the item in the destination that has been moved
var convertedItem = converter(sourceCollection[e.NewStartingIndex]);
if (!convertedItem.Equals(destinationCollection[e.OldStartingIndex]))
{
throw ExceptionHelper.CollectionSyncronizationException("The moved item (from pos {0} to {1}) does not match the item on position {0} in the {2} collection".WithParams(e.OldStartingIndex, e.NewStartingIndex, destinationCollectionName), null, this._sourceCollection, this._destinationCollection);
}
// move the item
destinationCollection.Move(e.OldStartingIndex, e.NewStartingIndex);
}
break;
When items are removed from the collection(NotifyCollectionChangedAction.Remove), then we have to do the same. We need some error checking that the items are the same, and that the indexes are valid, then we can remove from index. Unfortunately there is no bulk remove from ObservableCollection, so we call it one at a time.
case System.Collections.Specialized.NotifyCollectionChangedAction.Remove:
if (destinationCollection.Count < sourceCollection.Count + e.OldItems.Count)
{
throw ExceptionHelper.CollectionSyncronizationException("A remove of {0} items from position {1} has been initiated on the {2}, but the {3} has only {4} items".WithParams(e.OldItems.Count, e.OldStartingIndex, sourceCollectionName, destinationCollectionName, destinationCollection.Count), null, this._sourceCollection, this._destinationCollection);
}
var index = 0;
foreach (var item in e.OldItems.OfType<TItemSource>())
{
var convertedItem = converter(item);
if (!convertedItem.Equals(destinationCollection[e.OldStartingIndex]))
{
throw ExceptionHelper.CollectionSyncronizationException("The removed item (startingindex: {0}, position in list: {1}) from the {2} collection does not match the item in the {3} collection".WithParams(e.OldStartingIndex, index, sourceCollectionName, destinationCollectionName), null, this._sourceCollection, this._destinationCollection);
}
destinationCollection.RemoveAt(e.OldStartingIndex);
index++;
}
break;
When we specify a value by indexing the collection, then the NotifyCollectionChangedAction.Replace action is called. We need to make sure the replaced item is the correct one, and that the indexes are ok. Then:
case System.Collections.Specialized.NotifyCollectionChangedAction.Replace:
if (e.NewStartingIndex + e.NewItems.Count > destinationCollection.Count)
{
throw ExceptionHelper.CollectionSyncronizationException("The replaced item count is too big (starting at position {0} with {1} elements, but the {3} collection has only {2} elements".WithParams(e.NewStartingIndex, e.NewItems.Count, destinationCollection.Count, destinationCollectionName), null, this._sourceCollection, this._destinationCollection);
}
e.NewItems.OfType<TItemSource>().ForEach((item, idx) =>
{
var convertedItem = converter(item);
if (!convertedItem.Equals(destinationCollection[idx + e.NewStartingIndex]))
{
destinationCollection[idx + e.NewStartingIndex] = convertedItem;
}
});
break;
Now the NotifyCollectionChangedAction.Reset action is a bit more problematic. This essentially means to the handlers, that: "all your prior knowledge about the list is invalid now". So in order for this destaintion collection to raise the same event, we first clear it, and then essentially refill with he correct values.
case System.Collections.Specialized.NotifyCollectionChangedAction.Reset:
// now when a reset happens, it essentially means to the handlers, that: "all your prior knowledge about the list is invalid now"
// so we could either clear the list, and readd all items from source, or simply make sure they are synced now
// the second is probably a bit faster, but will not raise the reset event, so we will implement the first way
destinationCollection.Clear();
sourceCollection.ForEach((item, idx) => destinationCollection.Add(converter(item)));
break;
And now we are done implementing the handler. Just plug it into the the actual event handlers with the isActive flag check:
void _destinationCollection_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (isActive)
{
isActive = false;
HandleCollectionChanged(e, "destination", "source", this._destinationCollection, this._sourceCollection, this.ConvertBack);
isActive = true;
}
}
void _sourceCollection_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (isActive)
{
isActive = false;
HandleCollectionChanged(e, "source", "destination", this._sourceCollection, this._destinationCollection, this.Convert);
isActive = true;
}
}
And we are done.
Running the unit tests will make sure we did not make a mistake. It is a bit longer than I originally expected, but hopefully it will be useful to all of you who are interested.
And then finally you can create your extension method, and hide all this code behind a simple call to: .SyncFrom(collection, converter) with:
public static CollectionSyncronizationToken<TSource, TResult> SyncFrom<TSource, TResult>(this ObservableCollection<TResult> destinationCollection, ObservableCollection<TSource> sourecCollection, Func<TSource, TResult> converter, Func<TResult, TSource> convertBack = null)
{
return new CollectionSyncronizationToken<TSource, TResult>(sourecCollection, destinationCollection, converter, convertBack);
}
Room for improvement
Well as you can see from the code, if we handle the synchronization only one way, we dont need an ObservableCollection. Actually any object implementing INotifyCollectionChanged will do. As for the target collection, we need the Add(T), Insert(int, T), Remove(T), RemoveAt(int), Move(), [int](indexer) and Clear() methods. SO any interface implementing all these methods can be a potential target, you just need to change the types, and it should all work.
If you want two way synchronization, you could still create an interface, that implements INotifyCollectionChanged, IList, and the Move method, and then you can use simply that interface instead of ObservableCollection. Unfortunately ObservableCollection wil not implement your interface, so you might need to have 4 different signateures for the extension method, but hey that's not a biggie.
You could also create an interface for the token, and then allow others to create their own implementation, but as long as you have the source, it is fine.
If you have any suggestions regarding the above, please do not hesitate to comment or to contact me.
You can download the source code from
here.