Click here to Skip to main content
16,017,944 members
Articles / Desktop Programming / WPF

UniDock - A New Multiplatform UI Docking Framework. UniDock Power Features.

Rate me:
Please Sign up or sign in to vote.
4.98/5 (34 votes)
1 Jan 2024CPOL26 min read 53.3K   54   50
Describes the new powerful features of UniDock, the new multiplatform UI docking framework.
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 SamplesBasics 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:

Image 1

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:)

Image 2

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:

Image 3

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:

Image 4

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:

XAML
<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:

XAML
<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>
    <!-- Define the dock manager-->
    <np:DockManager x:Key="TheDockManager"/>
  </Window.Resources>
  <Grid>
    <!-- top level group should reference the dock manager-->
    <np:RootDockGroup TheDockManager="{StaticResource TheDockManager}">
      <!-- Second Level group arranges the top and the bottom parts 
           vertically-->
      <np:StackDockGroup TheOrientation="Vertical">
        <!-- top group arranges two top Dock Panes horizontally-->
        <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>

        <!-- Tabbed group at the bottom -->
        <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:

  1. DockItem represents the dockable or tab panes.
  2. 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.
  3. TabbedDockGroup represents a tabbed group of panes (each pane - is defined by a DockItem).
  4. 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:

  1. 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.
  2. 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:

Image 5

Press the button and then lo and behold, each group gets its own header with some information and buttons in them:

Image 6

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:

Image 7

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:

Image 8

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":

Image 9

Image 10

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":

Image 11

Take a look at the Lock/Unlock ToggleButton within the group's header:

Image 12

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:

Image 13

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:

Image 14

Of all four options, choose the Left hand side:

Image 15

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:

Image 16

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:

  1. DockManager's IsInEditableState property is set to true:
    XAML
    <ResourceDictionary>
      <np:DockManager x:Key="TheDockManager"
                      IsInEditableState="True"/>
    </ResourceDictionary>
  2. There is an "Edit" ToggleButton added at the bottom of the MainWindow.axaml file:
    XAML
    <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:

XAML
<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:

Image 17

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.

Image 18

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:

Image 19

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:

XAML
<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):

XAML
<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":

Image 20

Image 21

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:

XAML
<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">
          <!-- top group arranges two top Dock Panes horizontally-->
          <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:

XAML
<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:

XAML
<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:

Image 22

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:

XAML
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:

XAML
<Window.Resources>
  <!-- Define the dock manager-->
  <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":

XAML
<np:TabbedDockGroup IsStableGroup="True"
                    DockId="Tabs"/>

There are three buttons defined at the bottom: "AddTabButton", "SaveButton" and "RestoreButton".

XAML
<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:

C#
// set the uniDockService interface to contain the reference to the
// dock manager defined as a resource.
_uniDockService = (IUniDockService) this.FindResource("TheDockManager")!;

// set the DockItemsViewModels collection to an observable
// collection of DockItemViewModelBase items.
_uniDockService.DockItemsViewModels = 
    new ObservableCollection<DockItemViewModelBase>();  

Here is the handler of the AddButton's click event:

C#
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:

C#
private const string DockSerializationFileName = "DockSerialization.xml";
private const string VMSerializationFileName = "DockVMSerialization.xml";

private void SaveButton_Click(object? sender, RoutedEventArgs e)
{
    // save the layout
    _uniDockService.SaveToFile(DockSerializationFileName);

    // save the view models
    _uniDockService.SaveViewModelsToFile(VMSerializationFileName);
}

private void RestoreButton_Click(object? sender, RoutedEventArgs e)
{
    // clear the view models
    _uniDockService.DockItemsViewModels = null;

    // restore the layout
    _uniDockService.RestoreFromFile(DockSerializationFileName);

    // restore the view models
    _uniDockService.RestoreViewModelsFromFile(VMSerializationFileName);

    // select the first tab.
    _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):

Image 23

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:

XAML
<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":

XAML
<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:

XAML
<Window.Resources>
  <!-- Define the dock manager-->
  <np:DockManager x:Key="TheDockManager"/>

  <!-- Data template for the header of dock pane -->
  <DataTemplate x:Key="StockHeaderDataTemplate">
    <TextBlock Text="{Binding Path=Symbol, StringFormat='Symbol: \{0\}'}"/>
  </DataTemplate>

  <!-- Data template for the content of dock pane -->
  <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:

C#
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:

C#
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:

C#
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:

C#
// set the uniDockService interface to contain the reference to the
// dock manager defined as a resource.
_uniDockService = (IUniDockService) this.FindResource("TheDockManager")!;

// set the DockItemsViewModels collection to an observable
// collection of DockItemViewModelBase items.
_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:

C#
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:

C#
private int _stockNumber = 0;
private void AddStockButton_Click(object? sender, RoutedEventArgs e)
{
    // for even choose IBM, for odd - msft
    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:

C#
// restore the view models
_uniDockService.RestoreViewModelsFromFile
(   
    VMSerializationFileName,
    typeof(StockDockItemViewModel));  // the new type StockDockItemViewModel is added 

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:

Image 24

Image 25

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:

XAML
<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:

C#
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:

C#
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.

C#
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):

C#
// restore the view models
_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 DockItems 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:

XAML
<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:

C#
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:

XAML
<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:

XAML
<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:

Image 26

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:

Image 27

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:

Image 28

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":

Image 29

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:

C#
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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Architect AWebPros
United States United States
I am a software architect and a developer with great passion for new engineering solutions and finding and applying design patterns.

I am passionate about learning new ways of building software and sharing my knowledge with others.

I worked with many various languages including C#, Java and C++.

I fell in love with WPF (and later Silverlight) at first sight. After Microsoft killed Silverlight, I was distraught until I found Avalonia - a great multiplatform package for building UI on Windows, Linux, Mac as well as within browsers (using WASM) and for mobile platforms.

I have my Ph.D. from RPI.

here is my linkedin profile

Comments and Discussions

 
GeneralRe: My vote of 5 Pin
Nick Polyak8-Dec-21 4:22
mvaNick Polyak8-Dec-21 4:22 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA25-Nov-21 20:17
professionalȘtefan-Mihai MOGA25-Nov-21 20:17 
GeneralRe: My vote of 5 Pin
Nick Polyak26-Nov-21 3:44
mvaNick Polyak26-Nov-21 3:44 
Question5 stars ! Pin
Vlad&Nova4-Nov-21 10:13
Vlad&Nova4-Nov-21 10:13 
AnswerRe: 5 stars ! Pin
Nick Polyak4-Nov-21 11:00
mvaNick Polyak4-Nov-21 11:00 
QuestionGreat Job ! Pin
Member 149759584-Nov-21 9:17
Member 149759584-Nov-21 9:17 
AnswerRe: Great Job ! Pin
Nick Polyak4-Nov-21 11:00
mvaNick Polyak4-Nov-21 11:00 
QuestionNice job Pin
danzar1014-Nov-21 7:56
danzar1014-Nov-21 7:56 
Was a great read.

Side Note:
At first, I thought this article was going to be about "Docker". Big Grin | :-D
AnswerRe: Nice job Pin
Nick Polyak4-Nov-21 8:02
mvaNick Polyak4-Nov-21 8:02 
AnswerRe: Nice job Pin
Nick Polyak4-Nov-21 8:08
mvaNick Polyak4-Nov-21 8:08 
GeneralMy vote of 5 Pin
Igor Ladnik3-Nov-21 19:48
professionalIgor Ladnik3-Nov-21 19:48 
GeneralRe: My vote of 5 Pin
Nick Polyak4-Nov-21 4:41
mvaNick Polyak4-Nov-21 4:41 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.