[Caliburn Micro] Action filters - Dependencies on "properties of properties", using the Reactive Extensions
By Michael DELVA on Monday 4 October 2010, 17:00 - C# - Permalink
TweetBy default, Caliburn Micro (CM) doesn't contain the action filters which belong to Caliburn (C). There is a recipe on how to implement them back in CM, but unfortunately it doesn't cover all the initial functionalities which are in C.
One feature missing, and that I needed, is the possibility to depend on "properties of properties".
With the recipe, you can add a dependency between the property of a view-model (VM) and another property of this same VM. Thus, when the latter property is changed, the PropertyChanged event of the VM is fired, resulting in the update of the availability of the action on which the Dependencies attribute is put. But what it doesn't allow you to do, is to define something like:
[Dependencies("OtherProperty.ChildProperty")]
This would update the availability of the action if OtherProperty implements INotifyPropertyChanged and if the ChildProperty is modified. You could even use something like:
[Dependencies("OtherProperty.*")]
which would update the availability if any of the properties of OtherProperty is modified.
But let me show you how I implemented this feature on top of the recipe, with the great use of the Reactive Extensions.
The first thing we are going to do is to add a few fields to the class, and store the arguments passed to the constructor:
public class DependenciesAttribute : Attribute, IContextAware
{
private ActionExecutionContext context;
private INotifyPropertyChanged target;
private readonly string[] dependentProperties;
private IDisposable viewModelObserver;
private readonly IList<string> viewModelProperties = new List<string>();
private readonly IList<PropertyObserver> viewModelPropertiesObservers = new List<PropertyObserver>();
public DependenciesAttribute(params string[] propertyNames)
{
dependentProperties = propertyNames ?? new string[] { };
}
}
The viewModelProperties field has a pretty straightforward name, and doesn't need further explanations. The viewModelPropertiesObservers field will contain a list of the properties to watch on another VM property, if this one implements the INotifyPropertyChanged interface. Here is the PropertyObserver class:
private class PropertyObserver : IDisposable
{
private readonly string viewModelPropertyName;
private readonly IDisposable propertyObserver;
public PropertyObserver(string viewModelPropertyName, IDisposable propertyObserver)
{
this.viewModelPropertyName = viewModelPropertyName;
this.propertyObserver = propertyObserver;
}
public string ViewModelPropertyName
{
[DebuggerStepThrough]
get { return viewModelPropertyName; }
}
public void Dispose()
{
propertyObserver.Dispose();
}
}
Let's begin with the MakeAwareOf method:
public void MakeAwareOf(ActionExecutionContext context)
{
this.context = context;
target = context.Target as INotifyPropertyChanged;
if (target != null)
{
viewModelProperties.AddRange(ViewModelProperties);
viewModelObserver = Observable.FromEvent<PropertyChangedEventArgs>(target, "PropertyChanged")
.Where(ev => string.IsNullOrEmpty(ev.EventArgs.PropertyName) || viewModelProperties.Contains(ev.EventArgs.PropertyName))
.Subscribe(OnNextViewModelProperty);
}
viewModelPropertiesObservers.AddRange(ViewModelPropertiesObservers);
}
In this method, we first check that the context is convertible to INotifyPropertyChanged. If it is, we first store the properties to watch on this target in the viewModelProperties field. The ViewModelProperties properties will parse the arguments given to the constructor of DependenciesAttribute and will return the properties of the VM (those who don't have a dot in the declaration):
private IEnumerable<string> ViewModelProperties
{
get
{
return from property in dependentProperties.Select(propertyName => propertyName.Split('.'))
where property.Length == 1
select property[0];
}
}
As you have seen in MakeAwareOf, we filter the observable, so that it will only push notifications when the PropertyName of the event is empty or matches one of the properties of the watched VM. When an OnNext notification is pushed, the OnNextViewModelProperty method is called.
For the moment, we just have to update the availability of the action:
private void OnNextViewModelProperty(IEvent<PropertyChangedEventArgs> ev)
{
UpdateAvailability();
}
void UpdateAvailability()
{
Execute.OnUIThread(() => context.Message.UpdateAvailability());
}
In MakeAwareOf, the last line fills viewModelPropertiesObservers with the return value of a property ViewModelPropertiesObservers. Here is its definition:
private IEnumerable<PropertyObserver> ViewModelPropertiesObservers
{
get
{
return from property in dependentProperties.Select(propertyName => propertyName.Split('.'))
where property.Length == 2
let VMPropertyName = property[0]
let ChildPropertyName = property[1]
let propertyInfo = context.Target.GetType().GetProperty(VMPropertyName)
where propertyInfo != null
let propertyValue = propertyInfo.GetValue(target, null)
let inpc = propertyValue as INotifyPropertyChanged
where inpc != null
select new PropertyObserver(VMPropertyName,
Observable.FromEvent<PropertyChangedEventArgs>(inpc, "PropertyChanged")
.Where(ev =>
{
var propertyName = ev.EventArgs.PropertyName;
return ChildPropertyName == "*" || string.IsNullOrEmpty(propertyName) || propertyName == VMPropertyName;
})
.Subscribe(ev => UpdateAvailability()));
}
}
This is a quite long property getter, but it remains very readable (IMHO), thanks to LINQ.
This getter body will use the dependencies given in the attribute constructor to select only those who are in 2 parts (separated with a dot), skip the dependencies which doesn't exist and do not derive from INotifyPropertyChanged, create an observable which will observe the PropertyChanged event of the INPC, subscribe to this observable, and return an IEnumerable containing PropertyObserver instances, with the name of the VM property, and the observer. I don't know if this explanation is clearer than the code, so choose the version you prefer :)
Again, there is a Where filter during the creation of the observable. This will ensure that it will only push an OnNext event when one of these conditions is met:
- Any property is modified (the "*" clause)
- An empty property name is set
- The modified property name matches what has been set in the constructor of the Dependencies attribute
When an OnNext notification is pushed, we just have to once again update the availability of the action.
Now, we have to implement the Dispose method, which is straightforward:
public void Dispose()
{
if (viewModelObserver != null)
viewModelObserver.Dispose();
viewModelPropertiesObservers.ForEach(observer => observer.Dispose());
viewModelPropertiesObservers.Clear();
target = null;
}
If we have subscribed an observer to the target INPC, we just dispose the subscription, as well as the subscriptions to the properties of the VM.
This looks good, but there is a problem.
Suppose you are in a Conductor<IScreen> class, and one of the properties of this conductor depends on a property of the ActiveItem property. You would have something like:
public class VM : Conductor<IScreen>
{
[Dependencies("ActiveItem.*")]
public IEnumerable<IResult> Do() { ... }
}
If you change the active item of the conductor, the availability of the Do method won't be updated when any of the properties of the new ActiveItem will be changed. So, what we have to do, as a general way, is to reset the subscriptions on the properties of a property of a VM, which has been modified. In the example, when ActiveItem is changed, and if there are dependencies on properties of ActiveItem, we have to dispose the subscriptions on the properties of the old ActiveItem, and create new observers on the properties of the new ActiveItem. This reset if subscriptions must happen in the OnNext notification of the viewModelObserver, hence in the OnNextViewModelProperty method:
private void OnNextViewModelProperty(IEvent<PropertyChangedEventArgs> ev)
{
var propertyName = ev.EventArgs.PropertyName;
UpdateAvailability();
if (!viewModelProperties.Contains(propertyName) || !viewModelPropertiesObservers.Any(propertyObserver => propertyObserver.ViewModelPropertyName == propertyName))
return;
viewModelPropertiesObservers
.Where(observer => observer.ViewModelPropertyName == propertyName)
.ForEach(observer => observer.Dispose());
viewModelPropertiesObservers
.RemoveAll(observer => observer.ViewModelPropertyName == propertyName);
var propertyObservers = from propertiesOfProperty in ViewModelPropertiesObservers
where propertiesOfProperty.ViewModelPropertyName == propertyName
select propertiesOfProperty;
viewModelPropertiesObservers.AddRange(propertyObservers);
}
As I said before the code, we have to reset the subscriptions only of the changed property of the VM has been set as a dependency and there are some observers on this property. If these 2 conditions are met, we just have to dispose the subscriptions, remove the observers from the list, and subscribe again on the updated VM property.
Here we are at the end of this article. I hope you found it useful. If you have any comments, don't hesitate ;)
See you soon!