How do I make a WPF window movable by dragging the extended window frame?

BoltClock picture BoltClock · Mar 31, 2011 · Viewed 13.3k times · Source

In applications like Windows Explorer and Internet Explorer, one can grab the extended frame areas beneath the title bar and drag windows around.

For WinForms applications, forms and controls are as close to native Win32 APIs as they can get; one would simply override the WndProc() handler in their form, process the WM_NCHITTEST window message and trick the system into thinking a click on the frame area was really a click on the title bar by returning HTCAPTION. I've done that in my own WinForms apps to delightful effect.

In WPF, I can also implement a similar WndProc() method and hook it to my WPF window's handle while extending the window frame into the client area, like this:

// In MainWindow
// For use with window frame extensions
private IntPtr hwnd;
private HwndSource hsource;

private void Window_SourceInitialized(object sender, EventArgs e)
{
    try
    {
        if ((hwnd = new WindowInteropHelper(this).Handle) == IntPtr.Zero)
        {
            throw new InvalidOperationException("Could not get window handle for the main window.");
        }

        hsource = HwndSource.FromHwnd(hwnd);
        hsource.AddHook(WndProc);

        AdjustWindowFrame();
    }
    catch (InvalidOperationException)
    {
        FallbackPaint();
    }
}

private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
    switch (msg)
    {
        case DwmApiInterop.WM_NCHITTEST:
            handled = true;
            return new IntPtr(DwmApiInterop.HTCAPTION);

        default:
            return IntPtr.Zero;
    }
}

The problem is that, since I'm blindly setting handled = true and returning HTCAPTION, clicking anywhere but the window icon or the control buttons causes the window to be dragged. That is, everything highlighted in red below causes dragging. This even includes the resize handles at the sides of the window (the non-client area). My WPF controls, namely the text boxes and the tab control, also stop receiving clicks as a result:

What I want is for only

  1. the title bar, and
  2. the regions of the client area...
  3. ... that aren't occupied by my controls

to be draggable. That is, I only want these red regions to be draggable (client area + title bar):

How do I modify my WndProc() method and the rest of my window's XAML/code-behind, to determine which areas should return HTCAPTION and which shouldn't? I'm thinking something along the lines of using Points to check the location of the click against the locations of my controls, but I'm not sure how to go about it in WPF land.

EDIT [4/24]: one simple way about it is to have an invisible control, or even the window itself, respond to MouseLeftButtonDown by invoking DragMove() on the window (see Ross's answer). The problem is that for some reason DragMove() doesn't work if the window is maximized, so it doesn't play nice with Windows 7 Aero Snap. Since I'm going for Windows 7 integration, it's not an acceptable solution in my case.

Answer

BoltClock picture BoltClock · Mar 31, 2011

Sample code

Thanks to an email I got this morning, I was prompted to make a working sample app demonstrating this very functionality. I've done that now; you can find it on GitHub (or in the now-archived CodePlex). Just clone the repository or download and extract an archive, then open it in Visual Studio, and build and run it.

The complete application in its entirety is MIT-licensed, but you'll probably be taking it apart and putting bits of its code around your own rather than using the app code in full — not that the license stops you from doing that either. Also, while I know the design of the application's main window isn't anywhere near similar to the wireframes above, the idea is the same as posed in the question.

Hope this helps somebody!

Step-by-step solution

I finally solved it. Thanks to Jeffrey L Whitledge for pointing me in the right direction! His answer was accepted because if not for it I wouldn't have managed to work out a solution. EDIT [9/8]: this answer is now accepted as it's more complete; I'm giving Jeffrey a nice big bounty instead for his help.

For posterity's sake, here's how I did it (quoting Jeffrey's answer where relevant as I go):

Get the location of the mouse click (from the wParam, lParam maybe?), and use it to create a Point (possibly with some kind of coordinate transformation?).

This information can be obtained from the lParam of the WM_NCHITTEST message. The x-coordinate of the cursor is its low-order word and the y-coordinate of the cursor is its high-order word, as MSDN describes.

Since the coordinates are relative to the entire screen, I need to call Visual.PointFromScreen() on my window to convert the coordinates to be relative to the window space.

Then call the static method VisualTreeHelper.HitTest(Visual,Point) passing it this and the Point that you just made. The return value will indicate the control with the highest Z-Order.

I had to pass in the top-level Grid control instead of this as the visual to test against the point. Likewise I had to check whether the result was null instead of checking if it was the window. If it's null, the cursor didn't hit any of the grid's child controls — in other words, it hit the unoccupied window frame region. Anyway, the key was to use the VisualTreeHelper.HitTest() method.

Now, having said that, there are two caveats which may apply to you if you're following my steps:

  1. If you don't cover the entire window, and instead only partially extend the window frame, you have to place a control over the rectangle that's not filled by window frame as a client area filler.

    In my case, the content area of my tab control fits that rectangular area just fine, as shown in the diagrams. In your application, you may need to place a Rectangle shape or a Panel control and paint it the appropriate color. This way the control will be hit.

    This issue about client area fillers leads to the next:

  2. If your grid or other top-level control has a background texture or gradient over the extended window frame, the entire grid area will respond to the hit, even on any fully transparent regions of the background (see Hit Testing in the Visual Layer). In that case, you'll want to ignore hits against the grid itself, and only pay attention to the controls within it.

Hence:

// In MainWindow
private bool IsOnExtendedFrame(int lParam)
{
    int x = lParam << 16 >> 16, y = lParam >> 16;
    var point = PointFromScreen(new Point(x, y));

    // In XAML: <Grid x:Name="windowGrid">...</Grid>
    var result = VisualTreeHelper.HitTest(windowGrid, point);

    if (result != null)
    {
        // A control was hit - it may be the grid if it has a background
        // texture or gradient over the extended window frame
        return result.VisualHit == windowGrid;
    }

    // Nothing was hit - assume that this area is covered by frame extensions anyway
    return true;
}

The window is now movable by clicking and dragging only the unoccupied areas of the window.

But that's not all. Recall in the first illustration that the non-client area comprising the borders of the window was also affected by HTCAPTION so the window was no longer resizable.

To fix this I had to check whether the cursor was hitting the client area or the non-client area. In order to check this I needed to use the DefWindowProc() function and see if it returned HTCLIENT:

// In my managed DWM API wrapper class, DwmApiInterop
public static bool IsOnClientArea(IntPtr hWnd, int uMsg, IntPtr wParam, IntPtr lParam)
{
    if (uMsg == WM_NCHITTEST)
    {
        if (DefWindowProc(hWnd, uMsg, wParam, lParam).ToInt32() == HTCLIENT)
        {
            return true;
        }
    }

    return false;
}

// In NativeMethods
[DllImport("user32.dll")]
private static extern IntPtr DefWindowProc(IntPtr hWnd, int uMsg, IntPtr wParam, IntPtr lParam);

Finally, here's my final window procedure method:

// In MainWindow
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
    switch (msg)
    {
        case DwmApiInterop.WM_NCHITTEST:
            if (DwmApiInterop.IsOnClientArea(hwnd, msg, wParam, lParam)
                && IsOnExtendedFrame(lParam.ToInt32()))
            {
                handled = true;
                return new IntPtr(DwmApiInterop.HTCAPTION);
            }

            return IntPtr.Zero;

        default:
            return IntPtr.Zero;
    }
}