In search of the perfect dock

 

The ability to grab part of a window and drag it somewhere else has never been easy. WPF does not offer this out of the box, and much effort has been expended by a number of companies and individuals to mimic the docking offered by Visual Studio. Personally, I think Visual Studio's docking is rather cumbersome. The amount of code required to do it is nothing short of excessive. If you want this kind of docking, have a look at Avalon Dock, which provides all you need for free. Some commercial packages offer the same type of docking, but who am I to suggest that they are selling you what you can get for nothing?

I prefer the docking offered by programs such as Adobe Bridge, where objects are dragged into the space between two other items, or dropped directly over them to give a tabbed offering. In reality, this is little different to the Visual Studio method, but it seems a little more natural to me.

Neither method allows you to drag and drop a detached pane into the space indicated by the dotted line in the diagram below.

This is not a major failing, but it does point towards a limitation in current implementations.

Having had the luxury of being able to see other people's struggles - which always makes the problem easier - I believe docking should work like this:

 

I have omitted all of the other options for clarity.

The challenges are as follow:

  1. Deliver this functionality without creating a plethora of classes
  2. Do not write lots of code
  3. Do not require new docking classes matching each existing UIElement, e.g DockedCanvas, DockedTabControl
  4. Support moving splitters in the gaps between each pane
  5. Support docking in any flyout windows - all docking methods at present allow only one child per flyout window

I shall break the remaining discussion into a series of steps as follows:

  1. Detaching a control
  2. Reattaching a control
  3. Understanding the layout
1. Detaching a control

The goal here is to pick up and drag a pane either within the application, or outside of the main application window to create a new window. To implement dragging and dropping solely within the main application window would not be difficult, but the goal here is to be able to detach a window and put it on another screen in a multi-screen setup, so that (for example) code can be edited on one window, and the results can be watched on another.

This part of the problem is not too onerous. On the mousedown, using hit-testing or some other mechanism, determine the control to be moved into the flyout window, detatch it, and add it to the flyout. The actual window creation and switching of focus is done in the mousemove. Here is the mouse move code, where lastElementSelected is set in the MouseDown event.

        private void Window_MouseMove(object sender, MouseEventArgs e)
        {
            // set default height and width
            double height = 200;
            double width = 150;
            Point start=new Point(0,0);

            if (lastElementSelected != null && e.LeftButton==MouseButtonState.Pressed) // put it in a window
            {
                UIElement el = lastElementSelected;
                lastElementSelected = null;
                if (el is Canvas)
                {
                    // if the control is a Canvas, alter the size of the window accordingly
                    // note: at present, I only use the CAnvas and TabControl classes
                    Canvas canvas = (Canvas)el;
                    width = canvas.ActualWidth; 
                    height = canvas.ActualHeight;
                    Point pt = new Point(Canvas.GetLeft(canvas), Canvas.GetTop(canvas));
                    start=canvasRoot.PointToScreen(pt);
                }

                // create a new window
                DetachableWindow window = new DetachableWindow();
                window.Height = height;
                window.Width = width;
                window.WindowStyle = WindowStyle.None;

                // get the content and put it in the new window
                canvasRoot.Children.Remove(el);
                window.AddContent(el);
                window.Left = start.X - 4; // assuming the border is 4 pixels
                window.Top = start.Y - 4;
                window.Show();
                
                // create handlers for later dropping
                window.LocationChanged += new EventHandler(window_LocationChanged);
                window.DockMe += new DockWindow(window_DockMe); // Note 1
                window.MouseLeftButtonUp += new MouseButtonEventHandler(window_MouseLeftButtonUp);
                
                // get a handle to the new window, and send it a Windows message equivalent to 
                // clicking the window's caption
                IntPtr handle = new WindowInteropHelper(window).Handle; // handle=hwnd
                Win32.ReleaseCapture();
                window.Focus();
                Win32.SendMessage(handle, Win32.WM_NCLBUTTONDOWN, new IntPtr(Win32.HTCAPTION), new IntPtr(0));
            }
        }

The last line of this code snippet, using the Windows API SendMessage function, switches the focus to the flyout window and prepares it to be moved with the mouse. When the mouse is released, the control will either be re-docked into the existing window, or will remain in the separated window. To allow the new window to be dragged around, the WindowsStyle is set so a normal border is visible.

I had hoped to handle the windows message WM_NCLBUTTONUP, but this message is filtered out somewhere by WPF, so I made do in the end with handling the WM_EXITSIZEMOVE message, and setting the border using:

this.WindowStyle = WindowStyle.SingleBorderWindow;

I appreciate this piece of code on its own is not a full treatment, but I shall hold off posting any full solution until I am happy with all of the methods I have used.

2. Reattaching a control

In the same piece of code which handles the windows message WM_EXITSIZEMOVE, an event is raised - see Note 1 in the code snippet above - to dock the window being dragged. The relevant code to hook the windows message processing, and the code to handle the windows messages is this:

        bool primed = false;

        private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            short x = (short)((lParam.ToInt32() & 0xFFFF));
            short y = (short)((lParam.ToInt32() >> 16));

            switch (msg)
            {
                case Win32.WM_NCLBUTTONDOWN: //case WM_NCLBUTTONDOWN:
                    if (wParam.ToInt32() == Win32.HTCAPTION)
                    {
                        primed = true;
                    }
                    break;
                case Win32.WM_EXITSIZEMOVE:
                    if (Mouse.LeftButton == MouseButtonState.Released && primed)
                    {
                        primed = false;
                        // raise try and dock event
                        bool success = false;
                        DockMe(this, new Point(this.Left, this.Top), ref success);
                        if (success)
                        {
                            IntPtr handle = new WindowInteropHelper(this).Handle; // handle=hwnd
                            Win32.PostMessage(handle, Win32.WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
                        }
                        else
                        {
                            this.WindowStyle = WindowStyle.SingleBorderWindow;
                            Console.WriteLine("Setting border style");
                        }
                    }
                    break;
            }
            return IntPtr.Zero;
        }

        private void Window_Loaded(object sender, RoutedEventArgs e)
        {
            HwndSource source = HwndSource.FromHwnd(new WindowInteropHelper(this).Handle);
            source.AddHook(new HwndSourceHook(WndProc));
        }

Reattaching the code is a matter of reversing the actions in the first code snippet, providing the window is being dropped onto a relevant dockable area. To decide which dockable area is the right one, first I need to decide how the dockable window is laid out, and as soon as I have done, I shall post it here.

I have two options:

  1. Use the method described in the original avalon dock article on code project here
  2. Come up with something less restrictive

The benefit of the first method is that is supports splitters with little extra work. It also allows the controls to be docked, and isn't too hard to code. The drawback is that the delineations between the controls are either vertical or horizontal and the page must be subdivided into ever smaller regions.

3. Layout

I have decided to base the design upon nested grids, similar to the AvalonDock method, but not quite the same, as I shall use grids with 1 column and 2 or more rows, or 1 row and 2 or more columns. This will simplify grids which become overcomplex using only 2 panes per splitter.

4. Hovering

When a detatched window is picked up and dragged over the docking grid, a highlight appears showing where the window will be docked if it is dropped. I had originally used a Popup() for this, but the Popup was always on top, so I now use a Window() instead. It is semi transparent and has an animated green glowing background.