这真的让我很震惊...
语境
我目前正在开发一个应用程序,在这里我需要将多个集合(Receipt.Contact.Addresses
,Receipt.Contact.MainAddress
通过转换器转换为集合)合并到一个组合框(Receipt.BillingAddress
)的单一来源中。
问题
实际的应用程序已将Receipt.BillingAddress
绑定到具有描述的SelectedItem
的ComboBox
的CompositeCollection
属性。
更改Receipt.Contact
然后将擦除Receipt.BillingAddress
,因为Selector
就像这样工作。
但是,由于异步IO,这会引入随机行为,也就是问题(服务器收到null更新,发出null更新,服务器收到另一个更新,...)
从理论上讲,这可以通过每次实际的集合发生更改时分离并重新附加绑定来解决(因此,ItemsSourceAttached)
遗憾的是,这是行不通的,因为PropertyChangedHandler
仅在第一次更改时才被触发。
奇怪的东西
如果CollectionViewSource.Source
绑定内没有其他级别(Receipt.Contact.Addresses
与Addresses
),则此操作完全正常
如何复制(最小可行示例)
为了重现此行为,我创建了以下由3个类(Window,AttachedProperty和SomeContainer)和一个XAML文件(Window)组成的MVE:
附属财产
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;
}
}
窗口(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++;
}
}
窗口(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
的更多控制
您可以改为在视图模型和视图之间引入其他抽象,如in this post所述。我使用收据联系人为您的原始问题制作了一个演示。
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
(即,视图模型和视图之间的抽象)。任何帐单地址更改的逻辑都可以轻松地插入到上下文中或在上下文中实现。我引用的另一篇文章强调存储状态,直到上下文再次变为活动状态,例如
SelectedItem
。在这篇文章中,我们会动态创建ItemsContext
,因为可能会有很多联系人。当然,您可以随意调整它。