I’m currently working on a large legacy WPF project where my ViewModels often have a large number of properties, and those properties have some complex inter-relationships that need to be reflected in the behaviour of the app, via the ViewModels and XAML bindings.
Specifically, some properties may need to support some of the following behaviours:
1) A property may need to invoke a change notification in the UI if a related property changes
2) A property may be a calculated value, and the calculation may rely on other property values which may change.
3) A property may need to support validation
With these requirements in mind, I wrote a simple POC (proof of concept) of a ‘rules engine’ that may provide those features and can be utilised in the ViewModel in a straightforward way.
Below is the code for the rules engine class:
using System; using System.Collections.Generic; using System.ComponentModel; public class PropertyRuleEngine { private readonly Dictionary<string, List<string>> dependencies = new Dictionary<string, List<string>>(); private readonly Dictionary<string, Action> evaluations = new Dictionary<string, Action>(); private readonly Dictionary<string, List<Func<string>>> validations = new Dictionary<string, List<Func<string>>>(); private readonly object source; public PropertyRuleEngine(object source) { this.source = source; } public PropertyRuleEngine AddDependency(string dependentPropertyName, string propertyName) { if (!this.dependencies.ContainsKey(propertyName)) { this.dependencies.Add(propertyName, new List<string>()); } if (!this.dependencies[propertyName].Contains(dependentPropertyName)) { this.dependencies[propertyName].Add(dependentPropertyName); } return this; } public PropertyRuleEngine AddEvaluatedProperty(string propertyName, Action calculateAction) { this.evaluations.Add(propertyName, calculateAction); return this; } public PropertyRuleEngine AddValidationProperty(string propertyName, Func<string> validationFunction) { if (!this.validations.ContainsKey(propertyName)) { this.validations.Add(propertyName, new List<Func<string>>()); } this.validations[propertyName].Add(validationFunction); return this; } public void Notify(string property, PropertyChangedEventHandler handler) { this.InvokePropertyChangedHandler(property, handler); if (!this.dependencies.ContainsKey(property)) { return; } foreach (string tmp in this.dependencies[property]) { if (this.evaluations.ContainsKey(tmp)) { this.evaluations[tmp](); } else { this.InvokePropertyChangedHandler(tmp, handler); } } } public IEnumerable<string> GetErrors(string property) { List<string> result = null; if (!this.validations.ContainsKey(property)) { return result; } foreach (var validation in validations[property]) { var tmp = validation(); if (!string.IsNullOrEmpty(tmp)) { if (result == null) { result = new List<string>(); } result.Add(tmp); } } return result; } private void InvokePropertyChangedHandler(string propertyName, PropertyChangedEventHandler originalHandler) { PropertyChangedEventHandler handler = originalHandler; if (handler == null) { return; } Delegate[] delegates = handler.GetInvocationList(); foreach (Delegate d in delegates) { d.DynamicInvoke(new[] { this.source, new PropertyChangedEventArgs(propertyName) }); } } }
And here is the (rather simplistic) test ViewModel that utilises the rules engine. It demonstrates how it handles inter-property dependencies, calculated properties and validation
using System; using System.ComponentModel; public class TestDomainObjectViewModel : INotifyPropertyChanged, IDataErrorInfo { private readonly PropertyRuleEngine rulesEngine; public event PropertyChangedEventHandler PropertyChanged; public TestDomainObjectViewModel() { this.rulesEngine = new PropertyRuleEngine(this); this.rulesEngine .AddDependency("TestName", "TestId") .AddEvaluatedProperty("TestName", () => this.TestName = string.Format("Name : {0}", this.TestId.ToString())) .AddValidationProperty("TestId", () => this.TestId > 50 ? "Number too big" : string.Empty); } private string testName; public string TestName { get { return testName; } set { testName = value; this.rulesEngine.Notify("TestName", this.PropertyChanged); } } private int testId; public int TestId { get { return this.testId; } set { this.testId = value; this.rulesEngine.Notify("TestId", this.PropertyChanged); } } public string this[string columnName] { get { var result = rulesEngine.GetErrors(columnName); if (result == null) { return null; } return string.Join(Environment.NewLine, result); } } public string Error { get { return null; } } }
And finally, here is the XAML and (if your new to MVVM) the code-behind file for completeness
<Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" /> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="Auto" /> <RowDefinition Height="Auto" /> </Grid.RowDefinitions> <TextBlock Text="ID" /> <TextBox Text="{Binding TestId, Mode=TwoWay, ValidatesOnDataErrors=True, UpdateSourceTrigger=LostFocus}" Grid.Column="1" Width="100"> <TextBox.Style> <Style TargetType="TextBox"> <Style.Triggers> <Trigger Property="Validation.HasError" Value="True"> <Setter Property="ToolTip" Value="{Binding RelativeSource= {RelativeSource Mode=Self}, Path=(Validation.Errors)[0].ErrorContent}" /> </Trigger> </Style.Triggers> </Style> </TextBox.Style> </TextBox> <TextBlock Text="Name" Grid.Row="1" /> <TextBox Text="{Binding TestName, Mode=TwoWay}" Grid.Row="1" Grid.Column="1" Width="100" IsReadOnly="True" /> <Button Content="Update" Grid.Row="2" /> </Grid>
public MainWindow() { InitializeComponent(); DataContext = new TestDomainObjectViewModel(); }