Sample Code
Sample Code
Contents
Do you use or want to use AvalonDock?
Do you want to use it with MVVM?
In this article I demonstrate one way of adapting AvalonDock for use in an MVVM application.
The sample application I have developed for the article is a simple text editor.
The core code for the technique is in a class that I have named AvalonDockHost
. This class
is in its own project and can be reused in other AvalonDock applications.
AvalonDockHost
wraps AvalonDock and is an adapter that allows it to live in relative peace with the rest
of your MVVM application. It is important though to note that AvalonDockHost
is not intended to be
a complete wrapper for AvalonDock and it is not really intended to make AvalonDock easier to use.
I do believe however that the ultimate result is an easier to use AvalonDock, but
only in the way that MVVM often makes things easier, but usually only if you already know
MVVM and the underlying technology. So put more simply to get the most out of this
it will help if you already know at least the basics of using AvalonDock.
This article is composed of three parts.
The first is a walkthrough of the sample application and describes how to use AvalonDockHost
.
The second part discusses the implementation of AvalonDockHost
.
Please feel free to skip part 2 if you are not interested in the implementation.
The third part is a short reference for AvalonDockHost
.
Screenshot
This screenshot shows the sample application. The tabbed-document area contains
the multiple opened text files. The pane to the top right is a list of currently
open documents. The pane to the bottom right is a simple overview of the currently
active document.
Assumed Knowledge
It is assumed that you know
C#
and have at least a basic grasp of
WPF and
MVVM.
I also assume that you understand the basic workings and functionality of
AvalonDock.
To get up to speed I suggest you read the
AvalonDock getting started tutorial.
It seems too obvious to mention but I'll say it anyway.
If you don't know what AvalonDock or MVVM is, or indeed if you do know of these
technologies but don't know how to use them, then this article may not be what you are looking for.
This article isn't intended to be a tutorial about using AvalonDock or MVVM, it is an article about how to merge the two.
Background
I have been using AvalonDock for a number of years now and I think that generally it is really great, however
out-of-the-box it doesn't support MVVM. Although they probably have good reasons for this I really wanted to be able to
use AvalonDock with MVVM!
In most of the time that I have used AvalonDock I have used it in a non-MVVM style.
I can't say I was exactly unhappy with this state of affairs but I did for sometime have an itch
that wanted to be scratched.
There are of course alternatives to AvalonDock and some existing AvalonDock wrappers.
However after some research and experimenting I couldn't find anything I was happy with and
so I set about writing my own AvalonDock MVVM wrapper. I also felt that the community was
lacking a clear and simple account of how to use AvalonDock with MVVM.
I was pleasantly surprised, and I hope after reading this article that you will agree,
that it isn't actually as difficult as it might first seem.
After a few hours of experimenting and figuring out I had written the first draft of the code for this article.
Aims and Concepts
I'll be demonstrating the AvalonDock MVVM technique with
a simple text editor. As you read the article please bear in mind that the sample application is really only a toy and is only
as sophisticated as necessary.
The main aim is to demonstrate the use of AvalonDock in an MVVM application.
I want to add view-models for documents and panes to my application's view-model
and have the appropriate AvalonDock components automatically instantiated.
I also want to be able to indirectly manipulate AvalonDock components via my view-model.
Setting the active document/pane and showing/hidding panes programatically are some
examples of what I want to be able to do.
AvalonDockHost
is the class at the core of this technique.
It is a WPF user control whose purpose is to adapt AvalonDock to MVVM and allows
documents and panes to be reflected in the application's view-model.
Like any good WPF reusable control AvalonDockHost will make few assumptions about the
structure of the application's view-model.
Lastly I want AvalonDockHost
to have minimal dependencies. As it stands it only depends on
AvalonDock and the .Net framework.
I felt this was quite important because existing solutions to this problem
often come packaged with additional code, libraries and bloat that I don't need
and don't want. With the code for this article I don't require that you use any
other components, this means you are free to integrate whichever other components you want, such as
MVVM frameworks or extensibility frameworks and if you do indeed do this it would be
great to hear from you what the experience was like.
The Solution and Projects
Extract AvalonDockMVVMSampleCode.zip and open AvalonDockMVVMSample.2008.sln in Visual Studio
(I am still using VS 2008, please use AvalonDockMVVMSample.2010.sln if you are using
VS 2010).
The solution contains the following projects:
The main class, AvalonDockHost
, can be found in the AvalonDockMVVM project.
I'll discuss the internals of AvalonDockHost
in some detail in the Implementation section. In
the walkthrough I will only discuss how AvalonDockHost
is used.
The SampleApp project contains the application, the main window and the views for the panes.
The ViewModels project contains all the view-model classes for the application.
Running the Sample Application
You should start by running the sample app and exploring its functionality.
The sample app has the features you might expect of a simple text editor: creating, opening,
saving and closing documents. The View menu allows the panes to be hidden and shown.
The following annotated screenshot shows the View menu and labels the various components of the application.
Documents are created and opened in the central tabbed-document area.
To the right of the tabbed-documents area are two dockable panes. The top pane is a
list of currently open documents. The bottom pane is a simplistic overview of the currently
active document. I collectively refer to documents and panes as panels.
As you would expect with AvalonDock
the panels can be detached from the main window and
left as floating windows or redocked at different locations in the main window.
This allows the user to customize the layout of the application.
AvalonDockHost, Views and the View-model
The sample application has three distinct view classes: MainWindow
,
OpenDocumentsPaneView
and DocumentOverviewPaneView
.
There is a third view that doesn't have a distinct class. The view for a text file document
is so simple as to only contain a WPF TextBox
and I have inlined the view
as a DataTemplate
within MainWindow.xaml.
The ViewModels project contains the view-model classes for each of the views.
Here is an (simplified) overview of the classes (thanks to
StarUML):
The solid lines indicate derivation (class hierarchy) and the dashed lines indicate
dependency or usage.
MainWindowViewModel
has Documents
and Panes
properties that are
the collections of document and pane view-models. These properties
are data-bound to AvalonDockHost
's Documents
and Panes
properties
in MainWindow.xaml:
<AvalonDockMVVM:AvalonDockHost
x:Name="avalonDockHost"
Panes="{Binding Panes}"
Documents="{Binding Documents}"
...
/>
With these data-bindings in place adding a document to AvalonDock is now as easy as
adding to the Documents
collection in the view-model.
Similarly, a pane is added to AvalonDock by adding to the Panes
collection.
Actually we aren't quite there yet, because we still need to understand how
AvalonDockHost
transforms a panel view-model into an appropriate AvalonDock component and
we will come to that soon.
In MainWindow.xaml there are additional data-bindings for ActiveDocument
and ActivePane
:
<AvalonDockMVVM:AvalonDockHost
...
ActiveDocument="{Binding ActiveDocument}"
ActivePane="{Binding ActivePane}"
...
/>
ActiveDocument
is set to the view-model for the active document
and ActivePane
is set to the view-model for the active pane.
These properties can be set via the view-model and the change is propagated to AvalonDockHost
via the data-binding which causes AvalonDockHost
to activate and focus the specified AvalonDock component.
These properties also reflect AvalonDock's current internal state.
When the user interacts directly with AvalonDock (bypassing our view-model) to select and active
a panel AvalonDockHost
is notified and sets either ActiveDocument
or ActivePane
as appropriate.
This change is then propagated back to the view-model via the data-binding.
Now that we have looked at the bindings between AvalonDockHost
and the view-model
here is a diagram that summarizes the relationships:
I often think of the view-model for an application as being a view-model tree.
This is certainly the case in the sample application although the view-model tree here is not particularly deep.
Other more complicated applications have many more levels in the tree.
The root of the tree is the main window's view-model: MainWindowViewModel
.
At the next level down in the tree are the view-models for the various panels.
The following instantiation diagram shows the (simplified) view-model tree when documents are opened as seen in
the earlier screenshot.
View-model objects are in blue, their associated views are in purple.
The next image shows an annotated screenshot of the visual-tree for the same run of the sample application and
shows where AvalonDockHost
and the other views fit in.
To see how the view-model tree is instantiated let's look at MainWindowViewModel
's constructor.
First it saves a reference to the passed in IDialogProvider
interface:
public MainWindowViewModel(IDialogProvider dialogProvider)
{
this.DialogProvider = dialogProvider;
}
IDialogProvider
is my way of indirectly providing view-dependent services to the view-model.
It allows the view-model to invoke open and save file dialogs and to report
error messages. It is also used to bring up a dialog box to request user confirmation
when a modified file is being closed.
Next the view-models for the panes are created:
public MainWindowViewModel(IDialogProvider dialogProvider)
{
this.DocumentOverviewPaneViewModel = new DocumentOverviewPaneViewModel(this);
this.OpenDocumentsPaneViewModel = new OpenDocumentsPaneViewModel(this);
}
Note that a reference to MainWindowViewModel
is passed into the constructors for each of the pane
view-models. This allows them access to various services
such as retreiving the list of open documents and the currently active document.
This is a dependency that you might want to break in the design for a real text editor, maybe the main
window's view-model should provide its services to the sub-view-models using an interface, but for my purposes
this works and is simple.
Next the pane view-models are added to the Panes
collection:
public MainWindowViewModel(IDialogProvider dialogProvider)
{
this.Panes = new ObservableCollection<abstractpaneviewmodel>();
this.Panes.Add(this.DocumentOverviewPaneViewModel);
this.Panes.Add(this.OpenDocumentsPaneViewModel);
}
As the view-model Panes
collection is data-bound to AvalonDockHost
's Panes
collection
these view-models are subsequently pushed through to AvalonDockHost
where they are transformed
into appropriate AvalonDock components.
At the end of the constructor a sample text file document is instantiated and added to the Documents
collection.
Adding the document view-model to the Documents
collection causes it to then be transformed into an AvalonDock component:
public MainWindowViewModel(IDialogProvider dialogProvider)
{
this.Documents = new ObservableCollection<textfiledocumentviewmodel>();
this.Documents.Add(new TextFileDocumentViewModel(string.Empty, "test data!", true));
}
MainWindowViewModel
contains methods that implement the text editor's features.
Methods such as NewFile
, OpenFile
and SaveFile
that are
ultimately invoked by commands defined in MainWindow.xaml.
I won't explain most of these functions as they are all quite short
and easy to read. The important thing to note in reading these functions
is that adding a document to AvalonDock is achieved by adding the document's view-model
to the Documents
collection. Closing a document is achieved by removing the
document's view-model.
Adding and removing panes is similar and just as easy, however
it is the dynamic addition and removal of documents that happens most often
during a session of a text editor and is the most interesting.
I will now examine what happens when a document is closed after the user
has clicked on the AvalonDock document close button.
Besides clicking the AvalonDock document close button there are several other ways to close a document.
You can choose Close or Close All from the File menu.
Also exiting the application implicitly causes all documents to be closed.
When the user closes a document (or all documents) via the menu or when they exit the application
a view-model method is invoked that removes the document from the
Documents
collection, thus closing it. When a modified document is being closed
the user is first queried to confirm the closing of the modified document.
The document is only closed if the user has approved the action.
However when the AvalonDock document close button has been clicked things happen differently.
In this case the user is interacting directly with AvalonDock and the view-model is normally
by-passed. To notify the application of a document that is closed in this way AvalonDockHost
raises the DocumentClosing
event.
The sample application handles this event:
<AvalonDockMVVM:AvalonDockHost
...
DocumentClosing="avalonDockHost_DocumentClosing"
/>
The event-handler invokes the document closing logic in the view-model:
private void avalonDockHost_DocumentClosing(object sender, DocumentClosingEventArgs e)
{
var document = (TextFileDocumentViewModel)e.Document;
if (!this.ViewModel.QueryCanCloseFile(document))
{
e.Cancel = true;
}
}
It is important to reiterate that the DocumentClosing
event is only raised
when a document is being closed after the user has clicked the AvalonDock document close button.
It is not raised when the view-model removes a document from the Documents
collection.
This is logical because when the view-model directly removes a document, the view-model is already
aware that a document is being closed and so does not need to be notified.
However when a document is closed because of direct user interaction with AvalonDock the view-model has
no other way of knowing that a document is being closed other than by the DocumentClosing
event.
When the application handles DocumentClosing
the document is in the process of being closed but has not
actually been closed. You can see from the previous code snippet that it is possible to cancel the close operation
and prevent the document from being closed. After the DocumentClosing
event has finished and provided the close operation has not been
cancelled the document is then closed and AvalonDockHost
itself removes the document's view-model from the Documents
collection.
DataTemplates for AvalonDock Documents and Panes
Near the start of MainWindow.xaml, after the declarations of the
routed commands, are the data-templates that are used to transform panel view-models
into appropriate AvalonDock components.
The sample application has three such data-templates: one for text
file documents and one for each of the two panes.
The DataType
for each of the data-templates is set to the relevant view-model class.
When view-models are added to AvalonDockHost
's Documents
or Panes
collections
the visual-tree is searched for matching data-templates in a manner that
is very similar to the usual WPF mechansim for associating a view-model with a DataTemplate
.
When AvalonDockHost
transforms a view-model into a UI element it expects the root element
to be an AvalonDock component. This is why all the data-templates have DocumentContent
or DockableContent
as the root element.
Using another type of UI element as the root element will result in an exception being thrown.
As a first example you can see that TextFileDocumentViewModel
's data-template
contains an AvalonDock DocumentContent
with an embedded WPF TextBox
:
<!---->
<DataTemplate
DataType="{x:Type ViewModels:TextFileDocumentViewModel}"
>
<ad:DocumentContent
Title="{Binding Title}"
ToolTip="{Binding ToolTip}"
>
<TextBox
Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}"
/>
</ad:DocumentContent>
</DataTemplate>
You might now be wondering why it is so important that an AvalonDock component is required as the
data-template's root element. After all AvalonDockHost
has separate
collections for Documents
and Panes
, it therefore knows the difference between documents
and panes, why can't it automatically create an appropriate AvalonDock component instead of making the programmer
specify it in the data-template?
It is important for one reason.
It allows the properties of the AvalonDock component to be explicitly set or data-bound to the view-model.
For instance in the code snippet above Title
and ToolTip
are data-bound to view-model properties.
You could take this further if you were inclined and create your own wrappers for the AvalonDock
components to use in these data-templates.
Then you would be almost completely abstracted from AvalonDock. However a complete abstraction
is not my purpose here and personally I think it would take more time than is worth investing.
In the data-template above it is easy to see why there is no explicit view for a text file document.
The 'view' only contains a TextBox
so it seems like overkill to create a separate user control.
Instead I have declared the 'view' inline within the main window.
The TextBox
's Text
property is data-bound to the Text
property in
the document's view-model and UpdateSourceTrigger
is set to PropertyChanged
so that
the view-model's text is updated whenever the user changes the text in the text box.
To be sure this is not very efficient but it keeps the sample code simple.
The data-templates for the panes are also declared in MainWindow.xaml, however
unlike the view for the document view-model, the views for the pane view-models
are delegated to separate user controls.
As an example I show only the data-template for OpenDocumentsPaneViewModel
.
The view for DocumentOverviewPaneViewModel
is very similar so I leave you to look at that yourself.
This data-template contains an AvalonDock DockableContent
as the root element.
Embedded within that is an instance of the OpenDocumentsPaneView
user control:
<!---->
<DataTemplate
DataType="{x:Type ViewModels:OpenDocumentsPaneViewModel}"
>
<ad:DockableContent
x:Name="openDocumentsPane"
Title="Open Documents"
AvalonDockMVVM:AvalonDockHost.IsPaneVisible="{Binding IsVisible}"
>
<local:OpenDocumentsPaneView />
</ad:DockableContent>
</DataTemplate>
It is very important to give a name to each pane that is defined,
for instance here it is named openDocumentsPane
.
These names are required for AvalonDock layout to be saved and restored.
Without them you will not be able to persist custom user-layout between application sessions.
Note the data-binding of the IsPaneVisible
attached property.
In the next section I discuss how this data-binding allows the visibility of the pane
to be programmatically controlled via the view-model.
View-model Control of Pane Visibility
Unfortunately AvalonDock provides no settable IsVisible
property that we can data-bind to our view-model.
To work around this I use the
WPF attached property
mechanism to externally create and attach a new property to an AvalonDock pane to achieve the desired functionality.
IsPaneVisible
serves this purpose, let's look at that data-binding again:
AvalonDockMVVM:AvalonDockHost.IsPaneVisible="{Binding IsVisible}"
IsPaneVisible
is intended only to be attached to an AvalonDock DockableContent
.
It gives us a boolean property that can be set to true
or false
to either show or hide
the pane. Of course it is not intended to be used directly, rather it has been data-bound
to the view-model's IsVisible
property. Henceforth we can use the view-model property
to programmaticaly change the visibility of panes.
This image illustrates the IsPaneVisible
binding that was shown in the previous XAML snippets:
In the implementation section I'll explain in more detail how IsPaneVisible
works.
For now it is enough to know that pane visibility can be manipulated through the view-model.
A good example can be seen in the XAML for the main window's View menu.
The menu item's IsChecked
is data-bound to the view-model's IsVisible
:
<MenuItem
Header="_Open Documents"
IsChecked="{Binding OpenDocumentsPaneViewModel.IsVisible}"
IsCheckable="True"
/>
Clicking this menu item now toggles the visibility of the Open Documents pane and no extra commands or code-behind are required.
The ShowAllPanes
method is an example of programatically setting IsVisible
:
public void ShowAllPanes()
{
foreach (var pane in this.Panes)
{
pane.IsVisible = true;
}
}
This method operates entirely in the view-model, iterating all panes and setting the IsVisible
for each to true
.
HideAllPanes
works in a similar way but sets the property to false
.
Setting the Active Document or Active Pane
Let's refresh our memory of the data-bindings for ActiveDocument
and ActivePane
that we looked at earlier:
<AvalonDockMVVM:AvalonDockHost
...
ActiveDocument="{Binding ActiveDocument}"
ActivePane="{Binding ActivePane}"
...
/>
The data-binding allows AvalonDock's currently selected document or pane to be
queried and manipulated via the view-model.
There is an example of this in the code for the
Open Documents pane. Selecting a document in the open documents list causes that document
to be activated. OpenDocumentsPaneViewModel
's ActiveDocument
property
forwards through to MainWindowViewModel
's ActiveDocument
:
public TextFileDocumentViewModel ActiveDocument
{
get
{
return this.MainWindowViewModel.ActiveDocument;
}
set
{
this.MainWindowViewModel.ActiveDocument = value;
}
}
As MainWindowViewModel
's ActiveDocument
is data-bound to AvalonDockHost
's ActiveDocument
changes to the one are propagated to the other and causes the associated AvalonDock component to be activated.
By extension then changes to OpenDocumentsPaneViewModel
's ActiveDocument
also set the active document.
Now looking at OpenDocumentsPaneView.xaml we see
that OpenDocumentsPaneViewModel
's ActiveDocument
is data-bound as the ListBox
's selected item:
<!---->
<ListBox
x:Name="documentsListBox"
Grid.Row="0"
ItemsSource="{Binding Documents}"
SelectedItem="{Binding ActiveDocument}"
/>
So whenever the user changes the selection in the list, that choice sets the active document in AvalonDock.
The same applies in the reverse direction.
When the user interacts directly with AvalonDock and selects a document
that change is propagated through to the view-model and ultimately sets ListBox
's selected item.
For the change to be propagated
OpenDocumentsPaneViewModel
handles MainWindowViewModel
's ActiveDocumentChanged
event.
In response it raises its own PropertyChanged
event for its own ActiveDocument
property.
This causes data-binding to update the ListBox
's selected item:
private void MainWindowViewModel_ActiveDocumentChanged(object sender, EventArgs e)
{
OnPropertyChanged("ActiveDocument");
}
The discussion above also applies to setting the active pane, although instead using the ActivePane
property.
AvalonDock Layout
One problem that I encountered while developing the code was how I should handle
the default layout of documents and panes. Normally when using AvalonDock in a non-MVVM style you would create
the default layout directly in MainWindow.xaml in the way that was described in the
AvalonDock getting started tutorial.
While the application is running the user is able to rearrange the layout to their own tastes
and AvalonDock conveniently provides save and restore layout methods that make it remarkably easy to
persist user-layout between sessions.
However when using AvalonDock with MVVM you will find that it is not possible to directly specify
the default layout in the XAML. This is because it is AvalonDockHost
, my AvalonDock wrapper,
that provides the default layout that you would normally hard-code in MainWindow.xaml.
AvalonDockHost
's default layout is simple and can't be customized to your needs.
I could have added features to AvalonDockHost
that would have allowed customization of its default layout,
although I think at best this would have been a clumsy solution. Fortunately I came up with a better solution
that is simple to use and was easy to implement.
In short, you must create a file that contains the default layout, make this file a part of the application
as an
embedded resource,
and then when your application is loaded the first time it should restore its layout
from this embedded default layout file.
To generate the layout file in the first place your application should be up and running with AvalonDock.
After rearranging the panes into a satisfying default layout you should save the layout file using AvalonDock's
built-in SaveLayout
method. This will be easy if you already have your application setup to save user-layout
when the application closes. For example, in the sample application,
SaveLayout
is called when the application exits:
private void Window_Closing(object sender, CancelEventArgs e)
{
...
avalonDockHost.DockingManager.SaveLayout(LayoutFileName);
}
The generated layout file should now be added to the application
as an Embedded Resource. If the file type is set to something else you won't
be able to retreive the resource, so beware.
This screenshot shows the default layout file as an embedded resource in the sample application's project:
This screenshot shows the properties for default layout file. Note that Build Action is
set to Embedded Resource:
Now the final piece of the default layout puzzle.
The default layout should be applied when there is no custom user-layout file
existing on disk. This will be the case obviously when the application is
run for the first time but it also allows the user to delete their custom layout file
so that the application's layout reverts to the default next time it is restarted.
Layout, whether embedded default layout or custom user-layout, can only be restored
once AvalonDock has loaded. To this end AvalonDockHost
raises the AvalonDockLoaded
event which is handled by the sample application:
<AvalonDockMVVM:AvalonDockHost
...
AvalonDockLoaded="avalonDockHost_AvalonDockLoaded"
...
/>
The event-handler either loads a custom user-layout file or it loads the embedded default
layout file. In the first case, if a custom user-layout file already exists, we simply defer to AvalonDock's
RestoreLayout
method to load the file and restore the user's layout:
private void avalonDockHost_AvalonDockLoaded(object sender, EventArgs e)
{
if (System.IO.File.Exists(LayoutFileName))
{
avalonDockHost.DockingManager.RestoreLayout(LayoutFileName);
}
else
{
}
}
In the second case, where there is no existing user-layout file,
the layout is restored using a stream that reads the embedded resource:
private void avalonDockHost_AvalonDockLoaded(object sender, EventArgs e)
{
if (System.IO.File.Exists(LayoutFileName))
{
}
else
{
var assembly = Assembly.GetExecutingAssembly();
using (var stream = assembly.GetManifestResourceStream(DefaultLayoutResourceName))
{
avalonDockHost.DockingManager.RestoreLayout(stream);
}
}
}
The embedded resource is retrieved by specifying the full resource name, which in this case is:
private static readonly string DefaultLayoutResourceName = "SampleApp.Resources.DefaultLayoutFile.xml";
The obvious question now is how did I figure out what name to use?
From my example you could probably infer the name of any embedded resource.
It appears to be a combination of <namespace> + <resource-path> + <resource-file-name>, or something like that.
However there is a fool proof way to know for sure.
The following code retrieves an array of the full names of all embedded resources:
string[] names = this.GetType().Assembly.GetManifestResourceNames();
You can print this list to debug output and then pick out the name of the resource.
User Confirmation for Closing Files
When the user attempts to close a file that has been modified, but not saved,
a dialog box is invoked to request confirmation of whether the file should really be closed.
This also happens when the user closes all files
or exits the application when any of the open files have been modified.
The code for this is not specifically related to AvalonDock,
so I won't cover it in great detail, but it is a good
chance for me to explain various aspects of the sample application.
The key to the feature is TextFileDocumentViewModel
's IsModified
property.
You may remember from the earlier XAML snippet that a data-binding is setup
in such a way that any changes the user makes in the TextBox
are immediately propagated
to the view-model's Text
property.
It is the setter for the Text
property that sets IsModified
to true
.
In this way, whenever the user changes the text, the document is marked as modified.
The subsequent PropertyChanged
event fired for IsModified
causes a cascade of action
that result in the update of the document's title and tooltip and the application's titlebar.
The CloseFile
method in the view-model doesn't use IsModified
directly, instead it calls
the helper method QueryCanCloseFile
(which we also saw earlier)
to check whether it can close the file:
public bool CloseFile(TextFileDocumentViewModel document)
{
if (!QueryCanCloseFile(document))
{
return false;
}
this.Documents.Remove(document);
return true;
}
A modified document is only actually closed (i.e. removed from the Documents
collection)
provided the user has confirmed the action.
QueryCanClose
invokes the confirmation dialog for modified documents:
public bool QueryCanCloseFile(TextFileDocumentViewModel document)
{
if (document.IsModified)
{
if (!this.DialogProvider.QueryCloseModifiedDocument(document))
{
return false;
}
}
return true;
}
The confirmation dialog is invoked indirectly through the IDialogProvider
interface
and this keeps the view-model nicely separated from the view.
The close all files feature simply calls the CloseFile
method for each open document
and so it effectively operates in the same way.
Exiting the application, which implicitly closes all open documents, works in a similar way.
The main window's Closing
event calls OnApplicationClosing
in the view-model. If any documents are modified it invokes a different
dialog to request user confirmation:
public bool OnApplicationClosing()
{
if (this.AnyDocumentIsModified)
{
if (!this.DialogProvider.QueryCloseApplicationWhenDocumentsModified())
{
return false;
}
}
return true;
}
Again the confirmation is indirectly invoked through IDialogProvider
.
This concludes the walkthrough of the sample application. In the next part we will delve into the
internals of AvalonDockHost
.
This part of the article is dedicated to understanding the implementation of the AvalonDockHost
class.
You can find AvalonDockHost
in the AvalonDockMVVM project.
AvalonDockHost XAML
AvalonDockHost
is a user control. Its declaration in AvalonDockHost.xaml is trivial:
<UserControl
x:Class="AvalonDockMVVM.AvalonDockHost"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:local="clr-namespace:AvalonDockMVVM"
xmlns:ad="clr-namespace:AvalonDock;assembly=AvalonDock"
>
<ad:DockingManager
x:Name="dockingManager"
Loaded="AvalonDock_Loaded"
/>
</UserControl>
The XAML declaration contains only the DockingManager
, the root element in AvalonDock's visual-tree.
It is named dockingManager
and is directly referenced from the code-behind to add and remove
AvalonDock components.
The Loaded
event is handled and propagated to the application as the AvalonDockLoaded
event.
We have already seen how this event is handled in the sample application so that AvalonDock layout
can be restored.
Synchronizing ActiveDocument and ActivePane
AvalonDock's ActiveContentChanged
event is handled so that AvalonDockHost
is notified
when the active, or focused, panel has been changed.
The event is hooked in the constructor:
public AvalonDockHost()
{
InitializeComponent();
dockingManager.ActiveContentChanged += new EventHandler(dockingManager_ActiveContentChanged);
UpdateActiveContent();
}
The call to UpdateActiveContent
ensures that the ActiveDocument
and ActivePane
properties are
set to correct initial values. UpdateActiveContent
is also called by the ActiveContentChanged
event-handler
so that over time ActiveDocument
and ActivePane
remain synchronized with AvalonDock's internal state.
UpdateActiveContent
queries AvalonDock for the currently active component and then
updates either ActiveDocument
or ActivePane
depending on the component's type:
private void UpdateActiveContent()
{
var activePane = dockingManager.ActiveContent as DockableContent;
if (activePane != null)
{
this.ActivePane = activePane.DataContext;
}
else
{
var activeDocument = dockingManager.ActiveContent as DocumentContent;
if (activeDocument != null)
{
this.ActiveDocument = activeDocument.DataContext;
}
}
}
The application can set the ActiveDocument
and ActivePane
properties either programmatically or via data-binding
and we have seen an example of this in the walkthrough.
Internally both documents and panes are simply treated as panels so only a
a single method handles the property changed event for both:
private static void ActiveDocumentOrPane_PropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var c = (AvalonDockHost)d;
ManagedContent managedContent = null;
if (e.NewValue != null &&
c.contentMap.TryGetValue(e.NewValue, out managedContent))
{
managedContent.Activate();
}
}
contentMap
is a dictionary that maps a panel's view-model to its associated AvalonDock component.
The above snippet retreives the panel's AvalonDock component from contentMap
and calls the
Activate
method on it. We will soon see how contentMap
is initialized.
Synchronizing Documents and Panes
The Documents
and Panes
dependency properties are collections
that provide the view-models for panels that AvalonDockHost
transforms into AvalonDock components.
Again we will see that both documents and panes are treated internally as panels.
There is a single property changed event-handler that is invoked
when either of these properties have changed, for example when a new collection has been assigned,
the contents of the collection are transformed into AvalonDock components and
added to the DockingManager
.
The CollectionChanged
event is hooked for the two collections so that AvalonDockHost
is notified of any future
changes, such as adding or removing documents and panes.
Before dealing with the newly assigned collection the event-handler deals with,
if there was one, the previously assigned collection.
private static void DocumentsOrPanes_PropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var c = (AvalonDockHost)d;
if (e.OldValue != null)
{
var oldPanels = (IList)e.OldValue;
c.RemovePanels(oldPanels);
var observableCollection = oldPanels as INotifyCollectionChanged;
if (observableCollection != null)
{
observableCollection.CollectionChanged -= new NotifyCollectionChangedEventHandler(c.documentsOrPanes_CollectionChanged);
}
}
if (e.NewValue != null)
{
var newPanels = (IList)e.NewValue;
c.AddPanels(newPanels);
var observableCollection = newPanels as INotifyCollectionChanged;
if (observableCollection != null)
{
observableCollection.CollectionChanged += new NotifyCollectionChangedEventHandler(c.documentsOrPanes_CollectionChanged);
}
}
}
The CollectionChanged
event makes AvalonDockHost
aware of added or removed panels.
In response to this event AvalonDock components are either instantated and added to AvalonDock
or they are removed from AvalonDock.
private void documentsOrPanes_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Reset)
{
ResetDocumentsOrPanes(sender);
}
else
{
if (e.OldItems != null)
{
RemovePanels(e.OldItems);
}
if (e.NewItems != null)
{
AddPanels(e.NewItems);
}
}
}
ResetDocumentsOrPanes
handles the Reset
action and
is delegated to a separate method because it is somewhat complicated.
I have to admit that it is a bit of a hack. Mostly I have been able to use the same
event-handlers and methods to deal with both documents and panes, or panels as I collectively
call them. In this case though I can't and I'll blame this on what I perceive
as an inadequacy in CollectionChanged
's Reset
action. The problem is
that the Reset
action does not supply a list of the items that were removed.
Instead, it appears, you are expected to keep a separate internal list of the items
so that you already know the items that were previously in the collection.
This is an annoying problem and in the past (eg
my previous article
) I solved it by creating my own
version of ObservableCollection
that works in a friendlier way. In this article though I was aiming
to minimize dependencies so I haven't introduced that class.
Given that documents and panes are treated as panels internally
and that ResetDocumentsOrPanes
should remove either all documents or all panes it
must figure out which type of object to remove.
To acheive this it compares the event sender
to the Documents
and Panes
properties to determine which of the two collections has raised the CollectionChanged
event. After this test it knows what type of panel it should remove and
proceeds to remove them by inspecting
the only internal collection that AvalonDockHost
does have, the contentMap
dictionary
(something we will look at soon).
This is not my best ever piece of code but it does the job and is a convenient solution to an
annoying problem. Please examine the method yourself if you want (and please message me
if you think of a less hacky, but still just as convenient, solution).
Moving on, the AddPanels
method calls AddPanel
for each of the new panels.
AddPanel
then is where the interesting stuff happens: it is here that a panel view-model is transformed into
an AvalonDock component. And as we have seen in the walkthrough the AvalonDock component will be specified by a data-template.
The visual-tree must be searched to find the data-template resource. If a data-template is found that corresponds to the type
of the view-model it is instantiated and the resulting UI element, expected to be an AvalonDock component, is
added to AvalonDock.
Searching the visual-tree for a data-template is something that WPF already
does internally but unfortunately it does not appear to provide programatic access to this feature.
So I created my own method, DataTemplateUtils.InstanceTemplate
, that searches for and instantiates the data-template.
I won't cover the internals of this method, but please feel free to inspect the code yourself.
So with all that in mind AddPanel
's first task is to find and instantiate the AvalonDock component:
private void AddPanel(object panel)
{
var panelViewModelType = panel.GetType();
var uiElement = DataTemplateUtils.InstanceTemplate(panelViewModelType, this, panel);
if (uiElement == null)
{
throw new ApplicationException("Failed to find data-template for type: " + panel.GetType().Name);
}
}
Next the instantiated UI element must be checked to ensure that it is actually an AvalonDock component.
For this it is is type-cast to an AvalonDock ManagedContent
, the base-class for both AvalonDock documents and panes.
If the instantiated UI element is not a ManagedContent
it therefore is not an AvalonDock component and an exception is thrown.
After verifying the AvalonDock component it is added to the contentMap
dictionary so that it can be easily retrieved later:
private void AddPanel(object panel)
{
var managedContent = uiElement as ManagedContent;
if (managedContent == null)
{
throw new ApplicationException("Found data-template for type: " + panel.GetType().Name + ", but the UI element generated is not a ManagedContent (base-class of DocumentContent/DockableContent), rather it is a " + uiElement.GetType().Name);
}
contentMap[panel] = managedContent;
}
Next various events are hooked to keep track of the AvalonDock component's ongoing state:
private void AddPanel(object panel)
{
managedContent.Closed += new EventHandler(managedContent_Closed);
var documentContent = managedContent as DocumentContent;
if (documentContent != null)
{
documentContent.Closing += new EventHandler<canceleventargs>(documentContent_Closing);
}
else
{
var dockableContent = managedContent as DockableContent;
if (dockableContent != null)
{
dockableContent.StateChanged += new RoutedEventHandler(dockableContent_StateChanged);
}
else
{
throw new ApplicationException("Panel " + managedContent.GetType().Name + " is expected to be either DocumentContent or DockableContent.");
}
}
}
The Closed
event is handled for every AvalonDock component
and ensures that any panel that is closed is also internally removed from AvalonDockHost
.
Another event is hooked depending on the type of the panel. For documents it is the Closing
event
and handling it allows AvalonDockHost
to be notified when a document is being closed
after the user has clicked the AvalonDock document close button. In this situation AvalonDockHost
raises the DocumentClosing
event so that the application is aware that the document is closing.
For panes the StateChanged
event is hooked. AvalonDockHost
handles this event so that
it is notified when pane visibility has changed. In response it sets the IsPaneVisible
attached property to true
or false
to indicate the pane's visibility.
Continuing on, AddPanel
's last task is to show and active the AvalonDock component:
private void AddPanel(object panel)
{
managedContent.Show(dockingManager);
managedContent.Activate();
}
Now let's look at RemovePanel
. This method is called when either a document or pane has been removed from
the Documents
or Panes
collections. The AvalonDock component is retrieved from contentMap
and closed:
private void RemovePanel(object panel)
{
ManagedContent managedContent = null;
if (contentMap.TryGetValue(panel, out managedContent))
{
disableClosingEvent = true;
try
{
managedContent.Close();
}
finally
{
disableClosingEvent = false;
}
}
}
When a document is being closed the Closing
event is raised
and the disableClosingEvent
variable that was set to
true
in RemovePanel
comes into play here to prevent the DocumentClosingEvent
being propagated to the application:
private void documentContent_Closing(object sender, CancelEventArgs e)
{
var documentContent = (DocumentContent)sender;
var document = documentContent.DataContext;
if (!disableClosingEvent)
{
if (this.DocumentClosing != null)
{
var eventArgs = new DocumentClosingEventArgs(document);
this.DocumentClosing(this, eventArgs);
if (eventArgs.Cancel)
{
e.Cancel = true;
return;
}
}
}
documentContent.Closing -= new EventHandler<canceleventargs>(documentContent_Closing);
}
As RemovePanel
is only called when the application itself has removed a panel from
the Documents
or Panes
collection the DocumentClosing
event
doesn't need to be raised because the application is already aware that the document is closing.
When it is a document (and not a pane) that is removed
the Closing
event is handled by AvalonDockHost
.
As it will have been the application that removed the document it is thus already aware that the document
has been closed and there is no need to raise the DocumentClosing
event, hence the use of
disableClosingEvent
to prevent raising of the DocumentClosing
event.
When a user has clicked the AvalonDock document close button it is AvalonDock itself (and not the application or
AvalonDockHost
) that calls the
ManagedContent
's Close
method. This also causes the Closing
event to be raised and in this case
disableClosingEvent
will be set to its default value, which is false
.
This all means that in this case that DocumentClosing
is raised to notify the application.
This is what we want because the application has no other way of knowing that a
document is about to be closed and it is important that the application, and hence the user, is given the chance to
veto closing of the document.
Ultimately after a panel has been closed its Closed
event is raised.
In response to this event AvalonDockHost
removes the panel's contentMap
entry,
unhooks events and performs other cleanup tasks:
private void managedContent_Closed(object sender, EventArgs e)
{
var managedContent = (ManagedContent)sender;
var content = managedContent.DataContext;
contentMap.Remove(content);
managedContent.Closed -= new EventHandler(managedContent_Closed);
var documentContent = managedContent as DocumentContent;
if (documentContent != null)
{
this.Documents.Remove(content);
if (this.ActiveDocument == content)
{
this.ActiveDocument = null;
}
}
else
{
var dockableContent = managedContent as DockableContent;
if (dockableContent != null)
{
dockableContent.StateChanged -= new RoutedEventHandler(dockableContent_StateChanged);
this.Panes.Remove(content);
if (this.ActivePane == content)
{
this.ActivePane = null;
}
}
}
}
The Closed
event-handler also ensures that the document or pane has been
removed from the Documents
or Panes
collection. This is important
for when a document is closed by the AvalonDock document
close button, for this is the only way that the document is actually removed from
the view-model. If it happens to be the active document or pane that has ben closed either
ActiveDocument
or ActivePane
are reset to null
.
IsPaneVisible Attached Property
In this section I discuss the implementation of the IsPaneVisible
attached property.
This property is attached to an AvalonDock pane to allow its visibility
to be controlled by a boolean variable.
The main intention is to data-bind the property to a view-model property
and thus allow pane visibility to be controlled entirely within the view-model.
We have already seen earlier how the StateChanged
is hooked by AvalonDockHost
.
This event is raised when the visibility of a pane has changed, for example when
a pane is closed after the user has clicked the AvalonDock pane hide button.
The event-handler sets the attached property's value to true
or false
based on the pane's
current visibility:
private void dockableContent_StateChanged(object sender, RoutedEventArgs e)
{
var dockableContent = (DockableContent)sender;
SetIsPaneVisible(dockableContent, dockableContent.State != DockableContentState.Hidden);
}
In the walkthrough we looked at the data-templates for the sample application's panes in MainWindow.xaml.
Let's review the Open Documents pane data-template to refresh our memory:
<DataTemplate
DataType="{x:Type ViewModels:OpenDocumentsPaneViewModel}"
>
<ad:DockableContent
x:Name="openDocumentsPane"
Title="Open Documents"
AvalonDockMVVM:AvalonDockHost.IsPaneVisible="{Binding IsVisible}"
>
<local:OpenDocumentsPaneView />
</ad:DockableContent>
</DataTemplate>
The data-binding references IsPaneVisible
as AvalonDockMVVM:AvalonDockHost.IsPaneVisible
.
This is the standard syntax for referencing an attached property in a data-binding.
In this example IsPaneVisible
is data-bound to OpenDocumentPaneViewModel
's IsVisible
property.
With this data-binding in place, and as we have already seen in the walkthrough,
the visibility of the pane can be controlled via the view-model.
IsPaneVisible
is registered as an attached property by AvalonDockHost
:
public static readonly DependencyProperty IsPaneVisibleProperty =
DependencyProperty.RegisterAttached("IsPaneVisible", typeof(bool), typeof(AvalonDockHost),
new FrameworkPropertyMetadata(true, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, IsPaneVisible_PropertyChanged));
It is the property changed event-handler that does the main work of the attached property:
private static void IsPaneVisible_PropertyChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
{
var avalonDockContent = o as ManagedContent;
if (avalonDockContent != null)
{
bool isVisible = (bool)e.NewValue;
if (isVisible)
{
avalonDockContent.Show();
}
else
{
avalonDockContent.Hide();
}
}
}
When the IsPaneVisible
property is changed the event-handler is invoked and the pane is either shown or hidden
depending on IsPaneVisible
's new value.
This brings us to the end of the AvalonDockHost
implementation section.
Next is the conclusion followed up by the AvalonDockHost
reference section.
This article has discussed the use and implementation of AvalonDockHost
. AvalonDockHost
is a
an AvalonDock wrapper I have created to adapt AvalonDock for use in an MVVM application.
Thanks for taking the time to read the article.
This section is a reference for the properties of AvalonDockHost.
Panes
|
Gets and sets the collection of view-model objects for panes.
Adding an object to this collection results in
a pane being added to AvalonDock.
A DataTemplate should be defined for each type of
pane and the root of the DataTemplate
should be an AvalonDock DockableControl.
NOTE: This property is initially null, you should
either assign a collection to it or, as it is
intended to be used, data-bind it to a collection
in the view-model.
|
Documents
|
Gets and sets the collection of view-model objects for documents.
Adding an object to this collection results in
a document being added to AvalonDock.
A DataTemplate should be defined for each type of
document and the root of the DataTemplate should be
an AvalonDock DocumentContent.
NOTE: This property is initially null, you should
either assign a collection to it or, as it is
intended to be used, data-bind it to a collection
in the view-model.
|
ActivePane
|
Gets and sets the view-model object for the currently active pane.
Setting this property programatically changes the active
focused AvalonDock panel.
It can also be data-bound to a view-model property
so that the active pane can be set via the view-model.
This property is automatically updated when the user directly selects
an AvalonDock pane.
|
ActiveDocument
|
Gets and sets the view-model object for the currently active document.
Setting this property programatically changes the active
focused AvalonDock panel.
It can also be data-bound to a view-model property
so that the active document can be set via the view-model.
This property is automatically updated when the user directly selects
an AvalonDock document.
|
IsPaneVisible
|
Gets or sets the visibility state of a pane.
This attached property is intended to only be attached to
an AvalonDock DockableContent that is the root element in the
DataTemplate for a pane's view-model.
Setting this property to true shows the pane, setting to false hides the pane.
This property is automatically updated when the user directly hides
an AvalonDock pane by clicking the AvalonDock pane hide button.
|
DockingManager
|
Gets the AvalonDock DockingManager.
Direct access to the DockingManager allows
the application to save and restore AvalonDock
layout.
|
AvalonDockLoaded
|
This event is raised when AvalonDock has been loaded.
The application can respond to this event by restoring AvalonDock layout.
|
DocumentClosing
|
This event is raised when a document is being closed after
the user has clicked the AvalonDock document close button.
It allows the application to cancel, if necessary, the
document close operation.
NOTE: This event is not raised when a document is closed by
the application removing the document from the Documents collection
itself. In this case the application is already aware that the
document is being closed and the event is not needed.
|
SetIsPaneVisible
|
Sets the value of the IsPaneVisible
attached property.
Although it should not be necessary to use this directly
as IsPaneVisible is intended to be data-bound to
a view-model property and the view-model should be set instead
of calling this method.
|
GetIsPaneVisible
|
Gets the value of IsPaneVisible.
It should not be necessary to use this directly for the reason described above.
|
-
11/08/2011: Article first published.