Overview
MVVM (Model-View-ViewModel) is a fantastic design pattern, with numerous benefits. You already know this, or you wouldn't be reading MVVM articles. It can also get rather complicated, and you already know this as well, or you wouldn't be reading MVVM articles.
Contents
Introduction
This article, and the simple software presented here, addresses the following trickier aspects of MVVM:
- Error message localization
- Multi-control validation
- Validation with multiple instances of a View
- Whole-View validation
I also present the following positions regarding proper MVVM design:
- No displayed text should exist in the Model or ViewModel.
- The belief that Views should contain no code is incorrect.
- Display-specific code belongs in the View.
- Views should never contain business logic.
Basic Validation
There are two mechanisms for input validation in an MVVM pattern: the IDataErrorInfo
interface and the ValidationRule
class. In both cases, the validation can occur in the Model, the ViewModel, or both, as it should be, instead of in the View. The problem is that while validation should not occur in the View, the text of an error message is a display issue and not a business logic issue, so the View should be responsible for the actual error message text. An important subset of this issue is localization. One way to prove that an application follows the MVVM pattern is if the Model and ViewModel could have been written before the View (or Views) as a class library. It should be possible to build an application that contains such an M-VM class library, and add localizations without the need to modify the M-VM class library.
The demo application solves the problem of localizing error messages by placing validation rules (subclasses of ValidationRule
) in the M-VM class library, having the validation rules return error enumerations instead of error strings to the View, and placing a Converter in the binding for the error message which lets the View convert the error enumeration into a localizable string.
The demo application simulates software for a blood pressure study. I chose this topic because it allows the demonstration of validation across multiple controls, namely the systolic and diastolic pressures, since the diastolic needs to be smaller than the systolic.
The first window lets the user choose a language. The window itself will be displayed in the system's current culture, if it's Mexican Spanish or French French; otherwise, it will display in English.
Once Start is pressed, the application displays in the selected language, regardless of the system settings. The main window is then displayed, which contains a single button for creating and displaying one or more View instances.
Each time the button is pressed, another instance of the View is created and displayed. As the images below demonstrate, the error message shown in the tool tip is localized. Using a similar mechanism, whenever the systolic and diastolic pressures are both valid, the classification of the blood pressure's healthiness (such as normal, hypotension, stage 1 hypertension, etc.) is displayed, again in the correct language by converting a bound enumeration within the View's code-behind.
Dependent Validation and Multiple View Instances
One of the challenges faced when developing this application is that the ValidationRule
class' Validate
function signature only takes a value and a culture, and no other parameter to indicate the source. This is fine in most cases, when validation of a control isn't dependent on the values of other controls, and when only one instance of the View class can exist. When it does depend on other controls, a mechanism is needed to allow the validator to access the dependent value.
In this application, the current ViewModel is stored as a static variable of the ViewModel class, and set by the View when it's first loaded, and whenever it becomes active. The validator is then able to access the dependent value through the current ViewModel.
Whole-View Validation
Another challenge is knowing when a View is valid, such as to know if its Model can be saved, or to determine when a Save button or menu item can be enabled. When controls of a View are bound directly to members of the Model, and the binding system handles type conversion, when a control has a validation error, the ViewModel has no way of knowing about it, as it only has access to the last valid value transferred into the Model. A related problem is validating controls that were never edited. I used Josh Smith's small but mighty RelayCommand
class for the enabling and handling of the Save button within the ViewModel. My CanSave
function needed to force the View to validate all controls and then let the ViewModel know if everything in the View is valid or not. When the View constructs its ViewModel, it passes a function delegate that the ViewModel calls in CanSave
, and a function called after saving completes, that can pass an exception to the View so it can display any localized error message.
Inside the Demo Application
The demo application is organized as follows:
Rather than showing all of the source, I'll just show the relevant and interesting portions.
The View
The relevant part of the TextBox
for the diastolic pressure looks like:
<TextBox
Name="Diastolic_TextBox"
Validation.ErrorTemplate="{StaticResource validationTemplate}"
Style="{StaticResource diastolicTextBoxInError}">
<TextBox.Text>
<Binding
Path="Model.Diastolic"
UpdateSourceTrigger="Explicit">
<Binding.ValidationRules>
<vm:Diastolic_ValidationRule />
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
</TextBox>
Here, the style, error template, and validation rule are set as described on MSDN for the "Validation.HasError
Attached Property". The View's DataContext
is set to the ViewModel, which contains the model in a property named Model
.
The related portions of the window's resources include:
<clr:DiastolicErrorConverter
x:Key="diastolicErrorConverter" />
<ControlTemplate
x:Key="validationTemplate">
<DockPanel>
<TextBlock
Foreground="Red"
FontWeight="Bold"
VerticalAlignment="Center">
!
</TextBlock>
<AdornedElementPlaceholder />
</DockPanel>
</ControlTemplate>
<Style
x:Key="diastolicTextBoxInError"
TargetType="{x:Type TextBox}">
<Style.Triggers>
<Trigger
Property="Validation.HasError"
Value="true">
<Setter
Property="ToolTip"
Value="{Binding
RelativeSource={x:Static RelativeSource.Self},
Path=(Validation.Errors)[0].ErrorContent,
Converter={StaticResource diastolicErrorConverter}}" />
</Trigger>
</Style.Triggers>
</Style>
Most of this is standard. The interesting part is the converter on the ToolTip's binding, which allows the View to localize the error messages in its code-behind.
One misunderstanding about the MVVM pattern is that a View should never have any code-behind. It's okay for there to be a code-behind, as long as it only addresses display issues. To quote the book Advanced MVVM by Josh Smith, "When using ViewModels, your Views can and, in many cases, should still have certain kinds of code in their code-behind files." Below is the full code-behind file of the View:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using System.Globalization;
namespace ApplicationWithView
{
public partial class BloodPressure_Window : Window
{
private ClassLibraryOfModelAndViewModel.BloodPressure_ViewModel
_viewModel;
public BloodPressure_Window()
{
InitializeComponent();
_viewModel =
new ClassLibraryOfModelAndViewModel.BloodPressure_ViewModel(
ValidateView, ViewModelCompletedSaving );
DataContext = _viewModel;
}
private bool ValidateView()
{
bool
result;
BindingExpression
bindingExpression;
result = true;
bindingExpression =
Systolic_TextBox.GetBindingExpression( TextBox.TextProperty );
if ( bindingExpression != null )
{
bindingExpression.UpdateSource();
if ( Validation.GetErrors( Systolic_TextBox ).Count > 0 )
{
result = false;
}
}
bindingExpression =
Diastolic_TextBox.GetBindingExpression( TextBox.TextProperty );
if ( bindingExpression != null )
{
bindingExpression.UpdateSource();
if ( Validation.GetErrors( Diastolic_TextBox ).Count > 0 )
{
result = false;
}
}
return result;
}
private void ViewModelCompletedSaving(
Exception
anyException )
{
if ( anyException == null )
{
Close();
return;
}
}
private void BloodPressure_Window_Activated(
object
sender,
EventArgs
eventArgs )
{
ClassLibraryOfModelAndViewModel.
BloodPressure_ViewModel.ActiveViewModel = _viewModel;
}
private void BloodPressure_Window_Loaded(
object
sender,
RoutedEventArgs
routedEventArgs )
{
ClassLibraryOfModelAndViewModel.
BloodPressure_ViewModel.ActiveViewModel = _viewModel;
this.Activated += new EventHandler( BloodPressure_Window_Activated );
}
}
[ValueConversion( typeof(
ClassLibraryOfModelAndViewModel.BloodPressureTestResult.SystolicErrorType ),
typeof( String ) )]
public class SystolicErrorConverter : IValueConverter
{
public object Convert( object value, Type targetType,
object parameter, CultureInfo culture )
{
ClassLibraryOfModelAndViewModel.BloodPressureTestResult.SystolicErrorType
systolicErrorType;
if ( value.GetType() != typeof(
ClassLibraryOfModelAndViewModel.
BloodPressureTestResult.SystolicErrorType ) )
return "";
systolicErrorType =
(ClassLibraryOfModelAndViewModel.
BloodPressureTestResult.SystolicErrorType) value;
switch ( systolicErrorType )
{
case ClassLibraryOfModelAndViewModel.
BloodPressureTestResult.SystolicErrorType.None:
return "";
case ClassLibraryOfModelAndViewModel.BloodPressureTestResult.
SystolicErrorType.InvalidIntegerString:
return Main_Window.__InvalidIntegerValueText;
case ClassLibraryOfModelAndViewModel.
BloodPressureTestResult.SystolicErrorType.TooLow:
return Main_Window.__SystolicPressureTooLowText
+ ClassLibraryOfModelAndViewModel.BloodPressureTestResult.MinSystolic
+ Main_Window.__EndOfSentenceText;
case ClassLibraryOfModelAndViewModel.
BloodPressureTestResult.SystolicErrorType.TooHigh:
return Main_Window.__SystolicPressureTooHighText
+ ClassLibraryOfModelAndViewModel.BloodPressureTestResult.MaxSystolic
+ Main_Window.__EndOfSentenceText;
}
return ""; }
public object ConvertBack( object value, Type targetType,
object parameter, CultureInfo culture )
{
return "";
}
}
[ValueConversion( typeof( ClassLibraryOfModelAndViewModel.
BloodPressureTestResult.DiastolicErrorType ), typeof( String ) )]
public class DiastolicErrorConverter : IValueConverter
{
public object Convert( object value, Type targetType,
object parameter, CultureInfo culture )
{
ClassLibraryOfModelAndViewModel.BloodPressureTestResult.DiastolicErrorType
diastolicErrorType;
if ( value.GetType() != typeof( ClassLibraryOfModelAndViewModel.
BloodPressureTestResult.DiastolicErrorType ) )
return "";
diastolicErrorType = (ClassLibraryOfModelAndViewModel.
BloodPressureTestResult.DiastolicErrorType) value;
switch ( diastolicErrorType )
{
case ClassLibraryOfModelAndViewModel.
BloodPressureTestResult.DiastolicErrorType.None:
return "";
case ClassLibraryOfModelAndViewModel.BloodPressureTestResult.
DiastolicErrorType.InvalidIntegerString:
return Main_Window.__InvalidIntegerValueText;
case ClassLibraryOfModelAndViewModel.
BloodPressureTestResult.DiastolicErrorType.TooLow:
return Main_Window.__DiastolicPressureTooLowText
+ ClassLibraryOfModelAndViewModel.BloodPressureTestResult.MinDiastolic
+ Main_Window.__EndOfSentenceText;
case ClassLibraryOfModelAndViewModel.
BloodPressureTestResult.DiastolicErrorType.TooHigh:
return Main_Window.__DiastolicPressureTooHighText;
}
return ""; }
public object ConvertBack( object value, Type targetType,
object parameter, CultureInfo culture )
{
return "";
}
}
[ValueConversion( typeof( ClassLibraryOfModelAndViewModel.
BloodPressureTestResult.BloodPressureHealthinessType ), typeof( String ) )]
public class BloodPressureHealthinessTypeConverter : IValueConverter
{
public object Convert( object value, Type targetType,
object parameter, CultureInfo culture )
{
switch ( (ClassLibraryOfModelAndViewModel.BloodPressureTestResult.
BloodPressureHealthinessType) value )
{
case ClassLibraryOfModelAndViewModel.BloodPressureTestResult.
BloodPressureHealthinessType.Hypotension:
return Main_Window.__HypotensionText;
case ClassLibraryOfModelAndViewModel.BloodPressureTestResult.
BloodPressureHealthinessType.Normal:
return Main_Window.__NormalText;
case ClassLibraryOfModelAndViewModel.BloodPressureTestResult.
BloodPressureHealthinessType.Prehypertension:
return Main_Window.__PrehypertensionText;
case ClassLibraryOfModelAndViewModel.BloodPressureTestResult.
BloodPressureHealthinessType.Stage1Hypertension:
return Main_Window.__Stage1HypertensionText;
case ClassLibraryOfModelAndViewModel.BloodPressureTestResult.
BloodPressureHealthinessType.Stage2Hypertension:
return Main_Window.__Stage2HypertensionText;
case ClassLibraryOfModelAndViewModel.BloodPressureTestResult.
BloodPressureHealthinessType.Indeterminate:
return "";
}
return ""; }
public object ConvertBack( object value, Type targetType,
object parameter, CultureInfo culture )
{
return "";
}
}
}
The Model
Here is the whole Model:
using System;
using System.Collections.Generic;
using System.ComponentModel; using System.Linq;
using System.Text;
namespace ClassLibraryOfModelAndViewModel
{
public class BloodPressureTestResult : INotifyPropertyChanged
{
#region Support for INotifyPropertyChanged
public event PropertyChangedEventHandler PropertyChanged;
protected void Notify(
string
propertyName )
{
if ( PropertyChanged != null )
PropertyChanged( this, new PropertyChangedEventArgs( propertyName ) );
}
#endregion
#region Public Properties
public enum BloodPressureHealthinessType
{
Indeterminate,
Hypotension,
Normal,
Prehypertension,
Stage1Hypertension,
Stage2Hypertension
}
public enum SystolicErrorType
{
None,
InvalidIntegerString,
TooLow,
TooHigh
}
public enum DiastolicErrorType
{
None,
InvalidIntegerString,
TooLow,
TooHigh
}
public int TestNumber { get; set; }
public int Systolic
{
get
{
return _systolic;
}
set
{
_systolic = value;
SetBloodPressureHealthiness();
}
}
private int
_systolic;
public int Diastolic
{
get
{
return _diastolic;
}
set
{
_diastolic = value;
SetBloodPressureHealthiness();
}
}
private int
_diastolic;
public BloodPressureHealthinessType BloodPressureHealthiness
{
get
{
return _bloodPressureHealthiness;
}
private set
{
_bloodPressureHealthiness = value;
Notify( "BloodPressureHealthiness" );
}
}
private BloodPressureHealthinessType
_bloodPressureHealthiness;
#endregion
#region Public Static Properties
public static int MinSystolic
{
get
{
return 10;
}
}
public static int MaxSystolic
{
get
{
return 300;
}
}
public static int MinDiastolic
{
get
{
return 10;
}
}
#endregion
#region Private Static Variables
private static int
__numTests = 0;
#endregion
public BloodPressureTestResult()
{
__numTests++;
TestNumber = __numTests;
}
public SystolicErrorType GetSystolicError(
string
stringOfSystolic )
{
int
systolic;
if ( !int.TryParse( stringOfSystolic, out systolic ) )
return SystolicErrorType.InvalidIntegerString;
if ( systolic < BloodPressureTestResult.MinSystolic )
return SystolicErrorType.TooLow;
if ( systolic > BloodPressureTestResult.MaxSystolic )
return SystolicErrorType.TooHigh;
return SystolicErrorType.None;
}
public DiastolicErrorType GetDiastolicError(
string
stringOfDiastolic )
{
int
diastolic;
if ( !int.TryParse( stringOfDiastolic, out diastolic ) )
return DiastolicErrorType.InvalidIntegerString;
if ( diastolic < BloodPressureTestResult.MinDiastolic )
return DiastolicErrorType.TooLow;
if ( diastolic >= Systolic )
return DiastolicErrorType.TooHigh;
return DiastolicErrorType.None;
}
private void SetBloodPressureHealthiness()
{
if ( Systolic < MinSystolic || Systolic > MaxSystolic ||
Diastolic < MinDiastolic || Diastolic >= Systolic )
BloodPressureHealthiness = BloodPressureHealthinessType.Indeterminate;
else if ( Systolic < 90 || Diastolic < 60 )
BloodPressureHealthiness = BloodPressureHealthinessType.Hypotension;
else if ( Systolic >= 90 && Systolic <= 120 &&
Diastolic >= 60 && Diastolic <= 80 )
BloodPressureHealthiness = BloodPressureHealthinessType.Normal;
else if ( ( Systolic >= 121 && Systolic <= 139 ) ||
( Diastolic >= 81 && Diastolic <= 89 ) )
BloodPressureHealthiness = BloodPressureHealthinessType.Prehypertension;
else if ( ( Systolic >= 140 && Systolic <= 159 ) ||
( Diastolic >= 90 && Diastolic <= 99 ) )
BloodPressureHealthiness = BloodPressureHealthinessType.Stage1Hypertension;
else if ( Systolic >= 160 || Diastolic >= 100 )
BloodPressureHealthiness = BloodPressureHealthinessType.Stage2Hypertension;
else
BloodPressureHealthiness = BloodPressureHealthinessType.Indeterminate;
}
}
}
The ViewModel
Here is the whole ViewModel:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Windows.Input;
namespace ClassLibraryOfModelAndViewModel
{
public class BloodPressure_ViewModel
{
#region Delegates
public delegate bool ValidateView_Delegate();
public delegate void DoneSaving_Delegate(
Exception
anyException );
#endregion
#region Public Properties
public BloodPressureTestResult
Model { get; private set; }
public ICommand SaveCommand
{
get
{
if ( _saveCommand == null )
{
_saveCommand = new RelayCommand(
param => this.Save(),
param => this.CanSave );
}
return _saveCommand;
}
}
public static BloodPressure_ViewModel ActiveViewModel { get; set; }
#endregion
#region Private Members
private RelayCommand
_saveCommand;
private ValidateView_Delegate
_validateView_Callback;
private DoneSaving_Delegate
_doneSaving_Callback;
#endregion
public BloodPressure_ViewModel(
ValidateView_Delegate
validateView_Callback,
DoneSaving_Delegate
doneSaving_Callback )
{
this._validateView_Callback = validateView_Callback;
this._doneSaving_Callback = doneSaving_Callback;
Model = new BloodPressureTestResult();
}
bool CanSave
{
get
{
if ( this != ActiveViewModel )
return false;
if ( _validateView_Callback == null )
return false;
return _validateView_Callback(); }
}
private void Save()
{
_doneSaving_Callback( null ); }
}
}
The Validator
Here is the validator for the diastolic pressure:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows.Controls; using System.Globalization;
namespace ClassLibraryOfModelAndViewModel
{
public class Diastolic_ValidationRule : ValidationRule
{
public override ValidationResult Validate(
object
value,
CultureInfo
cultureInfo )
{
BloodPressureTestResult.DiastolicErrorType
diastolicErrorType;
diastolicErrorType =
BloodPressure_ViewModel.ActiveViewModel.
Model.GetDiastolicError( (string) value );
if ( diastolicErrorType == BloodPressureTestResult.DiastolicErrorType.None )
return new ValidationResult( true, null );
else
return new ValidationResult( false, diastolicErrorType );
}
}
}
Building the Demo
The demo application is localized, so there are a few steps with which some might not be familiar. If you open the solution, you will see that there are two projects: the startup project, named ApplicationWithView, and the class library, named ClassLibraryOfModelAndViewModel.
To make the application localizable, I edited ApplicationWithView.csproj by adding <UICulture>en-US</UICulture>
to the first PropertyGroup
element, and I uncommented the line "[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)]
" in the application project's AssemblyInfo.cs. I then did a Rebuild All of the project in Debug mode, which created ApplicationWithView.resources.dll in the application project's bin\Debug\en-US directory. Running Localize_CreateFileToTranslate.bat in the application project's folder, which runs Microsoft's localization tool, LocBaml, created ApplicationWithView_en-US.csv. I copied that .csv file twice, renaming them to ...es-MX.csv for Spanish (EspaƱol) for Mexico, and ...fr-FR.csv for French for France, and translated the text in those files. I then ran the batch files Localize_ProcessTranslatedFile_es-MX.bat and Localize_ProcessTranslatedFile_fr-FR.bat, which run LocBaml and create localized resources in folders named es-MX and fr-FR in the application project's bin\Debug directory.
To build the demo using Visual Studio 2010, please perform the following steps:
- You needn't edit the application's project file or the AssemblyInfo.cs file for localization, since I already did.
- Rebuild All the solution in Debug mode. This will create the English resource file for the application project.
- Open the folder for the application project, named ApplicationWithView, in a Windows Explorer window.
- You needn't create ApplicationWithView_en-US.csv by running Localize_CreateFileToTranslate.bat, since it already exists; but if you do, it will just replace it with an identical file.
- You needn't create the Spanish and French .csv files, since I already did.
- Run the batch files Localize_ProcessTranslatedFile_es-MX.bat and Localize_ProcessTranslatedFile_fr-FR.bat to create the Spanish and French resource files.
If you wish to build the Release version, for some reason, you may perform the following:
- Rebuild All the solution in Release mode. This will create the English resource file for the application project.
- Copy the es-MX and fr-FR directories from the application project directory's bin\Debug to bin\Release. Don't copy the en-US directory, because the release version of the application needs the release version of the English resource file.
Please note that the solution and LocBaml use .NET Framework 4.0. I mention this because as of this writing, if Microsoft has released a .NET 4 version of LocBaml, I couldn't find it. I did find a .NET 4 version, however, at http://michaelsync.net/2010/03/01/locbaml-for-net-4-0, and I am thankful to Michael Sync for having tweaked the existing version to work with .NET 4.
Summary
MVVM gives you the ability to fully assign the display functionality to the View, perform dependent input validation, handle multiple view instances, and perform whole-view validation. This article presented an example that addressed each of these tasks. I believe these approaches will help developers improve their software designs, and therefore develop more maintainable software.