Introduction
Starting development of an application with a complex user interface, we always face the same problems: how to organize data presentation, change views, route events, share resources and so on. Badly planned project structure leads to a headache and extensive rework. That's why before starting a big project, I'd like to make a prototype of a WPF-based solution and share my small experience with you.
Developing an application, we confront increasing complexity - the more controls, views, menus we add, the more tangled application architecture becomes. And one fine day we understand that it is easy to throw away all we've done before, than add yet another module. But thanks to design patterns, the problem can be solved. All we need is Composite Application Library. With its help, we can split user interface into regions and dynamically load modules into them. We can organize event and command routing between different modules. And what is more important - a loosely coupled design of an application built with the Composite Application Library allows different teams to create and test modules independently.
Sounds good, but if you have just decided to use the Composite Application Library, the next question you ask yourself is: "well, samples work fine, but how can I build something more realistic?"
I decided to create a small application emulating work with a few servers. Toolbar depends on a server context. Menu bar contains menu items, depending on currently selected module (Documents, Users or Security). Central area contains a view presenting current module data:
It is just a prototype. So, only one module was written more or less in more detail - Documents. My main goals were:
- to study how to load modules and change views dynamically
- to separate presentation from logic using Model-View-Presentation Model (MVP) pattern
- to find a way to display and process general menu items (like "Help") and module-specific menu items
- to share resources in such a way, that modules can be developed independently and styling with skins would be easy
Project Structure
The main project is CompositeWpfApp
. It contains the main application Window - Shell
. That's the first and the last UI element in the project - all other UI views, controls and menus will be created in different projects.
Another set of projects is located in the Common folder:
CWA.ResourceLibrary
- Contains shared resources: images, resource dictionaries, skins
CWA.UIControls
- Contains custom UI controls and general menu items
CWA.Infrastructure
- Contains classes and interfaces that should be easily accessible to all projects
These projects could be linked statically. But the projects in Modules folder will be loaded dynamically when they are needed.
In order to load these modules, they should be copied to the Modules directory in the CompositeWpfApp
project. Open Properties of a "Module
" project (e.g. CWA.Module.Documents
) and enter the following Post-build event command line:
xcopy "$(TargetDir)*.*" "$(SolutionDir)CompositeWpfApp\bin\
$(ConfigurationName)\Modules\" /Y
Besides, we should enumerate the modules in the App.config file in order that the ConfigurationModuleCatalog
could be able to locate and load them:
="1.0" ="utf-8"
<configuration>
<configSections>
<section name="modules"
type="Microsoft.Practices.Composite.Modularity.ModulesConfigurationSection,
Microsoft.Practices.Composite"/>
</configSections>
<modules>
<module assemblyFile="Modules/CWA.Module.DefaultModule.dll"
moduleType="CWA.Module.DefaultModule.DefaultModule,
CWA.Module.DefaultModule" moduleName="DefaultModule"/>
<module assemblyFile="Modules/CWA.Module.ServerSelector.dll"
moduleType="CWA.Module.ServerSelector.ServerSelector,
CWA.Module.ServerSelector" moduleName="ServerSelectorModule"/>
<module assemblyFile="Modules/CWA.Module.ModuleSelector.dll"
moduleType="CWA.Module.ModuleSelector.ModuleSelector,
CWA.Module.ModuleSelector" moduleName="ModuleSelectorModule"/>
<module assemblyFile="Modules/CWA.Module.StatusArea.dll"
moduleType="CWA.Module.StatusArea.StatusArea,
CWA.Module.StatusArea" moduleName="StatusAreaModule"/>
<module assemblyFile="Modules/CWA.Module.Documents.dll"
moduleType="CWA.Module.Documents.DocumentsModule,
CWA.Module.Documents" moduleName="Documents" startupLoaded="false"/>
<module assemblyFile="Modules/CWA.Module.Users.dll"
moduleType="CWA.Module.Users.UsersModule,
CWA.Module.Users" moduleName="Users" startupLoaded="false"/>
<module assemblyFile="Modules/CWA.Module.Security.dll"
moduleType="CWA.Module.Security.SecurityModule,
CWA.Module.Security" moduleName="Security" startupLoaded="false"/>
</modules>
</configuration>
Pay attention to module names - they are defined in the CWA.Infrastructure
project.
The ConfigurationModuleCatalog
is defined as our module enumerator in the Bootstrapper
class. It will be used by the Composite Application Library to get information about the modules and their location:
protected override IModuleCatalog GetModuleCatalog()
{
return new ConfigurationModuleCatalog();
}
Instead of the ConfigurationModuleCatalog
you can use DirectoryModuleCatalog
to discover modules in assemblies stored in a particular folder, or specify modules in your code or in a XAML file. DirectoryModuleCatalog
could be particularly useful for applications with plug-ins.
Now our spade-work is completed and we can proceed with UI.
Regions and Views
Regions are used to define a layout for a view. If you look at Shell.xaml, you will see region names in its markup:
<ItemsControl Name="MainMenuRegion"
cal:RegionManager.RegionName="{x:Static inf:RegionNames.MainMenuRegion}"
DockPanel.Dock="Top" Focusable="False" />
<ItemsControl Name="ServerSelectorRegion"
cal:RegionManager.RegionName="{x:Static inf:RegionNames.ServerSelectorRegion}"
DockPanel.Dock="Top" Focusable="False" />
<ItemsControl Name="ModuleSelectorRegion"
cal:RegionManager.RegionName="{x:Static inf:RegionNames.ModuleSelectorRegion}"
DockPanel.Dock="Top" Focusable="False"/>
<ItemsControl Name="StatusRegion"
cal:RegionManager.RegionName="{x:Static inf:RegionNames.StatusRegion}"
DockPanel.Dock="Bottom" Focusable="False" />
<ItemsControl Name="MainRegion"
cal:RegionManager.RegionName="{x:Static inf:RegionNames.MainRegion}"
Focusable="False">
These regions will be used to load modules into them:
Let's see how to load modules into the Main Region. ModuleController
is responsible for changing views in this region. First of all, we should register this class in the Bootstraper
class of the Shell
project:
Container.RegisterType<IGeneralController, ModuleController>
(ControllerNames.ModuleController, new ContainerControlledLifetimeManager());
The ModuleController
class implements IGeneralController
interface with the single method Run()
. CreateShell()
method of the Bootstraper
finds classes implementing IGeneralController
interface and invokes their Run()
methods. As a result, the ModuleController
subscribes to the ModuleChangeEvent
:
public void Run()
{
eventAggregator.GetEvent<ModuleChangeEvent>().Subscribe
(DisplayModule, ThreadOption.UIThread, true);
}
When we click on the "Documents", "Users" or "Security" button, ModuleSelectorPresententaionModel
publishes ModuleChangeEvent
with the corresponding module name. ModuleController
catches the event and displays the module in the following method (shortened version):
private void DisplayModule(string moduleName)
{
try
{
moduleManager.LoadModule(moduleName);
IModulePresentation module = TryResolve<IModulePresentation>(moduleName);
if (module != null)
{
IRegion region = regionManager.Regions[RegionNames.MainRegion];
currentView = region.GetView(RegionNames.MainRegion);
if (currentView != null)
region.Remove(currentView);
currentView = module.View;
region.Add(currentView, RegionNames.MainRegion);
region.Activate(currentView);
}
}
}
The RegionManager
is responsible for creating and managing regions - a kind of container for controls implementing IRegion
interface. Our duty is removing previously loaded content from the region and addition of new module view to it. The only requirement to the module view is implementation of the IModulePresentation
interface exposing a View
property.
Model-View-Presenter
Model-View-Presentation Model pattern is intended to separate data model from its presentation and business logic. On practice, that means that Presentation Model provides content for visual display (View) and tracks changes in visual content and data model.
Modules, loaded into the Main Region, should implement this pattern. Really, in this demo solution, only CWA.Module.Documents
follows this pattern. This module contains DocumentsPresentationModel
and DocumentsView
. Separate data model class is not implemented due to simplicity of the sample.
DocumentsView
code-behind does not contain any business logic. Instead, all processing is performed in the DocumentsPresentationModel
. To bind a View to the Presentation Model, we initialize the view in the DocumentsPresentationModel
constructor and make it publicly available as a View
property:
public object View
{
get { return view; }
}
public DocumentsPresentationModel(IUnityContainer container,
IServerContextService serverContextService)
{
this.container = container;
this.serverContextService = serverContextService;
view = container.Resolve<DocumentsView>();
}
This View
will be passed from the DocumentsPresentationModel
to the DocumentsModule
and later loaded into a region (see DocumentsModule.cs):
public object View
{
get
{
DocumentsPresentationModel presentationModel =
(DocumentsPresentationModel)TryResolve<IPresentationModel>
(serverContextService.CurrentServerContext.Uid);
if (presentationModel == null)
{
container.RegisterType<IPresentationModel, DocumentsPresentationModel>
(serverContextService.CurrentServerContext.Uid,
new ContainerControlledLifetimeManager());
presentationModel = (DocumentsPresentationModel)container.Resolve
<IPresentationModel>(serverContextService.CurrentServerContext.Uid);
}
return presentationModel.View;
}
}
Now we can operate with the view in the DocumentsPresentationModel
- for example, to bind some data to visual controls or, if necessary, display a view of another type. In the last case, Supervising Controller pattern would be more suitable.
Menus
Often an application can have menus containing some constant set of general items like "Help", "Exit", "About program", and context-dependent menu items. It makes sense to process general commands in the Shell project, while view-dependent commands should be processed in the corresponding module.
So, we have a few requirements:
- we have to be able to change menus dynamically
- general commands should be processed in the
Shell
project
- view-dependent commands should be processed in the corresponding Presentation Model class
- we should not duplicate general menu items in each module
First requirement can be easily met. There is a MenuController
in the Shell
project, which is very similar to ModuleController
described above. Its purpose is to display menu views in the Main Menu Region in response to the MainMenuChangeEvent
. A view generates this event when it is loaded and displayed, i.e., activated. Each module view is derived from the ModuleViewBase
class, that defines virtual event handler ViewActivated()
. Overridden method looks like this one:
protected override void ViewActivated(object sender, EventArgs e)
{
base.ViewActivated(sender, e);
if (Menu != null)
eventAggregator.GetEvent<MainMenuChangeEvent>().Publish(Menu);
}
Where the Menu
is a UserControl
initiated in the view's constructor:
Menu = container.Resolve<DocumentsMainMenuView>();
If you look at DocumentsMainMenuView.xaml, you will see the following XAML code:
<UserControl x:Class="CWA.Module.Documents.DocumentsMainMenuView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctl="clr-namespace:CWA.UIControls.Menus;assembly=CWA.UIControls"
Height="Auto" Width="Auto" Name="DocumentsMainMenu">
<Menu>
<ctl:MainMenuControl />
<MenuItem Header="Documents">
<MenuItem Header="New Document" Command="{Binding NewDocumentCommand}" />
<MenuItem Header="Cut" Command="{Binding CutCommand}" />
<MenuItem Header="Copy" Command="{Binding CopyCommand}" />
<MenuItem Header="Delete" Command="{Binding DeleteCommand}" />
<MenuItem Header="Rename" Command="{Binding RenameCommand}" />
<Separator />
<MenuItem Header="Properties" Command="{Binding PropertiesCommand}" />
</MenuItem>
<ctl:HelpMenuControl />
</Menu>
</UserControl>
It is our markup for menus loaded when a user clicks on "Documents". Do you remember we promised not to duplicate general menu items? We keep our promises - MainMenuControl
and HelpMenuControl
are defined in the CWA.UIControls
project. We'll return to them a bit later.
Now we have to provide a way to process menu commands in the Presentation Model, not in the Menu View class. To do that, we have to bind menu's DataContext to the Presentation Model. Let's return to the DocumentsPresentationModel
constructor:
public DocumentsPresentationModel(IUnityContainer container,
IServerContextService serverContextService)
{
this.container = container;
this.serverContextService = serverContextService;
view = container.Resolve<DocumentsView>();
view.Menu.DataContext = this;
view.Text = serverContextService.CurrentServerContext.Name;
NewDocumentCommand = new DelegateCommand<object>(NewDocument, CanExecuteCommand);
CutCommand = new DelegateCommand<object>(Cut, CanExecuteCommand);
CopyCommand = new DelegateCommand<object>(Copy, CanExecuteCommand);
DeleteCommand = new DelegateCommand<object>(Delete, CanExecuteCommand);
RenameCommand = new DelegateCommand<object>(Rename, CanExecuteCommand);
PropertiesCommand = new DelegateCommand<object>(Properties, CanExecuteCommand);
}
As to general menu items defined in the MainMenuControl
and HelpMenuControl
- they are sources of commands of RoutedUICommand
type. The commands are bubbling up to a window, containing command binding for those type of commands. Our responsibility is to create such a binding somewhere in the Shell
project. For that purpose, I created a few command controllers, registered and started them in the Bootstrapper
:
private void RegisterCommandControllers()
{
Container.RegisterType<IGeneralController, ExitCommandController>
(ControllerNames.ExitCommandController, new ContainerControlledLifetimeManager());
Container.RegisterType<IGeneralController, SkinCommandController>
(ControllerNames.SkinCommandController, new ContainerControlledLifetimeManager());
Container.RegisterType<IGeneralController, AboutCommandController>
(ControllerNames.AboutCommandController, new ContainerControlledLifetimeManager());
Container.RegisterType<IGeneralController, HelpCommandController>
(ControllerNames.HelpCommandController, new ContainerControlledLifetimeManager());
Container.RegisterType<IGeneralController, SettingsCommandController>
(ControllerNames.SettingsCommandController,
new ContainerControlledLifetimeManager());
}
These controllers add command binding to the main window and now we are able to process these command events like in the HelpCommandController
:
public void Run()
{
CommandBinding binding = new CommandBinding
(GlobalCommands.HelpCommand, Command_Executed, Command_CanExecute);
Application.Current.MainWindow.CommandBindings.Add(binding);
}
private void Command_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute = true;
e.Handled = true;
}
private void Command_Executed(object sender, ExecutedRoutedEventArgs e)
{
MessageBox.Show("HELP!!!");
}
That's it then.
Skins
It is good practice to keep application resources (brushes, styles, control templates) in one place. For that purpose, I created CWA.ResourceLibrary
project. The main application makes reference to it in the ResourceDictionary
element of the App.xaml file:
<Application x:Class="CompositeWpfApp.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/CWA.ResourceLibrary;
component/Skins/DefaultSkin.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
DefaultSkin.xaml contains some brushes and a reference to another, style-independent resource file - Resources.xaml.
If we wish to change the appearance of the controls dynamically, we have to fulfill two conditions. First, we should use DynamicResource
references for skin-depending properties like in the sample below:
<Border Background="{DynamicResource ServerSelectorBackgroundBrush}"
BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource ServerSelectorBorderBrush}">
The second condition is a bit tricky. If you look at DefaultSkin
, you will notice that it is derived from the ResourceDictionary
class. To do that, I recommend you first create a UserControl
and then change its base class to ResourceDictionary
. And don't forget about UserControl
element in the XAML!
Now we can change the skin. SkinCommandController
is responsible for that:
private void ChangeSkin(string skinName)
{
if (string.IsNullOrEmpty(skinName))
throw new ArgumentException("Skin Name is empty.", "skinName");
if (string.Compare(skinName, currentSkinName, true) != 0)
{
Application.Current.Resources.MergedDictionaries[0] =
SkinFactory.GetResourceDictionary(skinName);
currentSkinName = skinName;
}
}
It replaces the application resource dictionary with the new one, returned by the SkinFactory
. That's the case when inheritance of DefaultSkin
from ResourceDictionary
comes in handy:
public static ResourceDictionary GetResourceDictionary(string skinName)
{
if (string.IsNullOrEmpty(skinName))
throw new ArgumentException("Skin Name is empty.", "skinName");
if (skinTable.ContainsKey(skinName))
return (ResourceDictionary)skinTable[skinName];
ResourceDictionary resourceDictionary = null;
switch (skinName)
{
case SkinNames.DefaultSkin:
resourceDictionary = (ResourceDictionary)new DefaultSkin();
break;
case SkinNames.BlueSkin:
resourceDictionary = (ResourceDictionary)new BlueSkin();
break;
default:
throw new ArgumentException("Invalid Skin Name.");
}
if (resourceDictionary != null)
{
skinTable.Add(skinName, resourceDictionary);
}
return resourceDictionary;
}
Now, when a user selects "Blue" style, he or she will see new colors:
My sample application is very simple. In the "real" world, skinning is very non-trivial task - just glance at Menu.xaml file.
References
History
- 1st March, 2009: Initial version