UniDock is an Avalonia based multiplatform UI docking framework. This article describes its powerful features that should make it very attractive to the developers.
Introduction
Note that both the article and the samples code have been updated to work with latest version of Avalonia - 11.0.6. Note also that the new UniDock demos are located within [NP.Ava.Demos](https://github.com/npolyak/NP.Ava.Demos) repository, not within NP.Avalonia.Demos as before.
About UniDock
UniDock is a new multiplatform docking framework. It is built on top of Avalonia package for visual development.
Avalonia is a great multiplatform open source UI framework for developing
- Desktop solutions that will run across Windows, Mac and Linux
- Web applications to run in Browser (via WebAssembly)
- Mobile applications for Android, iOS and Tizen.
To learn more about Avalonia, take a look at Multiplatform UI Coding with AvaloniaUI in Easy Samples. Part 1 - AvaloniaUI Building Blocks, Multiplatform AvaloniaUI .NET Framework Programming Basic Concepts in Easy Samples, Basics of XAML in Easy Samples for Multiplatform Avalonia .NET Framework and Avalonia .NET Framework Programming Advanced Concepts in Easy Samples articles and at Avalonia documentation.
UniDock Features Demonstrated in this Article
The UniDock has recently been modified to work under Avalonia 11 (for desktop only).
Here are the features discussed in this article:
- Docking to the window sides.
- Editable docking state that allows the user to change some of the docking parameters and to use the group headers (visible in Editable state) to pull out whole groups of panes instead of going pane by pane.
- Locking several dock panes together in an Editable state, so that they become like a single pane. One can also break the previously existing lock in the Editable state.
- Changing some parameters of the Tabbed groups in the editable state, allowing to have the tabs on the right, top, left or bottom also allowing the tabs to be undraggable and indestructible.
- Stable groups - groups that can be pulled out of and added to some windows, but cannot be destroyed. If a floating window has one or more stable groups in it, such window cannot be destroyed unless the stable groups are pulled out of it.
- Default positions for the groups and panes - they can specify a stable group to be their default parent and then the functionality can be provided to move such panes and groups to their default positions.
- Creating the Floating Windows in XAML.
GroupOnlyById
flag that allows the panes and groups to be docked only to each other - this is good when you have a special area and want some kind of Dock Panes to be docked only there. - Controlling the visibility of Dock Panes.
- Using the non-visual (not dependent on Avalonia framework) View Models to add, remove, select or change visibility of a dock pane. The View Models can also be used for saving and restoring the parameters that are not part of the UniDock framework (they allow almost arbitrary extension of the saving/restoring functionality of UniDock.
- Highlighting the active pane within a window and different highlighting within an active window.
- Making the main window - the owner of all Floating Windows.
- Using the
MainWindow
's DataContext
within the docking groups.
Known Problems
Currently, UniDock on Windows is working perfect or close to perfect. The only known problem is that UniDock gets confused when a floating window is dragged over several overlapping windows. This is a current limitation of Avalonia - hopefully, it will either be addressed in Avalonia or I'll add some functionality to UniDock to deal with this issue.
Linux version has a problem with Compass not being positioned correctly. I plan to address it soon.
The known problem on Mac is that the floating (custom) windows cannot be resized.
Both Linux and Max have a problem that one needs to click again on the header of the newly created floating window after dragging it out of the other window. The users are usually learning it fast by themselves and it does not ruin the user experience.
UniDock Features Demonstrated
Code Location
The code for these samples is located under NP.Ava.Demos repository under NP.Demos.UniDockFeatures folder.
Docking to Window Sides
The UniDock functionality allows docking the content of a floating window to an empty group or to the sides within the top level group (which for a floating window means docking to its sides, since the top level group takes the whole floating window).
The sample is located under NP.Demos.DockingToWindowSidesDemo
project.
Open and run the project in Visual Studio. Here is what you'll see:
There are two panes at the top and three tabs at the bottom. Pull one of the tabs, e.g., Tab 2 out. Then move it over the main window and choose the drop area on the left of it (marked by the red ellipse on the following image:)
Move the mouse (together with the window) on top of that area and release the mouse to drop "Tab 2" on it. The "Tab 2" dock pane will be added on the right side of the window:
Likewise, you could have chosen to drop the content of the floating window to the top, left or bottom of the main window.
In order to hook to the UniDock functionality, you have to install the NP.Ava.UniDock
nuget package form nuget.org location. After that, you can even remove the references to Avalonia packages since UniDock package already contains references to them:
The code specific to the sample is located in two XAML files: App.axaml and MainWindow.axaml.
App.axaml simply contains reference to XAML Style and Resources files:
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.DockingToWindowSidesDemo.App">
<Application.Styles>
<SimpleTheme/>
<StyleInclude Source="avares://NP.Ava.Visuals/Themes/CustomWindowStyles.axaml"/>
<StyleInclude Source="avares://NP.Ava.UniDock/Themes/DockStyles.axaml"/>
</Application.Styles>
</Application>
MainWindow.axaml file contains the important code:
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="NP.Demos.DockingToWindowSidesDemo.MainWindow"
Title="NP.Demos.DockingToWindowSidesDemo"
xmlns:np="https://np.com/visuals"
Width="600"
Height="400">
<Window.Resources>
<np:DockManager x:Key="TheDockManager"/>
</Window.Resources>
<Grid>
<np:RootDockGroup TheDockManager="{StaticResource TheDockManager}">
<np:StackDockGroup TheOrientation="Vertical">
<np:StackDockGroup TheOrientation="Horizontal">
<np:DockItem Header="Hi">
<TextBlock Text="Hi World!"/>
</np:DockItem>
<np:DockItem Header="Hello">
<TextBlock Text="Hello World!"/>
</np:DockItem>
</np:StackDockGroup>
<np:TabbedDockGroup>
<np:DockItem Header="Tab1">
<TextBlock Text="This is tab1"/>
</np:DockItem>
<np:DockItem Header="Tab2">
<TextBlock Text="Tab2 is here"/>
</np:DockItem>
<np:DockItem Header="Tab3">
<TextBlock Text="Finally - Tab3"/>
</np:DockItem>
</np:TabbedDockGroup>
</np:StackDockGroup>
</np:RootDockGroup>
</Grid>
</Window>
Some Explanations of the UniDock Concepts
Based on the sample above, we can explain (or for those who read the previous article - review) the UniDock concepts.
All the main UniDock classes and interfaces are defined under NP.Ava.UniDock
project and the same named namespace - NP.Ava.UniDock
.
There are four major classes used to create the Dock layout:
DockItem
represents the dockable or tab panes. StackDockGroup
is used to arrange its content (the dock panes or other groups of dock panes) vertically or horizontally. Its property TheOrientation
is used to choose Vertical
or Horizontal
orientation. TabbedDockGroup
represents a tabbed group of panes (each pane - is defined by a DockItem
). RootDockGroup
is the top level group - should not have any parents within the Docking Hierarchy. The user defined windows (e.g., our MainWindow
) can have several RootDockGroups
within it, but each floating window has one and only one RootDockGroup
within it. RootDockGroup
can have no more than one child group or DockItem
.
Each of the four classes described above implement the IDockGroup
interface.
IDockGroup
plays a central role in docking.
Its most important properties are:
IDockGroup? DockParent
is the parent of the docking object within the docking hierarchy. The DockParent
of a RootDockGroup
object (that represents the top of the tree) will always be null
. IList<IDockGroup> DockChildren
is the collection of children of the IDockGroup
object within the docking hierarchy. DockChildren
of a DockItem
object (that represents the bottom of the tree) will always be empty.
Every floating window has a single docking hierarchy starting with the single RootDockGroup
at the top of the tree. User defined window might have several docking hierarchies in it or might have none depending on the will of the developer.
Editable Docking State
One of the most important features provided by the new version of UniDock is the ability to switch the DockManager
to an editable state and back.
The editable state provides the following capabilities for modifying the docking configuration:
- Each
StackDockGroup
and TabbedDockGroup
gets their own header and can be dragged out of a window at once instead of the user dragging each DockItem
one by one. - The header of the
StackDockGroup
allows locking (or unlocking) its content so that it becomes like a single document pane. - The
TabbedDockGroup
headers provide an important button allowing to change the location of the tabs - one can choose Top
, Right
, Bottom
and Left
locations. Another button allows making the tabs non-draggable. - Group header also provides some information that the developers or advanced users might need - the unique
DockId
of the group.
All of the above features are demonstrated in our example located under NP.Demos.EditableDockingStateDemo
project.
Open the solution in the Visual Studio, compile and run it. The initial layout will be almost exactly the same as the initial layout of the previous example, aside from a small ToggleButton
containing a pencil icon in the right bottom corner:
Press the button and then lo and behold, each group gets its own header with some information and buttons in them:
In order to move the window to the normal state, one needs to click the button again (but do not do it yet).
Place the mouse pointer over the "StackDockGroup_3
" header - the groups content will be highlighted in light blue:
The same will happen if you place the pointer above "StackDockGroup_2
" header, only now the whole docking layout will be highlighted in light blue since the group contains the whole docking layout:
Click somewhere within "StackDockGroup_3
" header and pull out the two horizontally stacked panes - "Hi
" and "Hello
".
There are currently two windows - the main window containing the tabs - "Tab1
", "Tab2
" and "Tab3
" and the floating window containing two horizontally stacked dockable panes "Hi
" and "Hello
":
Note that the former top level group "StackDockGroup_2
" disappeared from the main window - it became no longer needed since it only has a single subgroup and it was automatically removed during the docking optimization.
Also, note that the floating window is not in the editable state and the window's header contains an edit button with the pencil.
Click the edit button and the floating window will change into the editable state showing the header for its only group "StackDockGroup_3
":
Take a look at the Lock/Unlock ToggleButton
within the group's header:
Click the button, the content of the group goes into the locked state - the two Dock Panes "Hi
" and "Hello
" lose their individual headers and the button now indicates that the group is locked by changing the icon to locked and becoming dark blue:
For almost all purposes, the locked group acts like a single Dock Pane, except that you can still resize the panes using the pane splitters between them. The only thing that you cannot do at this point, is to add a locked pane to a tabbed group as one of the tabs. This feature will be added later.
To make the locked dock panes again behave like separate dock panes, you can unlock their group.
Now switch your attention to the main window containing the tabbed group. Take a look at the ComboBox
for changing the side of the Tabs:
Of all four options, choose the Left hand side:
The tabs still behave exactly the same - one can change tab order by dragging them within the tab area or one can drag a tab out completely or you can remove the tab by clicking its 'X' button.
Now uncheck the "Allow Tab Docking" checkbox:
You can no longer drag the tabs out or change their order or remove them.
Now take a look at the code. All relevant code is located within MainWindow.axaml file (App.axaml contains only reference to some styles that we use).
Almost all the functionality of this example is the same as in the previous one, with two important differences:
DockManager's
IsInEditableState
property is set to true
:
<ResourceDictionary>
<np:DockManager x:Key="TheDockManager"
IsInEditableState="True"/>
</ResourceDictionary>
- There is an "
Edit
" ToggleButton
added at the bottom of the MainWindow.axaml file:
<ToggleButton Classes="WindowIconButton IconButton IconToggleButton"
np:AttachedProperties.IconData="{StaticResource Pencil}"
IsChecked="{Binding Path=$parent[Window].
(np:DockAttachedProperties.IsInDockEditableState), Mode=TwoWay}"
Margin="5,0"
Grid.Row="1"
HorizontalAlignment="Right"/>
The button's IsChecked
property is bound to the np:DockAttachedProperties.IsInDockEditableState
Attached Property of the MainWindow
.
As you saw, once the DockManager
is switched into the editable state, the floating window's Edit/Stop Editing toggle button appears automatically within the window's header, but for the user defined windows (and our MainWindow
for sure is the user defined window), the developer should add such button himself and provide all the wirings for it.
Switching the np:DockAttachedProperties.IsInDockEditableState
attached property of the window to true
will switch all the groups within that window into an editable state.
Stable Groups
StackDockGroups
and TabbedDockGroups
have a property IsStableGroup
. This property is false
by default, but if set to true
, it makes the group stable. Stable groups cannot be removed and the floating windows that contain them cannot be (legally) destroyed, unless you pull such groups out of it.
The stability of a group, should only be set once at the group creation time (e.g., within the XAML code) and it should never be changed throughout the life cycle of the application.
Why the stable groups are needed will be explained in the further examples.
The stable group example located under NP.Demos.StableGroupDemo
solution has exactly the same code as the previous sample aside for one group property set within the MainWindow.xaml file:
<np:StackDockGroup TheOrientation="Horizontal"
IsStableGroup="True">
If you run the application and switch the main window into the editable state, you will see the anchor icon in the groups header:
Drag the group out of the main window into a floating window. You shall see that the floating window does not have the Close button or Menu option.
The floating windows would not close if they contain a stable group. When the main window closes, often the whole application needs to close, however, if you have some windows that do not close the application but might contain some docking panes, it is the developers' responsibility to provide the check and behaviors preventing the windows' closure if such windows have stable groups.
Default Parent Groups and Positions for Dock Groups and Panes
Every Dock Group and Dock Pane can be given the default parent and default order within the default parent's children. Then when they are not under the default parent, one could use some special functionality to move the group or pane under its default parent. This functionality is available as visual menus and also as public
methods that can be used by the developers.
Note that this is an example where the Stable groups come in handy - the Default Parent groups should all be stable, otherwise, if such group is removed, the child will not be able to find its default parent.
Compile and run the sample located under NP.Demos.DefaultParentDemo
project. Change the docking framework into an editable state and pull the whole Tabbed Group at the bottom into a separate floating window. Right click onto the floating window's header and choose "Restore Default Location" menu item:
The floating window will disappear and the tabbed group will reappear at its original place.
Try pulling the tabs or the Panes out of their default locations, you'll be able to return all of them to their original place.
The MainWindow.axaml code is almost the same as in the two previous samples, except that now every group (aside from root groups) is stable and has a manually assigned DockId
that carries some meaning. For example:
<np:StackDockGroup ...
DockId="TopLevelGroup"
IsStableGroup="True">
Also in this sample, most groups and DockItems
have the properties DefaultDockGroupId
and DefaultDockOrderInGroup
set (DefaultDockOrderInGroup
does not always have to be set for the first item in the group because it is 0
by default):
<np:DockItem Header="Hello"
DefaultDockGroupId="TopStackGroup"
DefaultDockOrderInGroup="1">
<TextBlock Text="Hello World!"/>
</np:DockItem>
Important Note: Currently, it is the developer's responsibility to make sure that each group specified by DefaultDockGroupId
property is stable. Not making it stable can result in application crash if such groups are removed by the user.
Creating Floating Windows in XAML
Sometimes, one might want to have a default location for their groups not in the main window but in the floating window. New UniDock version allows to specify the default floating window(s) within the XAML.
Compile and run the NP.Demos.DefineFloatingWindowInXamlDemo
sample. You will see two windows popping up - the main window, containing the tabs and the Floating Window containing two dock panes - "Hi
" and "Hello
":
The XAML contains the same tabbed group in the main window's dock structure, while the two panes that used to be at the top are now factored out into a separate Floating Window:
<np:RootDockGroup TheDockManager="{StaticResource TheDockManager}">
<np:RootDockGroup.FloatingWindows>
<np:FloatingWindowContainer WindowSize ="400, 200"
WindowRelativePosition="800,100"
WindowId="AnotherWindow"
Title="My Floating Window">
<np:StackDockGroup TheOrientation="Vertical"
DockId="TopLevelGroup"
IsStableGroup="True">
<np:StackDockGroup TheOrientation="Horizontal"
DockId="TopStackGroup"
DefaultDockGroupId="TopLevelGroup"
IsStableGroup="True">
<np:DockItem Header="Hi"
DefaultDockGroupId="TopStackGroup"
DefaultDockOrderInGroup="1">
<TextBlock Text="Hi World!"/>
</np:DockItem>
<np:DockItem Header="Hello"
DefaultDockGroupId="TopStackGroup"
DefaultDockOrderInGroup="1">
<TextBlock Text="Hello World!"/>
</np:DockItem>
</np:StackDockGroup>
</np:StackDockGroup>
</np:FloatingWindowContainer>
</np:RootDockGroup.FloatingWindows>
...
</np:RootDockGroup>
FloatingWindows
property of RootDockGroup
can contain any number or FloatingWindowContainer
objects, each of which will result in a floating window with some Dock structure. Note that WindowSize
, WindowRelativePosition
and WindowId
properties are required parameters (without them, the floating window will not appear). The rest of FloatingWindowContainer
properties (e.g., Title
) are optional.
Making all Floating Windows to be Children of the Main Window
In many applications, you want the floating windows to be children of the Main Window - so that the main window cannot block them (child windows are always on top of their parents) and also so that they would close automatically when the main window closes. This is achieved by a single line within the MainWindow
's XAML open tag: np:DockAttachedProperties.DockChildWindowOwner="{Binding RelativeSource={RelativeSource Self}}"
. For one of such line, you can find in the MainWindow.axaml file of the previous section NP.Demos.DefineFloatingWindowInXamlDemo
demo:
<Window xmlns="https://github.com/avaloniaui"
...
np:DockAttachedProperties.DockChildWindowOwner=
"{Binding RelativeSource={RelativeSource Self}}"
...>
Items that Can Dock Only to Some Locations
In Visual Studio, the documents usually can only dock to each other or to the so called main area of the Visual Studio, but cannot dock to the so called tool windows area or the solution explorer area. There is a simple feature added to UniDock allowing the same kind of behavior.
Try running NP.Demos.GroupOnlyByIdDemo
project. You can pull out various tabs from the bottom part of the main window, but you'll be able to dock them back only to the tab area and nowhere else. Also, you cannot pull the tab area out of the main window even in the editable mode, because its IsFloating
property is set to false
.
This feature is achieved by setting the property GroupByOnlyId
to the same non null string
(in our case, it is the string
"Documents
") only on the tabs and their TabbedDockGroup
:
<np:TabbedDockGroup IsStableGroup="True"
GroupOnlyById="Documents"
CanFloat="False">
<np:DockItem Header="Tab1"
GroupOnlyById="Documents">
<TextBlock Text="This is tab1"/>
</np:DockItem>
<np:DockItem Header="Tab2"
GroupOnlyById="Documents">
<TextBlock Text="Tab2 is here"/>
</np:DockItem>
<np:DockItem Header="Tab3"
GroupOnlyById="Documents">
<TextBlock Text="Finally - Tab3"/>
</np:DockItem>
</np:TabbedDockGroup>
The UniDock will make sure that the groups and items with GroupOnlyById
set to non-null
can only dock to other groups or items with the same GroupOnlyById
value.
Using View Models with the DockManager
The DockManager
also allows using the View Models for creating some or all of the DockItem
panes. View Models as well as the IUniDockService
non-visual interface of the DockManager
can be also used for simple manipulations with the with DockItems
including creating new dock items, removing dock items, selecting dock items. All of this will be explained in the coming samples.
Using View Models Demo
NP.Demos.UsingViewModelsDemo
shows how to manipulate the docking functionality using the view models.
Build and run the project. Click "Add Tab" button at the bottom several times, several tabs will be created at the lower half of the window, also the entries allowing to control the tabs visibility will be created at the top:
Play with changing the visibility of the tabs using the checkboxes at the top. The tabs should disappear and reappear correspondingly.
Now press "Save" button at the bottom. Add or remove or rearrange the tabs. Then press "Restore" button. The configuration you had when you saved should be restored (including the tab visibility).
Now let us look at the implementation of this functionality. Unlike in previous cases (where only MainWindow.axaml file had significant changes), this example also has MainWindow.axaml.cs file changed.
First, take a look at MainWindow.axaml file. The window tag currently has a line assigning np:DockAttachedProperty.TheWindowManager
to the resource:
np:DockAttachedProperties.TheDockManager="{DynamicResource TheDockManager}"
This is because we want to save and restore the layout - so the DockManager
needs to be aware of every window that has a docking hierarchy.
Note that we use DynamicResource
extension since the resource is defined after the Window
tag.
At the top of the XAML file, the DockManager
is defined as a resource and after that, we add the ListBox
of items that control the visibility of the tabs:
<Window.Resources>
<np:DockManager x:Key="TheDockManager"/>
</Window.Resources>
<Grid RowDefinitions="Auto, *, Auto"
Margin="5">
<ListBox Items="{Binding Path=DockItemsViewModels, Source={StaticResource TheDockManager}}"
Height="70">
<ListBox.ItemTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding Path=IsDockVisible, Mode=TwoWay}"
Content="{Binding DockId}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
...
</Grid>
The Items
property of the ListBox
is bound to the view models located in DockItemsViewModels
collection of the DockManager
. Each CheckBox
's IsChecked
property is two-way bound to the IsDockVisible
property of the corresponding item view model, while the CheckBox
's content displays the DockId
of the item.
The TabbedDockGroup
to which the view model items are added is defined as a stable group with DockId="Tabs"
:
<np:TabbedDockGroup IsStableGroup="True"
DockId="Tabs"/>
There are three buttons defined at the bottom: "AddTabButton
", "SaveButton
" and "RestoreButton
".
<StackPanel Orientation="Horizontal"
Grid.Row="2"
HorizontalAlignment="Right">
<Button x:Name="AddTabButton"
Content="Add Tab"
Padding="10,5"
Margin="5"/>
<Button x:Name="SaveButton"
Content="Save"
Padding="10,5"
Margin="5"/>
<Button x:Name="RestoreButton"
Content="Restore"
Padding="10,5"
Margin="5"/>
</StackPanel>
Now, switch your attention to the MainWindow.axaml.cs file. Note that we are dealing with IUniDockInterface
instead of the DockManager
class. This interface does not contain any references to Avalonia specific functionality and can be used in purely non-visual projects.
Here is how we get the reference to the DockManager
and set the View Models collection within the constructor:
_uniDockService = (IUniDockService) this.FindResource("TheDockManager")!;
_uniDockService.DockItemsViewModels =
new ObservableCollection<DockItemViewModelBase>();
Here is the handler of the AddButton
's click event:
private int _tabNumber = 1;
private void AddTabButton_Click(object? sender, RoutedEventArgs e)
{
string tabStr = $"Tab_{_tabNumber}";
_uniDockService.DockItemsViewModels.Add
(
new DockItemViewModelBase
{
DockId = tabStr,
Header = tabStr,
Content = $"This is tab {_tabNumber}",
DefaultDockGroupId = "Tabs",
DefaultDockOrderInGroup = _tabNumber,
IsSelected = true,
IsActive = true
});
_tabNumber++;
}
At every click of the AddButton
, we add a DockItemViewModelBase
object to the view models collection. It should have a unique DockId
- when dealing with the view models, it is up to the developer to ensure the uniqueness.
Other important properties are:
DefaultDockGroupId
- should point to the DockId
of the parent group under which we want to place the new DockItem
corresponding to the new DockItemViewModelBase
object. In our sample, we use the DockId
"Tabs" which our TabbedDockGroup
has. DefaultDockOrderInGroup
- determines the order in which the item will be inserted under its parent group.
Here is how we save and restore the layout and the view model items:
private const string DockSerializationFileName = "DockSerialization.xml";
private const string VMSerializationFileName = "DockVMSerialization.xml";
private void SaveButton_Click(object? sender, RoutedEventArgs e)
{
_uniDockService.SaveToFile(DockSerializationFileName);
_uniDockService.SaveViewModelsToFile(VMSerializationFileName);
}
private void RestoreButton_Click(object? sender, RoutedEventArgs e)
{
_uniDockService.DockItemsViewModels = null;
_uniDockService.RestoreFromFile(DockSerializationFileName);
_uniDockService.RestoreViewModelsFromFile(VMSerializationFileName);
_uniDockService.DockItemsViewModels?.FirstOrDefault()?.Select();
}
Note that you have to clear the view models before restoring the layout - otherwise, the layout might be affected by the change of the view models collection - the DockItems
with the same DockId
will be removed.
Dock View Models with Custom Content and Custom Visual Representation
Run the NP.Demos.CustomViewModelsDemo
application. Add several stocks by pressing "Add Stock" button. It will add several tabs - odd tabs will contain mock information for IBM stock and even will contain that for Microsoft (BTW, do not search for any ask/bid numbers close to real ones - it is all a 100% mock up):
Try to rearrange the tabs including, perhaps pulling out some of them. Save the layout. Restart the application and press the Restore button. Make sure that the layout is restored with the same data in its panes.
There are a couple of things here that we have not demoed before:
- We managed to create some entities with a complex View Model (stock) and demonstrate its complex visual representation within our tabs by using the
DataTemplate
as will be shown shortly. The header is also represented by its own DataTemplate
built around the same View Model. - We can save and restore these View Models corresponding to the stocks and synchronize them with the Dock layout so that the view models and their visual representation will appear within correct dock panes.
Now let us take a look at the code located within MainWindow.axaml, MainWindow.axaml.cs, StockViewModel.cs and StockDockItemViewModel.cs files.
The dock area is very simple - it consists of TabbedDockGroup
with DockId
"Stocks" within RootDockGroup
:
<np:RootDockGroup TheDockManager="{StaticResource TheDockManager}"
Grid.Row="1">
<np:TabbedDockGroup IsStableGroup="True"
DockId="Stocks"/>
</np:RootDockGroup>
There are three buttons at the bottom - "Add Stock
", "Save
" and "Restore
":
<StackPanel Orientation="Horizontal"
Grid.Row="2"
HorizontalAlignment="Right">
<Button x:Name="AddStockButton"
Content="Add Stock"
Padding="10,5"
Margin="5"/>
<Button x:Name="SaveButton"
Content="Save"
Padding="10,5"
Margin="5"/>
<Button x:Name="RestoreButton"
Content="Restore"
Padding="10,5"
Margin="5"/>
</StackPanel>
At the top of the file, within Window.Resources
section, we keep our DockManager
as a resource, but also define two DataTemplates
: one for the header and one for the content of the dock pane:
<Window.Resources>
<np:DockManager x:Key="TheDockManager"/>
<DataTemplate x:Key="StockHeaderDataTemplate">
<TextBlock Text="{Binding Path=Symbol, StringFormat='Symbol: \{0\}'}"/>
</DataTemplate>
<DataTemplate x:Key="StockDataTemplate">
<Grid Margin="5"
RowDefinitions="Auto, Auto, Auto, Auto">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left">
<TextBlock Text="Symbol: "/>
<TextBlock Text="{Binding Symbol}"
FontWeight="Bold"/>
</StackPanel>
<TextBlock Text="{Binding Description}"
Grid.Row="1"
Margin="0,10,0,5"
HorizontalAlignment="Left"/>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
Grid.Row="2"
Margin="0,5">
<TextBlock Text="Ask: "/>
<TextBlock Text="{Binding Path=Ask, StringFormat='\{0:0.00\}'}"
Foreground="Green"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
Grid.Row="3"
Margin="0,5">
<TextBlock Text="Bid: "/>
<TextBlock Text="{Binding Path=Bid, StringFormat='\{0:0.00\}'}"
Foreground="Red"/>
</StackPanel>
</Grid>
</DataTemplate>
</Window.Resources>
Both templates are defined around StockViewModel
class:
public class StockViewModel : VMBase
{
[XmlAttribute]
public string? Symbol { get; set; }
[XmlAttribute]
public string? Description { get; set; }
[XmlAttribute]
public decimal Ask { get; set; }
[XmlAttribute]
public decimal Bid { get; set; }
public override string ToString()
{
return $"StockViewModel: Symbol={Symbol}, Ask={Ask}";
}
}
Of course, in real life, we would make Ask
and Bid
property fire PropertyChanged
event when they change, but for our mockup, we simplify the parts of the example that are not directly related to the UniDock functionality.
We also define a class StockDockItemViewModel
- its purpose is to marry the StockViewModel
with the DockItemViewModel
:
public class StockDockItemViewModel : DockItemViewModel<StockViewModel>
{
}
Class DockItemViewModel<TViewModel>
derives from DockItemViewModelBase
class that we used in the previous example. It also defines property TheVM
of type TViewModel
that should contain the view model object (in our case, it will contain StockViewModel
object). Its Header
and Content
properties are overridden to return the object contained by TheVM
property:
public class DockItemViewModel<TViewModel> : DockItemViewModelBase
where TViewModel : class
{
#region TheVM Property
private TViewModel? _vm;
[XmlElement]
public TViewModel? TheVM
{
get
{
return this._vm;
}
set
{
if (this._vm == value)
{
return;
}
this._vm = value;
this.OnPropertyChanged(nameof(TheVM));
}
}
#endregion TheVM Property
[XmlIgnore]
public override object? Header
{
get => TheVM;
set
{
}
}
[XmlIgnore]
public override object? Content
{
get => TheVM;
set
{
}
}
}
Now take a look at MainWindow.axaml.cs file where everything comes together. We still assign our _uniDockService
to contain a reference to the dock manager:
_uniDockService = (IUniDockService) this.FindResource("TheDockManager")!;
_uniDockService.DockItemsViewModels =
new ObservableCollection<DockItemViewModelBase>();
We define two StockViewModel
objects - one for IBM and one for MSFT and then place them within an array Stocks
:
private static StockViewModel IBM =
new StockViewModel
{
Symbol = "IBM",
Description = "International Business Machines",
Ask = 51,
Bid = 49
};
private static StockViewModel MSFT =
new StockViewModel
{
Symbol = "MSFT",
Description = "Microsoft",
Ask = 101,
Bid = 99
};
private static StockViewModel[] Stocks =
{
IBM,
MSFT
};
When adding a new stock, we increase the _stockNumber
, if it is even, we choose IBM, if it is odd, we choose MSFT:
private int _stockNumber = 0;
private void AddStockButton_Click(object? sender, RoutedEventArgs e)
{
var stock = Stocks[_stockNumber % 2];
int tabNumber = _stockNumber + 1;
_uniDockService.DockItemsViewModels.Add
(
new StockDockItemViewModel
{
DockId = $"{stock.Symbol}_{tabNumber}",
TheVM = stock,
DefaultDockGroupId = "Stocks",
DefaultDockOrderInGroup = _stockNumber,
HeaderContentTemplateResourceKey = "StockHeaderDataTemplate",
ContentTemplateResourceKey = "StockDataTemplate",
IsSelected = true,
IsActive = true,
IsPredefined = false
});
_stockNumber++;
}
Then we create the StockDockItemViewModel
object and add it to the observable collection of the view models of our _uniDockService
.
Note, that we set StockDockItemViewModel.TheVM
property to our stock
object of StockViewModel
type. Also, very important is that we set:
- the default parent group to "
Stocks
" - DefaultDockGroupId = "Stocks"
. - the
HeaderContentTemplateResourceKey
to the resource Key of the DataTemplate
defined for the header: HeaderContentTemplateResourceKey = "StockHeaderDataTemplate"
- the
ContentTemplateResourceKey
to the resource key of the DataTemplate
defined for the content: ContentTemplateResourceKey = "StockDataTemplate"
IsPredefined=false
means that the DockItem
is not user defined, but comes from the view model.
Functionality for saving and restoring the layout and the view models is almost exactly the same as before aside from the fact that the type StockDockItemViewModel
is added to the serialization types at the restoration state:
_uniDockService.RestoreViewModelsFromFile
(
VMSerializationFileName,
typeof(StockDockItemViewModel));
Important Note: Often, the view models will be saved separately from the docking functionality as part of the rest of the view models within the application, so that instead of storing and restoring the view models via the DockManager
, one could get the view models from the rest of the application and create the corresponding Dock Item View Models for each one of them on the fly.
Using View Models of Two Different Types for Docking Functionality Demo
Assume that instead of having just stock tabs, we also want to have the tabs corresponding to the stock orders coming to the system. This is what will be demoed in this section.
Run NP.Demos.StocksAndOrdersViewModelsDemo
application. You will see two windows popping up: the main window and the "Orders
" window next to it. In comparison to the previous application, there is an extra "Add Order" button at the bottom of the main window. When that button is pressed, the "Orders
" window will get a tab corresponding to another order:
Add some stocks and orders and reshape the windows and then save and restore. Everything should be working fine.
The code is very similar to that of the previous sample. MainWindow.axaml file has two extra DataTemplates
defined in its Window.Resources
section - OrderHeaderDataTemplate
for the header of the order panes and OrderDataTemplate
for the content:
<DataTemplate x:Key="OrderHeaderDataTemplate">
<TextBlock Text="{Binding Path=Symbol, StringFormat='\{0\} Order'}"/>
</DataTemplate>
<DataTemplate x:Key="OrderDataTemplate">
<Grid Margin="5"
RowDefinitions="Auto, Auto, Auto, *">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left">
<TextBlock Text="Symbol: "/>
<TextBlock Text="{Binding Symbol}"
FontWeight="Bold"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
Grid.Row="1"
Margin="0,5">
<TextBlock Text="Number of Shares: "/>
<TextBlock Text="{Binding Path=NumberShares}"/>
</StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
Grid.Row="2"
Margin="0,5">
<TextBlock Text="Market Price: "/>
<TextBlock Text="{Binding Path=MarketPrice, StringFormat='\{0:0.00\}'}"/>
</StackPanel>
</Grid>
</DataTemplate>
It also has an extra "Add Order" button.
There is OrderViewModel
class defined for orders:
public class OrderViewModel : VMBase
{
public string? Symbol { get; set; }
public int NumberShares { get; set; }
public decimal MarketPrice { get; set; }
public override string ToString()
{
return $"OrderViewModel: Symbol={Symbol}";
}
}
And there is StockDockItemViewModel
class for fitting the orders into the Docking infrastructure:
public class OrderDockItemViewModel : DockItemViewModel<OrderViewModel>
{
}
MainWindow.axaml.cs file has a handler for "Add Order" button click creating a new OrderViewModel
and OrderDockItemViewModel
objects and inserting them into the View Model collection of the DockManager
.
int _orderNumber = 0;
private void AddOrderButton_Click(object? sender, RoutedEventArgs e)
{
var stock = Stocks[_orderNumber % 2];
OrderViewModel orderVM = new OrderViewModel
{
Symbol = stock.Symbol,
MarketPrice = (stock.Ask + stock.Bid) / 2m,
NumberShares = (_orderNumber + 1) * 1000
};
var newTabVm = new OrderDockItemViewModel
{
DockId = "Order" + _orderNumber + 1,
DefaultDockGroupId = "Orders",
DefaultDockOrderInGroup = _orderNumber,
HeaderContentTemplateResourceKey = "OrderHeaderDataTemplate",
ContentTemplateResourceKey = "OrderDataTemplate",
IsPredefined = false,
TheVM = orderVM
};
_uniDockService.DockItemsViewModels!.Add(newTabVm);
_orderNumber++;
}
Note that in this sample, we need to pass two types to the DockManager.RestoreViewModelsFromFile
method - typeof(StockDockItemViewModel)
and typeof(OrderDockItemViewModel)
:
_uniDockService.RestoreViewModelsFromFile
(
VMSerializationFileName,
typeof(StockDockItemViewModel),
typeof(OrderDockItemViewModel));
Using DataContext coming from the Main Window within DockItems and Groups
A question that many people asked about how to use the DataContext
and resources defined within the Main Window within the DockItem
s and groups.
Note that one should not directly use the DataContext
and resources of the MainWindow
within the docking objects, because they might be changing their positions within the visual tree and correspondingly lose their original data context and lose the references to the resources defined within the main window.
A simple demo solution NP.Demos.DataContextDemo
shows how to bind both header and properties of elements defined within a DockItem
to a resource defined within the MainWindow
. Binding to a DataContext
outside of the Docking
hierarchy should be done in the example in the same fashion.
The MainWindow.axaml defines a resource of type TestViewModel
:
<Window.Resources>
<ResourceDictionary>
...
<local:TestViewModel x:Key="TheViewModel"
Header="The is the Header"
Content="This is a test content"/>
</ResourceDictionary>
</Window.Resources>
TestViewModel
is a simple class containing two properties, Header
and Content
, both of type object
defined in the main project:
public class TestViewModel
{
public object? Header { get; set; }
public object? Content { get; set; }
}
The first DockItem
defined within the MainWindow
has its DockDataContextBinding
property containing the Avalonia Binding
that point to this resource object:
<np:DockItem ...
DockDataContextBinding="{Binding Source={StaticResource TheViewModel}}">
This setting of the DockDataContextBinding
makes the DockDataContext
property defined on the DockItem
itself to contain the TestViewModel
resource and not to change when the dock pane is moved or made to float
. Now one can bind to that property from within the DockItem
or from its header:
<np:DockItem Header="{Binding Path=DockDataContext.Header, RelativeSource={RelativeSource Self}}"
DockDataContextBinding="{Binding Source={StaticResource TheViewModel}}">
<TextBlock Text="{Binding Path=DockDataContext.Content,
RelativeSource={RelativeSource AncestorType=np:IDockDataContextContainer}}"/>
</np:DockItem>
IDockDataContextContainer
is an interface implemented by DockItem
and various Dock
group types, so the TextBlock
's binding RelativeSource
is simply referring to the DockItem
containing this TextBlock
.
Run the sample and here is what you'll see:
You can pull the Dock pane outside of the window or re-dock it wherever you want, the context will not change (unless you will make the TestViewModel
's properties notifiable and change them in C# code - the two visible text strings will be changed also because of the bindings).
Note that there is only one Binding
(DockDataContextBinding
) and one data context property (DockDataContext
) to be used for both DockItem's
header and content. This might cause a slight inconvenience when you need to connect both (as in the sample above). You will have to create a ViewModel
type that will combine both objects (as our TestViewModel
does)- still TestViewModel
is a very simple type and a very small additional work.
Default Layout Sample
The best way to create a default layout (the layout which the application adopts in the beginning and also to which the user can revert at any time) is my saving some layout that the user likes in an XML file, making this XML file part of your main project and using it to restore the default layout. There are two projects that show how to achieve it - NP.Demos.DefaultLayoutSaveDemo and NP.Demos.DefaultLayoutDemo.
Run the NP.Demos.DefaultLayoutSaveDemo first - initially the application will look like this:
Change the layout to whatever you want it to be. You can tab panes together or make them floating etc. Assume that you modified the layout to be smth like:
Press button "Save Layout". The layout will be stored inside "DefaultLayout.xml" within the same folder as the executable (for .NET5.0 it will be inside bin/Debug/net5.0 folder under the solution folder).
Code for saving the layout is located within MainWindow.axaml.cs file. We simply find the DockManager
within the resources and on "Save Layout" button click, call _dockManager.SaveToFile("DefaultLayout.xml");
.
Then we copy this layout file "DefaultLayout.xml" to the other solution NP.Demos.DefaultLayoutDemo. We add it to the main project of the solution and set its property "Build Action" to "Content" and "Copy to Ouput Directory" to "Copy if newer":
Now, build DefaultLayoutDemo project and run it. The docking will have the default layout initially.
This is achieved by getting the DockManager
from the resource and calling its method _dockManager.RestoreFromFile("DefaultLayout.xml") within the MainWindow constructor:
public MainWindow()
{
...
_dockManager = (DockManager)this.FindResource("TheDockManager")!;
_dockManager.RestoreFromFile("DefaultLayout.xml");
}
Conclusion
The new UniDock features bring it to the point of maturity. Currently, it is the best and the most feature rich multiplatform visual docking framework that works on Windows, Mac and Linux.
The framework is released under the most permissive MIT license. Please use it for your multiplatform projects and tell me about the ways to improve it and the bugs to fix.
Also, please drop a few lines as comments telling me what you think about the article and ways to improve it.
I plan to write another article describing how to customize the docking styles and behaviors in UniDock.
History
- 3rd November, 2021: Initial version
- 1st January 2023: Upgraded to Avalonia 11