1
votes

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 WPF ComboBoxes that have their ItemsSourceProperty bound to the same source (say, an attribute of the above "Customer" entity):

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

The above is done before we actually have an instance of the underlying object. This happens sometime later in the application:

var customer = new CustomerEntity();
parentPageOfComboboxes.DataContext = customer;

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.

I understand that this is because ComboBox is a Selector, which is an ItemsControl, and ItemsControl invokes ItemCollection.SetItemsSource when the ItemsSource DependencyProperty changes. ItemCollection then in turn makes use of CollectionViewSource.GetDefaultCollectionView( _itemsSource, ModelParent, GetSourceItem) to grab the default CollectionView associated with the given _itemsSource from cache. Therefore if you set the Filter on the underlying ItemCollection of one ComboBox, you are actually setting it on the cached and shared CollectionView associated with the ItemsSource that the ComboBox is bound to. I guess the WPF folks didn't expect anyone to want to do so - unfortunate. But what can I do about it?

I've found two similar questions on SO without any real answer: "Filtered Combobox ItemsSource binding issue" (which seems to be about a custom "FilteredComboBox") and "Wpf ListBoxes' ItemsSource strange behaviour (which involves listboxes without data binding).

I've also implemented a fairly ugly solution that I will include below as an answer. However, there must be a better way.

UPDATE/CLARIFICATION: I did not express myself clearly in my original question. The answer to this question is trivial in cases where we have access to the actual instance of the entity that we are binding to at the time of data binding. However, I have a complex application that generates forms "on-the-fly" from metadata (which can come from C# Attributes, or from meta-data stored in a database, it doesn't matter). In the other similar questions posed on SO, the actual binding source (the data context) is always available, so simply doing something like Items = new ListCollectionView(...); (either in code or in XAML) is possible. But what if the actual DataContext is only set somewhere much further down the application lifecycle based on metadata? What solution is there other than something very crude like looping through all generated UI elements and altering the binding source retroactively? I hope this clarification is understandable.

2

2 Answers

1
votes

An Ugly Solution

I created my own MyComboBox that inherits from System.Windows.Controls.ComboBox. In MyComboBox I override metadata for the ItemsSourceProperty as described in the docs so that whenever the ItemsSource changes I actually bind to a new ListCollectionView:

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;

        // We get into infinite recursion land without the following condition:
        if (newValue.GetType() == typeof(Dictionary<int, string>))
        {
            var cvs = new CollectionViewSource() { Source = ((Dictionary<int, string>)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.

So the above works but it's pretty ugly. Is there really no better way?

1
votes

Your observations are correct. Every ItemsControl that binds to the same source collection, shares a common default ICollectionView of this collection.

The solution is to create dedicated instances of ICollectionView for each ComboBox. You can define them in XAML using a CollectionViewSource like in the example below. If the filter is triggered by aButton you can use a command to bind this Button to filter predicate defined in your view model, passing the CollectionViewSource as CommandParameter.

Otherwise define the ICollectionView as properties in your view model and bind to them. You can now set the filter predicate directly without using a trigger (command) from the view.

ViewModel.cs

public ObservableCollection<string> Data source { get; set; }

ICommand ApplyFilterCommand => new RelayCommand(FilterCollectionView);

private void FilterCollectionView(object param)
{  
    CollectionView collectionView = (param as CollectionViewSource).View;
    collectionView.Filter = item => item.StartsWith("a");
    collectionView.Refresh();
}  

MainWindow.xaml

<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>