views:

1957

answers:

3

I'm creating a WPF application using the MVVM design pattern that consists of a ListView and some ComboBoxes. The ComboBoxes are used to filter the ListView. What I am trying to accomplish is populating the combobox with items in the related ListView column. In other words, if my ListView has Column1, Column2, and Column3, I want ComboBox1 to display all UNIQUE items in Column1. Once an item is selected in the ComboBox1, I want the items in ComboBox2 and ComboBox3 to be filtered based on ComboBox1's selection, meaning that ComboBox2 and ComboBox3 can only contain valid selections. This would be somewhat similar to a CascadingDropDown control if using the AJAX toolkit in ASP.NET, except the user can select any ComboBox at random, not in order.

My first thought was to bind ComboBoxes to the same ListCollectionView that the ListView is bound to, and set the DisplayMemberPath to the appropriate column. This works great as far as filtering the ListView and ComboBoxes together goes, but it displays all items in the ComboBox rather than just the unique ones (obviously). So my next thought was to use a ValueConverter to only return the only the unique items, but I have not been sucessful.

FYI: I read Colin Eberhardt's post on adding a AutoFilter to a ListView on CodeProject, but his method loops through each item in the entire ListView and adds the unique ones to a collection. Although this method works, it seems that it would be very slow for large lists.

Any suggestions on how to achieve this elegantly? Thanks!

Code Example:

<ListView ItemsSource="{Binding Products}" SelectedItem="{Binding SelectedProduct}">
    <ListView.View>
        <GridView>
            <GridViewColumn Header="Item" Width="100" DisplayMemberBinding="{Binding ProductName}"/>
            <GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding ProductType}"/>
            <GridViewColumn Header="Category" Width="100" DisplayMemberBinding="{Binding Category}"/>
        </GridView>
    </ListView.View>
</ListView>

<StackPanel Grid.Row="1">
    <ComboBox ItemsSource="{Binding Products}" DisplayMemberPath="ProductName"/>
    <ComboBox ItemsSource="{Binding Products}" DisplayMemberPath="ProductType"/>
    <ComboBox ItemsSource="{Binding Products}" DisplayMemberPath="Category"/>
</StackPanel>
A: 

If you are using MVVM, then all of your bound data objects are in your ViewModel class, and your ViewModel class is implementing INotifyPropertyChanged, correct?

If so, then you can maintain state variables for SelectedItemType1, SelectedItemType2, etc., that are bound to your ComboBox(es) SelectedItem dependency property. In the Setter for SelectedItemType1, populate the List property (which is bound to the ItemsSource for ComboBoxType2) and fire the NotifyPropertyChanged for the List property. Repeat this for Type3 and you should be in the ballpark.

As for the "refresh" issue, or how does the View know when something has changed, it all comes down to the binding mode and firing the NotifyPropertyChanged event at the correct moments.

You could do this with a ValueConverter, and I love ValueConverters, but I think in this case it is more elegant to manage your ViewModel so that the Binding just happens.

Joel Cochran
Thanks Joel, although your answer would work, it seems that it would take a lot of code to make this work in all directions. For example, in the Setter for SelectedItemType1 I would have to populate the list for all of the other ComboBoxes, and take into account that these comboboxes may already have a selected item, meaning I need to populate a list based on two or more selected items. The more comboboxes I add the worse it will get.
Brent
I like the idea of using one master list with several columns, and having the comboboxes bind to one of the columns. Any time a selection is made, the other comboboxes are filtered based on the selection automatically. However, the downside is that the dropdown shows all items, rather than the unique ones.
Brent
I think if you want them filtered, you are going to have to expose different properties for each filter. These could be in the Get method, and will be pulled when the PropertyChanged event fires. You would still need some kind of two way binding to indicate how the list should filter.I suppose another idea would be to try and modify the ItemsTemplate for each list to set item Visibility. This wouldn't help with sorting, but could be a different way to do filtering. Just another idea.
Joel Cochran
A: 

Why not just create another property that contained only the distinct values from the list using a linq query or something like that?

public IEnumerable<string> ProductNameFilters
{
     get { return Products.Select(product => product.ProductName).Distinct(); }
}

...etc.

You'll have to raise property changed notifications for each of the filter lists when your Product property changes, but that's not a big deal.

You should really consider your ViewModel as a big ValueConverter for your view. The only time I would use a ValueConverter in MVVM is when I need to change data from a data type that is not view-specific to one that is view-specific. Example: for values greater than 10, text needs to be Red and for values Less than 10, text needs to be Blue... Blue and Red are view-specific types and shouldn't be something that gets returned from a ViewModel. This is really the only case where this logic shouldn't be in a ViewModel.

I question the validity of the "very slow for large lists" comment... generally the "large" for humans and "large" for a computer are two very different things. If you are in the realm of "large" for both computers and humans, I would also question showing this much data on a screen. Point being, it's likely not large enough for you to notice the cost of these queries.

Anderson Imes
Thanks Anderson, but if I create an ICollectionView or ListCollectionView and filter the list, how can I update the ProductNameFilters property if I am always selecting distinct records from the ObservableCollection? Whenever I filter the list, it still returns all of the records using your method.
Brent
Sorry... not following. Can you update your question?
Anderson Imes
Anderson,The ListView is bound to an ObservableCollection. I then create an ICollectionView to do things like filtering, sorting, and grouping on the ListView. Using your method, if I create a property to return distinct values, this IEnumerable list is never filtered when I filter the ICollectionView. Does that make sense? In other words, calling myICollectionView.Filter = delgate(object obj){...}; and then raising the propery changed notification does not filter the IEnumerable<string> ProductNameFilters property.
Brent
That's because you'd need to change the linq expression to go against the filter resultset, rather than the source observable collection.
Anderson Imes
That's what I would like to do, but I don't know how. ICollectionView does not have a Select method. Can you help me out?
Brent
+1  A: 

Check this out:

<Window x:Class="DistinctListCollectionView.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:DistinctListCollectionView"
Title="Window1" Height="300" Width="300">
<Window.Resources>
    <local:PersonCollection x:Key="data">
        <local:Person FirstName="aaa" LastName="xxx" Age="1"/>
        <local:Person FirstName="aaa" LastName="yyy" Age="2"/>
        <local:Person FirstName="aaa" LastName="zzz" Age="1"/>
        <local:Person FirstName="bbb" LastName="xxx" Age="2"/>
        <local:Person FirstName="bbb" LastName="yyy" Age="1"/>
        <local:Person FirstName="bbb" LastName="kkk" Age="2"/>
        <local:Person FirstName="ccc" LastName="xxx" Age="1"/>
        <local:Person FirstName="ccc" LastName="yyy" Age="2"/>
        <local:Person FirstName="ccc" LastName="lll" Age="1"/>
    </local:PersonCollection>
    <local:PersonAutoFilterCollection x:Key="data2" SourceCollection="{StaticResource data}"/>
    <DataTemplate DataType="{x:Type local:Person}">
        <WrapPanel>
            <TextBlock Text="{Binding FirstName}" Margin="5"/>
            <TextBlock Text="{Binding LastName}" Margin="5"/>
            <TextBlock Text="{Binding Age}" Margin="5"/>
        </WrapPanel>
    </DataTemplate>
</Window.Resources>
<DockPanel>
    <WrapPanel DockPanel.Dock="Top">
        <ComboBox DataContext="{Binding Source={StaticResource data2}, Path=Filters[0]}" ItemsSource="{Binding DistinctValues}" SelectedItem="{Binding Value}" Width="120"/>
        <ComboBox DataContext="{Binding Source={StaticResource data2}, Path=Filters[1]}" ItemsSource="{Binding DistinctValues}" SelectedItem="{Binding Value}" Width="120"/>
        <ComboBox DataContext="{Binding Source={StaticResource data2}, Path=Filters[2]}" ItemsSource="{Binding DistinctValues}" SelectedItem="{Binding Value}" Width="120"/>
    </WrapPanel>
    <ListBox ItemsSource="{Binding Source={StaticResource data2}, Path=FilteredCollection}"/>
</DockPanel>
</Window>

And the view model:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Collections;
using System.ComponentModel;

namespace DistinctListCollectionView
{
    class AutoFilterCollection<T> : INotifyPropertyChanged
    {
        List<AutoFilterColumn<T>> filters = new List<AutoFilterColumn<T>>();
        public List<AutoFilterColumn<T>> Filters { get { return filters; } }

        IEnumerable<T> sourceCollection;
        public IEnumerable<T> SourceCollection
        {
            get { return sourceCollection; }
            set
            {
                if (sourceCollection != value)
                {
                    sourceCollection = value;
                    CalculateFilters();
                }
            }
        }

        void CalculateFilters()
        {
            var propDescriptors = typeof(T).GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public);
            foreach (var p in propDescriptors)
            {
                Filters.Add(new AutoFilterColumn<T>()
                {
                    Parent = this,
                    Name = p.Name,
                    Value = null
                });
            }
        }

        public IEnumerable GetValuesForFilter(string name)
        {
            IEnumerable<T> result = SourceCollection;
            foreach (var flt in Filters)
            {
                if (flt.Name == name) continue;
                if (flt.Value == null || flt.Value.Equals("All")) continue;
                var pdd = typeof(T).GetProperty(flt.Name);
                {
                    var pd = pdd;
                    var fltt = flt;
                    result = result.Where(x => pd.GetValue(x, null).Equals(fltt.Value));
                }
            }
            var pdx = typeof(T).GetProperty(name);
            return result.Select(x => pdx.GetValue(x, null)).Concat(new List<object>() { "All" }).Distinct();
        }

        public AutoFilterColumn<T> GetFilter(string name)
        {
            return Filters.SingleOrDefault(x => x.Name == name);
        }

        public IEnumerable<T> FilteredCollection
        {
            get
            {
                IEnumerable<T> result = SourceCollection;
                foreach (var flt in Filters)
                {
                    if (flt.Value == null || flt.Value.Equals("All")) continue;
                    var pd = typeof(T).GetProperty(flt.Name);
                    {
                        var pdd = pd;
                        var fltt = flt;
                        result = result.Where(x => pdd.GetValue(x, null).Equals(fltt.Value));
                    }
                }
                return result;
            }
        }

        internal void NotifyAll()
        {
            foreach (var flt in Filters)
                flt.Notify();
            OnPropertyChanged("FilteredCollection");
        }

        #region INotifyPropertyChanged Members

        public event PropertyChangedEventHandler PropertyChanged;
        protected void OnPropertyChanged(string prop)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(prop));
        }

        #endregion
    }

    class AutoFilterColumn<T> : INotifyPropertyChanged
    {
        public AutoFilterCollection<T> Parent { get; set; }
        public string Name { get; set; }
        object theValue = null;
        public object Value
        {
            get { return theValue; }
            set
            {
                if (theValue != value)
                {
                    theValue = value;
                    Parent.NotifyAll();
                }
            }
        }
        public IEnumerable DistinctValues
        {
            get
            {
                var rc = Parent.GetValuesForFilter(Name);
                return rc;
            }
        }

        #region INotifyPropertyChanged Members

        public event PropertyChangedEventHandler PropertyChanged;
        protected void OnPropertyChanged(string prop)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(prop));
        }

        #endregion

        internal void Notify()
        {
            OnPropertyChanged("DistinctValues");
        }
    }
}

The other classes:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace DistinctListCollectionView
{
    class Person
    {
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public int Age { get; set; }
    }
}

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace DistinctListCollectionView
{
    class PersonCollection : List<Person>
    {
    }

    class PersonAutoFilterCollection : AutoFilterCollection<Person>
    {
    }
}
Aviad P.
Aviad,This works great! I compiled your source code and it worked... I need to try it in my application, but I'm confident that it will work. I just have one question. Is it possible in the XAML code to change the DataContext of the comboboxes from: DataContext="{Binding Source={StaticResource data2}, Path=Filters[0]}", to something like: DataContext="{Binding Source={StaticResource data2}, Path=Filters.FirstName}"I would prefer to use names, rather than numbers if possible. Nevertheless, it does work and I've very grateful so I'll mark it as answered. Thanks for your help!
Brent
First of all, glad you found it useful, you're welcome. Using actual column names is possible if you implement ICustomTypeDescriptor on the AutoFilterCollection class. I just did it and it works, but it's tedious because you have to include standard properties (SourceCollection) in the ICustomTypeDescriptor provided properties to avoid design-time errors (no compile errors, or run time errors, though).
Aviad P.