Click here to Skip to main content
15,889,808 members
Articles / Programming Languages / C# 4.0

Change Notification with Nested Properties and Collection Support

Rate me:
Please Sign up or sign in to vote.
3.90/5 (6 votes)
14 Jul 2010CPOL2 min read 27.2K   401   13   2
A utility to provide change notification capable of monitoring nested properties and monitoring all items in collections at once.

ChangeListener.png

Introduction

This utility allows you to monitor changes in nested collections and properties with a single line of code. This can be useful, for example, to provide change notification for a property that computes its value in its get-method. Especially when many properties affect the calculation, it saves a lot of boilerplate code.

Features

  • Supports monitoring of classes that implement INotifyPropertyChanged, INotifyCollectionChanged, or DependencyObject
  • Supports unlimited nesting of collections and properties
  • Always uses only one event handler per object and automatically detaches handlers when no longer used

Using the Code

A simple situation might look like this:

C#
private ChangeListener _Listener;

public void AttachListeners() {
    // Create a new field an save it somewhere 
         // (else it will get destroyed and the handlers will be detached)
    _Listener = new ChangeListener();
         // Assuming the class has a property Dog and which has a Name property 
         // (which both implement change notification) this will call 
         // UpdateDogName when either the dog or its name is set.
    listener.AddAction(this, "Dog.Name", () => UpdateDogName());
}

We can nest as far as we want:

C#
// Monitors any change in dog, owner, country, anthem or name 
// (since changes in any of these result in a change of anthem).
listener.AddAction(this, "Dog.Owner.Country.Anthem.Name", () => UpdateAnthemName());

We can also monitor collections with "[?]" (without the quotes):

C#
// Monitors any change in dog, added/removed puppies or change in puppy name. 
listener.AddAction(this, "Dog.Puppies[?].Name", () => UpdatePuppyNames());

Nesting collections is also possible:

C#
// Monitors any change in dog, added/removed puppies, 
// added/removed friends or change in friend name. 
listener.AddAction(this, "Dog.Puppies[?].Friends[?].Name", 
    () => UpdatePuppyFriendNames());

Optionally, you can specify some additional options:

C#
// Use ExecuteOnCollectionReorder to also execute the action 
// when any of the collections have changed order.
listener.AddAction(this, "Dog.Puppies[?].Friends[?].Name", 
    () => UpdatePuppyFriendNames(), 
    ChangeListenerOptions.ExecuteOnCollectionReorder);

// Use IgnoreParentChange to only execute the action when the name attribute 
// is set directly.
// This means it will not fire if dog changes, or the puppies or 
// friends collections change.
listener.AddAction(this, "Dog.Puppies[?].Friends[?].Name", 
    () => UpdatePuppyFriendNames(), ChangeListenerOptions.IgnoreParentChange);

// Use AllowNonObservableProperties to only explicitly 
// allow some properties of the path to not have change notification.
// Only to be used if you have a property you know is not going to change.
listener.AddAction(this, "Dog.Puppies[?].Friends[?].Name", 
    () => UpdatePuppyFriendNames(), 
    ChangeListenerOptions.AllowNonObservableProperties);

To detach the event handlers:

C#
// To remove all actions of a specific property path:
listener.RemoveActions(this, "Dog.Puppies[?].Friends[?].Name");

// To remove all actions of all property paths. 
// This is also called in the destructor of the object.
listener.RemoveAllActions();

Points of Interest

This turned out to be harder to implement than I initially thought, as always. Every time I had just revised the code, I discovered another special case which invalidated my design. Several rewrites later, the system works something like this:

Every time AddAction is called, a Trigger is added. It contains a list of Link objects which represent the parts of the path (the chain). Part of the constructor code:

C#
public Trigger(ChangeListener changeListener, object root, string path,
    Action action, ChangeListenerOptions options, int number)
{
    // Property setting code omitted
    
    // Split the path in property names & collection indexer tokens
    // (currently "[?]")
    var pathParts = InterpretPath(path);
    Type currentSourceType = root.GetType();
    foreach(string pathPart in pathParts) {
        // Create and add the 'Link' for this part of the path.
        Link link = null;
        Type valueType = null; // = Property type or collections element type
        // Notice the links are constructed based on the types of the previous path part,
        // not on the actual objects since these vary (possibly it's even a collection)
        if(pathPart == _CollectionIndexer) {
            link = CreateCollectionLink(currentSourceType, out valueType);
        } else {
            link = CreatePropertyLink(currentSourceType, pathPart, out valueType);
        }
        Links.Add(link);
        currentSourceType = valueType; 
    }
    // Attach the root to the first link so that it now monitors it's changes.
    // This will also cause the sources for the other links to be set.
    Links[0].AddSourceUser(root); 
}

Each Link (which is a base class inherited by PropertyLink and CollectionLink) manages all objects for which it must monitor the property or collection. For example, the Link that monitors the Puppy.Name property monitors all the puppies of the monitored dog. To add an object that has to be monitored, AddSourceUser is called. The reuse of listeners can also be seen here:

C#
private abstract class Link
{
    // Other methods and properties omitted
    
    public abstract void AddSourceUser(object source);

    protected virtual int AddSourceUserInternal(object source)
    {
        // See if there already is a listener attached
        // to the link that monitors the same object.
        int index = _Listeners.FindIndex(
                      listener => listener.Source == source);
        if(index != -1) {
            _ListenerUserCounts[index]++;
            // Remember how often it is used (to know when to delete)
        } else {
            // See if in the entire ChangeListener a listener already exists for this object
            Listener listener = Trigger.ChangeListener._Listeners.Find(
                listenerParam => listenerParam.Source == source && 
                listenerParam.GetType() == _ListenerType);
            if(listener == null) { // If not create a new one
                listener = CreateListener(source);
                Trigger.ChangeListener._Listeners.Add(listener);
            }
            // Attach the link to the listener so it will be notified on change
            listener.AddLink(this);
            _Listeners.Add(listener);
            _ListenerUserCounts.Add(1);
            index = _Listeners.Count-1;
        }
        return index;
    }
}

As said, two classes implement the Link class: PropertyLink and CollectionLink. Part of the PropertyLink class is displayed below. Every time a listener detects a change that is relevant for a link, it will notify it; in case of a PropertyLink, it will call HandlePropertyChanged. Also displayed are the overrides of AddSourceUser and AddSourceUserInternal:

C#
private class PropertyLink : Link
{
    // Other methods and properties omitted

    public void HandlePropertyChanged(Listener listener)
    {
        // Update current value and save old value
        int listenerIndex = _Listeners.IndexOf(listener);
        object oldValue = _CurrentValues[listenerIndex];
        object newValue = Property.GetValue(listener.Source, null);
        _CurrentValues[listenerIndex] = newValue;
        // Probably the most important code.
        // If this is not the last link, update the source of the next link
        // Basically, if dog changes, we have to listen to a different puppy collection.
        int linkIndex = Trigger.Links.IndexOf(this);
        if(linkIndex < Trigger.Links.Count-1) {
            var nextLink = Trigger.Links[linkIndex+1];
            if(oldValue != null) {
                nextLink.RemoveSourceUser(oldValue);
            }
            if(newValue != null) {
                nextLink.AddSourceUser(newValue);
            }
        }
        // Perform action
        if(!Trigger.IgnoreParentChange || linkIndex == Trigger.Links.Count-1) {
            Trigger.Action();
        }
    }

    public override void AddSourceUser(object source)
    {
        int index = AddSourceUserInternal(source);
        object value = _CurrentValues[index];
        // Add the new source the the next link.
        // The old source was already removed by RemoveSourceUser.
        int linkIndex = Trigger.Links.IndexOf(this);
        if(linkIndex < Trigger.Links.Count-1) {
            if(value != null) {
                Trigger.Links[linkIndex+1].AddSourceUser(value);
            }
        }
    }

    protected override int AddSourceUserInternal(object source)
    {
        int index = base.AddSourceUserInternal(source);
        // Save the current value, because if the property changes
        // we can't retrieve it anymore.
        if(_Listeners.Count > _CurrentValues.Count) { // If listener added, add value
            object value = Property.GetValue(_Listeners[index].Source, null);
            _CurrentValues.Add(value);
        }
        return index;
    }
}

There are also two types of listeners, both derived from the abstract Listener class: PropertyListener and CollectionListener. Each link type only uses the corresponding listener type. The listener contains a list of all the attached links. Displayed is the PropertyChanged handler in a PropertyListener:

C#
private class PropertyListener : Listener
{
    // Other methods and properties omitted

    private void PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        // Get all links that need to be notified (that have the right property)
        var linksToSignal = new List<link />();
        foreach(var link in _Links) {
            if(((PropertyLink)link).Property.Name == e.PropertyName) {
                // Check for links with the same trigger.
                // Don't fire these twice. Ex.: node.Next.Next where node.Next == node
                Link linkWithSameTrigger = linksToSignal.FirstOrDefault(
                     linkParam => linkParam.Trigger == link.Trigger);
                if(linkWithSameTrigger != null) {
                    if(link.Trigger.Links.IndexOf(link) < 
                       linkWithSameTrigger.Trigger.Links.IndexOf(linkWithSameTrigger)) {
                          linksToSignal.Remove(linkWithSameTrigger);
                          // Remove further nested link.
                    } else {
                        continue; // Don't add this link
                    }
                }
                linksToSignal.Add(link);
            }
        }
        // Notify those links, ensuring that triggers that
        // where added earlier are also executed earlier.
        foreach(Link link in linksToSignal.OrderBy(link => 
                link.Trigger.ChangeListener._Triggers.IndexOf(link.Trigger))) {
            ((PropertyLink)link).HandlePropertyChanged(this);
        }
    }
}

I added dependency property support later on. To my surprise, this was not so straightforward. Turned out that the only property change event that you could hook up to at runtime did not supply information about what property had changed. I solved this by adding a subclass that holds the property changed method and relays it:

C#
private class PropertyListener : Listener
{
    // Other methods and properties omitted
    
    public override void AddLink(Link link)
    {
        base.AddLink(link);
        if(Source is DependencyObject) {
            // Get dependency property. Current implementation
            // does not work for attached properties.
            var dependencyProperty = DependencyPropertyDescriptor.FromName(
                ((PropertyLink)link).Property.Name, 
                 Source.GetType(), Source.GetType());
            // Also add null values to make indices correspond to list of links.
            _DependencyProperties.Add((dependencyProperty != null) ?
                new DependencyPropertyListener(this, dependencyProperty) : null);
        }
    }
    
    private class DependencyPropertyListener
    {
        // Other methods and properties omitted
        
        public DependencyPropertyListener(PropertyListener parentListener, 
               DependencyPropertyDescriptor dependencyProperty)
        {
            // Property setting code omitted
            _DependencyProperty.AddValueChanged(_ParentListener.Source, PropertyChanged);
        }

        private void PropertyChanged(object sender, EventArgs e)
        {
            _ParentListener.PropertyChanged(sender, 
                   new PropertyChangedEventArgs(_DependencyProperty.Name));
        }
    }
}

History

  • July 12 2010: Initial release.
  • July 14 2010: Expanded Points of Interest.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Netherlands Netherlands
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
GeneralYou left out the good stuff Pin
Josh Fischer13-Jul-10 4:54
Josh Fischer13-Jul-10 4:54 
GeneralRe: You left out the good stuff Pin
Patrick Pineapple14-Jul-10 10:56
Patrick Pineapple14-Jul-10 10:56 
Thanks for the feedback, I updated the article.

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.