Home > Software engineering >  WPF: How to trigger PropertyChanged on INotifyPropertyChanged items in my view model from my control
WPF: How to trigger PropertyChanged on INotifyPropertyChanged items in my view model from my control

Time:10-05

Let's say I have my own ItemSource in my control. It can assign ObservableCollection type to it, so, the collection could be notified if my control changes a property of an item.

The problem is when I get the item modified and I know the collection implements INotifyPropertyChanged, I don't know how to trigger the PropertyChanged event. The interface defines only event, not the method that triggers it.

Yet somehow original WPF controls do that. I feed my collection to let's say DataGrid, when the grid is editable and I change a value, the PropertyChanged event is triggered by the Datagrid control on my source collection.

So it seems definitely possible. What's more, I even created my own type of observable collection implementing INotifyPropertyChanged and INotifyCollectionChanged interfaces myself and it still works. DataGrid changes the item, my item's PropertyChanged event is triggered on the collection.

I want to do the same in my new control. I have the changed item, I have the property name, I have the source observable collection, now I just want the collection to trigger it's PropertyChanged event.

How to do it? How built-in WPF controls like DataGrid do that?

What is the code? Let's say it's something like "check box combo box". A kind of ComboBox with checkable items. The ComboBoxes are complicated beasts, a lot of XAML I don't love, so I invented super easy way around it. I just Menu instead. I made my own control that contains menu with it's main item acting as a label / button, and checkable subitems acting as checkboxes. It looks just fine, it even kind of works.

The item source for that thing is an (observable) collection of (string value, bool isChecked) tuples. They are super easy to manipulate with LINQ. This thingie is to be used as a filter for a DataGrid view. It's filled with all available record types, and then they will be able to be unchecked and filtered from the view. The advantage of this approach is absolutely no XAML, no styling, no stuff for graphic designers. It looks OK with default styles and feel absolutely natural and intuitive without any visual tweaking.

The thingie is (almost) done, however, I want my ViewModel to be notified properly when my collection of filtered items changes, so my view model could request appropriate filtering.

I'm missing something obvious here...

Relevant view model framgents:

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}"/>
<!-- ... -->

And my control source:

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;

    }

}

UPDATE: I have a nasty bug here and I know it. Tuples are IMMUTABLE. So anyway I won't be able to synchronize the collections of tuples by modifying their properties.

Fix to this is simple: I will use objects instead, like var x = new { Value = "Item1", IsChecked = true }. These are mutable, so...

Anyway, the main problem remains. I found a workaround:

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

...but it's an ugly hack. But then again - it works. It does exactly what it should and what controls like DataGrid do. I mean - it's the same effect. I still don't know if they use Reflection to call protected methods of the collections.

CodePudding user response:

Firing the PropertyChanged-event has primarily nothing to do with WPF specifically per se, it just happens to coincide.

A PropertyChanged-event needs to be implemented in the class containing the property that should notify its changes. For example you have a simple class with a name:

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

If you want this class to raise an event whenever the Name changes, you have to implement it by hand like this:

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

Now every time you change the Name-property, the PropertyChanged-event will fire. The same also goes for ObservableCollections and the CollectionChanged-event, they are all manually implemented in the class itself.

WPF comes into play when you use Bindings. If you have a binding to a property configured in TwoWay- or OneWayToSource-mode, WPF will write the value back to the property when the value in the control changes. And because the property will fire the event whenever it changes, the event will fire whenever WPF decides to update the binding source.

Regarding some of your statements:

In your second paragraph you mix some things up, I think. Yes, an ObservableCollection implements INotifyPropertyChanged, but this event is for this class only and not for its children. In general, the PropertyChanged-event only fires for the object with the property that has changed, it does not propagate. And the interface does indeed only define the event, as the trigger function is generally to be kept private or protected. If you want a collection, that is aware of changes to its children, you'll have to implement that yourself. WPF can do it automagically because while a DataGrid binds to a collection, the individual rows bind to the individual items of that collection (and to their events).

That also explains your third paragraph (in conjunction to how the event is generally implemented), because a change in the DataGrid will set the property and the property will in turn fire the event.

In your first snippet; you might want to register your event-handler to the CollectionChanged-event, not the PropertyChanged-event (if you want to listen to new items), as only the former will notify you if you add or remove items. And you'll need to register the event handler before you add your items, not after. Because if you do the event for Item1 and Item2 will already have been fired without a handler to listen to them, because you're too late. You will catch Items added afterwards though.

If you want to catch changes in the items themselves, ObservableCollections PropertyChanged does not do that. You'll have to iterate over the items and attach an event listener to the individual items and also watch for collection changes. (As far as I can see in the reference source, it only fires for the properties Count and the indexer Item[])

CodePudding user response:

So... I found the solution. You will probably not like it, but if it's ugly, but it works, why not use it?

#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

We assume that our control contains the ItemsSource property. Also, when ItemsSource property is changed we must call GetItemsSourceOnPropertyChanged(). It's done once on the binding.

Then, it will work if the object bound to the ItemSource implements INotifyPropertyChanged interface. I think ObservableCollection does.

Then, the collection must also contain OnPropertyChanged() method accepting an object and PropertyChangedEventArgs.

Again, ObservableCollection does.

The method is non-public, but well. We can call it. If the ItemsSource is null, or it doesn't implement INotifyPropertyChanged or it doesn't have non public method triggering the PropertyChanged event - nothing will happen.

So - if we provide a compatible collection - it will work.

I just finished my control. It works. My view model observes it's item collection, if one is checked or unchecked, the collection is updated and the event is triggered, so my view model can do whatever it needs with it.

Goal achieved, because now I can forget about the view implementation. The view model provide checkable items, and when their state is changed - it can react to that with zero knowledge about the view.

Also the view knows nothing about the view model. It only knows the collection type (and the item type to be precise).

So the binding is as pure as it gets.

Yes, I know, I could probably just make another bindable property in my control to provide the changes done in the view to the view model binding. It could be done without any hacks and easier. But I'm not sure. The consuming code would not be smaller, more readable or simpler. Nah, this is neat.

BTW, don't copy the code from the question, the full working version will be on GitHub as soon as I finish the project.

  • Related