Thursday, December 11, 2008

Observable collections independent of WPF

I've recently been working with a client who has made the decision to keep the business logic assembly of their WPF application independent of WPF. This is fair enough, keeping the business logic separate from the presentation code and independent of WPF will promote decoupled design and reduce the pain down the line if the business logic needs to be transplanted elsewhere.

The client had the further requirement that the collections within the businsess logic should be observable by WPF in order to avoid the overhead of creating an entire View-Model layer to support the UI. Even though I'm unsure as to whether there's anyone else out there being so strict about their dependencies, implementing this turns out to be less straightforward than you'd expect, so I thought I'd write a post about it anyway.

First of all, here's a quick reminder of the interfaces that WPF data binding generally uses to observe the contents of a collection:
  • IList - used to traverse the items in a collection.
  • INotifyPropertyChanged - used to inform listeners that the value of a property of a collection has changed i.e. the count and the indexer properties.
  • INotifyCollectionChanged - used to inform listeners that a collection has changed in some way e.g. an item has been added, removed, moved etc.

The problem here is that INotifyCollectionChanged is part of WPF as it is defined in WindowsBase and therefore can't be used in the proposed structure. An alternative to using INotifyCollectionChanged is to use the somewhat older interface, System.ComponentModel.IBindingList, which has been around since .NET 1.0 supporting data binding in Windows Forms. IBindingList is also supported by WPF data binding and this post focuses on it as an alternative to INotifyCollectionChanged thus removing the dependency on WPF.

As well as change notification, IBindingList contains functionality for Adding, Removing, Sorting and Searching (it's a bit of a bizarre interface really, containing all this different functionality). In this implementation, sorting will be disabled since it was desired that it is handled in the view as it is for collections implementating INotifyCollectionChanged. Adding, removing and searching can all be implemented as you see fit but in this example I will be disabling them.

Implementing IBindingList is straightforward enough. If you were to implement it on your collection, it should end up looking something like this:
class MyObservableCollection<T> : Collection<T>, IBindingList
{
    protected override void ClearItems()
    {
        base.ClearItems();
        OnListChanged(new ListChangedEventArgs(ListChangedType.Reset), -1);
    }

    protected override void InsertItem(int index, T item)
    {
        base.InsertItem(index, item);
        OnListChanged(new ListChangedEventArgs(ListChangedType.ItemAdded, index));
    }

    protected override void RemoveItem(int index)
    {
        base.RemoveItem(index);
        OnListChanged(new ListChangedEventArgs(ListChangedType.ItemDeleted, index));
    }

    protected override void SetItem(int index, T item)
    {
        base.SetItem(index, item);
        OnListChanged(new ListChangedEventArgs(ListChangedType.ItemChanged, index));
    }

    protected virtual void OnListChanged(ListChangedEventArgs e)
    {
        var handler = ListChanged;
        if (handler != null) handler(this, e);
    }

    // IBindingList Members 
    public void AddIndex(PropertyDescriptor property) {}
    public object AddNew() {}
    public void ApplySort(PropertyDescriptor property, ListSortDirection direction) {}
    public int Find(PropertyDescriptor property, object key) {}
    public void RemoveIndex(PropertyDescriptor property) {}
    public void RemoveSort() {}
    public event ListChangedEventHandler ListChanged;
    public bool SupportsChangeNotification { get { return true; } } // Must return true
    public bool AllowEdit { get { return false; } }
    public bool AllowNew { get { return false; } }
    public bool AllowRemove { get { return false; } }
    public bool IsSorted { get { return false; } }
    public ListSortDirection SortDirection
    { get { throw new NotSupportedException(); } }
    public PropertyDescriptor SortProperty
    { get { throw new NotSupportedException(); } }
    public bool SupportsChangeNotification { get { return true; } }
    public bool SupportsSearching { get { return false; } }
    public bool SupportsSorting { get { return false; } }
}
So far, so good. We now have a collection that implements IBindingList and can therefore be used for data binding. So let's try it by binding a ListBox to the new collection:
<Window ...>
    <StackPanel>
        <Button Content="Add" Click="ButtonAdd_Click"/>
        <ListBox ItemsSource="{Binding}"/>
    </StackPanel>
</Window>
public partial class Window1 : Window
{
    private readonly MyObservableCollection<int> mCollection;
    private int mCounter;

    public Window1()
    {
        InitializeComponent();
        mCollection = new MyObservableCollection<int>();
        DataContext = mCollection;
    }

    private void ButtonAdd_Click(object sender, RoutedEventArgs e)
    {
        mCollection.Add(mCounter++);
    }
}
It works! You can click on the Add button and the listbox correctly updates. Now let's try sorting a view of the collection using a CollectionViewSource:
<Window ...>
    <Window.Resources>
        <CollectionViewSource x:Key="cvs" Source="{Binding}">
            <CollectionViewSource.SortDescriptions>
                <compModel:SortDescription Direction="Descending"/>
            </CollectionViewSource.SortDescriptions>
        </CollectionViewSource>
    </Window.Resources>
    <StackPanel>
        <Button Content="Add" Click="ButtonAdd_Click"/>
        <ListBox ItemsSource="{Binding Source={StaticResource cvs}}"/>
    </StackPanel>
</Window>
Hmmm...this isn't nearly so successful. The window now throws an exception on startup:

InvalidOperationException: 'System.Windows.Data.BindingListCollectionView' view does not support sorting.

This is because the BindingListCollectionView, the implementation of ICollectionView created by the CollectionViewSource for viewing collections implementing IBindingList is using the internal sorting mechanism provided by the IBindingList interface. Since SupportsSorting is set to false, an exception now gets thrown when an attempt is made to sort the collection. SupportsSorting can't be set to true as then sorting would have to actually be implemented by the collection and the requirement is for it to be handled in the view as it is for collections implementing INotifyCollectionChanged.

This poses a bit of a problem. Luckily, we have a dirty workaround to rememdy the situation.

CollectionViewSource can be subclassed and its Source property coerced into using an adapted version of original collection which implements INotifyCollectionChanged. This adapter maps the change events from the IBindingList interface to the equivalents on the INotifyCollectionChanged interface.

But...there's another problem. There isn't enough information from IBindingList's ListChangedEventArgs to fully populate INotifyCollectionChanged's NotifyCollectionChangedEventArgs. So we'll also need to subclass the ListChangedEventArgs class before raising the change events from the collection.

Here's a rundown of all the bits that are required. Firstly, here's the new CollectionViewSource which coerces an instance of the new BindingListAdapter class when its Source property gets set:
class ExtendedCollectionViewSource : CollectionViewSource
{
    private BindingListAdapter mAdapter;

    static ExtendedCollectionViewSource()
    {
        CollectionViewSource.SourceProperty.OverrideMetadata(
            typeof(ExtendedCollectionViewSource),
            new FrameworkPropertyMetadata(null, CoerceSource));
    }

    // This class should be kept internal as the Coerce is a little dodgy. 
    // Consumers of this class could reasonably expect the Source property
    // to return the same instance that was set to it.
    
    private static object CoerceSource(DependencyObject d, object baseValue)
    {
        ExtendedCollectionViewSource cvs = (ExtendedCollectionViewSource)d;
        if (cvs.mAdapter != null)
        {
            cvs.mAdapter.Dispose();
            cvs.mAdapter = null;
        }
        IBindingList bindingList = baseValue as IBindingList;
        if (bindingList != null)
        {
            cvs.mAdapter = new BindingListAdapter(bindingList);
            return cvs.mAdapter;
        }
        return baseValue;
    }
}
Next, the BindingListAdapter class, which adapts a class implementing IBindingList into one which implements INotifyCollectionChanged.
class BindingListAdapter : IList, IDisposable, INotifyPropertyChanged, 
    INotifyCollectionChanged
{
    private readonly IBindingList mBindingList;

    public BindingListAdapter(IBindingList bindingList)
    {
        mBindingList = bindingList;
        mBindingList.ListChanged += mBindingList_ListChanged;
    }

    private void mBindingList_ListChanged(object sender, ListChangedEventArgs e)
    {
        ExtendedListChangedEventArgs ee = (ExtendedListChangedEventArgs)e;
        
        if (e.ListChangedType == ListChangedType.ItemAdded)
        {
            OnPropertyChanged(new PropertyChangedEventArgs("Count"));
            OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
            OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                NotifyCollectionChangedAction.Add, mBindingList[e.NewIndex],
                e.NewIndex));
        }
        else if (e.ListChangedType == ListChangedType.ItemChanged)
        {
            OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
            OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                NotifyCollectionChangedAction.Replace, mBindingList[e.NewIndex],
                ee.Item));
        }
        else if (e.ListChangedType == ListChangedType.ItemDeleted)
        {
            OnPropertyChanged(new PropertyChangedEventArgs("Count"));
            OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
            OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                NotifyCollectionChangedAction.Remove, ee.Item, e.NewIndex));
        }
        else if (e.ListChangedType == ListChangedType.ItemMoved)
        {
            OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
            OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                NotifyCollectionChangedAction.Move, ee.Item, e.NewIndex, e.OldIndex));
        }
        else if (e.ListChangedType == ListChangedType.Reset)
        {
            OnPropertyChanged(new PropertyChangedEventArgs("Count"));
            OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
            OnCollectionChanged(new NotifyCollectionChangedEventArgs(
                NotifyCollectionChangedAction.Reset, null, -1));
        }
    }
    
    protected virtual void OnPropertyChanged(PropertyChangedEventArgs e)
    {
        var handler = PropertyChanged;
        if (handler != null) handler(this, e);
    }
    
    protected virtual void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
    {
        var handler = CollectionChanged;
        if (handler != null) handler(this, e);
    }

    // I've excluded the implementation of these interfaces here. 
    // Source code is available at the foot of the post.
    
    // IList Members
    // ICollection Members
    // IEnumerable Members
    // INotifyPropertyChanged Members
    // INotifyCollectionChanged Members
    // IDisposable
}
...and the ExtendedListChangedEventArgs class which contains extra information required to populate the INotifyCollectionChangedEventArgs.
class ExtendedListChangedEventArgs : ListChangedEventArgs
{
    public object Item { get; private set; }

    public ExtendedListChangedEventArgs(ListChangedType listChangedType, object item, 
        int newIndex) : base(listChangedType, newIndex)
    {
        Item = item;
    }

    public ExtendedListChangedEventArgs(ListChangedType listChangedType, object item, 
        int newIndex, int oldIndex) : base(listChangedType, newIndex, oldIndex)
    {
        Item = item;
    }
}
...and finally, the original collection, updated to use ExtendedListChangedEventArgs:
class MyObservableCollection<T> : Collection<T>, IBindingList
{
    protected override void ClearItems()
    {
        base.ClearItems();
        OnListChanged(
            new ExtendedListChangedEventArgs(ListChangedType.Reset, null, -1));
    }

    protected override void InsertItem(int index, T item)
    {
        base.InsertItem(index, item);
        OnListChanged(
            new ExtendedListChangedEventArgs(ListChangedType.ItemAdded, item, index));
    }

    protected override void RemoveItem(int index)
    {
        T item = base[index];
        base.RemoveItem(index);
        OnListChanged(new ExtendedListChangedEventArgs(
            ListChangedType.ItemDeleted, item, index));
    }

    protected override void SetItem(int index, T item)
    {
        T oldItem = base[index];
        base.SetItem(index, item);
        OnListChanged(new ExtendedListChangedEventArgs(
            ListChangedType.ItemChanged, oldItem, index));
    }

    protected virtual void OnListChanged(ListChangedEventArgs e)
    {
        var handler = ListChanged;
        if (handler != null) handler(this, e);
    }

    // IBindingList Members (not shown here)
}
...and that (believe it or not) actually works. The ExtendedCollectionViewSource successfully coerces the collection into an adapted version of the original before passing it on to CollectionViewSource. The sorting is now entirely handled in the view as it is for collections implementing INotifyCollectionChanged.

As far as I know this approach has no disadvantages to using INotifyCollectionChanged (except that you have to write all of this rhubarb to make it work of course.)

Download Full Source Code



3 comments:

Anonymous said...

I've come across this before as well.
INotifyPropertyChanged is defined in system assemblies, but INotifyCollectionChanged is defined in WPF assembly. Definately a frustration trying to build presentation agnostic bindable collections.

Franklin said...

Ok...that works in WPF, but will it work in Silverlight? I have a feeling it will not compile in silverlight which is a bummer. We have all kinds of Entities that we've developed for Winforms that we would like to use for Silverlight but they use BindingList. I think MS missed the boat on this one...letting us use existing entities in silverlight.

Annie Calvert said...

I have been using the sList class in my development in cunjunction with the ultragrid. Does this change mean that I will have to adapt my all of my code using the new ListChanged notification?
http://www.dapfor.com/en/net-suite/net-grid/features/performance