Introduction
During the last years the Model-View-ViewModel pattern (MVVM) has reached more and more popularity and is nowadays a de facto standard
for Windows Presentation Foundation (WPF) programming. However, using property names stored in strings driving the most critical part
of an MVVM based architecture, the data binding, has many disadvantages: it makes the development error-prone, ignores Intellisense, constricts refactoring, and makes debugging difficult.
In this article, I will introduce an approach to encapsulate WPF data binding with a typesafe data binding layer that is used to bind
the view model to the view while keeping the view model independent from the view. This way you will get a Type-Safe View Model (TVM).
At the View Model: Connectors Instead of Properties
A TVM is a simple class, no inheritance is needed. It uses Connectors in order to communicate with the view. Traditional MVVM uses
properties instead. These properties are discovered by the view using Reflection.
You can think of a Connector
as a value that is a part of the view model's state. The type of the Connector's value can be any .NET type
like a Byte
or an object representing a database table.
Thus, a simple view model might look like this:
using TypesaveViewModel;
…
class SimpleControlsViewModel
{
public readonly Connector<string> TextBoxConnector = new Connector<string>();
public readonly Connector<bool?> CheckBoxConnector = new Connector<bool?>();
public readonly Connector<DateTime?> DatePickerConnector = new Connector<DateTime?>();
}
Each Connector
has a Value
property. You may use it to propagate information to the view:
internal void ResetAll()
{
this.TextBoxConnector.Value = null;
this.CheckBoxConnector.Value = null;
this.DatePickerConnector.Value = null;
}
Of course it can be used to read from the view, too:
internal void AdvanceAll()
{
if (string.IsNullOrWhiteSpace(this.TextBoxConnector.Value) ||
this.TextBoxConnector.Value[0] >= 'z')
this.TextBoxConnector.Value = "A";
else
this.TextBoxConnector.Value =
((char)((int)this.TextBoxConnector.Value[0] + 1)).ToString();
if (!this.CheckBoxConnector.Value.HasValue)
this.CheckBoxConnector.Value = false;
else if (this.CheckBoxConnector.Value == false)
this.CheckBoxConnector.Value = true;
else
this.CheckBoxConnector.Value = false;
this.DatePickerConnector.Value =
!this.DatePickerConnector.Value.HasValue
? DateTime.Today
: this.DatePickerConnector.Value.Value.AddDays(1);
}
At the View: Type-Save Binding in Code
First, the view model needs to be included into the view's XAML:
<Window x:Class="MyTVMApp.SimpleControlsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:MyTVMApp="clr-namespace:MyTVMApp"
Title="Simple Controls"
Height="300"
Width="300">
<Window.DataContext>
<MyTVMApp:SimpleControlsViewModel x:Name="viewModel" />
</Window.DataContext>
<Grid>
…
<TextBox Name="textBox1" Grid.Column="1" />
…
<Button Content="Reset all" Grid.Row="7"
Click="buttonResetAll_Click" />
…
Note that the view model as well as the controls need to get names. This enables us to access these objects in code. This way we can place our type-safe bindings in the code-behind file:
using TypesaveViewModel.WpfBinding;
…
public partial class SimpleControlsWindow : Window
{
public SimpleControlsWindow()
{
InitializeComponent();
this.textBox1.BindText(this.viewModel.TextBoxConnector);
this.checkBox1.BindIsChecked(this.viewModel.CheckBoxConnector);
this.datePicker1.BindSelectedDate(this.viewModel.DatePickerConnector);
}
…
}
When you're typing this code, you can see Intellisense helping you:
Intellisense recognizes the type of textBox1
and shows you common binding opportunities like BindText
or BindIsEnabled
.
In addition, you may use Bind<>
in order to specify less common properties as binding targets.
I enabled Intellisense for TVM simply by providing a set of extension methods like BindText
. This set can easily be extended.
By the way - code behind is also a great way to connect buttons to methods of your view model:
private void buttonResetAll_Click(object sender, RoutedEventArgs e)
{
this.viewModel.ResetAll();
}
Detecting Changes
Another important thing in View-ViewModel communication is to detect changes. One direction is done automatically: If the Value
property
of a Connector
is set to a new value, this is automatically propagated to all bound targets in the view. The other direction is sometimes also needed.
This is the case when the view model has to take actions immediately when something has been entered at the view. An example is the Hierarchy window in the sample code.
The items displayed at the second list depend on the item selected in the first list. Thus, if the selection of the first list changes
from "sea" to "forest", the second list should change from "fish, submarine, whale" to "tree, deer ranger".
Such a scenario can be covered by assigning an Action
to the property OnValueChanged
of the Connector
that is bound to the selected item:
public class HierarchyViewModel
{
public ListConnector<xelement> List1 = new ListConnector<xelement>();
public Connector<xelement> Selected1 = new Connector<xelement>();
public ListConnector<xelement> List2 = new ListConnector<xelement>();
…
public HierarchyViewModel()
{
…
Selected1.OnValueChanged = () => setDependentList(Selected1.Value, List2);
Selected2.OnValueChanged = () => setDependentList(Selected2.Value, List3);
…
}
private static void setDependentList(XElement v, ListConnector<xelement> dependentList)
{
dependentList.Value = v == null ? null : v.Elements();
}
}
Converting Types
One of the biggest challenges in data binding is to deal with different types. Imagine you have an integer value in your model that you want to be edited in your view.
The standard WPF controls are offering a TextBox
that has a Text
property of type String
, only. In most cases (i.e., between convertible types
including their nullable variants) the TVM system handles type conversion under the hood. But sometimes things are beyond triviality. Consider a control that should
change its color depending on a boolean value in your model.
The Numbers example shows some trivial conversions (between numbers and strings) as well as a more
sophisticated case. Depending on a BindingErrorEventArgs
object (containing the latest error information), various properties in the view should be changed.
In the view model things are kept simple:
public Connector<BindingErrorEventArgs> LastErrorConnector
= new Connector<BindingErrorEventArgs>();
For the view, we're first binding to the Text
property of a TextBlock
. That text should be:
null
, if the BindingErrorEventArgs
object is null
- otherwise it should be "This error occurred on Connector:" or "This error is removed from Connector:" depending
on
BindingErrorEventArgs.IsRemoved
this.textBlockLastErrorCaption.Bind(
this.viewModel.LastErrorConnector,
TextBlock.TextProperty,
new Binding<string, BindingErrorEventArgs>(
binding =>
binding.Connector.Value == null
? null
: string.Format(
"This error {0} \"{1}\":",
binding.Connector.Value.IsRemoved ? "is removed from" : "occured on",
binding.Connector.Value.Binding.Connector.Name),
null
)
);
Second, the ErrorMessage
should be displayed as the Text
of textBoxLastErrorText
:
this.textBoxLastErrorText.Bind(
this.viewModel.LastErrorConnector,
TextBox.TextProperty,
new Binding<string, BindingErrorEventArgs>(
binding =>
binding.Connector.Value == null
? null
: binding.Connector.Value.ErrorMessage,
null
)
);
Third, if BindingErrorEventArgs.IsRemoved
is true
, the text of textBoxLastErrorText
should be displayed in gray, otherwise in red.
this.textBoxLastErrorText.Bind(
this.viewModel.LastErrorConnector,
Control.ForegroundProperty,
new Binding<Brush, BindingErrorEventArgs>(
binding =>
binding.Connector.Value != null &&
binding.Connector.Value.IsRemoved ? Brushes.Gray : Brushes.Red,
null
)
);
Note that in all three cases we are instantiating a new Binding<TView, TModel>
object. The TView
type is string
whereas
the TModel
type is BindingErrorEventArgs
for the first two cases and Brush
for the third case. Each constructor gets
a Func
argument that specifies how the value needed for the view is retrieved from the model. The second constructor argument is null
in all three cases,
indicating that all three view properties are "read-only", i.e., the data flows
unidirectional from model to view.
Supporting RadioButtons
A special case of type conversion is frequently used. For that reason, I provided special support for
RadioButton
s. This allows you to model a state
as an enum
and represent it by a set of RadioButton
controls at the view. It looks like this at the view model:
using TypesaveViewModel;
…
class SimpleControlsViewModel
{
public enum Choice { A=1, B, C }
public readonly Connector<Choice> RadioButtonConnector = new Connector<Choice>();
…
At the view, the RadioButtonConnector
is bound to a set of RadioButton
controls:
using TypesaveViewModel.WpfBinding;
…
public partial class SimpleControlsWindow : Window
{
public SimpleControlsWindow()
{
InitializeComponent();
…
this.radioButton1A.BindIsChecked(SimpleControlsViewModel.Choice.A,
this.viewModel.RadioButtonConnector);
this.radioButton1B.BindIsChecked(SimpleControlsViewModel.Choice.B,
this.viewModel.RadioButtonConnector);
this.radioButton1C.BindIsChecked(SimpleControlsViewModel.Choice.C,
this.viewModel.RadioButtonConnector);
…
Handling Errors
In my eyes, one of the most amazing features of WPF is the validation support. My first implementation of the TVM pattern was much shorter than the
one I'm introducing here. It completely bypassed the WPF binding. The disadvantage was that I found no simple way to weave TVM into the WPF validation.
My current implementation of type-safe binding allows simple native WPF binding error handling for the view. For example, if you like to see the validation
error in a tooltip of your TextBox
control, your XAML might look like this:
<Window x:Class="MyTVMApp.NumbersWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:MyTVMApp="clr-namespace:MyTVMApp" Title="Numbers"
Height="338"
Width="498">
At the view model you may use the ErrorInfo
property in order to get access to the TVM error handling system.
Often it is a good idea to use the same error handling for several Connectors: Imagine an entry form with an "OK" or "Send" button.
When the user presses that button, you might want to check for errors in the entry fields. If there are errors, you would tell the user what she should
do and decline the operation until the errors have been fixed. Such a set of Connectors can be built by the ConnectorCollection
class:
using TypesaveViewModel;
…
public class NumbersViewModel
{
public Connector<byte> ByteConnector = new Connector<byte> { Name = "Byte" };
public Connector<int> IntConnector = new Connector<int> { Name = "Int" };
public Connector<double> DoubleConnector = new Connector<double> { Name = "Double" };
public Connector<double?> NullableDoubleConnector = new Connector<double?> { Name = "Nullable Double" };
public Connector<string> ResultsConnector = new Connector<string>();
…
private readonly ConnectorCollection connectors;
public NumbersViewModel()
{
connectors = new ConnectorCollection(ByteConnector, IntConnector, DoubleConnector, NullableDoubleConnector);
…
}
…
internal void GetResults()
{
var results = string.Format("Byte: {0}\nInt: {1}\nDouble: {2}\nNullable Double: {3}",
ByteConnector.Value, IntConnector.Value, DoubleConnector.Value, NullableDoubleConnector.Value);
if (connectors.ErrorInfo.HasError)
results += string.Format("\n\n-- Errors --\n{0}", connectors.ErrorInfo);
this.ResultsConnector.Value = results;
}
}
Note that this allows you to poll for errors. But sometimes you may want to be notified whenever an error occurs, immediately.
This is the time when you should subscribe for the ErrorChanged
event that is a member of the ErrorInfo
class:
public NumbersViewModel()
{
connectors = new ConnectorCollection(ByteConnector, IntConnector,
DoubleConnector, NullableDoubleConnector);
connectors.ErrorInfo.ErrorChanged += ErrorInfo_ErrorChanged;
}
void ErrorInfo_ErrorChanged(object sender, BindingErrorEventArgs e)
{
…
= e; }
The ErrorInfo
property can not only be found at the ConnectionCollection
class, but also at the Connector
class and at the type-safe binding classes.
Summary
After making experiences in some MVVM-projects, I've designed the TVM pattern introduced in this article. It covers a bunch of common use cases found in many projects.
I will use it in future projects and will post enhancements here and I really hope that you will find it useful and share your experiences here, too. Any comments are greatly appreciated.
Click here to view the class diagram in a new window