Click here to Skip to main content
Click here to Skip to main content

Enhanced MVVM Design with Type-Safe View Models (TVM)

, 20 Sep 2012 CPOL
Rate this:
Please Sign up or sign in to vote.
Develop your applications quicker and make them more robust with typesafe binding.

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 RadioButtons. 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; // Use info found in BindingErrorEventArgs
}

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

License

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

Share

About the Author

No Biography provided

Comments and Discussions

 
AnswerRe: Huh? PinmemberHarry von Borstel4-Sep-12 22:47 

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

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

| Advertise | Privacy | Terms of Use | Mobile
Web02 | 2.8.141223.1 | Last Updated 20 Sep 2012
Article Copyright 2012 by Harry von Borstel
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid