未针对 CompositeCollection 和嵌套 CollectionViewSource.Source 绑定正确引发依赖项 PropertyChangedHandler

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

这真的让我很震惊......

上下文

我目前正在开发一个应用程序,我需要将多个集合(Receipt.Contact.AddressesReceipt.Contact.MainAddress 通过转换器转换为集合)合并为一个组合框的单一来源 (Receipt.BillingAddress) .

问题

实际应用程序已将 Receipt.BillingAddress 绑定到具有所述 CompositeCollectionComboBoxSelectedItem 属性。 更改 Receipt.Contact 然后将删除 Receipt.BillingAddress,因为 Selector 就是这样工作的。

然而,由于异步 IO(服务器接收空更新,发送空更新,服务器接收另一个更新,...),这引入了随机行为,也就是问题。

理论上,这可以通过每次分离和重新附加绑定来解决,实际集合发生变化(因此 ItemsSourceAttached)

遗憾的是,这不起作用,因为 PropertyChangedHandler 只会在第一次更改时触发。

奇怪的东西

如果 CollectionViewSource.Source 绑定中没有额外的级别(Receipt.Contact.Addresses vs Addresses

,这完全有效

如何重现(最小可行示例)

为了重现此行为,我创建了以下 MVE,其中包含 3 个 类(Window、AttachedProperty 和 SomeContainer)和一个 XAML 文件(Window):

附加属性

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

一些容器

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

Window (C#) 和 DataContext(为简单起见)

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++;
    }
}

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>

谢谢你的时间。 我真的希望有人能指出我似乎无法找到的明显错误...

CollectionView 非常适合对绑定集合进行过滤/分组/排序。一旦开始即时交换 ItemsSource,您将需要 keep everything in sync.

但是,鉴于您的用例需求:

  • 自定义数据收集以组成集合
  • 交换时取消绑定\绑定行为
  • 更多地控制 SelectedItem

您可以改为在视图模型和视图之间引入额外的抽象,如 所述。我为您最初的收据联系人问题编写了一个演示。

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

接下来,额外的 ItemsContext 抽象和 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));
        }
    } 
}

为了应用 ItemsContext,我也选择使用附加的 属性,尽管您也可以选择子类 ComboBox(或从 Selector 派生的任何东西) .

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 });
        }
    }
}

结束视图。

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

如果你 运行 演示你会看到切换上下文时没有 null 地址弹出,只是因为我们在上下文本身上实现 SelectedItem (即抽象在视图模型和视图之间)。任何帐单地址更改 逻辑都可以很容易地注入或在上下文中实现。

我引用的另一个 post 强调存储状态,直到上下文再次激活,例如SelectedItem。在此 post 中,我们即时创建 ItemsContext,因为可能有很多联系人。当然,您可以随意调整它。