WPF:如何从控件中对视图模型中的INotifyPropertyChanged项触发PropertyChanged



假设我可以控制自己的ItemSource。它可以为其分配ObservableCollection类型,因此,如果我的控件更改了项的属性,则可以通知集合。

问题是,当我修改了项,并且我知道集合实现了INotifyPropertyChanged时,我不知道如何触发PropertyChanged事件。接口只定义事件,而不定义触发它的方法

然而,不知何故,WPF最初的控制措施做到了这一点。我将我的集合馈送到DataGrid,比如说,当网格是可编辑的并且我更改了一个值时,PropertyChanged事件由我的源集合上的Datagrid控件触发。

所以这似乎是绝对可能的。更重要的是,我甚至自己创建了自己类型的可观察集合,实现了INotifyPropertyChangedINotifyCollectionChanged接口,它仍然有效。DataGrid更改项目,我的项目的PropertyChanged事件在集合上触发。

我想在我的新控制中也这样做。我有更改的项,我有属性名称,我有源可观察集合,现在我只想让集合触发它的PropertyChanged事件。

怎么做?像DataGrid这样的内置WPF控件是如何做到这一点的?

代码是什么?比方说,它有点像"复选框组合框";。一种具有可检查项的ComboBoxComboBox是复杂的野兽,很多XAML我不喜欢,所以我发明了一种非常简单的方法来解决它。我只使用Menu。我制作了自己的控件,该控件包含菜单,其中主项充当标签/按钮,可检查子项充当复选框。它看起来很好,甚至有点管用。

该项的项源是(字符串值,bool-isChecked)元组的(可观察的)集合。它们非常容易用LINQ操作。这个东西将用作DataGrid视图的过滤器。它填充了所有可用的记录类型,然后可以从视图中取消选中和筛选它们。这种方法的优点是绝对没有XAML,没有样式,没有图形设计师的东西。它与默认样式看起来不错,感觉绝对自然直观,没有任何视觉调整。

然而,事情(几乎)已经完成了,我希望我的ViewModel在我的过滤项集合更改时得到正确的通知,这样我的视图模型就可以请求适当的过滤。

我错过了一些显而易见的东西。。。

相关视图模型框架:

ObservableCollection<(string value, bool isChecked)> Checks = new();
Checks.Add(("Item1", true));
Checks.Add(("Item2", true));
Checks.PropertyChanged += (s, e) {
// item changed reaction
}
<!-- ... -->
<c:Checks ItemsSource="{Binding Checks}"/>
<!-- ... -->

我的控制来源:

using System;
using System.Collections;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
namespace Woof.Windows.Controls {

public class Checks : UserControl {
public object Empty {
get => GetValue(EmptyProperty);
set => SetValue(EmptyProperty, value);
}
public IEnumerable ItemsSource {
get => (IEnumerable)GetValue(ItemsSourceProperty);
set => SetValue(ItemsSourceProperty, value);
}
public static readonly DependencyProperty EmptyProperty =
DependencyProperty.Register(
nameof(Empty),
typeof(object),
typeof(Checks),
new PropertyMetadata(new PropertyChangedCallback(OnEmptyPropertyChanged))
);
public static readonly DependencyProperty ItemsSourceProperty =
DependencyProperty.Register(
nameof(ItemsSource),
typeof(IEnumerable),
typeof(Checks),
new PropertyMetadata(new PropertyChangedCallback(OnItemsSourcePropertyChanged))
);
private static void OnEmptyPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) {
// TODO
}
private static void OnItemsSourcePropertyChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) {
if (sender is Checks control)
control.OnItemsSourceChanged((IEnumerable)e.OldValue, (IEnumerable)e.NewValue);
}
private void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue) {
if (oldValue is INotifyCollectionChanged oldCollection)
oldCollection.CollectionChanged -= new NotifyCollectionChangedEventHandler(OnCollectionChanged);
if (newValue is INotifyCollectionChanged newCollection) {
newCollection.CollectionChanged += new NotifyCollectionChangedEventHandler(OnCollectionChanged);
if (newValue.OfType<object>().Any()) {
foreach (var item in newValue) AddItem(item);
SetHeader();
}
}
}
void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) {
switch (e.Action) {
case NotifyCollectionChangedAction.Add:
if (e.NewItems is null) return;
foreach (var item in e.NewItems) AddItem(item);
SetHeader();
break;
case NotifyCollectionChangedAction.Remove:
if (e.OldItems is null) return;
foreach (var item in e.OldItems) RemoveItem(item);
SetHeader();
break;
case NotifyCollectionChangedAction.Replace:
throw new NotImplementedException("Replace action is not implemented by Checks control");
case NotifyCollectionChangedAction.Move:
throw new NotImplementedException("Move action is not implemented by Checks control");
case NotifyCollectionChangedAction.Reset:
foreach (var menuItem in MenuContent.Items.OfType<MenuItem>()) menuItem.Checked -= Item_Checked;
MenuContent.Items.Clear();
if (e.NewItems is null) return;
foreach (var item in e.NewItems) AddItem(item);
SetHeader();
break;
}
}
private (object value, bool isChecked) GetCheckItem(object item) {
if (item is ValueTuple<string, bool> label) return (label.Item1, label.Item2);
else if (item is ValueTuple<object, bool> boxed) return (boxed.Item1, boxed.Item2);
else return (item, false);
}
private void AddItem(object item) {
var checkItem = GetCheckItem(item);
if (MenuContent.Items.OfType<MenuItem>().Any(i => i.Tag == checkItem.value)) return;
var newItem = new MenuItem {
Header = checkItem.value,
IsCheckable = true,
IsChecked = checkItem.isChecked,
Tag = checkItem.value,
};
newItem.Checked += Item_Checked;
newItem.Unchecked += Item_Unchecked;
MenuContent.Items.Add(newItem);
}
private void RemoveItem(object item) {
var checkItem = GetCheckItem(item);
var menuItem = MenuContent.Items.OfType<MenuItem>().FirstOrDefault(i => i.Tag == checkItem.value);
if (menuItem is not null) {
menuItem.Checked -= Item_Checked;
menuItem.Unchecked -= Item_Unchecked;
MenuContent.Items.Remove(menuItem);
}
}
private void Item_Checked(object sender, RoutedEventArgs e) {
var sourceValue = (e.Source as MenuItem)!.Tag;
var sourceItem = ItemsSource.OfType<object>().Select(i => GetCheckItem(i)).FirstOrDefault(i => i.value == sourceValue);
sourceItem.isChecked = true;
if (ItemsSource is INotifyPropertyChanged nItemSource) {
// nItemSource.PropertyChanged.Invoke(sourceItem, new PropertyChangedEventArgs("isChecked"));
// Nah, this won't work. There must be another way...
}
}
private void Item_Unchecked(object sender, RoutedEventArgs e) {
var sourceValue = (e.Source as MenuItem)!.Tag;
var sourceItem = ItemsSource.OfType<object>().Select(i => GetCheckItem(i)).FirstOrDefault(i => i.value == sourceValue);
sourceItem.isChecked = false;
if (ItemsSource is INotifyPropertyChanged nItemSource) {
// HERE, I NEED TO NOTIFY THE SOURCE!
}
}
private void SetHeader() {
var labels = ItemsSource
.OfType<object>()
.Select(i => GetCheckItem(i))
.Where(i => i.isChecked)
.Select(i => i.value.ToString())
.ToArray();
var header = String.Join(", ", labels);
MenuContent.Header = header;
}
public Checks() {
Content = MenuContainer = new Menu();
MenuContainer.Items.Add(MenuContent = new MenuItem());
}
private readonly Menu MenuContainer;
private readonly MenuItem MenuContent;
}
}

更新:我这里有一个讨厌的bug,我知道。Tuple是不可战胜的。所以无论如何,我都无法通过修改元组的属性来同步它们的集合。

修复方法很简单:我将使用对象,如var x = new { Value = "Item1", IsChecked = true }。这些是可变的,所以…

无论如何,主要问题仍然存在。我找到了一个变通办法:

if (ItemsSource is INotifyPropertyChanged nItemSource) {
var t = ItemsSource.GetType();
var m = t.GetMethod("OnPropertyChanged", BindingFlags.NonPublic | BindingFlags.Instance);
m.Invoke(ItemsSource, new object?[] { sourceItem, new PropertyChangedEventArgs("isChecked") });
}

但这是一个丑陋的黑客。但话说回来,它是有效的。它做了它应该做的和像DataGrid这样的控件做的。我的意思是——这是同样的效果。我仍然不知道他们是否使用反射来调用集合的受保护方法。

触发PropertyChanged-事件主要与WPF本身无关,只是碰巧重合。

PropertyChanged-事件需要在包含应该通知其更改的属性的类中实现。例如,您有一个名为的简单类

class Test {
public string Name { get; set; }
}

如果您希望这个类在Name更改时引发一个事件,您必须手动实现它,如下所示:

class Test : INotifyPropertyChanged {
private string _name;
public event PropertyChangedEventHandler PropertyChanged;
public string Name {
get => _name;
set {
if(value == _name) return;
_name = value;
OnPropertyChanged();
}
}
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) {
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

现在,每次更改Name-属性时,都会触发PropertyChanged-事件。ObservableCollections和CollectionChanged-事件也是如此,它们都是在类本身中手动实现的。

当您使用绑定时,WPF就会发挥作用。如果绑定到以TwoWay-或OneWayToSource-模式配置的属性,则当控件中的值发生更改时,WPF会将该值写回该属性。由于该属性在任何更改时都会激发该事件,因此每当WPF决定更新绑定源时,该事件都会激发。

关于您的一些声明:

我认为,在你的第二段中,你把一些东西搞混了。是的,ObservableCollection实现INotifyPropertyChanged,但此事件仅适用于此类,不适用于其子类。通常,PropertyChanged-事件只为属性已更改的对象激发,而不会传播。接口实际上只定义了事件,因为触发函数通常保持为privateprotected。如果您想要一个能够意识到其子项更改的集合,则必须自己实现。WPF可以自动做到这一点,因为当DataGrid绑定到集合时,各个行绑定到该集合的各个项(以及它们的事件)。

这也解释了您的第三段(结合事件的一般实现方式),因为DataGrid中的更改将设置该属性,而该属性又将触发该事件。

在你的第一个片段中;您可能希望将事件处理程序注册到CollectionChanged-事件,而不是PropertyChanged-事件(如果您想侦听新项),因为只有前者会在添加或删除项时通知您。您需要在添加项目之前注册事件处理程序,而不是之后。因为如果您为Item1和Item2执行该事件,则在没有处理程序侦听它们的情况下已经被激发,因为您已经太迟了。不过,您将看到随后添加的项目。

如果您想捕捉项目本身的更改,ObservableCollectionPropertyChanged不会这样做。您必须对这些项进行迭代,并将事件侦听器附加到各个项,同时还要注意集合的更改。(据我在参考源中所见,它只为属性Count和索引器Item[]触发)

所以。。。我找到了解决办法。你可能不会喜欢它,但如果它很难看,但它有效,为什么不使用它呢?

#region ItemsSource notifier
private void NotifySourceItemChanged(object item, string propertyName)
=> ItemsSourceOnPropertyChanged(item, new PropertyChangedEventArgs(propertyName));
private void ItemsSourceOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
=> ItemsSourceOnPropertyChangedMethod?.Invoke(ItemsSource, new object?[] { sender, e });
private MethodInfo? GetItemsSourceOnPropertyChanged() {
if (ItemsSource is null) return null;
foreach (var method in ItemsSource.GetType().GetMethods(BindingFlags.NonPublic | BindingFlags.Instance)) {
var parameters = method.GetParameters();
if (parameters.Length == 2 &&
parameters[0].ParameterType == typeof(object) &&
parameters[1].ParameterType == typeof(PropertyChangedEventArgs)) return method;
}
return null;
}
private MethodInfo? ItemsSourceOnPropertyChangedMethod;
#endregion

我们假设我们的控件包含ItemsSource属性。此外,当ItemsSource属性更改时,我们必须调用GetItemsSourceOnPropertyChanged()。装订完成一次。

然后,如果绑定到ItemSource的对象实现了INotifyPropertyChanged接口,它就会工作。我认为ObservableCollection是这样。

然后,集合还必须包含接受objectPropertyChangedEventArgsOnPropertyChanged()方法。

同样,ObservableCollection也是如此。

该方法是非公开的,但效果良好。我们可以称之为。如果ItemsSource为null,或者它没有实现INotifyPropertyChanged,或者它不具有触发PropertyChanged事件的非公共方法,则不会发生任何事情。

因此,如果我们提供一个兼容的集合,它就会起作用。

我刚刚完成控制。它有效。我的视图模型观察到它的项目集合,如果其中一个被选中或未选中,则该集合将被更新并触发事件,因此我的视图模式可以用它做任何需要的事情。

目标实现了,因为现在我可以忘记视图的实现了。视图模型提供了可检查的项,当它们的状态发生变化时,它可以在对视图一无所知的情况下对此做出反应。

此外,视图对视图模型一无所知。它只知道集合类型(确切地说是项类型)。

因此,绑定是最纯粹的。

是的,我知道,我可能只需要在我的控件中创建另一个可绑定的属性,就可以将视图中所做的更改提供给视图模型绑定。它可以在没有任何技巧的情况下完成,而且更容易。但我不确定。消耗代码不会更小、更可读或更简单。不,这很整洁。

顺便说一句,不要复制问题中的代码,完整的工作版本在GitHub上:https://github.com/HTD/Woof.Windows/blob/master/Woof.Windows.Controls/Checks.cs

最新更新