Other Articles In This Series:
Introduction
In part one of this series of articles, I introduced my take on MVVM pattern, and discussed some of the shortfalls I felt existed in some implementations and, indeed, with the model itself.
In part two, I introduced the base classes and interfaces I use in my implementation that, for want of a better title, I'm calling MVVM#
In part three, I presented the code for enough of the application to get us up and running, displaying a Customer Selection View in a Form, containing data at both run time and design time.
In this part of the series, I will build upon this basic application to show real functionality.
Filtering
The specification for our filtering requirements is pretty simple. Allow the user to type in a State code, and filter the list to include only those customers in that state. (I'm an ex-pat pom living in Aus - so I'm using Australian state codes here - the full set is QLD, NSW, SA, WA, TAS, NT, ACT, VIC if you're interested.) (Actually, that's the full set whether you're interested or not)
As usual, a million and one ways to solve this, but my designer wanted to do this as a "Search As you Type".
The first part to achieve this is actually already in the XAML of our CustomerSelectionView
:
Text="{Binding Path=StateFilter, UpdateSourceTrigger=PropertyChanged}"></TextBox>
This line binds the state text box to a StateFilter
property, and the UpdateSourceTrigger
attribute tells WPF to update the property whenever it is changed.
A reminder may be in order here; this property, while an Observable
property, is part of the functionality rather than part of the data being acted upon - so the property resides in the ViewModel
rather than the ViewData
object.
So, what will happen is, the user types something into the TextBox
, that action sets the property on the ViewModel
, which will ask the Controller
to provide a newly filtered set of data. As that data is bound to the View
, the DataGrid
in the View
will update.
But wait! If we implement that, as soon as a single letter is typed, the list will blank, as no States have a single letter code. we could do the filtering as a 'starts-with' filter - but then typing 'N' for NT would bring up all the NSW customers as well. OK - it's not such a problem really, but there could be an issue with the time it takes to get the filtered results - so we don't want to keep refreshing results as the user presses a key.
So, what I want to do is introduce a delay. No filtering will take place for, say, half a second after the user has typed something - then after half a second, the list will be filtered using the contents of the TextBox
, unless another key is pressed, in which case the half-second countdown starts again.
So, we need a Timer
- I'm using a DispatcherTimer
, so a reference to WindowsBase
needs to be added to the ViewModels
project.
Then, we can add a new private
field:
DispatcherTimer stateFilterTimer;
And we need to add our ObservableProperty StateFilter
.
private string stateFilter;
public string StateFilter
{
get
{
return stateFilter;
}
set
{
if (value != stateFilter)
{
stateFilterTimer.Stop();
stateFilter = value;
RaisePropertyChanged("StateFilter");
stateFilterTimer.Start();
}
}
}
In the constructor, we need to instantiate the timer, and set its timespan to half a second, and give it an event handler method so we can handle the event when the timer reaches zero.
public CustomerSelectionViewModel(ICustomerController controller,
IView view, string stateFilter = "")
: base(controller, view)
{
controller.Messenger.Register(MessageTypes.MSG_CUSTOMER_SAVED,
new Action<Message>(RefreshList));
stateFilterTimer = new DispatcherTimer()
{
Interval = new TimeSpan(0, 0, 0, 0, 500)
};
stateFilterTimer.Tick += StateFilterTimerTick;
StateFilter = stateFilter;
RefreshList();
}
Of course, then we need to write that event handler...
void StateFilterTimerTick(object sender, EventArgs e)
{
stateFilterTimer.Stop();
RefreshList();
}
Then all that is required is to change the RefreshList
method so that we pass the StateFilter
property, rather than an empty string
.
private void RefreshList()
{
ViewData = CustomerController.GetCustomerSelectionViewData(StateFilter);
}
That should do us - give it a run. Remembering in my test data I only used two states - Qld and NSW.
Editing
Well, the whole point of this application is to be able to edit customer details, so let's get started by creating our CustomerEditViewModel
.
using System;
using System.Windows.Input;
using Messengers;
namespace ViewModels
{
public class CustomerEditViewModel : BaseViewModel
{
#region Private Fields
#endregion
#region Properties
private ICustomerController CustomerController
{
get
{
return (ICustomerController)Controller;
}
}
#region Observable Properties
#endregion
#endregion
#region Commands
#region Command Relays
private RelayCommand<IView> cancelledCommand;
private RelayCommand<IView> saveCommand;
public ICommand CancelledCommand
{
get
{
return cancelledCommand ?? (cancelledCommand =
new RelayCommand<IView>(param => ObeyCancelledCommand(param),
param => CanObeyCancelledCommand(param)));
}
}
public ICommand SaveCommand
{
get
{
return saveCommand ?? (saveCommand = new RelayCommand<IView>
(param => ObeySaveCommand(param), param => CanObeySaveCommand(param)));
}
}
#endregion
#region Command Handlers
private bool CanObeyCancelledCommand(IView view)
{
return true;
}
private void ObeyCancelledCommand(IView view)
{
CloseViewModel(false);
}
private bool CanObeySaveCommand(IView view)
{
return true;
}
private void ObeySaveCommand(IView view)
{
CustomerController.UpdateCustomer((CustomerEditViewData)ViewData);
CloseViewModel(true);
}
#endregion #endregion
#region Constructor
public CustomerEditViewModel(ICustomerController controller)
: this(controller, null)
{
}
public CustomerEditViewModel(ICustomerController controller, IView view)
: base(controller, view)
{
controller.Messenger.Register(MessageTypes.MSG_CUSTOMER_SELECTED_FOR_EDIT,
new Action<Message>(HandleCustomerSelectedForEditMessage));
}
#endregion
private void HandleCustomerSelectedForEditMessage(Message message)
{
CustomerListItemViewData customer =
message.Payload as CustomerListItemViewData;
if (customer != null && customer.CustomerId ==
((CustomerEditViewData)ViewData).CustomerId)
{
message.HandledStatus = MessageHandledStatus.HandledCompleted;
ActivateViewModel();
}
}
}
}
Let's spend a minute looking through this code to make sure we understand what's what.
Again, I've defined a private
property of type ICustomerController
to return the IController
defined in the BaseViewModel
, just to save me casting it every time I use it.
There's no ObservableProperties
. The ObservableProperties
for the Customer
data that we are editing are in the CustomerViewData
- the absense of ObservableProperties
in the ViewModel
tells us that there is no additional bound functionality in this View.
We've defined two RelayCommands
of type IView
(cancelledCommand
and saveCommand
) instantiated when needed by the associated property Getter.
The CanObeyCancelledCommand
method always returns true
- so the user can always cancel.
The CanObeySaveCommand
also always returns true
. In the real world, of course, you'd probably only want to return true
if the current CustomerEditViewData
was 'dirty' - that is, the user had made changes - but this series of articles is long enough without adding the additional complexity of change tracking!
The ObeyCancelledCommand
method (which is the method that gets called when the user cancels) uses the CloseViewModel
method with a False
parameter. This parameter determines, if a view is shown as a dialog, whether the dialogresult
is true
or false
.
The ObeySaveCommand
asks the CustomerController
to save the data in our ViewData
(which is bound to the controls that the user is using to make changes, and so reflects those changes). It then uses the CloseViewModel
method, passing 'True
' so that, if the view is shown as a dialog, the DialogResult
will be true
.
In the constructor, you will see that the CustomerEditViewModel
registers to receive messages of the type 'MSG_CUSTOMER_SELECTED_FOR_EDIT
'. The reason for this is so that we can, if we so desire, open several CustomerEditViewModels
, each editing its own Customer
- but we really don't want the same customer to be edited in two View
s - so every time a Customer
is selected for Edit, the CustomerEditViewModel
inspects the message and, if the customer
selected matches the customer
it's currently editing, it will set the message status to HandledCompleted
and 'Activate' itself, which really means it will Activate the View - so the effect to the user will be that the window containing the View will become the Active window.)
This is all achieved in the HandleCustomerSelectedForEditMessage
method. The Message
object has a Payload
property, which in the case of this message type, is a CustomerListItemViewData
. (As an aside, I have implemented nothing in this version to either enforce that this message type's payload
object is of the correct type, or made any attempt to automate this casting. It's not that hard to do so, if you want to - but the more you move down that route, the more you move toward a complicated Framework - and that's what I wanted to avoid.)
Okay - that's the ViewModel
- let's knock up a View to suit - then we can ship it off to the designer to make it look pretty.
CustomerEditView.xaml
<views:BaseView x:Class="Views.CustomerEditView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:views="clr-namespace:Views"
mc:Ignorable="d"
d:DesignHeight="243"
d:DesignWidth="346"
d:DataContext="{d:DesignInstance
Type=views:DesignTimeCustomerEditViewModel,
IsDesignTimeCreatable=true}">
<StackPanel Margin="10">
<Grid >
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100*" />
<ColumnDefinition Width="200*" />
</Grid.ColumnDefinitions>
<TextBlock Text="Name"
Grid.Column="0"
Margin="8" />
<TextBox Text="{Binding ViewData.Name}"
Grid.Column="1"
Margin="2" />
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100*" />
<ColumnDefinition Width="200*" />
</Grid.ColumnDefinitions>
<TextBlock Text="Address"
Grid.Column="0"
Grid.Row="0"
Margin="8" />
<TextBox Text="{Binding ViewData.Address}"
Grid.Column="1"
Grid.Row="0"
Margin="2" />
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100*" />
<ColumnDefinition Width="200*" />
</Grid.ColumnDefinitions>
<TextBlock Text="Suburb"
Grid.Column="0"
Grid.Row="0"
Margin="8" />
<TextBox Text="{Binding ViewData.Suburb}"
Grid.Column="1"
Grid.Row="0"
Margin="2" />
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100*" />
<ColumnDefinition Width="200*" />
</Grid.ColumnDefinitions>
<TextBlock Text="State"
Grid.Column="0"
Grid.Row="0"
Margin="8" />
<TextBox Text="{Binding ViewData.State}"
Grid.Column="1"
Grid.Row="0"
Margin="2" />
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100*" />
<ColumnDefinition Width="200*" />
</Grid.ColumnDefinitions>
<TextBlock Text="PostCode"
Grid.Column="0"
Grid.Row="0"
Margin="8" />
<TextBox Text="{Binding ViewData.PostCode}"
Grid.Column="1"
Grid.Row="0"
Margin="2" />
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100*" />
<ColumnDefinition Width="200*" />
</Grid.ColumnDefinitions>
<TextBlock Text="Phone"
Grid.Column="0"
Grid.Row="0"
Margin="8" />
<TextBox Text="{Binding ViewData.Phone}"
Grid.Column="1"
Grid.Row="0"
Margin="2" />
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100*" />
<ColumnDefinition Width="200*" />
</Grid.ColumnDefinitions>
<TextBlock Text="eMail"
Grid.Column="0"
Grid.Row="0"
Margin="8" />
<TextBox Text="{Binding ViewData.Email}"
Grid.Column="1"
Grid.Row="0"
Margin="2" />
</Grid>
<StackPanel Orientation="Horizontal"
FlowDirection="RightToLeft"
Height="35">
<Button Content="Save"
Command="{Binding Path=SaveCommand, Mode=OneTime}"
Height="23"
Width="75"
Margin="5,5,25,2" />
<Button Content="Cancel"
Command="{Binding Path=CancelledCommand, Mode=OneTime}"
Height="23"
Width="75"
Margin="5,5,25,2" />
</StackPanel>
</StackPanel>
</views:BaseView>
Nothing much to see in the edit view. A bunch of Text Blocks in pairs with TextBox
es. The TextBox
es are bound to the properties of the CustomerEditViewData
.
There's two buttons - one to Cancel and one to Save, each bound to the appropriate Command. And that's about it!
So if we head back to the CustomerController_ViewManagement.cs, the GetCustomerEditView
can be un-commented as we now have a CustomerEditView
so it will compile. Also in the CustomerController.cs source, the contents of the editCustomer
method can be un-commented.
Before we go further, remember that part of our goal is Blendability - the ability to ship off our View
s to be messed with by Designers to make them look pretty? In the CustomerEditView
XAML, we have:
d:DataContext="{d:DesignInstance Type=views:DesignTimeCustomerEditViewModel,
IsDesignTimeCreatable=true}">
which tells the designer to instantiate an instance of a DesignTimeCustomerEditViewModel
so our designer can see some data. So, we better create one.
DesignTimeCustomerEidtViewModel.cs
using ViewModels;
namespace Views
{
class DesignTimeCustomerEditViewModel : CustomerEditViewModel
{
public DesignTimeCustomerEditViewModel()
{
ViewData = new CustomerEditViewData()
{
Address = "23 Netherington on Wallop Street",
CustomerId = 123,
Email = "Oldhag@GeeMail.Com",
Name = "Betty Boop",
Phone = "0414 4142424",
PostCode = "4540",
State = "QLD",
Suburb = "Indooroopilly"
};
}
}
}
Blendability - An Aside
Look at the three screenshots below. They're comparisons of what you see in VS2010, Expression Blend 4 and at Runtime with the customer form with a simple Drop Shadow effect added to the buttons (this effect is not included in the listings presented here, nor in the downloaded version.)
Interesting how Blend aligns the Buttons and the drop Shadow to the left, while VS2010 (and the runtime) aligns them both right! This sort of thing tends to upset Designers, but at least they can be mollified by the fact that they are looking at data rather than blank entry fields.
On With the Show
Now, run the program. You should be able to select a customer
, click the buton to edit it, which should open a window in which you can make changes to the customer
. The window isn't modal, so you can go back and select another customer
, which opens another window.
Close the Selection window, and all of the Edit windows will be closed too.
Make a change to one of the fields shown in the Selection list, save the customer
, and the selection list refreshes to show the modified details.
Make a change to one of the same fields and cancel, and no changes are shown in the selection list.
Select the same customer
twice, and the same window you opened the first time takes focus.
I reckon that fits the specification nicely (if you can remember back that far to part 1).
Changes
Of course, we all know life ain't that simple! As soon as our project sponsor sees the application, he wants it changed. He doesn't like being able to have multiple edit windows open - far too confusing.
OK, let's make the change for him.
Pop into the CustomerController
source, and find the EditCustomer
method. This is where we do whatever we want to do when we want to edit a customer - so here's where we need to change that first parameter in the view.ShowInWindow
from false
to true
.
Job done.
Conclusion
We've come a long way over four instalments, but I hope this has been of some help to somebody.
As I have stressed, this is not a Framework, but just an example of how I have put together workable components to make an MVVM WPF application that works, and improves, for me, on some of the shortcomings of other solutions.
This might not be for you. You may want to use one of the frameworks out there - Cinch or MVVM Light or one of the gazzilion others - or you may want to roll your own. Or, like me, you may want to do it your own way, implementing the thing you find works best in your environment.
Whatever you do, I'd really appreciate your feedback. I'm sure I've made mistakes along the way, and am always happy to learn from others to improve my own stuff.
Once again, many thanks to the giants on whose shoulders I've stood - especially Pete O'Hanlon.