WPF Window allows tabbing by drag and drop - CodeProject

:

: 11

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 IE 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 opensource 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 TabWindows 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.

1. Two separate TabWindows floating.

2. A "Test 0" window is dragged over "Test Demo" window.

3. 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 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 processes 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.