Introduction
This articles introduces a shell window called TabWindow
embedded with TabControl
that allows detaching a tab item to a new window through a drag-and-drop. It also allows a floating window being tabbed to a stationary window through the drag-and-drop.
Background
Can you imagine a WPF window that behaves like a Chrome or Internet Explorer browser? At run time, a window can be tabbed to another window through the drag-and-drop. Tabs can be re-ordered and an individual tab can be closed. The TabWindow
supports those features. However it's not a mere copy-cat of the modern browser like Chrome. There are a few main differences. For instance, a tab header disappears when there is only one item left in the TabWindow
. Space is a premium in GUI as you know. Also when you tab one window to another, you drag it by the title bar instead of the tab header as it's done with the Chrome. The TabWindow
, however, is not a docking control. There are many commercial and open source docking controls available out there already. The TabWindow
derives from WPF Window
class, hence all window features are exposed at a developer's hands.
Using the Code
It's simple to use TabWindow
in your code. After adding the reference to the TabWindow
library to your project, first instantiate TabWindow
as you would've done it for a regular WPF Window, then call AddTabItem
method by passing the Control
instance which will be the content of the TabWindow
instance. So build your own beautiful user control, then pass it to the TabWindow
.
TabWindow.TabWindow tabWin = new TabWindow.TabWindow();
TextBox tb = new TextBox();
tb.Text = "Test Demo";
tabWin.AddTabItem(tb.Text, tb);
tabWin.Show();
Depending on your need, create as many TabWindow
s as possible, then start tabbing windows by dragging one window over another. As one window being dragged enters the border of a stationary TabWindow
, a tab drop target image will appear. Keep dragging until your mouse pointer is over the tab drop image then let the mouse go. The dragged window vanishes and the stationary window will be added with a new tab containing the content of the dragged window.
- Two separate
TabWindows
floating.
- A "Test 0" window is dragged over "Test Demo" window.
- The tab zone highlights on the "Test Demo" window. Release the mouse button pressed, then the "Test 0" window will be tabbed to the "Test Demo" window.
In order to separate a tab to new window, grab the tab header and drag it out of the existing window or double-click the tab header. It will create an independent window.
Breakdown of TabWindow Library
Mainly, there are three parts in the library. Each part is responsible for its own functionality.
- Custom
TabItem
with a close button - Derived
TabControl
which supports the drag and drop of custom TabItem
TabWindow
that allows tabbing one window to another
Custom TabItem with a Close Button
There are a number of ways to accomplish this task according to a quick search on the Internet. I took an approach on creating a custom control deriving from TabItem
. To draw [x] mark on a tab header, the control template style was declared in the XAML. Initially, I thought of using an image file to show the [x] mark when the tab is selected but ended up using the System.Windows.Shapes.Path
object to draw the x shape. This is how the [x] button is defined in Generic.xaml.
<ControlTemplate TargetType="{x:Type Button}">
<Border x:Name="buttonBorder" CornerRadius="2"
Background="{TemplateBinding Background}" BorderBrush="DarkGray" BorderThickness="1">
<Path x:Name="buttonPath" Margin="2" Stroke="DarkGray" StrokeThickness="2"
StrokeStartLineCap="Round" StrokeEndLineCap="Round" Stretch="Fill" >
<Path.Data>
<PathGeometry>
<PathFigure StartPoint="0,0">
<LineSegment Point="13,13"/>
</PathFigure>
<PathFigure StartPoint="0,13">
<LineSegment Point="13,0"/>
</PathFigure>
</PathGeometry>
</Path.Data>
</Path>
</Border>
<ControlTemplate.Triggers>
...
</ControlTemplate.Triggers>
</ControlTemplate>
This close button style is applied to the tab header template as shown below. The DockPanel
consists of the [x] button docked to the far right and the header ContentPresenter
. The default visibility of the [x] button is hidden. It becomes visible when the tab gets selected. Used Trigger
to show or hide the [x] button.
<Style TargetType="{x:Type local:CloseEnabledTabItem}">
...
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:CloseEnabledTabItem}">
<Grid SnapsToDevicePixels="true" IsHitTestVisible="True" x:Name="gridHeader">
<Border x:Name="tabItemBorder" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1,1,1,0" >
<DockPanel x:Name="tabItemDockPanel">
<Button x:Name="tabItemCloseButton"
Style="{StaticResource tabItemCloseButtonStyle}"
DockPanel.Dock="Right" Margin="3,0,3,0" Visibility="Hidden" />
<ContentPresenter x:Name="tabItemContent"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
RecognizesAccessKey="True"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
ContentSource="Header" Margin="{TemplateBinding Padding}"/>
</DockPanel>
</Border>
</Grid>
...
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
Now we need to wire some actions. I want the tab item to be removed when the [x] button is clicked. I also would like to raise an event when the tab header is double-clicked. This double-click notification will be consumed by TabWindow
where it will generate a new TabWindow
and move the content from the clicked tab item to new window. Basically, it's equivalent to dragging the tab out to a new window so double-clicking on the tab header creates a new TabWindow
instance and removes the double-clicked tab item.
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
Button closeButton = base.GetTemplateChild("tabItemCloseButton") as Button;
if (closeButton != null)
closeButton.Click += new System.Windows.RoutedEventHandler(closeButton_Click);
Grid headerGrid = base.GetTemplateChild("gridHeader") as Grid;
if (headerGrid != null)
headerGrid.MouseLeftButtonDown +=
new MouseButtonEventHandler(headerGrid_MouseLeftButtonDown);
}
void closeButton_Click(object sender, System.Windows.RoutedEventArgs e)
{
var tabCtrl = this.Parent as TabControl;
if (tabCtrl != null)
tabCtrl.Items.Remove(this);
}
void headerGrid_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ClickCount == 2)
this.RaiseEvent(new RoutedEventArgs(TabHeaderDoubleClickEvent, this));
}
Derived TabControl Which Supports the Drag and Drop Among Custom Tabs
There are many drag-and-drop tutorials on the web so I won't go in detail about re-ordering the tabs by the drag-and-drop. However dragging the tab out to create a new window is not a typical drag-and-drop operation. The .NET Framework provides the QueryCotinueDrag
event which is raised continuously during the dragging the mouse pointer. The dragged mouse position is kept on checked and when it goes out of the tab control border, it creates a new TabWindow
. Once the new TabWindow
is created, the Left
and Top
properties of the new window get updated by handling the QueryContinueDrag
event. This event also provides the signal when the drop operation occurs. As e.KeyStates
is set to DragDropKeyStates.None
, it's time to remove the tab item from the tab control.
void DragSupportTabControl_QueryContinueDrag(object sender, QueryContinueDragEventArgs e)
{
if (e.KeyStates == DragDropKeyStates.LeftMouseButton)
{
Win32Helper.Win32Point p = new Win32Helper.Win32Point();
if (Win32Helper.GetCursorPos(ref p))
{
Point _tabPos = this.PointToScreen(new Point(0, 0));
if (!((p.X >= _tabPos.X && p.X <= (_tabPos.X + this.ActualWidth)
&& p.Y >= _tabPos.Y && p.Y <= (_tabPos.Y + this.ActualHeight))))
{
var item = e.Source as TabItem;
if (item != null)
UpdateWindowLocation(p.X - 50, p.Y - 10, item);
}
else
{
if (this._dragTornWin != null)
UpdateWindowLocation(p.X - 50, p.Y - 10, null);
}
}
}
else if (e.KeyStates == DragDropKeyStates.None)
{
this.QueryContinueDrag -= DragSupportTabControl_QueryContinueDrag;
e.Handled = true;
if (this._dragTornWin != null)
{
_dragTornWin = null;
var item = e.Source as TabItem;
if (item != null)
this.RemoveTabItem(item);
}
}
}
Unfortunately, the WPF does not provide a reliable way to retrieve the current mouse position on the desktop screen. If your mouse pointer is located within the Control
, then there is a dependable way to get the accurate mouse position but it is not the case when you drag your mouse pointer out of the control or window. It was crucial for me to retrieve the mouse position whether or not the mouse pointer is within the control or out of the window. My help came from Win32 API.
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool GetCursorPos(ref Win32Point pt);
TabWindow that Allows Tabbing One Window to Another
Allowing one window to be dragged and dropped on a different window to be tabbed was a challenging task. First of all, if the window is dragged by the window title bar, there is no drag-and-drop events raised. I had to use HwndSource
class to process necessary window messages. In SourceInitialized
event handler (after the TabWindow
is created), get the HwndSource
of the current window instance then call AddHook
to include in the window procedure chain.
void TabWindow_SourceInitialized(object sender, EventArgs e)
{
HwndSource source = HwndSource.FromHwnd(new WindowInteropHelper(this).Handle);
source.AddHook(new HwndSourceHook(WndProc));
}
So when the window is grabbed by the title bar and dragged around, the Win32 messages are received in the hook handler. We only process the window messages that's relevant to our goal. What's our goal? I want to get notified when a TabWindow
gets started with dragging by the title bar. That's WM_ENTERSIZEMOVE
message. While the TabWindow
gets dragged around, the coordinate of the window needs to be processed and that's WM_MOVE
message. Finally the WM_EXITSIZEMOVE
indicates the dragging is done. Handling these winProc
messages accomplishes our goal. When a TabWindow
is dragged over another TabWindow
, the tab drop zone image will appear. Drop the dragged window onto the tab drop zone image, the dragged window will be added to the stationary window successfully.
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == Win32Helper.WM_ENTERSIZEMOVE)
_hasFocus = true;
else if (msg == Win32Helper.WM_EXITSIZEMOVE)
{
_hasFocus = false;
DragWindowManager.Instance.DragEnd(this);
}
else if (msg == Win32Helper.WM_MOVE)
{
if (_hasFocus)
DragWindowManager.Instance.DragMove(this);
}
handled = false;
return IntPtr.Zero;
}
How does the dragged TabWindow
figures out the window underneath is the type of TabWindow
or not? Well, as TabWindow
gets instantiated, it registers itself to the DragWindowManger
singleton instance. Whenever the TabWindow
is moved, it loops through all registered windows to detect if the dragged mouse position is over one of the TabWindow
instances.
public void DragMove(IDragDropToTabWindow dragWin)
{
if (dragWin == null) return;
Win32Helper.Win32Point p = new Win32Helper.Win32Point();
if (!Win32Helper.GetCursorPos(ref p)) return;
Point dragWinPosition = new Point(p.X, p.Y);
foreach (IDragDropToTabWindow existWin in _allWindows)
{
if (dragWin.Equals(existWin)) continue;
if (existWin.IsDragMouseOver(dragWinPosition))
{
if (!_dragEnteredWindows.Contains(existWin))
_dragEnteredWindows.Add(existWin);
}
else
{
if (_dragEnteredWindows.Contains(existWin))
{
_dragEnteredWindows.Remove(existWin);
existWin.OnDrageLeave();
}
}
}
...
}
Once the dragged TabWindow
is dropped on the tab drop zone, the content of the dragged window is transferred to a new tab created on the target TabWindow
. Then the dragged TabWindow
vanishes.
public void DragEnd(IDragDropToTabWindow dragWin)
{
if (dragWin == null) return;
Win32Helper.Win32Point p = new Win32Helper.Win32Point();
if (!Win32Helper.GetCursorPos(ref p)) return;
Point dragWinPosition = new Point(p.X, p.Y);
foreach (IDragDropToTabWindow targetWin in _dragEnteredWindows)
{
if (targetWin.IsDragMouseOverTabZone(dragWinPosition))
{
System.Windows.Controls.ItemCollection items = ((ITabWindow)dragWin).TabItems;
for (int i = 0; i < items.Count; i++)
{
System.Windows.Controls.TabItem item = items[i] as
System.Windows.Controls.TabItem;
if (item != null)
((ITabWindow)targetWin).AddTabItem(item.Header.ToString(),
(System.Windows.Controls.Control)item.Content);
}
for (int i = items.Count; i > 0; i--)
{
System.Windows.Controls.TabItem item = items[i - 1] as
System.Windows.Controls.TabItem;
if (item != null)
((ITabWindow)dragWin).RemoveTabItem(item);
}
}
targetWin.OnDrageLeave();
}
if (_dragEnteredWindows.Count > 0 && ((ITabWindow)dragWin).TabItems.Count == 0)
{
((Window)dragWin).Close();
}
_dragEnteredWindows.Clear();
}
Summary
The TabWindow
library can be very useful in a composite application where a module can be loaded directly into a TabWindow
instance. Then leave the decision to user on how to merge the windows into tabs dynamically. The highlights of the TabWindow
are:
- Allows re-ordering of tab items
- Allows a tab item to be closed
- Tab header becomes invisible when there is only one tab item left in the window
- A tab item can be dragged out to a new window
- Double-clicking on a tab header creates a new window
- A window can be dragged by the title bar and dropped over another window. A content of the source window becomes a new tab item of the target window.
History
- 6th April, 2015: Initial version