I've put together what I hope is a pretty robust solution, including some of the techniques in other answers. It is a new class derived from ObservableCollection<>
, which I'm calling FullyObservableCollection<>
It has the following features:
- It adds a new event,
ItemPropertyChanged
. I've deliberately kept this separate from the existing CollectionChanged
: - To aid backward compatibility.
- So more relevant detail can be given in the new
ItemPropertyChangedEventArgs
that accompanies it: the original PropertyChangedEventArgs
and the index within the collection.
- It replicates all the constructors from
ObservableCollection<>
. - It correctly handles the list being reset (
ObservableCollection<>.Clear()
), avoiding a possible memory leak. - It overrides the base class's
OnCollectionChanged()
, rather than a more resource-intensive subscription to the CollectionChanged
event.
Code
The complete .cs
file follows. Note that a few features of C# 6 have been used, but it should be fairly simple to backport it:
using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; namespace Utilities { public class FullyObservableCollection<T> : ObservableCollection<T> where T : INotifyPropertyChanged { /// <summary> /// Occurs when a property is changed within an item. /// </summary> public event EventHandler<ItemPropertyChangedEventArgs> ItemPropertyChanged; public FullyObservableCollection() : base() { } public FullyObservableCollection(List<T> list) : base(list) { ObserveAll(); } public FullyObservableCollection(IEnumerable<T> enumerable) : base(enumerable) { ObserveAll(); } protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e) { if (e.Action == NotifyCollectionChangedAction.Remove || e.Action == NotifyCollectionChangedAction.Replace) { foreach (T item in e.OldItems) item.PropertyChanged -= ChildPropertyChanged; } if (e.Action == NotifyCollectionChangedAction.Add || e.Action == NotifyCollectionChangedAction.Replace) { foreach (T item in e.NewItems) item.PropertyChanged += ChildPropertyChanged; } base.OnCollectionChanged(e); } protected void OnItemPropertyChanged(ItemPropertyChangedEventArgs e) { ItemPropertyChanged?.Invoke(this, e); } protected void OnItemPropertyChanged(int index, PropertyChangedEventArgs e) { OnItemPropertyChanged(new ItemPropertyChangedEventArgs(index, e)); } protected override void ClearItems() { foreach (T item in Items) item.PropertyChanged -= ChildPropertyChanged; base.ClearItems(); } private void ObserveAll() { foreach (T item in Items) item.PropertyChanged += ChildPropertyChanged; } private void ChildPropertyChanged(object sender, PropertyChangedEventArgs e) { T typedSender = (T)sender; int i = Items.IndexOf(typedSender); if (i < 0) throw new ArgumentException("Received property notification from item not in collection"); OnItemPropertyChanged(i, e); } } /// <summary> /// Provides data for the <see cref="FullyObservableCollection{T}.ItemPropertyChanged"/> event. /// </summary> public class ItemPropertyChangedEventArgs : PropertyChangedEventArgs { /// <summary> /// Gets the index in the collection for which the property change has occurred. /// </summary> /// <value> /// Index in parent collection. /// </value> public int CollectionIndex { get; } /// <summary> /// Initializes a new instance of the <see cref="ItemPropertyChangedEventArgs"/> class. /// </summary> /// <param name="index">The index in the collection of changed item.</param> /// <param name="name">The name of the property that changed.</param> public ItemPropertyChangedEventArgs(int index, string name) : base(name) { CollectionIndex = index; } /// <summary> /// Initializes a new instance of the <see cref="ItemPropertyChangedEventArgs"/> class. /// </summary> /// <param name="index">The index.</param> /// <param name="args">The <see cref="PropertyChangedEventArgs"/> instance containing the event data.</param> public ItemPropertyChangedEventArgs(int index, PropertyChangedEventArgs args) : this(index, args.PropertyName) { } } }
NUnit Tests
So you can check changes you might make (and see what I tested in the first place!), I've also included my NUnit test class. Obviously, the following code is not necessary just to use FullyObservableCollection<T>
in your project.
NB The test class uses BindableBase
from PRISM to implement INotifyPropertyChanged
. There is no dependency on PRISM from the main code.
using NUnit.Framework; using Utilities; using Microsoft.Practices.Prism.Mvvm; using System.Collections.Specialized; using System.Collections.Generic; namespace Test_Utilities { [TestFixture] public class Test_FullyObservableCollection : AssertionHelper { public class NotifyingTestClass : BindableBase { public int Id { get { return _Id; } set { SetProperty(ref _Id, value); } } private int _Id; public string Name { get { return _Name; } set { SetProperty(ref _Name, value); } } private string _Name; } FullyObservableCollection<NotifyingTestClass> TestCollection; NotifyingTestClass Fred; NotifyingTestClass Betty; List<NotifyCollectionChangedEventArgs> CollectionEventList; List<ItemPropertyChangedEventArgs> ItemEventList; [SetUp] public void Init() { Fred = new NotifyingTestClass() { Id = 1, Name = "Fred" }; Betty = new NotifyingTestClass() { Id = 4, Name = "Betty" }; TestCollection = new FullyObservableCollection<NotifyingTestClass>() { Fred, new NotifyingTestClass() {Id = 2, Name = "Barney" }, new NotifyingTestClass() {Id = 3, Name = "Wilma" } }; CollectionEventList = new List<NotifyCollectionChangedEventArgs>(); ItemEventList = new List<ItemPropertyChangedEventArgs>(); TestCollection.CollectionChanged += (o, e) => CollectionEventList.Add(e); TestCollection.ItemPropertyChanged += (o, e) => ItemEventList.Add(e); } // Change existing member property: just ItemPropertyChanged(IPC) should fire [Test] public void DetectMemberPropertyChange() { TestCollection[0].Id = 7; Expect(CollectionEventList.Count, Is.EqualTo(0)); Expect(ItemEventList.Count, Is.EqualTo(1), "IPC count"); Expect(ItemEventList[0].PropertyName, Is.EqualTo(nameof(Fred.Id)), "Field Name"); Expect(ItemEventList[0].CollectionIndex, Is.EqualTo(0), "Collection Index"); } // Add new member, change property: CollectionPropertyChanged (CPC) and IPC should fire [Test] public void DetectNewMemberPropertyChange() { TestCollection.Add(Betty); Expect(TestCollection.Count, Is.EqualTo(4)); Expect(TestCollection[3].Name, Is.EqualTo("Betty")); Expect(ItemEventList.Count, Is.EqualTo(0), "Item Event count"); Expect(CollectionEventList.Count, Is.EqualTo(1), "Collection Event count"); Expect(CollectionEventList[0].Action, Is.EqualTo(NotifyCollectionChangedAction.Add), "Action (add)"); Expect(CollectionEventList[0].OldItems, Is.Null, "OldItems count"); Expect(CollectionEventList[0].NewItems.Count, Is.EqualTo(1), "NewItems count"); Expect(CollectionEventList[0].NewItems[0], Is.EqualTo(Betty), "NewItems[0] dereference"); CollectionEventList.Clear(); // Empty for next operation ItemEventList.Clear(); TestCollection[3].Id = 7; Expect(CollectionEventList.Count, Is.EqualTo(0), "Collection Event count"); Expect(ItemEventList.Count, Is.EqualTo(1), "Item Event count"); Expect(TestCollection[ItemEventList[0].CollectionIndex], Is.EqualTo(Betty), "Collection Index dereference"); } // Remove member, change property: CPC should fire for removel, neither CPC nor IPC should fire for change [Test] public void CeaseListentingWhenMemberRemoved() { TestCollection.Remove(Fred); Expect(TestCollection.Count, Is.EqualTo(2)); Expect(TestCollection.IndexOf(Fred), Is.Negative); Expect(ItemEventList.Count, Is.EqualTo(0), "Item Event count (pre change)"); Expect(CollectionEventList.Count, Is.EqualTo(1), "Collection Event count (pre change)"); Expect(CollectionEventList[0].Action, Is.EqualTo(NotifyCollectionChangedAction.Remove), "Action (remove)"); Expect(CollectionEventList[0].OldItems.Count, Is.EqualTo(1), "OldItems count"); Expect(CollectionEventList[0].NewItems, Is.Null, "NewItems count"); Expect(CollectionEventList[0].OldItems[0], Is.EqualTo(Fred), "OldItems[0] dereference"); CollectionEventList.Clear(); // Empty for next operation ItemEventList.Clear(); Fred.Id = 7; Expect(CollectionEventList.Count, Is.EqualTo(0), "Collection Event count (post change)"); Expect(ItemEventList.Count, Is.EqualTo(0), "Item Event count (post change)"); } // Move member in list, change property: CPC should fire for move, IPC should fire for change [Test] public void MoveMember() { TestCollection.Move(0, 1); Expect(TestCollection.Count, Is.EqualTo(3)); Expect(TestCollection.IndexOf(Fred), Is.GreaterThan(0)); Expect(ItemEventList.Count, Is.EqualTo(0), "Item Event count (pre change)"); Expect(CollectionEventList.Count, Is.EqualTo(1), "Collection Event count (pre change)"); Expect(CollectionEventList[0].Action, Is.EqualTo(NotifyCollectionChangedAction.Move), "Action (move)"); Expect(CollectionEventList[0].OldItems.Count, Is.EqualTo(1), "OldItems count"); Expect(CollectionEventList[0].NewItems.Count, Is.EqualTo(1), "NewItems count"); Expect(CollectionEventList[0].OldItems[0], Is.EqualTo(Fred), "OldItems[0] dereference"); Expect(CollectionEventList[0].NewItems[0], Is.EqualTo(Fred), "NewItems[0] dereference"); CollectionEventList.Clear(); // Empty for next operation ItemEventList.Clear(); Fred.Id = 7; Expect(CollectionEventList.Count, Is.EqualTo(0), "Collection Event count (post change)"); Expect(ItemEventList.Count, Is.EqualTo(1), "Item Event count (post change)"); Expect(TestCollection[ItemEventList[0].CollectionIndex], Is.EqualTo(Fred), "Collection Index dereference"); } // Clear list, chnage property: only CPC should fire for clear and neither for property change [Test] public void ClearList() { TestCollection.Clear(); Expect(TestCollection.Count, Is.EqualTo(0)); Expect(ItemEventList.Count, Is.EqualTo(0), "Item Event count (pre change)"); Expect(CollectionEventList.Count, Is.EqualTo(1), "Collection Event count (pre change)"); Expect(CollectionEventList[0].Action, Is.EqualTo(NotifyCollectionChangedAction.Reset), "Action (reset)"); Expect(CollectionEventList[0].OldItems, Is.Null, "OldItems count"); Expect(CollectionEventList[0].NewItems, Is.Null, "NewItems count"); CollectionEventList.Clear(); // Empty for next operation ItemEventList.Clear(); Fred.Id = 7; Expect(CollectionEventList.Count, Is.EqualTo(0), "Collection Event count (post change)"); Expect(ItemEventList.Count, Is.EqualTo(0), "Item Event count (post change)"); } } }