Multiple Document Interfaces (MDI) have come and gone in .NET and I am sure will someday come back again. In the meantime, I decided to write my very own implementation of MDI in WPF. Instead of multiple documents, I decided to extend my design to house multiple windows. The Multiple Window Interface contains many of the features of MDI with the addition of attachable windows.
A very basic MDI should contain a parent container which can display multiple child windows (documents). The children, being windows, can be minimized, maximized, closed and resized and are confined within the bounds of the parent container. More advanced features include the ability to rearrange children within the parent. Cascade, Tile, Horizontal Tiling and Vertical Tiling are all common ways to arrange MDI children.
I wanted the child windows in my interface to act and look like windows. Since I could not add a window to a control in WPF (at least not the way I wanted to), I created a custom control which was styled to look like a window. The MwiChild
class is templated to look and feel like a window. It is this object type that I used for the children of the parent container.
The MwiChild
class was templated to contain a DockPanel
within a Border
. The DockPanel
contains another DockPanel
that holds the Icon
, DisplayTitle
and window Buttons
. I added an additional ItemsControl
to contain user-created Buttons
. The ItemsControl
makes use of the MwiChild
property TitleBarButtons
to add new Buttons
.
<Border x:Name="MwiBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="3,1,3,3"
CornerRadius="2,2,2,2"
>
<AdornerDecorator>
<DockPanel x:Name="DockPanelWindow" >
<DockPanel x:Name="PanelTitle" DockPanel.Dock="Top"
Style="{StaticResource MyDockPanelStyle}"
MinHeight="{Binding Path=MinSize.Height}"
local:Controls.MwiChild.IsDraggable="true"
local:Controls.MwiChild.IsActive="{TemplateBinding IsSelected}"
local:Controls.MwiChild.Minimized="{TemplateBinding IsMinimized}" >
<StackPanel x:Name="PanelButtons" Orientation="Horizontal"
DockPanel.Dock="Right" HorizontalAlignment="Right"
Visibility="{TemplateBinding PanelButtonsVisiblity}" >
<ItemsControl x:Name="MyItemsControl1"
ItemsSource="{Binding TitleBarButtons}"
ItemsPanel="{StaticResource ButtonItemsPanel}" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" >
...
</StackPanel>
<StackPanel x:Name="PanelTitleIcon" Orientation="Horizontal"
DockPanel.Dock="Left" local:Controls.MwiChild.IsDraggable="true" >
<Image x:Name="ImageIcon" Source="{Binding Icon}" Width="16"
Height="16" Stretch="Uniform"
local:Controls.MwiChild.IsDraggable="true" Margin="1,0,1,0" />
<TextBlock x:Name="TextBlockTitle" Text="{Binding DisplayTitle}"
Foreground="White" VerticalAlignment="Center"
local:Controls.MwiChild.IsDraggable="true" />
</StackPanel>
</DockPanel>
I added three attached properties to MwiChild
: IsDraggable
, IsActive
and Minimized
. IsDraggable
is used on a control that can be clicked to drag on the underlying Canvas
. The IsActive
property is used to invoke a state change in a UIElement
. Depending on the value, the UIElement
template can be altered. Finally, Minimized
is used to denote another status change. This one is denoting whether or not the UIElement
is minimized. Again, it is used in conjunction with a template alteration.
In order to get the coloring that I wanted for each window, I created a converter class called SolidBrushToColorConverter
. The converter is, as named, a converter between a SolidColorBrush
and the Color
that it contains. To facilitate color matching throughout the control, I added a parameter to the converter. The parameter takes an int
and uses that value to add an offset to the resulting color. Therefore, if the parameter is a positive integer, the color will be lighter. Similarly, if it is a negative integer, the color will become darker. This way I can always get a variation of the original SolidColorBrush
color.
Color cResult = Colors.Black;
int add = 0;
try
{
SolidColorBrush brush = (SolidColorBrush)value;
if (parameter != null)
add = int.Parse(parameter.ToString());
cResult = Color.FromArgb(255, (byte)Math.Max(Math.Min(brush.Color.R + add, 255), 0),
(byte)Math.Max(Math.Min(brush.Color.G + add, 255), 0),
(byte)Math.Max(Math.Min(brush.Color.B + add, 255), 0));
}
catch (Exception ex) { }
return cResult;
A MwiChild
contains the three standard window buttons -- minimize, maximize and close -- and an additional button for detaching an MwiChild
from its parent container.
In order to detach a child from its parent and maintain the integrity of its content, a new window-like container object was needed. As a result, the AttachableWindow
class was created.
The AttachableWindow
class inherits from Window
and provides a container for the content of a MwiChild
object when it is detached from its parent. An AttachableWindow
takes advantage of styling in WPF to make the window look and feel like the MwiChild
by which it was created. In order to style a Window
, the WindowStyle
and AllowsTransparency
properties need to be set to None
and True
, respectively.
<Window x:Class="WPFMwiWindows.Controls.AttachableWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="{Binding Path=Child.Height, Mode=TwoWay, FallbackValue=300}"
Width="{Binding Path=Child.Width, Mode=TwoWay, FallbackValue=300}"
WindowStyle="None" AllowsTransparency="True" Background="Black" BorderThickness="2.0"
xmlns:controls="clr-namespace:WPFMwiWindows.Controls" x:Name="MyWindow" >
Once those properties are set, AttachableWindow
can be templated in the same way that the MwiChild
was. The big difference between the template of an MwiChild
and that of an AttachableWindow
is the addition of a ContentPresenter
within the AttachableWindow
template. The ContentPresenter
is used to display the content of a detached MwiChild
.
<Border x:Name="BorderWindow"
Background="{TemplateBinding BorderBrush}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="3,1,3,3"
CornerRadius="2,2,2,2" >
<DockPanel x:Name="DockPanelWindow" >
...
<ContentPresenter Content="{Binding Child.Content}" DockPanel.Dock="Top" />
</DockPanel>
</Border>
At this point, the AttachableWindow
looks very much like the MwiChild
that spawned it. The AttachableWindow
contains the same three standard window buttons as the MwiChild
and an additional attach button. This button is used to reattach the MwiChild
to its parent container.
Since the AttachableWindow
was given a WindowStyle
of None
, it can no longer be dragged or resized. In order to allow dragging of the AttachableWindow
, a few lines of code needed to be added to the class. The window should only be draggable when the left mouse button is down on the titlebar (not including the titlebar buttons). To ensure this, a PreviewMouseLeftButtonDown
event needed to be handled on the StackPanel
that contained the appropriate portion of the titlebar.
<StackPanel x:Name="PanelTitle" Orientation="Horizontal" Background="Transparent"
PreviewMouseLeftButtonDown="DragWindow" DockPanel.Dock="Left" >
<Image x:Name="ImageIcon" Source="{TemplateBinding Icon}"
Width="16" Height="16" Stretch="Uniform" Margin="1,0,1,0" />
<TextBlock x:Name="TextBlockTitle" Text="{Binding Path=Child.DisplayTitle}"
Foreground="White" VerticalAlignment="Center" />
</StackPanel>
Once the left mouse button event is caught, a simple call to DragMove
takes care of all the window dragging.
protected void DragWindow(object sender, MouseEventArgs e)
{
this.DragMove();
}
Unfortunately, resizing an AttachableWindow
is not as simple (at least, not the way I wanted resizing to work). In order to get the result I desired, I created a ResizeAdorner
to assist in resizing both an AttachableWindow
and an MwiChild
.
The ResizeAdorner
inherits from Adorner
and is responsible for allowing the user to resize an AttachableWindow
or MwiChild
. The adorner draws a transparent border around the object capable of being resized. The border consists of twelve points of contact (two for each of the four corners and one for each of the four sides). These twelve points are represented by an Enum
named HotSpot
. The adorner tracks mouse movements and clicks. Depending on the hot spot that the mouse is over, the cursor changes accordingly (hovering over the top side results in a SizeNS
cursor). If a hot spot is clicked and held and then the mouse is moved, the adorner will run through a series of calculations to determine if and how the adorned object should be resized. The MwiChild
contains a property for the minimum size of an instance. Amongst its resize calculations, the ResizeAdorner
checks to see if the resized height or width is smaller than these minimum values. Since an AttachableWindow
contains a property for the MwiChild
which spawned it, the adorner can retrieve the same minimum values when resizing a detached child.
The MwiWindow
class inherits from UserControl
and is responsible for maintaining the MwiChild
instances created. A MwiWindow
maintains the current list of all children whether they are minimized, detached or attached (in a non-minimized state). It does so through a series of collections: Children, MinChildren, DetachedChildren and AttachedChildren. This way, all of the MwiChild
objects created are stored in an appropriate collection that can be displayed as needed. To display the children, two ItemsControl
elements are used: one for the minimized children and another for the non-minimized attached children.
<DockPanel Background="Gray" x:Name="MwiGrid" >
<ItemsControl Background="Transparent" ItemsSource="{Binding Path=MinChildren}"
ItemsPanel="{StaticResource MyMinItemsPanelTemplate}"
DockPanel.Dock="Bottom" />
<ItemsControl Background="Transparent" ItemsSource="{Binding Path=Children}"
ItemsPanel="{StaticResource MyItemsPanelTemplate}"
DockPanel.Dock="Top" />
</DockPanel>
The ItemsPanel
for the minimized children is a modified WrapPanel
. The LayoutTransform
of the WrapPanel
is set so that the y-axis is flipped. This way, the panel is populated from bottom to top.
<ItemsPanelTemplate x:Key="MyMinItemsPanelTemplate" >
<WrapPanel Orientation="Horizontal" Background="Transparent" VerticalAlignment="Top" >
<WrapPanel.LayoutTransform>
<ScaleTransform ScaleX="1" ScaleY="-1" />
</WrapPanel.LayoutTransform>
</WrapPanel>
</ItemsPanelTemplate>
However, this is not enough. Now the objects within the panel will render upside down. To resolve this, each item in the panel needed its own LayoutTransform
(once again flipping the y-axis). For the MwiChild
, this was done within the template when it it is in a minimized state.
<DataTrigger Binding="{Binding IsMinimized}" Value="True" >
<Setter Property="LayoutTransform" >
<Setter.Value>
<ScaleTransform ScaleX="1" ScaleY="-1" />
</Setter.Value>
</Setter>
The non-minimized attached children needed a container of their own, one which would allow them to be dragged within the bounds of the MwiWindow
. I accomplished this by implementing a modified DragCanvas
. I got the DragCanvas
idea from an article by Josh Smith.
The DragCanvas
inherits from Canvas
and provides a container for MwiChild
objects to be dragged around. The class basically handles mouse clicks, checking if a UIElement
was clicked. If one was clicked (and it wasn't the DragCanvas
), then the mouse is captured and the UIElement
is dragged until the mouse is released. I modified the DragCanvas
a little by adding a check for an attachable property named IsDraggable
. When a UIElement
is clicked, then it is hit-tested until an element with the IsDraggable
property is found or the test returns no hits.
protected HitTestFilterBehavior MyHitTestFilter(DependencyObject obj)
{
isdrag = (bool)obj.GetValue(MwiChild.IsDraggableProperty);
if (isdrag)
return HitTestFilterBehavior.ContinueSkipSelf;
else
return HitTestFilterBehavior.Continue;
}
If a UIElement
is draggable, then the visual tree is searched for the element's corresponding MwiChild
. Once found, the DragCanvas
captures the mouse as described above and allows the user the ability to drag the MwiChild
object.
protected override void OnPreviewMouseLeftButtonDown(MouseButtonEventArgs e)
{
...
if (isdrag)
{
if (e.Source.GetType().Equals(typeof(MwiChild)))
originalElement = (UIElement)e.Source;
else
originalElement = (UIElement)VisualTreeSearch.FindByParentType(
(UIElement)e.Source, typeof(MwiChild));
if (originalElement != null)
{
Another common feature of MDI's is the ability to arrange child windows. In the MwiWindow
class, I implement four methods that can be used to rearrange child windows.
The cascade arrangement places a child window starting at the top left corner. Each additional window is then placed at an offset to the right and down. This continues until the next child cannot fit within the bounds of the parent and is placed in the top left corner where the offset begins anew.
The tiling algorithm I used finds the smallest X by X grid which would fit all of the children. Then it subtracts from each column, starting at the left and moving right, until the desired number of children are placed. For example, if there are five children, then the largest X by X grid that will fit them is 3 by 3. Since there are nine possible slots for these five children, one slot is subtracted starting from the left-most column and moving right. So you remove one from the first column (leaving 8 slots), then one from the second and third (leaving 7 then 6 slots). Finally you loop back to the first column, removing another slot and leaving five slots, which is the desired number children that need to be placed (giving a final layout of 1 - 2 - 2).
This simple arrangement positions the children from left to right, side by side. The children are resized to fit the height of the screen and given a width that cannot be smaller than the minimum width of a child window. Any excess children are repositioned starting back at the left of the screen.
This simple arrangement positions the children from top to bottom, one after the other. The children are resized to fit the width of the screen and given a height that cannot be smaller than the minimum height of a child window. Any excess children are repositioned starting back at the top of the screen.
For the demo, I created a simple user interface which consists of a Menu
and an MwiWindow
. The "File" MenuItem
allows for the creation of new child windows and the closing of the application. The "View" MenuItem
contains items used to rearrange and select child windows.
Each of the arranged items and the "New" MenuItem
make use of the Command
property. In order to function properly, the commands being used need to be registered in the code-behind.
cmdBinding = new CommandBinding(ApplicationCommands.New);
cmdBinding.PreviewExecuted += new ExecutedRoutedEventHandler(delegate(object sender,
ExecutedRoutedEventArgs e)
{ this.gMwiWindow.CreateNewMwiChild(); e.Handled = true; });
cmdBinding.CanExecute += new CanExecuteRoutedEventHandler(delegate(object sender,
CanExecuteRoutedEventArgs e)
{ e.CanExecute = true; });
cmdBinding.PreviewCanExecute += new CanExecuteRoutedEventHandler(delegate(object sender,
CanExecuteRoutedEventArgs e)
{ e.CanExecute = true; });
CommandBindings.Add(cmdBinding);
...
cmdBinding = new CommandBinding(MwiWindow.Cascade);
cmdBinding.PreviewExecuted += new ExecutedRoutedEventHandler(delegate(object sender,
ExecutedRoutedEventArgs e)
{ this.gMwiWindow.CascadeChildren(); e.Handled = true; });
cmdBinding.CanExecute += new CanExecuteRoutedEventHandler(delegate(object sender,
CanExecuteRoutedEventArgs e)
{ if (this.gMwiWindow.AttachedChildren.Count > 0) { e.CanExecute = true; } });
cmdBinding.PreviewCanExecute += new CanExecuteRoutedEventHandler(delegate(object sender,
CanExecuteRoutedEventArgs e)
{ if (this.gMwiWindow.AttachedChildren.Count > 0) { e.CanExecute = true; } });
CommandBindings.Add(cmdBinding);
I wanted to make sure that the Cascade
command was only enabled when one or more child windows were attached. So I added a check for the number of AttachedChildren
in the CanExecute
and PreviewCanExecute
delegates. I did this similarly for all of the arrange commands. For the New
and Close
commands, I set them up so that they are always enabled (as they should be).
Following the arrange items is a list of currently attached child windows. The IsChecked
property of the list is bound to the IsSelected
property of an MwiChild
. So if a child is selected it is checked and vice-versa. The list of windows is generated by a custom control that I created, named MenuItems
, and will be discussed in detail in a forthcoming article.
<con:MenuItems IsCheckable="True" HeaderBindingPath="ShortTitle"
IsCheckedBindingPath="IsSelected"
ItemsSource="{Binding ElementName=gMwiWindow, Path=AttachedChildren}"
TopSeparatorVisibility="Visible" />
Below the Menu
, I created an MwiWindow
and gave it two children. The first child contains a Grid
and a TextBox
. The TextBox
is bound to the DisplayTitle
of the MwiChild
. The second child makes use of another custom control I am working on that mimics a database-like grid. It is important to note that each child must have a Canvas.Left
and Canvas.Top
. Otherwise it will not be draggable within the DragCanavas
.
<Control:MwiWindow x:Name="gMwiWindow"
Width="{Binding ElementName=MyCanvas, Path=ActualWidth}"
Height="{Binding ElementName=MyCanvas, Path=ActualHeight}"
Background="Gray" >
<Control:MwiWindow.Children>
<Control:MwiChild Background="DarkGray" BorderBrush="Black"
Icon="pack://application:,,,/Resources/app.ico"
Width="300" Height="300" Title="Hello World 1"
Canvas.Left="0" Canvas.Top="0" >
<Grid Background="Blue" >
<TextBox Text="{Binding DisplayTitle}" Width="100" Background="Yellow"
Height="24" IsEnabled="True" />
</Grid>
</Control:MwiChild>
<Control:MwiChild Background="Green" BorderBrush="Green"
Icon="pack://application:,,,/Resources/app.ico"
Width="300" Height="300" Title="Hello World 2"
Canvas.Left="200" Canvas.Top="200" >
<con:WPFSQLGrid />
</Control:MwiChild>
</Control:MwiWindow.Children>
-
The arrangement of child windows is not maintained. Once you arrange the windows, if you then resize the MwiWindow
the children are not similarly resized or rearranged. Also, under certain arrangements, it may be possible to temporarily resize a child too small, despite the minimum size restrictions. In situations like this, it would be nice if the arrangement applied an overlapping offset in order to maintain the proper size restrictions.
-
Currently the application doesn't automatically close any detached windows when it is closed.
-
When adding a child window in XAML, the MwiChild
and its children cannot contain Name
properties. This makes it somewhat difficult to add bindings, but there are plenty of workarounds to be found.
-
I would like to add a "new child template" that is used to generate the content of a child when added dynamically to the MwiWindow
. This way you could create, for example, a grid control that is applied to all new child windows by wiring up a template into the MwiWindow
(perhaps through a NewItemContentTemplate property).
-
I am unhappy with the DisplayTitle
code and would like to see some improvement there to get the result looking more like windows.
-
A new method could be added in the MwiWindow
class to arrange child windows using side-by-side comparison. The method would take two children and arrange them so that they were horizontally tiled, hiding any other child windows. This would most likely be accompanied by a dialog that allowed a user to select children to compare.
-
New properties to manage the visibility of the minimize, maximize and close buttons on MwiChild
and AttachableWindow
objects.
Version A (January 14th, 2008)
I finally got around to revisiting the child and detached window display titles. After a little trial and error, I came up with a simple solution to the problem. TextBlock
has two properties, TextWrapping
and TextTrimming
, which are used to determine the look of text when the control is smaller than its content. I didn't want the title text to wrap, so I chose NoWrap
for TextWrapping
. I wanted the title to look and feel like a standard window title when it was resized, so I set TextTrimming
to CharacterEllipsis
. This, however, only provided me with half the solution. The TextBlock
was not resizing within the confines of a StackPanel
. So, I replaced the StackPanel
with a Grid
.
<Grid x:Name="PanelTitleIcon" DockPanel.Dock="Left"
local:Controls.MwiChild.IsDraggable="true" >
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
-->
<Image x:Name="ImageIcon" Source="{Binding Icon}" Width="16" Height="16"
Grid.Column="0" Stretch="Uniform" local:Controls.MwiChild.IsDraggable="true"
Margin="1,0,1,0" />
<TextBlock x:Name="TextBlockTitle" Text="{Binding Title}"
Foreground="White" Grid.Column="1"
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis"
HorizontalAlignment="Left"
VerticalAlignment="Center" local:Controls.MwiChild.IsDraggable="true" />
-->
</Grid>
Initial Version (December 2007)
Version A (January 14th, 2008) - Fixed a problem with the background color that occurred when a child did not contain content and was then detached. Revisited the DisplayTitle
issues and developed a much more simple solution.