Search code examples
c#wpfdata-bindingdependency-propertiesattached-properties

Dependency PropertyChangedHandler not raised properly for CompositeCollection & nested CollectionViewSource.Source bindings


This really is striking me hard right now ...

Context

I am currently developing an application, where i need to combine multiple collections (Receipt.Contact.Addresses, Receipt.Contact.MainAddress via converter into collection) into a single source for a combobox (Receipt.BillingAddress).

The Problem

The real application has Receipt.BillingAddress bound to the SelectedItem property of a ComboBox with the described CompositeCollection. Changing the Receipt.Contact then will erase the Receipt.BillingAddress as Selector simply works like that.

This however, introduces random behavior, aka issues, due to async IO (server receives null update, sends out null update, server receives another update, ...)

Theoretically, this could be fixed by detaching and reattaching the binding everytime, the actual collection changes (hence the ItemsSourceAttached)

Sadly, this is not working as the PropertyChangedHandler is only ever fired the very first time it gets changed.

The weird stuff

This is fully working, if there is no extra levels inside the CollectionViewSource.Source binding (Receipt.Contact.Addresses vs Addresses)

How To Reproduce (Minimum Viable Example)

To reproduce this behavior, i created the following MVE consisting out of 3 Classes (Window, AttachedProperty and SomeContainer) and a single, XAML file (Window):

The AttachedProperty

public static class ItemsSourceAttached
{
    public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.RegisterAttached(
        nameof(Selector.ItemsSource),
        typeof(IEnumerable),
        typeof(ItemsSourceAttached),
        new FrameworkPropertyMetadata(null, ItemsSourcePropertyChanged)
    );

    public static void SetItemsSource(Selector element, IEnumerable value)
    {
        element.SetValue(ItemsSourceProperty, value);
    }

    public static IEnumerable GetItemsSource(Selector element)
    {
        return (IEnumerable)element.GetValue(ItemsSourceProperty);
    }

    static void ItemsSourcePropertyChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
    {
        MessageBox.Show("Attached Changed!");
        if (element is Selector target)
        {
            target.ItemsSource = e.NewValue as IEnumerable;
        }
    }
}

SomeContainer

public class SomeContainer : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    public string[] Data1 { get; }
    public string[] Data2 { get; }
    public SomeContainer(string[] data1, string[] data2)
    {
        this.Data1 = data1;
        this.Data2 = data2;
    }
}

The Window (C#) & DataContext (for simplicity)

public partial class CompositeCollectionTest : Window, INotifyPropertyChanged
{
    public SomeContainer Data
    {
        get => this._Data;
        set
        {
            this._Data = value;
            this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Data)));
        }
    }
    private SomeContainer _Data;
    
    
    // Not allowed to be NULLed on ItemsSource change
    public string SelectedItem
    {
        get => this._SelectedItem;
        set
        {
            this._SelectedItem = value;
            this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.SelectedItem)));
        }
    }
    private string _SelectedItem;

    public bool SomeProperty => false;

    public event PropertyChangedEventHandler PropertyChanged;

    public CompositeCollectionTest()
    {
        this.InitializeComponent();
        var descriptor = DependencyPropertyDescriptor.FromProperty(ItemsControl.ItemsSourceProperty, typeof(Selector));
        descriptor.AddValueChanged(this.MyComboBox, (sender, e) => {
            MessageBox.Show("Property Changed!");
        });
    }

    static int i = 0;
    private void Button_Click(object sender, RoutedEventArgs e)
    {
        this.Data = new SomeContainer(new string[]
        {
            $"{i}-DATA-A-1",
            $"{i}-DATA-A-2",
            $"{i}-DATA-A-3"
        },
        new string[]
        {
            $"{i}-DATA-B-1",
            $"{i}-DATA-B-2",
            $"{i}-DATA-B-3"
        });
        i++;
    }
}

The Window (XAML):

<Window x:Class="WpfTest.CompositeCollectionTest"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfTest"
        mc:Ignorable="d"
        Title="CompositeCollectionTest"
        Height="450" Width="800"
        DataContext="{Binding RelativeSource={RelativeSource Mode=Self}}">
    <Window.Resources>
        <CollectionViewSource x:Key="ViewSource1" Source="{Binding Data.Data1}"/>
        <CollectionViewSource x:Key="ViewSource2" Source="{Binding Data.Data2}"/>
    </Window.Resources>
    <StackPanel>
        <ComboBox x:Name="MyComboBox" SelectedItem="{Binding SelectedItem}">
            <ComboBox.Style>
                <Style TargetType="ComboBox">
                    <Style.Triggers>
                        <DataTrigger Binding="{Binding SomeProperty}" Value="False">
                            <Setter Property="local:ItemsSourceAttached.ItemsSource">
                                <Setter.Value>
                                    <CompositeCollection>
                                        <CollectionContainer Collection="{Binding Source={StaticResource ViewSource1}}"/>
                                        <CollectionContainer Collection="{Binding Source={StaticResource ViewSource2}}"/>
                                    </CompositeCollection>
                                </Setter.Value>
                            </Setter>
                        </DataTrigger>
                    </Style.Triggers>
                </Style>
            </ComboBox.Style>
        </ComboBox>
        <Button Content="Generate" Click="Button_Click"/>
    </StackPanel>
</Window>

Thank you for your time already. And i really hope someone can point me at my obvious mistake that i cannot seem to find...


Solution

  • CollectionView is a good fit for filtering / grouping / sorting a bound collection. Once you start swapping the ItemsSource on the fly, you'll need to keep everything in sync.

    However, given your use case desires:

    • Custom data gathering to compose the collection
    • Unbind \ bind behavior when swapping
    • More control over the SelectedItem

    You could instead introduce an additional abstraction between the viewmodel and the view, as explained in this post. I composed a demo for your original problem with the receipt contacts.

    namespace WpfApp.Models
    {
        public interface IAddress
        {
            string Street { get; }
        }
    
        public class Address : IAddress
        {
            public Address(string street)
            {
                Street = street;
            }
    
            public string Street { get; }
        }
    
        public class Contact
        {
            public Contact(string name, IAddress mainAddress, IAddress[] addresses)
            {
                Name = name;
                MainAddress = mainAddress;
                Addresses = addresses;
            }
    
            public string Name { get; }
            public IAddress MainAddress { get; }
            public IAddress[] Addresses { get; }
        }
    }
    

    Next, the additional ItemsContext abstraction and the ReceiptViewModel.

    namespace WpfApp.ViewModels
    {
        public class ItemsContext : ViewModelBase
        {
            public ItemsContext(Contact contact)
            {
                if (contact == null) throw new ArgumentNullException(nameof(contact));
    
                // Compose the collection however you like
                Items = new ObservableCollection<IAddress>(contact.Addresses.Prepend(contact.MainAddress));
                DisplayMemberPath = nameof(IAddress.Street);
                SelectedItem = Items.First();
            }
    
            public ObservableCollection<IAddress> Items { get; }
            public string DisplayMemberPath { get; }
    
            private IAddress selectedItem;
            public IAddress SelectedItem
            {
                get { return selectedItem; }
                set
                {
                    selectedItem = value;
                    OnPropertyChanged();
                    // Prevent XAML designer from tearing down VS
                    if (!DesignerProperties.GetIsInDesignMode(new DependencyObject()))
                    {
                        MessageBox.Show($"Billing address changed to {selectedItem.Street}");
                    }
                }
            }
        }
    
        public class ReceiptViewModel : ViewModelBase
        {
            public ReceiptViewModel()
            {
                Contacts = new ObservableCollection<Contact>(FetchContacts());
                SelectedContact = Contacts.First();
            }
    
            public ObservableCollection<Contact> Contacts { get; }
    
            private Contact selectedContact;
            public Contact SelectedContact
            {
                get { return selectedContact; }
                set
                {
                    selectedContact = value;
                    SelectedContext = new ItemsContext(value);
                    OnPropertyChanged();
                }
            }
    
            private ItemsContext selectedContext;
            public ItemsContext SelectedContext
            {
                get { return selectedContext; }
                set
                {
                    selectedContext = value;
                    OnPropertyChanged();
                }
            }
    
            private static IEnumerable<Contact> FetchContacts() =>
                new List<Contact>
                {
                    new Contact("Foo", new Address("FooMain"), new Address[] { new Address("FooA"), new Address("FooB") }),
                    new Contact("Bar", new Address("BarMain"), new Address[] { new Address("BarA"), new Address("BarB") }),
                    new Contact("Zoo", new Address("ZooMain"), new Address[] { new Address("ZooA"), new Address("ZooB") }),
                };
        }
    
        abstract public class ViewModelBase : INotifyPropertyChanged
        {
            public event PropertyChangedEventHandler PropertyChanged;
    
            protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
            {
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
            }
        } 
    }
    

    To apply the ItemsContext I chose to use an attached property as well, though you could opt to subclass ComboBox (or anything deriving from Selector) too.

    namespace WpfApp.Extensions
    {
        public class Selector
        {
            public static ItemsContext GetContext(DependencyObject obj) => (ItemsContext)obj.GetValue(ContextProperty);
            public static void SetContext(DependencyObject obj, ItemsContext value) => obj.SetValue(ContextProperty, value);
    
            public static readonly DependencyProperty ContextProperty =
                DependencyProperty.RegisterAttached("Context", typeof(ItemsContext), typeof(Selector), new PropertyMetadata(null, OnItemsContextChanged));
    
            private static void OnItemsContextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
            {
                var selector = (System.Windows.Controls.Primitives.Selector)d;
                var ctx = (ItemsContext)e.NewValue;
    
                if (e.OldValue != null) // Clean up bindings from previous context, if any
                {
                    BindingOperations.ClearBinding(selector, System.Windows.Controls.Primitives.Selector.SelectedItemProperty);
                    BindingOperations.ClearBinding(selector, ItemsControl.ItemsSourceProperty);
                    BindingOperations.ClearBinding(selector, ItemsControl.DisplayMemberPathProperty);
                }
    
                selector.SetBinding(System.Windows.Controls.Primitives.Selector.SelectedItemProperty, new Binding(nameof(ItemsContext.SelectedItem)) { Source = ctx, Mode = BindingMode.TwoWay });
                selector.SetBinding(ItemsControl.ItemsSourceProperty, new Binding(nameof(ItemsContext.Items)) { Source = ctx });
                selector.SetBinding(ItemsControl.DisplayMemberPathProperty, new Binding(nameof(ItemsContext.DisplayMemberPath)) { Source = ctx });
            }
        }
    }
    

    Wrapping up with the view.

    <Window x:Class="WpfApp.MainWindow"
            xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
            xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
            xmlns:vm="clr-namespace:WpfApp.ViewModels"
            xmlns:ext="clr-namespace:WpfApp.Extensions"
            mc:Ignorable="d"
            Title="MainWindow" Height="450" Width="800" WindowStartupLocation="CenterScreen">
        <Window.DataContext>
            <vm:ReceiptViewModel/>
        </Window.DataContext>
        <Window.Resources>
            <Style TargetType="{x:Type ComboBox}">
                <Setter Property="Width" Value="150"/>
                <Setter Property="HorizontalAlignment" Value="Left"/>
                <Setter Property="Margin" Value="0,0,0,20"/>
            </Style>
        </Window.Resources>
        <Grid Margin="20">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="100" />
                <ColumnDefinition />
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
            </Grid.RowDefinitions>
            <TextBlock Grid.Row="0" Grid.Column="0" Text="Contact Name" />
            <ComboBox Grid.Row="0" Grid.Column="1" ItemsSource="{Binding Contacts}" SelectedItem="{Binding SelectedContact}" DisplayMemberPath="Name" />
            <TextBlock Grid.Row="1" Grid.Column="0" Text="Billing Address" />
            <ComboBox Grid.Row="1" Grid.Column="1" ext:Selector.Context="{Binding SelectedContext}" />
        </Grid>
    </Window>
    

    If you run the demo you'll see there are no null addresses popping up when switching contexts, simply because we implement SelectedItem on the context itself (i.e. the abstraction between the viewmodel and the view). Any billing address changed logic could easily be injected into or implemented in the context.

    The other post I referenced puts the emphasis on storing state until the context becomes active again, e.g. SelectedItem. In this post we create ItemsContexts on the fly, as there could be many contacts. You can, of course, tweak this however you like.