WPF is a super powerful framework, especially when you leverage the MVVM paradigm, but it can be a real headache (and time consuming) to write the glue to synchronize models and view models.
The Robotic Arm Software Component Library (Rascl) attempts to alleviate this issue. Rascl provides an MVVM framework through a couple of key components, including PropertyAttribute
, PropertyCollection
and SynchronizedCollection
. With Rascl, you only need a few lines of code to keep models and view models in sync with one another. As an added bonus, you get an undo system, a property grid control, a color picker, collection editor and other components to use in your applications.
To get started, all you need to do is add a reference to the Rascl library to your project. From there, you can start adding property attributes to your model, like so:
using Rascl.Core;
namespace ControlsTest
{
[Identifier("ItemName", Default = "TestItemA_*")]
public class TestItemA : ModelBase
{
[StringProperty("Item Name", Editor = "TestStringTemplate")]
public string ItemName
{
get; set;
}
[FloatProperty("Start", Min=0.0, Max=100.0)]
public double Start
{
get; set;
}
}
}
Deriving our class from ModelBase
gives us support for automatic property change notifications (notice that there are no explicit OnPropertyChanged
calls shown here), so although it isn't absolutely necessary, it is recommended,
OnPropertyChanged can still be called
in cases where you have to access a properrty directly instead of going through the PropertyCollection.
The Identifier
attribute for the class is optional, but allows built in controls like the collection editor to use one of the object's properties as its name for display purposes. Setting Default
here causes the name to be automatically generated on creation, with the asterisk character being replaced by an auto-icrementing value.
StringProperty
is a C# attribute derived from PropertyAttribute
. The only required parameter is the display name, in this case "Item Name", which is always the first parameter for any PropertyAttribute
.
FloatProperty
adds some additional named parameters, Min
and Max
. These can be used for validation, or for customizing the behavior of the control used to display the data.
There are additional property attributes for colors, files, collections, enums, and more, each one having their own default editor for displaying in the Rascl PropertyGrid
control. This editor can be overridden to use your own custom editor, like so:
[FloatProperty("Start", Editor="TestDoubleTemplate", Min=0.0, Max=100.0)]
Here, we've set the Editor
parameter, which allows us to choose the editor used for this property in the property grid. This can be a built in Rascl editor, or your own custom editor. You can even create editors to handle complex data types, like classes. Besides the type-specific property attributes, you can also use the base PropertyAttribute
for properties that don't need any metadata (like Min
and Max
for float
s and double
s), other than the Editor
name.
The Rascl property system is opt-in, which means that any property that doesn't have a PropertyAttribute
won't be included in the property system. This is important because the property system adds capabilities like undo automatically to your properties, If you want to exclude a property from the PropertyGrid
, but still want to use the other features of the property system, you can use the base PropertyAttribute
without setting the display name, like so:
[Property("")] // this property will exist in the properties list,
// but will not show up in the PropertyGrid
Now that we've seen how to use C# attributes to mark up our models, let's move on to synchronizing those models with our view models. Take a look at the code below:
using Rascl.Core;
using Rascl.Core.Undo;
namespace ControlsTest
{
public class TestItemViewModel : ViewModelBase
{
public TestItemViewModel(ModelBase model, UndoContext undo) : base(model, undo)
{
m_model = model;
}
private ModelBase m_model;
}
}
That's all you need to create a view model that's synchronized with the model shown above. Of course, you may need additional functionality specific to your application, but with this much, we can already write a view to display and edit all of the data from the model, keeping the model and view model in sync.
ViewModelBase contains a Properties
property, which is a PropertyCollection
. Properties
gets initialized with the underlying model class and the current UndoContext
(more on that later). This creates a list of values at the view model layer that update whenever the properties from the model layer are changed. We can bind to this list of properties from XAML or access them from codebehind using an indexer like so:
XAML:
<TextBox Text="{Binding Path=Properties[ItemName].Value}"/>
C#:
Properties["ItemName"].Value = "Rascl is here";
If we were to change the model directly, we'd be bypassing the automatic PropertyChanged events and other benefits from the property system, like undo. That's the reason why we go through the property indexer here. If there are multiple bindings to the same property, changing once through the property indexer will update it everywhere, as you'd expect.
Often in WPF, our data is modeled in a tree-like structure, where one model class can contain one or more observable collections of other models. We generally want to maintain this structure at the view model layer, but that can be a pain. Every time a list in the model changes, whether it be an add, remove, move or clear operation, we need to do the same in the view model, and when that tree structure goes several layers deep, we often end up with copy-paste-edit code to handle all those operations in a custom way per class.
SynchronizedCollection<T>
is designed to handle just that case. Take a look at the following model class:
using Rascl.Core;
using System.Collections.ObjectModel;
namespace ControlsTest
{
[Identifier("Name")]
public class TestData : ModelBase
{
public TestData()
{
TestItems.Add(new TestItemA() { ItemName = "First" });
TestItems.Add(new TestItemB() { ItemName = "Second" });
TestItems.Add(new TestItemA() { ItemName = "Third" });
}
[Property("My Name", Editor="TestStringTemplate")]
public string Name
{
get; set;
}
[CollectionProperty("Test Items", new System.Type[] { typeof(TestItemA), typeof(TestItemB) })]
public ObservableCollection<ModelBase> TestItems
{
get; set;
} = new ObservableCollection<ModelBase>();
}
}
As you can see here, we're using the CollectionProperty
attribute to describe a list of items all derived from model base, consisting of two distinct types, TestItemA
and TestItemB
. It isn't necessary for the items to be derived from ModelBase
or for the collection to have a PropertyAttribute
for SynchronizedCollection
to work, but it will allow us to edit the list of objects in the property grid. Next, let's look at the corresponding view model.
using Rascl.Core;
using Rascl.Core.Undo;
namespace ControlsTest
{
public class TestDataViewModel : ViewModelBase
{
public TestDataViewModel(TestData model, UndoContext undo) : base(model, undo)
{
Items = new SynchronizedCollection<TestItemViewModel>(model.TestItems, UndoContext, x => CreateItemViewModel(x));
}
public SynchronizedCollection<TestItemViewModel> Items
{
get
{
return m_items;
}
private set
{
m_items = value;
OnPropertyChanged("Items");
}
}
private TestItemViewModel CreateItemViewModel(object model)
{
if (model is TestItemA itemA) // this is not really necessary here, since both items use the same view model, but this is how you would create different view models, if necessary
{
return new TestItemViewModel(itemA, UndoContext);
}
else if (model is TestItemB itemB)
{
return new TestItemViewModel(itemB, UndoContext);
}
return null;
}
private SynchronizedCollection<TestItemViewModel> m_items;
}
In the constructor, we create a SynchronizedCollection
of TestItemViewModel
. Since we've looked at the properties already, let's look deeper into SynchronizedCollection
.
When we create a new SynchronizedCollection
, we pass in three parameters. The first is the underlying collection that we will synchronize with, the second is the UndoContext
so we can undo and redo changes to the collection, and the third is the delegate used to create new view models from newly added models, in this case, we call the CreateItemViewModel
function, passing in the model (x
) that was created. Having access to the model that was just created allows us to create the appropriate view model, even if the underlying collection contains models of varying types that need specific view models that have different constructors or properties to initialize.
In this case, though, we're using one view model class to represent two different models. This is possible because, using the property system, we don't need to define specific properties in the view model to keep them in sync with the model. If necessary, though, we could define a view model class per model class and keep them all in the synchronized collection, as long as the SynchronizedCollection
is specialized using a common base class or interface of the view models.
We can display this hierarchy in a standard WPF TreeView
using HierarchicalDataTemplate
to define how to display the items, like so:
<HierarchicalDataTemplate DataType="{x:Type local:TestDataViewModel}"
ItemsSource="{Binding Path=Items, Mode=OneWay}">
<TextBlock Text="{Binding Path=Properties.Identifier.Value}"/>
</HierarchicalDataTemplate>
<HierarchicalDataTemplate DataType="{x:Type local:TestItemViewModel}">
<TextBlock Text="{Binding Path=Properties.Identifier.Value}"/>
</HierarchicalDataTemplate>
The ItemsSource
for the TestDataViewModel
parent object is bound to its Items
property, which is a SynchronizedCollection
of TestItemViewModel
. SynchronizedCollection
implements the necessary interfaces to be displayed and to notify the view when changes occur.
We're using the Identifier
attribute here to display the names of the objects in the tree. We could also go through the Properties
indexer, but this is an easy shortcut to the same value, and if we have models with different name properties, we don't have to differentiate between them. We can then define the TreeView
and a Rascl PropertyGrid
to display the items and edit their properties like so:
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TreeView x:Name="Tree" Grid.Column="0" MinWidth="200"
ItemsSource="{Binding Path=TestDataCollection}"/>
<Border Grid.Column="2" BorderBrush="Black" BorderThickness="1">
<ctrls:PropertyGrid Properties=
"{Binding ElementName=Tree, Path=SelectedItem.Properties}" Width="450" />
</Border>
</Grid>
In the codebehind, TestDataCollection
is just defined as an ObservableCollection<TestDataViewModel>
and we initialize it with some sample data. The TreeView
has its ItemsSource
set to this collection, and the PropertyGrid
is bound to the Properties
of the SelectedItem
in the TreeView
.
Here's a look at the result:
When you want to change an existing collection in a model that has a corresponding SynchronizedCollection
in the view model, go through the appropriate functions provided by the SynchronizedCollection
(Add
, Insert
, IndexOf
, Remove
, RemoveAt
, and Clear
). This allows the undo system to automatically hook into the changes being made. Add
, Insert
, IndexOf
, and Remove
all take an object of the underlying collection type. When Add
is called, for instance, it adds the passed-in object to the underlying collection, creates a new view model for the object in the synchronized collection, and stores the operation on the undo stack.
There's one more thing you'll need in order to get everything working. You'll need to include the Generic.xaml resource dictionary in App.xaml. You can also include your own custom editors as data templates there as well if you want to override the default ones. This allows the editors to be found by the name passed into the PropertyAttribute
on the model properties.
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/Rascl;
component/Themes/Generic.xaml" />
</ResourceDictionary.MergedDictionaries>
<DataTemplate x:Key="TestDoubleTemplate" DataType="{x:Type core:Property}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Slider Grid.Column="0" Value="{Binding Path=Value}"
Minimum="{Binding Path=Metadata.Min}"
Maximum="{Binding Path=Metadata.Max}"
undo:UndoBehavior.AtomicChangeType="MouseOnly" />
<TextBox Grid.Column="1" Text="{Binding Path=Value, StringFormat=N2}"
Width="100"/>
</Grid>
</DataTemplate>
</ResourceDictionary>
</Application.Resources>
Here. you can see how we've defined TestDoubleTemplate
. We bind directly to Value
, since the DataContext
will be set to the property, itself. You can also see how we've bound to the Min
and Max
values of the FloatProperty
attribute. The attribute data is stored as Metadata
on the property, so we can use that as well to affect the behavior of the control right in the DataTemplate
.
You can also see another interesting feature at the end of the Slider
declaration. The UndoBehavior.AtomicChangeType
is set to MouseOnly
. When we drag the Slider
, the undo system tracks the value change as a single operation, and records the results when the mouse button is released. That way, when we undo the change, we rewind all the way back to the beginning of the operation, instead of displaying incremental changes made by the user while the mouse button was down. This behavior can be applied to just about any type of click-and-drag operation.
As we've discussed, the undo system automatically hooks into the property system and the SynchronizedCollection
. But you can also use it for your own custom operations. Whenever you perform an operation that you want to be able to undo, you just wrap it in an UndoableActionPair
and execute it like so:
undoContext.Execute(new UndoableActionPair(new Action(() =>
{
// Code to execute here
}),
new Action(() =>
{
// Do the opposite here (for undo)
})));
You just need the appropriate UndoContext
to execute your undo/redo pairing. For many applications, a single UndoContext
is fine, but if you have a multiple document interface (MDI) design, you could have one per document, and the UndoManager.CurrentContext
needs to be changed based on which document is active. Then when the user presses Ctrl+Z, the appropriate undo action is performed based on that context.
If UndoableActionPair
is insufficient for your needs, you can create your own class that implements the IUndoableOperation
interface, and pass an instance of it into UndoContext.Execute()
.
If you have custom operations that need to be handled as a single, atomic operation, so that hitting undo undoes everything at once, you can wrap all your related changes between calls to UndoContext.StartAtomicOperation()
and UndoContext.EndAtomicOperation()
.
In your MainWindow
, you can create command bindings to ApplicationCommands.Undo/Redo
and handle those in codebehind like this:
private void Undo_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute = Framework.Instance.UndoManager.CurrentContext.CanUndo;
e.Handled = true;
}
private void Undo_Executed(object sender, ExecutedRoutedEventArgs e)
{
Framework.Instance.UndoManager.CurrentContext.Undo();
e.Handled = true;
}
private void Redo_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute = Framework.Instance.UndoManager.CurrentContext.CanRedo;
e.Handled = true;
}
private void Redo_Executed(object sender, ExecutedRoutedEventArgs e)
{
Framework.Instance.UndoManager.CurrentContext.Redo();
e.Handled = true;
}
You can also use UndoContext.CanUndo
to determine if there are changes that need to be saved for the application, and display a hint to the user (like an asterisk next to the file name) so the user knows that they need to save. This is just an added benefit of having an undo system that tracks all data changes.
There's much more to Rascl library than the functionality described here, including several more controls, and other useful classes but we will leave those for a later article.