Wednesday, 21 July 2010

MVVM and Multiple Selection – Part III

Update: Feb 2nd 2012 - I've updated this code to work with DataGrid.

Download the source code for this article

In the previous two blog posts in this series I explored the current best practises for dealing with multiple selections in MVVM, and explained the problems one may encounter.

My feeling for a long time has been “What I really need is a CollectionView that supports multiple selections.” So I decided to write one, and it turned out to be pretty easy!

So without delay, here’s the code for MultiSelectCollectionView:

    public class MultiSelectCollectionView<T> : ListCollectionView, IMultiSelectCollectionView
    {
        public MultiSelectCollectionView(IList list)
            : base(list)
        {
            SelectedItems = new ObservableCollection<T>();
        }

        void IMultiSelectCollectionView.AddControl(Selector selector)
        {
            this.controls.Add(selector);
            SetSelection(selector);
            selector.SelectionChanged += control_SelectionChanged;
        }

        void IMultiSelectCollectionView.RemoveControl(Selector selector)
        {
            if (this.controls.Remove(selector))
            {
                selector.SelectionChanged -= control_SelectionChanged;
            }
        }

        public ObservableCollection<T> SelectedItems { get; private set; }

        void SetSelection(Selector selector)
        {
            MultiSelector multiSelector = selector as MultiSelector;
            ListBox listBox = selector as ListBox;

            if (multiSelector != null)
            {
                multiSelector.SelectedItems.Clear();

                foreach (T item in SelectedItems)
                {
                    multiSelector.SelectedItems.Add(item);
                }
            }
            else if (listBox != null)
            {
                listBox.SelectedItems.Clear();

                foreach (T item in SelectedItems)
                {
                    listBox.SelectedItems.Add(item);
                }
            }
        }

        void control_SelectionChanged(object sender, SelectionChangedEventArgs e)
        {
            if (!this.ignoreSelectionChanged)
            {
                bool changed = false;

                this.ignoreSelectionChanged = true;

                try
                {
                    foreach (T item in e.AddedItems)
                    {
                        if (!SelectedItems.Contains(item))
                        {
                            SelectedItems.Add(item);
                            changed = true;
                        }
                    }

                    foreach (T item in e.RemovedItems)
                    {
                        if (SelectedItems.Remove(item))
                        {
                            changed = true;
                        }
                    }

                    if (changed)
                    {
                        foreach (Selector control in this.controls)
                        {
                            if (control != sender)
                            {
                                SetSelection(control);
                            }
                        }
                    }
                }
                finally
                {
                    this.ignoreSelectionChanged = false;
                }
            }
        }

        bool ignoreSelectionChanged;
        List<Selector> controls = new List<Selector>();
    }

As you can see, it’s pretty simple. The class derives from ListCollectionView and maintains an ObservableCollection to hold the selected items and a List to hold the controls that are bound to it.

One thing to notice is that the class is generic, in order to allow the SelectedItems collection to be strongly typed. This may seem like a strange choice considering that CollectionViews are usually not strongly typed.

The reason for this is that the items in a CollectionView will usually only be used by the View side of the application and view controls do not care about having strongly typed values. However, the new SelectedItems collection will usually be used from the ViewModel side of things, where having strongly typed values is important.

Now, WPF controls have no knowledge of MultiSelectCollectionView so if you hook it up to an ItemsControl now, it will work exactly the same as a ListCollectionView, with no multi-select support. We add in multi-select support using the magic of attached properties:

    public static class MultiSelect
    {
        static MultiSelect()
        {
            Selector.ItemsSourceProperty.OverrideMetadata(typeof(Selector), new FrameworkPropertyMetadata(ItemsSourceChanged));
        }

        public static bool GetIsEnabled(Selector target)
        {
            return (bool)target.GetValue(IsEnabledProperty);
        }

        public static void SetIsEnabled(Selector target, bool value)
        {
            target.SetValue(IsEnabledProperty, value);
        }

        public static readonly DependencyProperty IsEnabledProperty =
            DependencyProperty.RegisterAttached("IsEnabled", typeof(bool), typeof(MultiSelect), 
                new UIPropertyMetadata(IsEnabledChanged));

        static void IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e)
        {
            Selector selector = sender as Selector;
            IMultiSelectCollectionView collectionView = selector.ItemsSource as IMultiSelectCollectionView;

            if (selector != null && collectionView != null)
            {
                if ((bool)e.NewValue)
                {
                    collectionView.AddControl(selector);
                }
                else
                {
                    collectionView.RemoveControl(selector);
                }
            }
        }

        static void ItemsSourceChanged(object sender, DependencyPropertyChangedEventArgs e)
        {
            Selector selector = sender as Selector;

            if (GetIsEnabled(selector))
            {
                IMultiSelectCollectionView oldCollectionView = e.OldValue as IMultiSelectCollectionView;
                IMultiSelectCollectionView newCollectionView = e.NewValue as IMultiSelectCollectionView;

                if (oldCollectionView != null)
                {
                    oldCollectionView.RemoveControl(selector);
                }

                if (newCollectionView != null)
                {
                    newCollectionView.AddControl(selector);
                }
            }
        }
    }

Here we create an attached property, MultiSelect.IsEnabled. When set to true on a control derived from Selector, it adds the control to the MultiSelectCollectionView’s list of bound controls.

We also override the metadata for the ItemsSource property for all Selector controls. In this way we can detect when a control’s ItemsSource changes and ensure that the control is registered with the correct MultiSelectCollectionView. Note that because MultiSelectCollectionView is generic, we use the non-generic IMultiSelectCollectionView interface to add/remove controls. IMultiSelectCollectionView looks like this:

    public interface IMultiSelectCollectionView
    {
        void AddControl(Selector selector);
        void RemoveControl(Selector selector);
    }

Now we can add multiple selection capabilites to a control like so:

<ListBox ItemsSource="{Binding Items}" SelectionMode="Extended" local:MultiSelect.IsEnabled="True"/>

That’s it! You can download the source code here. Let me know of any problems you find and also any improvements!

21 comments:

  1. Hi,

    This is some great work! Is there a fairly simple way to get it to work with a DataGrid multiple selection too?

    ReplyDelete
    Replies
    1. I know this is a little late, but I've just posted a new article about using it with DataGrid: http://grokys.blogspot.com/2012/02/mvvm-and-multiple-selection-part-iv.html

      Delete
  2. Hi Steve, thanks! I've not tried it with DataGrid yet but theoretically it should work in the same way as with a ListBox. If it doesn't work for you, tomorrow I will try it out on a DataGrid and post any changes necessary.

    ReplyDelete
  3. Yes, it appears to work as-is with DataGrid. LMK of any problems you find.

    ReplyDelete
  4. That's interesting. I can't get it to work.

    Basically the MetaDescription override for the Selecor.ItemsSourceProperty in the static MultiSelect constructor doesn't seem to work for the DataGrid (or at least the ItemsSourceChanged handler doesn't seem to get fired so the AddControl method on the collection never gets called.

    I'm using VS2010 and .NET 4 framework.

    ReplyDelete
  5. Sorry to be OT, but I recently read this post of yours on StackOverflow, and I was wondering if you could help:

    http://stackoverflow.com/questions/2179042/rendertargetbitmap-and-viewport3d-quality-issues

    Did you ever find an efficient way of rendering (off-screen) WPF content into a bitmap that used a hardware path rather than RenderTargetBitmap's (horrifically slow) software path? I find it ironic that the argument for not providing the ability to render in hardware then transfer across to system RAM is that it would be "too inefficient", and then provide a software rendering path instead that takes ten times as long :S

    I'd be really grateful for any assistance as to the best forward, as I've only just hit this issue and don't have a huge amount of time to solve it.

    Thanks,

    Darren Myatt

    ReplyDelete
  6. Hi Darren,

    No, I never did find a way to get it to work - in fact I ended up having to write a separate DirectX renderer for my purposes. Very poor indeed.

    ReplyDelete
  7. Hi Steven,

    Thanks a lot for replying: I had a bad feeling that would be the answer, though. I've found a (non-ideal) way to work around the problem in our application, but it would have made my life so much easier if the WPF team had thought to provide this pretty obvious functionality.

    Darren

    ReplyDelete
  8. Hi Steven,

    Thank you for your sample. Do you think about improving this sample by adding SelectedItemsChanged event ? I try to implement this by adding an event in MultiSelectCollectionView class and raising it when boolean "changed" is true.

    What about CurrentChanged and CurrentItem ? this event never seems to be raised now and in the case of one selection (old use of a ICollectionView) current item seems to be null. What do you think about?

    Thank you in advance :)

    (sorry for my english but I'm french ;))

    ReplyDelete
  9. Hi,

    Nice work. I'm implementing your solution in my ICollectionView implementation for silverlight.

    However there is a problem here because Silverlight doesn't have a MultiSelector, so instead I created a IMultiSelectAdapter interface and use it instead of the Selectors :

    public interface IMultiSelectAdapter
    {
    event SelectionChangedEventHandler SelectionChanged;
    IList SelectedItems { get; }
    }

    Then the MultiSelect class creates specific adapters for each type of control. For instance, RadGridView has one and ListBox has a different one.

    Best regards,

    Manuel Felício.

    ReplyDelete
  10. Hi,
    I am having the same problem as Steve. The ItemsSourceChanged event never fires. All I have done is changed the example ListBox to DataGrid and it doesn't work. Did you do something else when you were testing with the DataGrid?

    Thanks

    ReplyDelete
    Replies
    1. I know this is a little late, but I've just posted a new article about using it with DataGrid: http://grokys.blogspot.com/2012/02/mvvm-and-multiple-selection-part-iv.html

      Delete
  11. Hi,
    Very great work, and nice idea, this is what I have been missing a long time. I had one issue though, I tried clearing the selected items from code, I tried Items.SelectedItems.Clear() in the DisplaySelectionCount button in MasterViewModel and nothing changed in the listbox. Any idea how to fix this ? also will it work in a DataGrid ?

    ReplyDelete
    Replies
    1. No,, unfortunately I've not had chance/need to set the selection from code. It should be simple enough to implement though - please let me know if you do.

      However I have had need to use it with DataGrid and posted a new version that addresses that need: http://grokys.blogspot.com/2012/02/mvvm-and-multiple-selection-part-iv.html

      Delete
  12. Although SelectedItems is ObservableCollection, I don't see anywhere that it is being observed. MultiSelectCollectionView doesn't watch for changes in SelectedItems so I'm not surprised if SelectedItems.Clear() has no visible effect.

    The way MultiSelectCollectionView updates SelectedItems it inefficient; if you Select All, it will update SelectedItems in O(N^2) time. It would often be faster if control_SelectionChanged simply duplicated the entire list of selected items.

    I wonder if there is a way to accomplish this without the attached property. I wonder if the WPF data binding system provides any way for an object (that is being data-bound) to be notified that a binding to it is being created in a certain control.

    Where is the "Items" property that ItemsSource="{Binding Items}" refers to? Is this just an instance of MultiSelectCollectionView?

    People are reporting that it doesn't work with DataGrid. What about ListView?

    ReplyDelete
    Replies
    1. Thanks for your comments Qwertie, I'd love to see a version that fixed the issues you mention; unfortunately I don't have to work on this any further than to fix the issues I encounter. That's why it's a blog post not a library ;)

      Having said that I have updated it to work with DataGrid if you're interested: http://grokys.blogspot.com/2012/02/mvvm-and-multiple-selection-part-iv.html

      Delete
  13. @Qwertie, works fine with a ListView. Very nice solution.

    ReplyDelete
  14. Thanks for the code! Just integrated it into our project and it works flawlessly. Added a SelectionChanged event though to respond directly when a user clicks on an entry.
    Saved me a lot of time...
    Cheers
    Jonkman

    ReplyDelete
  15. This is a great solution. I just wanted to ask how is that possible to do selection via ViewModel?

    ReplyDelete
  16. Nice solution. In my XAML file, the reference to MultiSelect.IsEnabled generates the error "PropertyMetadata is already registered for type 'Selector'" though the solution builds and the control seems to work. I'm using .NET 4.5 and VS 2012 Professional with Update 3.

    ReplyDelete
  17. Can somebody tell me where part 2 of this series is, I've googled but I only get 1, 3, and 4 (Also, naming them I, II, III AND IV makes them really hard to search for, what's wrong with 1,2,3,4?)

    ReplyDelete