This article will be going over a style of user interfaces that allow docking windows and splitting them. It’s a common style seen in a lot of advanced editor applications. We’ll start with some notes on a few UI patterns that existed before and then cover the basic concept of implementing it.
This article will mention some applications when examples are needed. I am not affiliated with any applications mentioned or shown on this page.
Dinosaur Methods of Multiple Dialogs
There are two main reasons to need additional dialogs. Either a utility dialog is opened, or we want to bring up another document without closing the current document.
A common strategy in very early applications with many dialogs was to let them float as modeless windows.
Modeless dialogs are dialog windows that can be used along with the application. This is different from modal windows, that pause the application and must be closed before the rest of the application is usable. (e.g., a save dialog)
The issue with this was that it was very cluttered and unorganized. It was also very inefficient to manipulate; if you wanted to move the application from one screen to another, you had to drag every single floating window. Often with these applications, I would spend a ridiculous amount of time shuffling through all the overlapping dialogs looking for a single dialog. And then after I did a little work, I would reshuffle to find the next dialog I needed.
Another old pattern was called Multiple-Document Interface, or MDI for short. This is similar to the previous pattern of having multiple floating dialogs, but these dialogs would float within a single main app window instead of the desktop.
Docking And Splitting
These days, these clunkier patterns have been mostly relegated to history or legacy software. They have fallen out of fashion for UI patterns that allow docking content and placing them with a layout engine.
These newer UIs have a data structure that defines adjacency of dialogs and regions, which is then used to position and resize dialogs with a layout engine. To make things easy and intuitive for the user, there is a drag and drop interface to dock and rip dialogs from the layout. Often overlapping windows are allowed with a notebook tab interface to switch between the windows.
Along with tiled placement, the area in-between windows are draggable sashes that allow resizing the areas of the layout.
Many source code IDEs use this to view multiple documents and utility dialogs at once. For example, Visual Studio.
Many of these UI systems also allow hovering dialog that can be placed on different monitors, making use of screen real estate on multiple screens.
Visual Studio is taking up two entire screens. One screen is dedicated to source editing, one screen is dedicated to debugging, and one screen is dedicated to viewing the application.
The style is also popular amongst Digital Content Creation (DCC) software. This is because there is often a need for a lot of features, but depending on your current task and workflow preferences, what UI controls are needed, and their optimal placement will vary.
Sidenote: Unity allows scripting custom editor windows that directly integrate into the IDE’s UI.
This pattern has many similarities to MDI or having many floating modeless dialogs, but integrates features for easily and intuitively managing the views. Also, because documents can hover, it’s a superset of the modeless dialogs approach.
Getting Access To An Implementation
Advanced UI libraries often have this feature. It’s either already supported in the OS, or there are 3rd party libraries that implement it.
That being said, we’re going to cover a basic implementation of it for educational reasons. Implementing the basics of these systems is mostly proper event handling, rectangle calculations, and data structures.
The Demo
Here’s the interactable demo of the algorithm and source we’ll be covering.
The source code for the Unity Project is available on GitHub.
Fullscreen- “Add Window” can be pressed to add a new hovering window to experiment with.
- “Cascade” is a convenient feature to undock everything.
- Dragging a window to the center of a blank canvas will make it the layout’s root.
- Dragging a window to the edge of a docked window will provide a docking preview. If the mouse is released on the preview, docking will occur.
- Docked windows can have their title bars dragged to rip them out of the docked layout and turned back into a hovering window.
- Dragging a window into the center of another window (you’ll need to drop it into the green square that appears) will create a notebook tab system.
Because this demo is in Unity, hovering windows cannot escape the game region.
The Data structure
“To understand recursion, your must first understand recursion.”
I’m not sure if there’s an “official” way it should be done, but I will go over the theory and execution of how I implemented it. This section is mostly going to be a series of small excerpts and illustrations. Note that the diagrams will have a legend to the left of them.
In the sample code, the node in the tree data structure is called a Dock. Here is a snippet of its definition:
// Dock implements a layout node. public class Dock { public enum Type { Void, // Unset or error type. Window, // The Dock is a window node. Horizontal, // The Dock is a horizontal container node Vertical, // The Dock is a vertical container node. Tab // The Dock is a tab } public Dock parent; // The parent node. public Type dockType = Type.Void; // What type of node is the object? public Window window = null; // Reference to the window, only relevant Dock is a window node. public List<Dock> children = null; // The children nodes - only relevant if Dock is a container node. public Rect cachedPlace; // The location of the node in the layout, calculated from the last layout. public Vector2 minSize = Vector2.zero; // The size of the node, calculated from the last layout. }
First off, there’s a region of space we’re managing. In the diagrams, this will be referred to as the root. This is the area where docked content will reside. And if we dock a single-window into it, it takes up the entire managing region. Actually, if we have any windows docked, their layout will take up the entire region.
Right) A layout with a single window node parented to the root.
After docking a single window, we can add another one and they will be split. This can happen either horizontally or vertically. I’ll often refer to splitting in a certain direction (i.e. horizontally or vertically) as the “grain”. And a grain node will refer to either a horizontal or vertical container node.
Right) Two windows stacked horizontally. Horizontal layout requires parenting the window nodes to a horizontal container node.
Note how in the illustrations, in order to split, we have to replace the root container with the proper grain container, and then we can insert multiple windows to be split. We could keep adding more windows to be split if we wanted to. There’s no limit except for running low on space and having the layout get awkward.
Right) Many window nodes aligned horizontally.
But the layout system doesn’t only allow us to do that, we can also have horizontal splits inside of vertical, vice versa, and do this to arbitrary depths. And through this process, we can imagine these layouts as tree data structures.
Topology Constraints
When dealing with this data structure, there are some constraints that need to be enforced to maintain sanity.
- Grain nodes can’t contain their same grain as direct children.
- A horizontal container node can’t contain a direct child that’s a horizontal node.
- A vertical container node can’t contain a direct child that’s a vertical node.
- There’s no theoretical upper limit to the number of children grains can have, only practical limits.
- i.e., the data structure can get as deep as you want as long as all other constraints are enforced, but in reality, the UI gets clunky and messy at a certain point because of unwieldy density.
- Container (grains) nodes must have more than 1 child or else they get replaced with their child.
- There’s no point in keeping around containers if they’re not holding multiple children.
“Grain nodes can’t contain their same grain as direct children.”
So here’s a question, what if we allow a vertical split to have a vertical split child in it? Or a horizontal split to have a horizontal split child in it? Well, while this may be possible, in practice, this creates huge complexity when managing the tree data-structure. If we didn’t enforce this constraint, some code would be simpler, and some would be more complex – and if we forbid this situation, then this is still true in different ways, but the complexity is more manageable. So if we have a situation where a grained container has a container child with the same grain, we collapse it.
“Container (grains) nodes must have more than 1 child or else they get replaced with their child.”
Another rule is that we can’t have grains with only one child in them. If that’s the case, we’re better off getting rid of the grain. If we enforce this constraint, this allows us to make assumptions in different parts of the code that greatly simplifies things.
That’s pretty much it! We have a graph where a node can either be
- a window
- a container aligning multiple children nodes vertically
- a container aligning multiple children nodes horizontally
Deletion
The logic for undocking and removal can be deduced from the rules for how the data structure should be maintained and how to add nodes and windows. The task involves removing windows from the data structure while also following the data structure topology constraints.
The biggest issue is detecting when cascading removals are needed. If a container node with two items has one item removed, it will then be left with one child. Since container nodes can’t have one child, this means the container must also be removed. To do this, the container’s parent needs to replace the container’s reference with the single window that’s left.
This situation can also be complicated if a cascaded deletion leaves a container with a direct child node containing a container of the same grain. If this happens, the data structure needs to be fixed by removing the same grained child. This is done by replacing it with the inner container’s children and disposing of the inner container.
After the deletion, sashes need to be managed, the layout needs to be recalculated, and windows need to be repositioned and resized.
Tabs
Coverage of tabs is going to be omitted because it’s somewhat involved – although it uses a lot of the basic concepts for horizontal and vertical grained containers.
The biggest issues are managing the extra assets for the tabs, and a constraint that only window nodes can exist as children inside of tabs.
There’s also the additional state-keeping work of tracking the active tab and making sure its contents are visible while turning off the other windows.
Arbitrary Placement
This is the ability to dock absolutely anywhere. For example, if I had a vertical split with many windows and wanted to dock a window to the very right, alongside all the vertically split windows, how does the user specify that? How does one tell the docking system that they want the window docked to the very right, instead of placing the window to the right of an individual docked window, forming a row inside?
To allow either docking option with a drag and drop interface, the docking system first needs to know there are multiple options possible and then provide a way to specify which possibility is their intent. To implement this properly, this check also needs to be done recursively because if the tree is complex enough, there may be more than 2 ambiguities.
Demo built with Unity 2019.4.16f1
Authored and tested in Chrome.
– William Leu. Stay strong, code on.