Introduction
The MVVM design pattern (a.k.a. ViewModel) is discussed a lot. That is not a sign of strength. Mature, established design patterns are not discussed that much. They are simply studied and used.
If anything, the many discussions reflect a need for a better understanding. Many articles and blog posts discuss MVVM on the conceptual level. Quite a few bloggers think that additional frameworks are needed, and focus on the framework rather than the design pattern itself. Some provide an actual example of a ViewModel, but the problem addressed is something that could be simply implemented without a ViewModel. Very few actually describe (like this one) a non-trivial problem that is then elegantly solved with a ViewModel.
This article describes a common UI problem and shows how it can be solved with MVVM.
Disclaimer: Unlike other patterns, MVVM has not been described clearly (e.g., on Wikipedia, that does a pretty good job defining other patterns), so there is a (small) chance that this might not actually be an example of MVVM. The problem and solution are interesting nonetheless.
The problem
In user interfaces, it often happens that several items on the screen need to be synchronized. A simple example is a Save button that indicates whether there is anything to save; disabling the button gives the user feedback that a save operation was completed successfully. Another example is updating a record shown in a DataGrid
, in a DataForm
. The DataGrid
shows the original values, and the DataForm
shows the updated, but not yet saved ones. This is the example discussed here.
We require:
- The
DataForm
displays the item selected in the DataGrid
.
- The
DataGrid
is read-only, and displays persisted values; the DataForm
displays the current (potentially changed, not saved) values.
- Both the
DataGrid
and the DataForm
indicate with a special background color whether an item still needs to be saved.
- There is a summary line that indicates how many unsaved records there are.
- There is a Save button that saves a (single) selected record.
- There is a Reset button that resets a (single) selected record.
- Both the Save and the Reset buttons are enabled if and only if the selected record is not persisted yet.
- The content shown (person records) can be filtered, both by Gender and by a record's Change State.
Note that the state the data is in is reflected in various ways that all need to be in sync. The user is able to edit individual records in parallel, and can undo or save individual changes. At any time, the application gives a clear feedback on what the state of the data is. We could implement this with a lot of event handling. But Silverlight databinding supports a more elegant solution: the ViewModel solution.
The Visual Studio solution
In Visual Studio, we have a standard Silverlight WCF service that sends a list of Person objects to the client upon request. The Person
class is defined in the Model/Person.cs file in the ASP.NET web application project. Upon creating the service reference, Visual Studio creates equivalent Person objects in the ViewModelDemo.DemoServiceReference
namespace. We rename this namespace:
using Model = ViewModelDemo.DemoServiceReference;
so that the class of our Person objects on the client can be called Model.Person
. On the client, we also have a ViewModel.Person
class that wraps the Model.Person
class:
public class Person : INotifyPropertyChanged
{
private Model.Person _modelPerson = null;
public Person(Model.Person modelPerson)
{
_modelPerson = modelPerson;
_firstName = _modelPerson.FirstName;
_lastName = _modelPerson.LastName;
_dob = _modelPerson.DateOfBirth;
_gender = _modelPerson.Gender;
}
We could say that the Model.Person
object is kept in the ViewModel.Person
object to remember the original values of its properties. This way, we can easily reset the values and check whether they are changed. After a successful Save operation, the corresponding Person objects have the same property values again. For each property of Model.Person
, we have two properties for ViewModel.Person
. They are used for binding to the DataForm
and the DataGrid
, respectively (Req. 2).
private string _firstName;
[Display(Name="First Name")]
public string FirstName
{
get
{
return _firstName;
}
set
{
if (_firstName != value)
{
_firstName = value; NotifyPropertyChanged("FirstName");
IsDirty = (_firstName != _modelPerson.FirstName);
}
}
}
[Display(AutoGenerateField = false)]
public string FirstNameModel
{
get
{
return _modelPerson.FirstName;
}
private set
{
if (_modelPerson.FirstName != value)
{
_modelPerson.FirstName = value;
NotifyPropertyChanged("FirstNameModel");
}
}
}
There is an IsDirty
property for the ViewModel.Person
that helps implement Req. 7.
private bool _isDirty;
[Display(AutoGenerateField = false)]
public bool IsDirty
{
get
{
return _isDirty;
}
private set
{
_isDirty = value;
NotifyPropertyChanged("IsDirty");
NotifyPropertyChanged("BackgroundColorString");
}
}
Before we finish Req. 7, let's look at the property BackgroundColorString
:
[Display(AutoGenerateField=false)]
public string BackgroundColorString
{
get
{
if (_isDirty)
{
return "#FFCE5B"; }
return null;
}
}
Indeed, this covers Req. 3 because the the Background
property of the DataForm
and (the Panel
in each of the cells in) the DataGrid
rows observe this BackgroundColorString
property - whenever it changes, they update their background. Back to Req. 7, the two buttons are defined in XAML, like so:
<Button x:Name="SaveButton"
IsEnabled="{Binding SelectedItem.IsDirty, ElementName=TheGrid}"
VerticalAlignment="Top" Margin="5">
<StackPanel Orientation="Horizontal" Margin="5">
<TextBlock Text="Save" VerticalAlignment="Center" Margin="5,0" />
<Image Source="arrow-forward_32.png" />
</StackPanel>
</Button>
We use element binding to synchronize the buttons to the selected item in the DataGrid
, and bind the IsEnabled
property of the buttons to the IsDirty
property of the currently selected Person record. This concludes Req. 7, so three down, five to go. Element binding is also used to synchronize the DataGrid
and the DataForm
(Req. 1); the definition of the DataForm
contains:
CurrentItem="{Binding SelectedItem, ElementName=TheGrid, Mode=OneWay}"
Wow. Element Binding is my friend! The summary line, positioned right below the DataGrid
(Req. 4), is a simple TextBlock
that binds to (or observes) a SummaryText
property. Now, this property is not a property of a ViewModel.Person
, but of the Application Cache.
Caching
Why use a cache? Well, why not? In the old days, with HTML and JavaScript (remember?), the possibility of caching on the client did not even occur to me, probably because data and markup were inseparably mixed. But, now that we have the Silverlight technology, it's obvious we want to cache data on the client to minimize the load on the server and to optimize application performance. So, we set the main page's DataContext
to an Application Cache (a Singleton, even though we have only a single page). The cache has a property People
that the DataGrid
, the Pager
, and the DataForm
bind to:
private PagedCollectionView _people;
public PagedCollectionView People
{
get
{
if (_people == null)
{
_people = new PagedCollectionView(new Collection<viewmodel.person>());
DemoServiceClient proxy = new DemoServiceClient();
proxy.GetPeopleCompleted +=
new EventHandler<getpeoplecompletedeventargs>(proxy_GetPeopleCompleted);
proxy.GetPeopleAsync();
}
return _people;
}
set
{
_people = value;
NotifyPropertyChanged("People");
}
}
private void proxy_GetPeopleCompleted(object sender, GetPeopleCompletedEventArgs e)
{
if (e.Error != null)
{
MessageBox.Show(e.Error.Message); return;
}
Collection<viewmodel.person> people = new Collection<viewmodel.person>();
foreach (Model.Person modelPerson in e.Result)
{
ViewModel.Person person = new ViewModel.Person(modelPerson);
people.Add(person);
person.PropertyChanged += new PropertyChangedEventHandler(person_PropertyChanged);
}
People = new PagedCollectionView(people);
ApplyFilters();
}
private void person_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case "IsDirty":
NotifyPropertyChanged("ChangeSummary");
break;
}
}
Did you see that? The Appication Cache observes each ViewModel.Person
in the People
collection, and whenever their IsDirty
property is changed, any UI Element observing the ChangeSummary
property of the Application Cache gets notified. And, you guessed it, the summary line observes this property of the Application Cache:
public string ChangeSummary
{
get
{
int count = 0;
foreach (ViewModel.Person person in _people.SourceCollection)
{
if (person.IsDirty)
{
count += 1;
}
}
if (count == 1)
{
return String.Format("There is one person with unsaved changes.", count);
}
return String.Format("There are {0} persons with unsaved changes.", count);
}
}
If you run the application with a breakpoint on the getter of the People
property, you see that it is hit several times. The very first time, a call to the server is made and _people
is given a non-null value. The latter is done because there are several UI Elements bound to the People
property, and we want the server call to be made only once. Once the server has returned the People
collection, the setter of the People
property is called, triggering all these UI Elements to call the getter once again to obtain the PagedCollectionView
. Neat.
Req. 6 is simple to implement in the code-behind (Quelle Horreur!) of the page:
private void ResetButton_Click(object sender, RoutedEventArgs e)
{
ViewModel.Person currentPerson = TheGrid.SelectedItem as ViewModel.Person;
if (currentPerson != null)
{
currentPerson.Reset();
}
}
which calls a simple method of ViewModel.Person
:
public void Reset()
{
FirstName = _modelPerson.FirstName;
LastName = _modelPerson.LastName;
DateOfBirth = _modelPerson.DateOfBirth;
Gender = _modelPerson.Gender;
}
Req. 5 requires a little bit more code since we need to call the server. We make sure this call is successful before we actually update the UI. The latter, again, is done implicitly, by setting properties that raise PropertyChanged
events:
public void Save()
{
DemoServiceClient proxy = new DemoServiceClient();
Model.Person tmpPerson = new Model.Person()
{
PersonId = _modelPerson.PersonId,
FirstName = _firstName,
LastName = _lastName,
DateOfBirth = _dateOfBirth,
Gender = _gender
};
proxy.SavePersonCompleted +=
new EventHandler<savepersoncompletedeventargs>(SavePersonCompleted);
proxy.SavePersonAsync(tmpPerson);
}
private void SavePersonCompleted(object sender, SavePersonCompletedEventArgs e)
{
if (e.Error != null)
{
MessageBox.Show(e.Error.Message,
"Server Error", MessageBoxButton.OK);
return;
}
Model.Person saved = e.Result;
if (saved.PersonId == this.PersonId)
{
FirstNameModel = _firstName;
LastNameModel = _lastName;
DateOfBirthModel = _dateOfBirth;
GenderModel = _gender;
IsDirty = false;
}
}
This leaves Req. 8. We could do this using the SelectionChanged
of the ComboBox
es (not shown in the picture above). But, now that we have a new hammer, we could see whether we can nail this requirement with it as well. Yes, we can! We can TwoWay bind the CurrentItem
property of each ComboBox
to a property of the Application Cache. In this way, we can detect that a filter selection has changed and set the People
's (PagedCollectionView
's) filter. However, this is somewhat of a stretch.
Points of interest
Did you notice:
- The only event used to synchronize UI elements is
PropertyChanged
.
- Data is retrieved only once, and only on demand. Rich Internet Applications can minimize the load on servers (and hence may be more performant).
- Next to MVVM, the solution uses the Observer and Singleton patterns.
You might also have noticed that validation is not addressed and that (quite inconsistently) we use AutoGenerateColumns="false"
for the DataGrid
and AutoGenerateFields="true"
for the DataForm
, so we need to suppress the display of the properties that give access to the Model.Person
- [Display(AutoGenerateField = false)]
.
Before running the example application
- Set ViewModelDemo.Web as the StartUp Project.
- Set ViewModelDemoTestPage.aspx as the Start Page.
- Add a Service Reference to the Service References folder in the Silverlight project. Discover, and call the namespace
DemoServiceReference
.
- Build.
History
- Dec. 2, 2009 - First version.