In this article, you will see a new multiplatform docking framework that I created. This framework is based on Avalonia cross-platform package.
Introduction
In this article, I am introducing a new multiplatform Docking framework I have recently created. This framework is based on Avalonia cross-platform package.
Avalonia is a great UI package, very similar to WPF but working across multiple platforms - Windows, MacOS and Linux without having many problems, that Web and Xamarin frameworks have. In particular, it is 100% composable (unlike both Web and Xamarin) and results in a single .NET executable that can run on multiple platforms (unlike Xamarin).
To learn more about starting to work with Avalonia and its advantages over JavaScript/TypeScript and Xamarin, read the following article: Multiplatform UI Coding with Avalonia in Easy Samples. Part 1 - Avalonia Building Blocks. That article also explains how to install the Avalonia Visual Studio templates and how to create your first Avalonia projects.
There is already an existing Avalonia based Dock framework, however, it is suffering from the following shortcomings:
- It is purely View Model based, so in order to create even the simplest docking infrastructure, you should be writing a lot of C# code. Some people find it counter intuitive.
- There is a lack of documentation for AvaloniaDock.
- There are some visual problems with AvaloniaDock:
- One cannot drag the window out - instead one has to use a "
Float
" menu option. - Also, even for docking the window is not dragged out visually - instead one needs to press a mouse on a header and drag the pressed mouse within the framework without any visual cues that anything is happening until the compass shows up.
On top of it - friendly competition is always good for the products and the frameworks in which they are built (in our case, it is Avalonia framework).
UniDock framework is not a port of any WPF or other framework product, in fact, I had ideas for it long ago - with the main purpose - to simplify the docking functionality both for the simplicity of usage and simplicity of the implementation, while at the same time making sure that the software can run on three major platforms: Windows, Linux and MacOS.
For the sake of simplicity, I only implemented those features that many of my clients in financial, health, scientific and military software were requesting. Because of that, I dropped a feature that no-one ever requested from me - a division between the tool and the document docking panes. How to achieve a similar effect using UniDock will be explained in one of the future articles.
I strove for the visual perfection (at least for the Windows platform - which is my main platform). I tested the framework on MacOS and Linux Ubuntu 20.04 as well. They both suffer from the same problem (which in my experience the users can perfectly live with) - when you drag a tab or a pane out of the docked cluster, you'll have to click on the floating window again before you can drag it further.
On Windows 10, however, this problem does not exist and the visual experience is virtually perfect.
UniDock framework core is currently built and is ready to be used, even though a lot of features increasing its flexibility are still going to be added in the nearest future.
One feature to be added soon is the ability to pin (temporary hide) the panes (currently pinning is not part of the functionality).
Note that at this point, I did not have time to test UniDock running on multiple screens, so there might be some problems discovered there also.
The UniDock framework is released under the most permissive MIT license (same as Avalonia). Please, use it and file requests for improvements and bugs under the UniDock github project.
Also, please comment on this article, stating what you want to add and improve.
The future UniDock articles will talk about advanced features (some of which are still in development) and customizations.
The code for UniDock is located under UniDock Code.
I built UniDock using .NET 5 code and Visual Studio 2019. I do not think I've been using any .NET 5 specific features, so I believe the code can be easily downgraded to e.g. .NET 3.1 and further.
Docking Demo
The code for the demo for this article is located under UniDock Demo. I used .NET 5 and VS 2019 to build and run the demo.
Try running the demo project NP.Demos.UniDockWindowsSample
. Here is what you'll see:
Note that I chose the demo layout to be similar to that of Sample AvaloniaDock Application in order to show how much simpler the resulting demo code is under UniDock framework.
You can play with the demo by moving the light gray horizontal and vertical separators or by pulling the tabs out.
E.g., pull out "LeftTop 1" tab. It will turn into a floating window, while the tab structure will be removed (since it will have only one "LeftTop 2" tab left) and turned into a simpler single pane (with no tabs):
Now you can use the pane's header to pull "LeftTop 2" pane out, creating another floating window.
You can also drag it by its header to the "LeftTop 1" window which will display the so called "Compass" in its middle allowing you to choose how to dock those two windows together:
Choosing the middle of the Compass will create a tabbed structure similar to those we saw in the main window, while using one of the 4 sides will dock the documents together next or on top of each other. Here, we show the result of dropping the dragged window into the bottom part of the Compass:
You can resize the panes using the separator in the middle, or pull the panes out or remove the panes using the X buttons. Let us expand the window vertically and make the second pane a little smaller than the first.
Now let us drag another pane out of the main window and dock it to the right side of the bottom pane:
The bottom panel now split vertically:
Now, let us drag another panel from the main window and drop it onto the middle part of the Compass of "LeftTop 1" pane within the floating window:
Since we dropped the new pane in the middle, we got the tabs at the top again:
,
and the document that we dropped last will be the leftmost tab and selected.
The order of the tabs, btw, can be easily changed by dragging the tabs within the tab row and dropping them at the desired position.
Now, press "Serialize" button on the main window. The layout will be serialized.
After that, restart the application and press the "Restore" button. The saved layout will be restored (including the original position of the main window within the screen - you might observe a jump.)
Docking Principles
There are following four different docking classes involved:
RootDockGroup
- This is a docking object used as a docking root within a window (whether a predifined window, e.g., main window or a floating window). It can have only a single docking child - of any other type of a Docking
class. StackDockGroup
- A docking object that arranges its docking children either vertically or horizontally depending on its TheOrientation
property. The reason I use "The
" before some property names is because I do not like when the name of the property is the same as the name of a type (in our case Orientation
is also an enumeration type). TheOrientation
property should not change once set in the beginning. Its docking children can be of any type (except for RootDockGroup
- used as we mentioned above only for window roots). TabbedDockGroup
contains tabs - each of which represents a DockItem
object. DockItem
objects are the leaf objects representing the docked documents. They cannot contain any docking children.
The TabbedDockGroup
and DockItem
objects can display the Compass during the drag/drop process and their compass can be used to drop the dragged window into.
When you drop a floating window onto the middle square of a compass - there can be two scenarios
- The compass belongs to a Tabbed group - in such case, all the
DockItems
from the dragged floating window will be inserted into this Tabbed group in front of the items that were already there and the first new inserted item will be selected. - If the compass belongs to a
DockItem
which is not inside a Tabbed group, the new TabbedDockGroup
object will be created in place of the DockItem
, this DockItem
will be inserted in it as the last tab and the tabs before will correspond to all the DockItems
from the dragged floating window.
Now I am going to describe what happens if the dragged floating window is dropped onto a side square of a compass of either a TabbedDockGroup
or a DockItem
. Without losing genericity of the approach, we can assume that it is dropped onto the right square of the compass. There are also two scenarios:
- The parent of the compass object is a horizontally oriented
StackDockGroup
. In such case, we simply insert the top level child of the floating window (the child of the root group of the floating window) to be the next item (to the right) of the item on which it is dropped. - If not (if the parent is either
RootDockGroup
or a vertically oriented StackDockGroup
object), we create a new horizontally oriented StackDockGroup
object, insert it in place of the object onto which we drop (which displays the compass), remove the object onto which we drop from its previous parent and insert it into the newly created group and insert the top level child of the floating window into that newly created group after the previous object (so that it will be on the right of it).
If we remove a DockItem
from a group by dragging it out or by pressing its X button, some reorganization might also be done:
- If its parent group does not have any children left (and its
AutoDestroy
property is not set to false
), the group is removed from its parent. - If its parent group has only 1 child left (and its
AutoDestroy
property is not set to false
), the parent group is removed from its parent and its former remaining single child is inserted into its parent in place of the group.
The purpose of the removal operations described above is to simplify the dock structure as much as possible and these operations will automatically propagate to the root of the group.
Now that we described the principles of building the Dock Tree, we can describe the default layout of the demo:
There is a RootDockGroup
as the root dock group. Its child is the horizontally oriented StackDockGroup
containing the main three columns of the demo. It has three children - each one corresponding to one of the three columns.
Its leftmost child is the vertical StackDockGroup
containing two TabbedDockGroup
s each containing two DockItem
objects.
Its middle child is a TabbedDockGroup
containing two DockItem
objects - "Document 1" and "Document 2".
Its rightmost child is a vertical StackDockGroup
containing two horizontal StackDockGroup
objects each containing two DockItem
objects.
Description of the Demo Code
The demo code is all located under NP.Demos.UniDockIntroductionDemo project on github.com.
Note that on top of dependencies on Avalonia 0.10.7 packages, a dependency on NP.Avalonia.UniDock
package is required.
Only three files are modified:
- MainWindow.axaml - contains most of the code - the Dock tree
- App.axaml contains the style references
- MainWindow.axaml.cs contains some code for calling the layout saving/restoring functionality
You can compare this to Sample AvaloniaDock Application of similar complexity with many various source code files representing different view models.
Most of the demo specific code is XAML code within MainWindow.axaml file:
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:np="https://np.com/visuals"
Width="700"
Height="700"
np:DockAttachedProperties.TheDockManager="{DynamicResource TheDockManager}"
np:DockAttachedProperties.DockChildWindowOwner=
"{Binding RelativeSource={RelativeSource Mode=Self}}"
np:DockAttachedProperties.WindowId="TheMainWindow"
x:Class="NP.Demos.UniDockWindowsSample.MainWindow"
Title="NP.Demos.UniDockWindowsSample">
<Window.Resources>
<ResourceDictionary>
<np:DockManager x:Key="TheDockManager"/>
</ResourceDictionary>
</Window.Resources>
<Grid RowDefinitions="*, Auto, Auto">
<np:RootDockGroup DockId="RootGroup"
np:DockAttachedProperties.TheDockManager=
"{StaticResource TheDockManager}">
<np:StackDockGroup TheOrientation="Horizontal">
<np:StackDockGroup TheOrientation="Vertical">
<np:TabbedDockGroup TabStripPlacement="Bottom">
<np:DockItem Header="LeftTop 1"
DockId="LeftTop1">
<TextBlock Text="Left Top 1"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
<np:DockItem Header="LeftTop 2"
DockId="LeftTop2">
<TextBlock Text="Left Top 2"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
</np:TabbedDockGroup>
<np:TabbedDockGroup TabStripPlacement="Bottom">
<np:DockItem Header="LeftBottom 1"
DockId="LeftBottom1">
<TextBlock Text="Left Bottom 1"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
<np:DockItem Header="LeftBottom 2"
DockId="LeftBottom2">
<TextBlock Text="Left Bottom 2"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
</np:TabbedDockGroup>
</np:StackDockGroup>
<np:TabbedDockGroup>
<np:DockItem Header="Document 1"
DockId="Document1">
<Grid Background="Gray">
<TextBlock Text="Document 1"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Grid>
</np:DockItem>
<np:DockItem Header="Document 2"
DockId="Document2">
<Grid Background="Gray">
<TextBlock Text="Document 2"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Grid>
</np:DockItem>
</np:TabbedDockGroup>
<np:StackDockGroup TheOrientation="Vertical">
<np:StackDockGroup TheOrientation="Horizontal">
<np:DockItem Header="RightTop 1"
DockId="RightTop1">
<TextBlock Text="Right Top 1"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
<np:DockItem Header="RightTop 2"
DockId="RightTop2">
<TextBlock Text="Right Top 2"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
</np:StackDockGroup>
<np:StackDockGroup TheOrientation="Horizontal">
<np:DockItem Header="RightBottom 1"
DockId="RightBottom1">
<TextBlock Text="Right Bottom 1"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
<np:DockItem Header="RightBottom 2"
DockId="RightBottom2">
<TextBlock Text="Right Bottom 2"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</np:DockItem>
</np:StackDockGroup>
</np:StackDockGroup>
</np:StackDockGroup>
</np:RootDockGroup>
<Grid x:Name="Separator"
Grid.Row="1"
Height="3"
Background="LightGray"/>
<StackPanel Grid.Row="2" Orientation="Horizontal"
HorizontalAlignment="Right">
<Button Width="100"
Height="50"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="Serialize"
np:CallAction.TheEvent="{x:Static Button.ClickEvent}"
np:CallAction.TargetObject="{Binding RelativeSource=
{RelativeSource AncestorType=Window}}"
np:CallAction.MethodName="Serialize"
Margin="10,20"/>
<Button Width="100"
Height="50"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="Restore"
np:CallAction.TheEvent="{x:Static Button.ClickEvent}"
np:CallAction.TargetObject="{Binding RelativeSource=
{RelativeSource AncestorType=Window}}"
np:CallAction.MethodName="Restore"
Margin="10,20"/>
</StackPanel>
</Grid>
</Window>
Note that we define the DockId
for the serialization (it will be mapped to the WindowId
). Also, we define the np:DockAttachedProperties.TheDockManager
property only at the root group level (which should always be the RootDockGroup
object). The rest of the DockGroups
and DockItems
will get the DockManager
automatically. This is done because in the future, I want to expand the functionality to handle multiple DockManager
(currently only one is allowed).
Note, that unique DockId
properties are defined only on the root RootDockGroup
and on the leaf (DockItem
) objects. This is because the groups do not need to be saved in a unique way - they are flexible, we only need to know the group type in order to display it.
The Saving/Restoring code of the demo is placed in MainWindow.axaml.cs file and consists of two simple methods Save()
and Restore()
:
const string SerializationFilePath = "../../../SerializationResult.xml";
public void Save()
{
DockManager dockManager = DockAttachedProperties.GetTheDockManager(this);
dockManager.SaveToFile(SerializationFilePath);
}
public void Restore()
{
DockManager dockManager = DockAttachedProperties.GetTheDockManager(this);
dockManager.RestoreFromFile(SerializationFilePath);
}
Each of the methods above are called when the user clicks the corresponding button.
Note about Saving/Restoring: this introductory demo only shows how to restore the locations of the windows and docking of the items that exist in the UI. If you remove an item by clicking its X button and then try to restore it, the item's content will not be restored correctly. Handling such case will be described in further article(s).
File App.axaml has the StyleInclude
tags for all the styles used in the docking application:
<Application.Styles>
<StyleInclude Source="avares://Avalonia.Themes.Default/Accents/BaseLight.xaml"/>
<StyleInclude Source="avares://Avalonia.Themes.Default/DefaultTheme.xaml"/>
<StyleInclude Source="avares://NP.Avalonia.Visuals/Themes/TextStyles.axaml"/>
<StyleInclude Source="avares://NP.Avalonia.Visuals/Themes/CustomWindowStyles.axaml"/>
<StyleInclude Source="avares://NP.Avalonia.UniDock/Themes/DockStyles.axaml"/>
</Application.Styles>
Conclusion
UniDock is a new simple fast developing Avalonia based multiplatform framework with a lot of promise. I need it for my projects and plan to add a lot of new and exciting features to it as well as working on its maintenance and documentation.
I release this framework 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 I'd appreciate if you can drop a few lines stating your opinion about this article and what can be improved in it.
History
- 29th August, 2021: Initial version