WPF: filtering ItemCollection of a ComboBox also filters other ComboBoxes bound to the same ItemsSource
February 2, 2020

WPF: filtering ItemCollection of a ComboBox also filters other ComboBoxes bound to the same ItemsSource

The Problem

Given a "customer" entity:

public class CustomerEntity: EntityBase
{
    public Dictionary<int, string> ClientsOfCustomer = new Dictionary<int, string>();
    
    public CustomerEntity() 
    {
        // Load ClientsOfCustomer...
    }
}

and two or more ComboBoxes that have their ItemsSourceProperty dyamically bound to the same source (say, an attribute of the above "Customer" entity):

var customer = new CustomerEntity();
var comboBox1 = new ComboBox();
var comboBox2 = new ComboBox();
comboBox1.DataContext = customer;
comboBox2.DataContext = customer;
comboBox1.SetBinding(ItemsControl.ItemsSourceProperty,
    new Binding(itemsSourceProperty) { ElementName = "ClientsOfCustomer" });
comboBox2.SetBinding(ItemsControl.ItemsSourceProperty,
    new Binding(itemsSourceProperty) { ElementName = "ClientsOfCustomer" });

if we then filter the ItemCollection of either ComboBox:

comboBox1.Items.Filter = i => ((KeyValuePair<int, string>)i).Value.StartsWith("a");

this ends up filtering the other ComboBox as well.

Cause

This is because ComboBox is a Selector, which is an ItemsControl, and ItemsControl reacts to a change in its ItemsSource as follows:

namespace System.Windows.Controls
{
public class ItemsControl : Control, IAddChild, IGeneratorHost, IContainItemStorage
    {
    
        [CommonDependencyProperty]
        public static readonly DependencyProperty ItemsSourceProperty
            = DependencyProperty.Register("ItemsSource", typeof(IEnumerable), typeof(ItemsControl), new FrameworkPropertyMetadata((IEnumerable)null, new PropertyChangedCallback(OnItemsSourceChanged)));

        private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            ItemsControl ic = (ItemsControl) d;
            IEnumerable oldValue = (IEnumerable)e.OldValue;
            IEnumerable newValue = (IEnumerable)e.NewValue;

            ((IContainItemStorage)ic).Clear();

            BindingExpressionBase beb = BindingOperations.GetBindingExpressionBase(d, ItemsSourceProperty);
            if (beb != null)
            {
                // ItemsSource is data-bound.   Always go to ItemsSource mode.
                // Also, extract the source item, to supply as context to the
                // CollectionRegistering event
                ic.Items.SetItemsSource(newValue, (object x)=>beb.GetSourceItem(x) );

            }
            else if (e.NewValue != null)
            {
                // ItemsSource is non-null, but not data-bound.  Go to ItemsSource mode
                ic.Items.SetItemsSource(newValue);
            }
            else
            {
                // ItemsSource is explicitly null.  Return to normal mode.
                ic.Items.ClearItemsSource();
            }

            ic.OnItemsSourceChanged(oldValue, newValue);
        }
        
        protected virtual void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue)
        {
        }

        [Bindable(true), CustomCategory("Content")]
        [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
        public IEnumerable ItemsSource
        {
            get { return Items.ItemsSource; }
            set
            {
                if (value == null)
                {
                    ClearValue(ItemsSourceProperty);
                }
                else
                {
                    SetValue(ItemsSourceProperty, value);
                }
            }
        }
        ...

Note the line ic.Items.SetItemsSource(newValue, (object x)=>beb.GetSourceItem(x) );

This refers to ItemCollection.SetItemsSource:

namespace System.Windows.Controls
{
    public sealed class ItemCollection 
    {

        internal void SetItemsSource(IEnumerable value, Func<object, object> GetSourceItem = null)
        {
            // Allow this while refresh is deferred.

            // If we're switching from Normal mode, first make sure it's legal.
            if (!IsUsingItemsSource && (_internalView != null) && (_internalView.RawCount > 0))
            {
                throw new InvalidOperationException(SR.Get(SRID.CannotUseItemsSource));
            }

            _itemsSource = value;
            _isUsingItemsSource = true;

        SetCollectionView(CollectionViewSource.GetDefaultCollectionView( _itemsSource, ModelParent, GetSourceItem));
        }
        ...

The key line here is CollectionViewSource.GetDefaultCollectionView.

The CollectionView according to the docs "Represents a view for grouping, sorting, filtering, and navigating a data collection." This is needed because the underlying IEnumerable collection has no such functionality.

The reason for this behavior is actually spelled out in the docs for the CollectionViewSource.GetDefaultView(Object) Method:

All collections have a default CollectionView. WPF always binds to a view rather than a collection. If you bind directly to a collection, WPF actually binds to the default view for that collection. This default view is shared by all bindings to the collection, which causes all direct bindings to the collection to share the sort, filter, group, and current item characteristics of the one default view. Alternatively, you can create a view of your collection in Extensible Application Markup Language (XAML) or code using the CollectionViewSource class, and binding your control to that view. For an example, see How to: Sort and Group Data Using a View in XAML.

Solution for "Static" Applications

If we have access to the data-context at the time of binding, the solution is trivial. Simply create separate CollectionViewSources and bind to them, as explained in this SO answer:

<Window>
  <Window.DataContext>
    <ViewModel />
  <Window.DataContext>
  <Window.Resources>
    <CollectionViewSource x:Key="FirstCollectionSource" 
                          Source="{Binding DataSource}" />
    <CollectionViewSource x:Key="SecondCollectionSource" 
                          Source="{Binding DataSource}" />
  <Window.Resources>

  <StackPanel>
    <ComboBox ItemsSource="{Binding Source={StaticResource FirstCollectionSource}}" />
    <ComboBox ItemsSource="{Binding Source={StaticResource SecondCollectionSource}}" />

    <!-- Filter the second ComboBox -->
    <Button Command="{Binding ApplyFilterCommand}"
            CommandParameter="{StaticResource SecondCollectionSource}" />
  </StackPanel>
<Window>

But what about dynamic apps?

The real issue arises if your application generates forms on-the-fly based off of meta-data that describes what attributes should be generated, and how they should bind to the underlying entity etc. This metadata can come from C# attributes attached to properties, or from a database, it doesn't matter. If we don't have access to the data context when we create the binding, then we can't use the above solution. What then?

One solution is to somehow override the above behavior and ensure that the ItemsControl is bound to a brand new CollectionView when the binding is created. However this is easier said than done. You cannot create the CollectionView when defining the binding, because your entity might not even exist yet. And ItemsControl.OnItemsSourceChanged is private static, while ItemCollection.SetItemsSource is internal void.

Luckily it is possible to override metadata for a dependency property as described in the docs. You have to create your own ComboBox class that derives from the regular ComboBox, and then:

public partial class MyComboBox : ComboBox
	{
		static MyComboBox()
		{
			ItemsSourceProperty.OverrideMetadata(typeof(MyComboBox),
				new FrameworkPropertyMetadata((IEnumerable)null, new PropertyChangedCallback(OnItemsSourceChanged)));
		}
        
        private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
		{
			ItemsControl ic = (ItemsControl)d;
			IEnumerable oldValue = (IEnumerable)e.OldValue;
			IEnumerable newValue = (IEnumerable)e.NewValue;

			if (newValue.GetType() == typeof(Dictionary<int, string>))
			{
                var cvs = new CollectionViewSource() { Source = (ObservableDictionary)newValue };
                ic.ItemsSource = cvs.View;
            }
        }
        ...

Note that the if (newValue.GetType() == typeof(Dictionary<int, string>)) condition is required, otherwise you get into an infinite recursion loop (MyComboBox sets ItemsControl.ItemsSource which changes the ItemsSourceProperty, which then triggers ic.OnItemsSourceChanged(oldValue, newValue) leading back to MyComboBox.OnItemsSourceChanged and so on.

Another caveat: simply doing ic.ItemsSource = new ListCollectionView(((Dictionary<int, string>)newValue).ToList()); would seem to work except that any changes to the underlying ObservableCollection will not update the bound combobox. So it seems necessary to create a new CollectionViewSource, set the Source and bind to the view as above. This is also what is recommended in the official docs for CollectionView:

You should not create objects of this class in your code. To create a collection view for a collection that only implements IEnumerable, create a CollectionViewSource object, add your collection to the Source property, and get the collection view from the View property.

References

WPF: filtering ItemCollection of a ComboBox also filters other ComboBoxes bound to the same ItemsSource
Share this