Introduction
This article explains how to write a WPF application using the Model-View-ViewModel pattern and make it extensible so you (or third parties) can extend it with additional features.
Background
Earlier this year, I set out to write an IDE-like application for the home automation space. I had three requirements:
- It had to be extensible by third parties
- It had to be WPF using the Model-View-ViewModel pattern (mostly because I wanted to learn it)
- I was going to release it under the GPL, so all the code had to be GPL compatible
When I found the SharpDevelop Core, I was pretty excited. This covered points 1 and 3 above, but only the latest version (4) was written in WPF, and it didn't use the MVVM pattern. I was disappointed, but I spent many long nights digging through the SharpDevelop code to understand how the extensibility part worked. I can't stress enough how much I was influenced by this excellent project, and I highly recommend the book Dissecting a C# Application: Inside SharpDevelop which explains how the SharpDevelop team wrote that application.
Tearing SharpDevelop down to the basics was still an option, but I wanted to be thorough. I went looking for other extensibility frameworks. I found System.Addin in the .NET Framework. This is a very solid framework for extensibility, but it's also quite complicated. It has a "7 stage pipeline" for every extensibility point, just to give you an idea. If I were on a team writing some huge enterprise application, like SAP, then I'd want us to use something like System.Addin
.
Then, I stumbled on Mono.Addins and the Managed Extensibility Framework (MEF), at roughly the same time. I really liked both, but I ultimately decided to go with MEF for two reasons:
- MEF is going to be part of .NET 4.0, and when possible, I prefer to build on existing system libraries as much as possible.
- In MEF, you can do everything with attributes in your code, but in Mono.Addins, some features are only available using manifest files.
SoapBox Core
I took everything you would need to build your own extensible IDE-like application, and I put it in a framework that I named SoapBox Core (SoapBox is just a nod to "free as in speech, not as in beer"). It has been open sourced under the LGPL license, so you can use it in proprietary applications. The framework consists of these components:
- Host: Bootstraps your application, loads all extensions, and displays the main window.
- Logging: Includes a wrapper for the popular NLog logging framework (or you can swap it out with your preferred logging library).
- Workbench: Provides a main application window with an extensible main menu, extensible tool bar tray, and extensible status bar.
- Layout: Provides an "IDE-like" layout manager (a wrapper around AvalonDock) that extends the Workbench to provide tabbed document windows, and dockable (or floating) tool windows.
- Options: Extends the Workbench with an extensible Options dialog so all of your application extensions can provide a single place for settings and configuration.
- Arena: A built-in 2D physics simulator (a wrapper around Physics2D.Net) that lets you build a 2D environment of dynamic objects that follow rules like gravity, mass, velocity, and collisions.
When you run SoapBox Core by itself, with no extensions, you end up with something that looks like this:
Boring? Yes. But it's a fully functioning application, and what you don't see is a Tool Bar Tray, a Status Bar, and a Menu, just waiting for you to hook in your extensions.
Building an Application on SoapBox Core
Everyone uses a text editor as a demo application. I figured since I had the 2D Physics engine in there anyway, why not make something a little more dynamic? That's why the demo application is a simple Pin Ball game. First, I'll explain how the Pin Ball demo is written as an extension to SoapBox Core, and then I'll show you how I extended the Pin Ball demo with a "High Scores" add-in.
Creating SoapBox.Demo.PinBall as an Add-In
I recommend using the following folder structure. If you download the source code, this is how it's structured:
- AvalonDock
- (AvalonDock project goes in here)
- NLog
- (NLog project goes in here)
- Physics2D
- (Physics2D projects go in here)
- References
- (DLLs like the MEF library go in here)
- SoapBox
- SoapBox.Core
- (all SoapBox.Core projects go in here, each in their own folder)
- YourNamespace
- YourSubNamespace1
- (your projects go in here, each in their own folder)
- YourSubNamespace2
- bin
- (Both
SoapBox.Core
and your projects will all compile into here, so they can find each other)
Here are the steps for creating a new Visual Studio Solution and a project for your new SoapBox Core Add-In:
- Create a Visual Studio Solution for whatever it is you're building in the root directory. (The demo is SoapBox.Demo.sln.)
- You will need to include all of the SoapBox.Core projects in this solution. I suggest putting them in a SoapBox\SoapBox.Core solution folder. Make sure SoapBox.Core.Host is the startup project.
- SoapBox Core will have project references for AvalonDock, NLog, and Physics2D, so you will need to include these in your solution as well.
- I suggest creating solution folders for your own projects. First, create a top level one for YourNamespace, then sub folders for each of YourSubNamespaces.
- Create a new WPF User Control project, give it a name like YourNameSpace.YourSubNamespace.AddInName. In the location box, specify the \YourNameSpace\YourSubNamespace directory. This will create a new folder under that directory and place your new project in there. (The demo has a project called
SoapBox.Demo.PinBall
.)
- Your new project will have an automatically created user control called
UserControl1
. You can delete this.
- Add a reference to \References\System.ComponentModel.Composition.dll.
- Edit your project properties for this new project and go to the Build tab. Change Output path to ..\..\..\bin\ so that the DLL will be deposited in the bin directory with the Host executable.
- Add a project reference for this new project to
SoapBox.Core.Contracts
. This gives you access to interfaces and helper classes you will need to add menu items, tool bars and tool bar items, status bars, options pads, documents, and tool pads to the workbench, along with grabbing references to the logging component.
- If you want to build something based on the Arena module (the 2D simulator), you will also need a project reference to
SoapBox.Core.Arena
, but this is optional. (The demo uses this module.)
- When you build the project, you should see your new Add-In DLL in the \bin directory. When SoapBox.Core.Host.exe runs, it will scan that DLL for exports that extend the
SoapBox.Core
library modules. Of course, we haven't written any yet, so it won't do anything...
Documents and Pads
SoapBox Core works like an IDE, so you have "documents" (typically editable things that sit in the middle of your window) and "pads" (which are basically tool windows that you can leave free-floating, or dock them to a side of the Workbench). A document is anything that implements the SoapBox.Core.IDocument
interface, and a pad is anything that implements the SoapBox.Core.IPad
interface. In MVVM terminology, document and pad objects are both "ViewModels". You also define a View in XAML that tells WPF how you want to render your document or pad when it comes across them in the visual tree. SoapBox Core offers some helper classes you can derive from, that already implement these interfaces: SoapBox.Core.AbstractDocument
and SoapBox.Core.AbstractPad
.
It just so happens that the Arena (2D Physics Engine) module defines an AbstractArena
class that already implements IDocument
for us. In fact, the AbstractArena
class already has a DataTemplate
defined for it that gives us a basic View. The DataTemplate
renders the Arena as a Canvas
, and renders objects in the 2D Physics engine as PathGeometry
UI elements at the appropriate position on the Canvas
(based on their position in "space" calculated in the Physics engine). So, defining our first document is as simple as inheriting from AbstractArena
and using the MEF Export
attribute to tell the Workbench
that we exist:
using SoapBox.Core;
using System.ComponentModel.Composition;
namespace SoapBox.Demo.PinBall
{
[Export(SoapBox.Core.ExtensionPoints.Workbench.Documents, typeof(IDocument))]
[Export(CompositionPoints.PinBall.PinBallTable, typeof(PinBallTable))]
[Document(Name = PinBallTable.DOCUMENT_NAME)]
public class PinBallTable : AbstractArena, IPartImportsSatisfiedNotification
{
public const string DOCUMENT_NAME = "PinBallTable";
public PinBallTable()
{
Name = DOCUMENT_NAME;
Title = Resources.Strings.Arena_PinBallTable_Title;
Gravity = new ArenaVector(0.0f, -800.0f);
Scale = 0.5f;
PinBalls.Add(new PinBall(this, new Point(-100f, 0)));
PinBalls.Add(new PinBall(this, new Point(0, 0)));
PinBalls.Add(new PinBall(this, new Point(100f, 0)));
foreach (PinBall ball in PinBalls)
{
AddArenaBody(ball);
}
}
[Import(SoapBox.Core.Services.Logging.LoggingService, typeof(ILoggingService))]
private ILoggingService logger { get; set; }
[Import(SoapBox.Core.Services.Host.ExtensionService, typeof(IExtensionService))]
private IExtensionService extensionService { get; set; }
[ImportMany(ExtensionPoints.PinBall.GameOverCommands,
typeof(IExecutableCommand), AllowRecomposition=true)]
private IEnumerable<IExecutableCommand> gameOverCommands { get; set; }
private IList<IExecutableCommand> m_gameOverCommands = null;
public void OnImportsSatisfied()
{
m_gameOverCommands = extensionService.Sort(gameOverCommands);
}
public Collection<PinBall> PinBalls
{
get
{
return m_PinBalls;
}
}
private readonly Collection<PinBall> m_PinBalls =
new Collection<PinBall>();
}
}
So, what's happening here? First, we're using MEF to export ourselves as an IDocument
type, specifically for the contract name SoapBox.Core.ExtensionPoints.Workbench.Documents
. When the Host starts, it begins by looking for a Window
that exports itself as the contract SoapBox.Core.CompositionPoints.App.MainWindow
. In our case, that's the Workbench
. The Workbench
constructor imports a list of documents. That means, this class will be instantiated and passed to a property of Workbench
when the application runs. This process in MEF is called Composition.
We're actually exporting this class as a PinBallTable
under a different contract as well. That's so that other parts of the application can find this specific extension object, rather than just the collection of documents as a whole.
During the composition, this class actually imports objects exported by other parts. Therefore, we have some properties defined with the Import
attribute. In our case, we want a reference to the logging service, so one of the imports is [Import(SoapBox.Core.Services.Logging.LoggingService, typeof(ILoggingService))]
. Now, we can log debug, error, and trace information.
As I mentioned earlier, we also want our pin ball game to be extensible. We can offer many different extension points in our project. In this case, we're importing a list of IExecutableCommand
objects that we are going to execute when the game is over (that's how the High Scores add-in will hook in). However, when these commands are imported, they will be in the order that MEF found them during the compose. SoapBox Core offers an Extension Service that can sort the extensions into an order you prefer. To grab a reference to the Extension Service, we use this attribute: [Import(SoapBox.Core.Services.Host.ExtensionService, typeof(IExtensionService))]
. Then, our class implements IPartImportsSatisfiedNotification
, specifically OnImportsSatisfied()
to sort the extensions: m_gameOverCommands = extensionService.Sort(gameOverCommands);
.
In case you are wondering how it sorts them, I borrowed an idea from SharpDevelop. All extensions have to implement SoapBox.Core.IExtension
:
namespace SoapBox.Core
{
public enum RelativeDirection
{
Before = 1,
After
}
public interface IExtension : IViewModel
{
string ID { get; }
string InsertRelativeToID { get; }
RelativeDirection BeforeOrAfter { get; }
}
}
There is a handy SoapBox.Core.AbstractExtension
which implements this interface for you, and if you inherit from that, you can just set these properties in the constructor of your extension. You don't have to set these properties if you don't care about the sort order. To use these, imagine there are three extensions being imported (possibly from separate DLLs):
ID
= "a"
ID
= "b", InsertRelativeToID
= "a", BeforeOrAfter
= After
ID
= "c", InsertRelativeToID
= "b", BeforeOrAfter
= Before
In this case, ExtensionService.Sort()
will sort them in the order a, c, b. This applies to all extensions including menu item, tool bar, status bar, and options dialog extensions, so if you wanted to insert a new Main Menu item between the View and the Tools menus, you could.
The last thing the PinBallTable
class does is add some PinBall
objects to the Arena (2D Physics Engine). As you can see, this is done by calling the AddArenaBody
method. You can use this to add any object that implements SoapBox.Core.Arena.IArenaBody
. Here's what PinBall
looks like:
namespace SoapBox.Demo.PinBall
{
public class PinBall : AbstractArenaDynamicBody
{
public const float PIN_BALL_RADIUS = 20.0f;
public PinBall(PinBallTable table, Point startingPoint)
{
Mass = 1.8f;
Friction = 0.0001f; Restitution = 0.5f;
m_table = table;
InitialX = (float)startingPoint.X;
InitialY = (float)startingPoint.Y;
Sprite = new PinBallSprite();
}
private PinBallTable m_table = null;
}
}
The PinBall
class only defines the physical properties of the object, but not the geometry. That is actually defined in a separate class called PinBallSprite
:
namespace SoapBox.Demo.PinBall
{
public class PinBallSprite : AbstractSprite
{
public PinBallSprite()
{
Geometry = new EllipseGeometry(new Point(0, 0),
PinBall.PIN_BALL_RADIUS, PinBall.PIN_BALL_RADIUS);
}
}
}
Note: It looks like you can use any Geometry
class here, but you can't. You have to stick to simple shapes like ellipses, rectangles, etc., or you can use a PathFigure
, but in that case, you have to define the points of the figure in a counter-clockwise direction.
So we haven't defined what the PinBall
looks like. Actually, you don't have to! If you don't define a View for this ViewModel, SoapBox Core provides a default View that renders all AbstractSprite
objects as solid filled black objects based on the given Geometry
. This is very handy for figuring out the physics of the Pin Ball game before worrying about what it looks like. Of course, eventually, you want the ball to look like a ball, so we actually use a DataTemplate
for this. Here's the View for the PinBall
:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SoapBox.Demo.PinBall"
xmlns:arena="clr-namespace:SoapBox.Core.Arena;assembly=SoapBox.Core.Arena"
x:Class="SoapBox.Demo.PinBall.PinBallView">
-->
<DataTemplate DataType="{x:Type local:PinBallSprite}">
<Path Stroke="Black"
StrokeThickness="1"
Data="{Binding Path=Geometry}">
<Path.Fill>
<RadialGradientBrush GradientOrigin="0.33,0.33">
<GradientStop Offset="0" Color="White"/>
<GradientStop Offset="1" Color="Black"/>
</RadialGradientBrush>
</Path.Fill>
</Path>
</DataTemplate>
-->
<DataTemplate DataType="{x:Type local:PinBall}">
<ContentControl Content="{Binding Path=(arena:IArenaBody.Sprite)}">
<ContentControl.RenderTransform>
<TransformGroup>
-->
<ScaleTransform ScaleX="{Binding State.Scale}"
ScaleY="{Binding State.Scale}"/>
-->
<TranslateTransform X="{Binding State.ScreenX}"
Y="{Binding State.ScreenY}" />
</TransformGroup>
</ContentControl.RenderTransform>
</ContentControl>
</DataTemplate>
</ResourceDictionary>
Normally, you only need the first DataTemplate
(for the sprite), and the default DataTemplate
for AbstractArenaBody
takes care of scaling, translating, and rotating the body based on its position in the Arena. However, in this case, we're defining a ball with a shiny spot on it, and we don't want the shiny spot to rotate when the ball rotates, so we are overriding the default AbstractArenaBody
View with one specifically for PinBall
. This is identical to the default, but it doesn't have the rotation transform (we're taking advantage of the fact that balls are... well, round).
Applying Views to ViewModels
So, we just defined a couple of DataTemplate
s in a ResourceDictionary
. Normally, we would need to include a reference to this ResourceDictionary
in a merged application dictionary. But we can't do that if the Host doesn't know anything about the extensions beforehand. That's where MEF comes to the rescue. The Host imports a collection of ResourceDictionary
extensions on startup, and manually inserts them into the application resources:
[ImportMany(ExtensionPoints.Host.Styles,
typeof(ResourceDictionary), AllowRecomposition=true)]
private IEnumerable<ResourceDictionary> Styles { get; set; }
[ImportMany(ExtensionPoints.Host.Views,
typeof(ResourceDictionary), AllowRecomposition=true)]
private IEnumerable<ResourceDictionary> Views { get; set; }
foreach (ResourceDictionary r in Styles)
{
this.Resources.MergedDictionaries.Add(r);
}
foreach (ResourceDictionary r in Views)
{
this.Resources.MergedDictionaries.Add(r);
}
... but how do we "export" the PinBallView
ResourceDictionary
so that the Host will find it? It turns out that you can manually add a code-behind to a ResourceDictionary
. Just add a .cs file with the same name as your .xaml file, but with a .cs extension. For instance, the ResourceDictionary
for the PinBallView
is PinBallView.xaml. Here's the contents of PinBallView.xaml.cs:
namespace SoapBox.Demo.PinBall
{
[Export(SoapBox.Core.ExtensionPoints.Host.Views, typeof(ResourceDictionary))]
public partial class PinBallView : ResourceDictionary
{
public PinBallView()
{
InitializeComponent();
}
}
}
This pattern is repeated over and over again in both SoapBox Core, and the Pin Ball Demo. This is how you apply a View to documents, pads, option dialog pads, or any other ViewModel.
Also notice that Styles
and Views
have the AllowRecomposition
parameter to the attribute set to true
. This means these properties support recomposition. New extensions can be discovered after the application has started, the extensions can be added to the part catalog and a recompose will occur. That means MEF will set these properties again, and will call OnImportsSatisfied
. Currently SoapBox Core doesn't have a mechanism to add new extensions during execution, but it will eventually.
Everything's a ViewModel
In SoapBox Core, (nearly) every class is a ViewModel. You may notice that I haven't used the standard ViewModel suffix for all the ViewModel classes. I found this was getting far too verbose. Instead, anything that implements SoapBox.Core.IViewModel
is considered a ViewModel. IViewModel
is just a proxy for INotifyPropertyChanged
.
Showing the Document
Now that we've done all this work creating a document to show, something needs to tell the LayoutManager
to show it. The simplest method is just to show it on startup. The Host imports a list of IExecutableCommand
extensions that can execute when the application starts, and we can hook into that to show our document:
namespace SoapBox.Demo.PinBall
{
[Export(SoapBox.Core.ExtensionPoints.Host.StartupCommands,
typeof(IExecutableCommand))]
class StartupCommand : AbstractExtension, IExecutableCommand
{
[Import(SoapBox.Core.CompositionPoints.Host.MainWindow, typeof(Window))]
private Lazy<Window> mainWindow { get; set; }
[Import(SoapBox.Core.Services.Layout.LayoutManager, typeof(ILayoutManager))]
private Lazy<ILayoutManager> layoutManager { get; set; }
[Import(CompositionPoints.PinBall.PinBallTable, typeof(PinBallTable))]
private Lazy<PinBallTable> pinBallTable { get; set; }
public void Run(params object[] args)
{
mainWindow.Value.Title = Resources.Strings.Workbench_Title;
layoutManager.Value.ShowDocument(pinBallTable.Value);
}
}
}
Notice the use of "lazy" imports here. In this case, the imported object isn't actually instantiated until the .Value
property is accessed. Since documents and pads can be expensive to instantiate, all imports of these objects are done with lazy imports to avoid instantiating them until they're actually needed.
Now if you run the application, the PinBallTable
will be displayed.
Extending the Menu
Of course, the user could just close the document, and they would have no way to display it again without restarting the application. To be consistent with other Microsoft Windows applications, we should probably add an item to the View menu to let the user display the Pin Ball Table themselves:
namespace SoapBox.Demo.PinBall
{
[Export(SoapBox.Core.ExtensionPoints.Workbench.MainMenu.ViewMenu, typeof(IMenuItem))]
class ViewMenuPinBallTable : AbstractMenuItem
{
public ViewMenuPinBallTable()
{
ID = "PinBallTable";
InsertRelativeToID = "ToolBars";
BeforeOrAfter = RelativeDirection.Before;
Header = Resources.Strings.Workbench_MainMenu_View_PinBallTable;
}
[Import(SoapBox.Core.Services.Layout.LayoutManager, typeof(ILayoutManager))]
private Lazy<ILayoutManager> layoutManager { get; set; }
[Import(CompositionPoints.PinBall.PinBallTable)]
private Lazy<PinBallTable> table { get; set; }
protected override void Run()
{
base.Run();
layoutManager.Value.ShowDocument(table.Value);
}
}
}
As you can see, we just need to inherit from AbstractMenuItem
and export the class as an IMenuItem
for the contract SoapBox.Core.ExtensionPoints.Workbench.MainMenu.ViewMenu
. You may have noticed that we're inserting this menu item before another item called "ToolBars". SoapBox Core actually defines a View menu item called ToolBars that displays all toolbar extensions in the system (and actually lets the user enable or disable individual tool bars). Since there are no tool bars defined right now, you can't see it.
Extending the Status Bar
Extending the Status Bar works the same way, except that there are many different types of controls that can go in the Status Bar, like labels, buttons, radiobuttons, separators, etc. There is a different abstract class defined for each one. Here's how you define a label in the status bar:
[Export(SoapBox.Core.ExtensionPoints.Workbench.StatusBar, typeof(IStatusBarItem))]
public class MyLabel : AbstractStatusBarLabel
{
public MyLabel()
{
ID = "MyLabel";
Text = Resources.Strings.Workbench_StatusBar_MyLabel;
}
}
Adding a Toolbar
A toolbar itself has to import toolbar items to display. Here's how to create a toolbar:
[Export(SoapBox.Core.ExtensionPoints.Workbench.ToolBars, typeof(IToolBar))]
public class MyToolBar : AbstractToolBar, IPartImportsSatisfiedNotification
{
public MyToolBar()
{
Name = Resources.Strings.MyToolBar_Name;
Visible = true; }
[Import(SoapBox.Core.Services.Host.ExtensionService, typeof(IExtensionService))]
private IExtensionService extensionService { get; set; }
[ImportMany(ExtensionPoints.Workbench.ToolBars.MyToolBar,
typeof(IToolBarItem), AllowRecomposition=true)]
private IEnumerable<IToolBarItem> items { get; set; }
public void OnImportsSatisfied()
{
Items = extensionService.Sort(items);
}
}
That will add a toolbar to the View->ToolBars menu, and let the user control the visibility of it with a checkable menu item. However, you then need to add items to the toolbar, like this button:
[Export(ExtensionPoints.Workbench.ToolBars.MyToolBar, typeof(IToolBarItem))]
public class MyToolBarButton : AbstractToolBarButton
{
public MyToolBarButton()
{
ID = "MyToolBarButton";
ToolTip = Resources.Strings.MyToolBarButton_Tooltip;
SetIconFromBitmap(Resources.Images.MyToolBarButton_Icon);
}
protected override void Run()
{
}
}
Extending the Extension
So I finished the pin ball game, and it kept score and had levels, but when the game was over, it just started a new game. I figured it would be neat to create an add-in that extended the pin ball game and kept track of high scores.
I followed the same procedure as before to create an entirely separate project called SoapBox.Demo.HighScores
. I created a new Pad called HighScores
that inherits from SoapBox.Core.AbstractPad
. It takes care of loading, displaying, and saving the high scores. Then, I had to write an IExecutableCommand
extension that hooked into the GameOverCommands
extensibility point on the pin ball game:
namespace SoapBox.Demo.HighScores
{
[Export(SoapBox.Demo.PinBall.ExtensionPoints.PinBall.GameOverCommands,
typeof(IExecutableCommand))]
class GameOverCommand : AbstractExtension, IExecutableCommand
{
[Import(CompositionPoints.Workbench.Pads.HighScores, typeof(HighScores))]
private Lazy<HighScores> highScores { get; set; }
public void Run(params object[] args)
{
if (args.Length >= 1)
{
PinBallTable table = args[0] as PinBallTable;
if (table != null)
{
highScores.Value.LogNewHighScore(String.Empty,
table.Score, table.Level);
}
}
}
}
}
I also added another menu item to the View menu to display the HighScores
pad. That's all there is to it.
Latest Version of SoapBox Core
Points of Interest
- Check out WorkBenchView.xaml in the
SoapBox.Core.Workbench
project to see how to implement a WPF menu with the MVVM pattern, including menu separators.
SoapBox.Core.Contracts
has a helper static
class called NotifyPropertyChangedHelper
that lets you implement INotifyPropertyChanged
without using hard coded strings for property names.
- Everything that implements
SoapBox.Core.IControl
(which is pretty much every menu item, status bar item, and tool bar item) has a VisibleCondition
property. This can be set to anything that implements SoapBox.Core.ICondition
, but I recommend using SoapBox.Core.ConcreteCondition
for this. A "condition" is just an abstraction of a boolean condition that one part can export and other parts can import.
- Anything that inherits from
AbstractCommandControl
(buttons, usually) has an EnableCondition
property that controls if the button is enabled. If it's not enabled, it automatically changes the icon to gray scale.
- Take a look at
PinBallOptionsItem
and PinBallOptionsPad
to see how to extend the Options dialog and store your editable options in the user settings.
History
- November 7, 2009: Article published (based on SoapBox Core v2009.11.04)
- November 12, 2009: Article modified (based on SoapBox Core v2009.11.11)