Skip to content

Instantly share code, notes, and snippets.

@jnm2
Created June 16, 2015 13:24
Show Gist options
  • Save jnm2/4e792518242466ce8e63 to your computer and use it in GitHub Desktop.
Save jnm2/4e792518242466ce8e63 to your computer and use it in GitHub Desktop.
This class provides dead simple automatic property notifications for calculated and proxy properties, even if the source properties do not report changes.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Techsola.Threading;
/// <summary>
/// This class provides dead simple automatic property notifications for calculated and proxy properties, even if the source properties do not report changes.
/// </summary>
public sealed class ObjectPropertyBinder
{
private readonly object instance;
private readonly Action<string> onPropertyChanged;
private readonly Dictionary<string, PropertyBindingInfo> properties = new Dictionary<string, PropertyBindingInfo>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// If true, setters with asynchronous tasks will run the application message pump and only return when the task is complete (the way ShowDialog does). The default is true.
/// </summary>
public bool AwaitAsyncSetters { get; set; }
/// <summary>
/// If true, setters will check each tracked property for potential changes after any tracked setter runs. The default is true.
/// </summary>
public bool DirtyCheckAfterSetters { get; set; }
private void OnPropertyChanged(string propertyName)
{
if (onPropertyChanged != null) onPropertyChanged.Invoke(propertyName);
}
/// <summary>
/// Checks each tracked property for potential changes and raises property change notifications for the changed properties.
/// </summary>
public void DirtyCheck()
{
foreach (var info in properties.Values.ToArray())
info.CheckChanged();
}
/// <summary>
/// Creates a blank ObjectPropertyBinder.
/// </summary>
/// <param name="instance">The object for which the binder will raise property changed events.</param>
/// <param name="onPropertyChanged">The method to invoke in order to raise property changed events. (E.g. if the object inherits NotifyObject, specify the protected method group 'OnPropertyChanged'.)</param>
public ObjectPropertyBinder(object instance, Action<string> onPropertyChanged)
{
if (instance == null) throw new ArgumentNullException("instance");
if (onPropertyChanged == null) throw new ArgumentNullException("onPropertyChanged");
this.instance = instance;
this.onPropertyChanged = onPropertyChanged;
this.DirtyCheckAfterSetters = true;
this.AwaitAsyncSetters = true;
}
private PropertyBindingInfo GetPropertyInfo(string propertyName)
{
PropertyBindingInfo r;
if (!properties.TryGetValue(propertyName, out r))
{
r = new PropertyBindingInfo(this, instance.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic));
properties.Add(propertyName, r);
r.InitializeCurrentValue();
}
return r;
}
/// <summary>
/// Evaluates the given expression, while also subscribing to property changed events for every property access in the expression.
/// </summary>
/// <param name="getExpression">The expression to evaluate and subscribe to changes of.</param>
/// <param name="callerProperty">For compiler use only. This parameter identifies which property has a dependency on the pproperty changed events in the expression.</param>
public T Track<T>(Expression<Func<T>> getExpression, [CallerMemberName] string callerProperty = default(string))
{
return GetPropertyInfo(callerProperty).PerformGet(getExpression);
}
/// <summary>
/// Invokes the property setter and performs a dirty check on tracked properties if DirtyCheckAfterSetters is true.
/// </summary>
/// <param name="setter">Any arbitrary action that may or may not modify the value of tracked properties.</param>
public void Set(Action setter)
{
setter.Invoke();
if (this.DirtyCheckAfterSetters) DirtyCheck();
}
/// <summary>
/// Invokes the property setter and performs a dirty check on tracked properties if DirtyCheckAfterSetters is true.
/// </summary>
/// <param name="setter">Any arbitrary action that may or may not modify the value of tracked properties.</param>
public void Set(Func<Task> setter)
{
if (this.AwaitAsyncSetters)
{
setter.Invoke().Await();
if (this.DirtyCheckAfterSetters) DirtyCheck();
}
else
{
SetAsyncNotAwaited(setter);
}
}
private async void SetAsyncNotAwaited(Func<Task> setter)
{
await setter.Invoke();
if (this.DirtyCheckAfterSetters) DirtyCheck();
}
private sealed class PropertyBindingInfo
{
private readonly ObjectPropertyBinder objectPropertyBinder;
private readonly string propertyName;
private readonly MethodInfo getter;
private readonly ConditionalWeakTable<object, ExpressionInfo> compiledExpressions = new ConditionalWeakTable<object, ExpressionInfo>();
private object currentValue;
public PropertyBindingInfo(ObjectPropertyBinder objectPropertyBinder, PropertyInfo property)
{
this.objectPropertyBinder = objectPropertyBinder;
propertyName = property.Name;
getter = property.GetGetMethod(true);
}
internal void CheckChanged()
{
var oldValue = currentValue;
currentValue = getter.Invoke(objectPropertyBinder.instance, null);
if (ShouldRaiseChangedEvent(oldValue, currentValue))
objectPropertyBinder.OnPropertyChanged(propertyName);
}
internal void InitializeCurrentValue()
{
currentValue = getter.Invoke(objectPropertyBinder.instance, null);
}
private static bool ShouldRaiseChangedEvent(object oldValue, object newValue)
{
if (oldValue == null) return newValue != null;
if (newValue == null) return true;
var oldValueType = oldValue.GetType();
return oldValueType != newValue.GetType() || (oldValueType.IsValueType ? !oldValue.Equals(newValue) : oldValue != newValue);
}
private sealed class ExpressionInfo
{
public readonly Delegate Getter;
private readonly List<PropertyListener> listeners;
public ExpressionInfo(Delegate getter, List<PropertyListener> listeners)
{
Getter = getter;
this.listeners = listeners;
}
~ExpressionInfo()
{
foreach (var listener in listeners)
listener.Dispose();
}
}
public T PerformGet<T>(Expression<Func<T>> expression)
{
ExpressionInfo info;
if (!compiledExpressions.TryGetValue(expression, out info))
{
var listeners = new List<PropertyListener>();
new ListenerExpressionVisitor(this, listeners).Visit(expression);
compiledExpressions.Add(expression, info = new ExpressionInfo(expression.Compile(), listeners));
}
return ((Func<T>)info.Getter).Invoke();
}
private sealed class ListenerExpressionVisitor : ExpressionVisitor
{
private readonly PropertyBindingInfo propertyBindingInfo;
private readonly List<PropertyListener> listenerList;
private readonly Stack<PropertyListener> dependentListeners = new Stack<PropertyListener>();
public ListenerExpressionVisitor(PropertyBindingInfo propertyBindingInfo, List<PropertyListener> listenerList)
{
this.propertyBindingInfo = propertyBindingInfo;
this.listenerList = listenerList;
}
// Down the road it would be nice to integrate with Sinq and aggregate collections with Sum and Count and First and Single etc.
protected override Expression VisitMember(MemberExpression node)
{
var propertyInfo = node.Member as PropertyInfo;
if (propertyInfo == null) return base.VisitMember(node);
var listener = new PropertyListener(propertyBindingInfo, dependentListeners.Count == 0 ? null : dependentListeners.Peek(), Expression.Lambda<Func<object>>(Expression.Convert(node.Expression, typeof(object))).Compile(), propertyInfo.Name);
listenerList.Add(listener);
dependentListeners.Push(listener);
try
{
return base.VisitMember(node);
}
finally
{
dependentListeners.Pop();
}
}
}
private sealed class PropertyListener : IDisposable
{
private readonly PropertyBindingInfo propertyBindingInfo;
private readonly PropertyListener dependentListener;
private readonly Func<object> currentInstanceGetter;
private object currentInstance;
private readonly string propertyName;
private EventHandler dedicatedEventHandler;
public PropertyListener(PropertyBindingInfo propertyBindingInfo, PropertyListener dependentListener, Func<object> currentInstanceGetter, string propertyName)
{
this.dependentListener = dependentListener;
this.currentInstanceGetter = currentInstanceGetter;
this.propertyName = propertyName;
this.propertyBindingInfo = propertyBindingInfo;
currentInstance = currentInstanceGetter.Invoke();
Subscribe();
}
private void Subscribe()
{
var npc = currentInstance as INotifyPropertyChanged;
if (npc != null)
npc.PropertyChanged += npc_PropertyChanged;
else
{
var eventInfo = currentInstance.GetType().GetEvent(propertyName + "Changed");
if (eventInfo != null)
eventInfo.AddEventHandler(currentInstance, dedicatedEventHandler ?? (dedicatedEventHandler = currentInstance_DedicatedPropertyChanged));
}
}
private void Unsubscribe()
{
var npc = currentInstance as INotifyPropertyChanged;
if (npc != null)
npc.PropertyChanged -= npc_PropertyChanged;
else
{
var eventInfo = currentInstance.GetType().GetEvent(propertyName + "Changed");
if (eventInfo != null)
eventInfo.RemoveEventHandler(currentInstance, dedicatedEventHandler);
}
}
void currentInstance_DedicatedPropertyChanged(object sender, EventArgs e)
{
propertyBindingInfo.CheckChanged();
if (dependentListener != null) dependentListener.DependancyChanged();
}
void npc_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e == null || string.IsNullOrEmpty(e.PropertyName) || !e.PropertyName.Equals(propertyName, StringComparison.OrdinalIgnoreCase)) return;
propertyBindingInfo.CheckChanged();
if (dependentListener != null) dependentListener.DependancyChanged();
}
private void DependancyChanged()
{
Unsubscribe();
currentInstance = currentInstanceGetter.Invoke();
Subscribe();
}
public void Dispose()
{
Unsubscribe();
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment