如何通过拖动扩展窗口框架使WPF窗口可移动?

55
在诸如Windows资源管理器和Internet Explorer之类的应用程序中,可以抓取标题栏下方的扩展框架区域并拖动窗口。
对于WinForms应用程序,表单和控件尽可能接近本机Win32 API;一个简单的方法是重写表单中的WndProc()处理程序,处理WM_NCHITTEST窗口消息,并通过返回HTCAPTION来欺骗系统,使其认为在框架区域上的点击实际上是在标题栏上的点击。我已经在自己的WinForms应用程序中这样做,效果非常好。
在WPF中,我也可以实现类似的WndProc()方法,并将其钩到我的WPF窗口句柄上,同时将窗口框架扩展到客户端区域,像这样:
// 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;
    }
}

问题在于,由于我盲目地设置了handled = true并返回HTCAPTION,因此点击窗口图标或控制按钮以外的任何地方都会导致窗口被拖动。也就是说,下面红色部分中的所有内容都会导致拖动。这甚至包括窗口边缘的调整手柄(非客户区域)。我的WPF控件,即文本框和选项卡控件,也因此停止接收点击:

我想要的是仅有:

  1. 标题栏和
  2. 客户端区域中未被我的控件占用的区域...
  3. ...可以拖动。

也就是说,我只想让这些红色区域可以拖动(客户端区域+标题栏):

我该如何修改我的WndProc()方法以及窗口的其余XAML/代码后台,以确定哪些区域应返回HTCAPTION,哪些不应该? 我考虑使用Point来检查单击的位置与控件位置的关系,但我不确定如何在WPF中实现。

编辑[4/24]:其中一种简单的方法是让一个不可见的控件,甚至是窗口本身,通过调用DragMove()来响应MouseLeftButtonDown(请参见Ross's answer)。问题在于,由于某种原因,如果窗口最大化,DragMove()将无法正常工作,因此它不能与Windows 7 Aero Snap配合使用。由于我正在进行Windows 7集成,因此在我的情况下,这不是可接受的解决方案。

10
这个问题包括了一个简短的教程,介绍如何在 C# 中处理窗口消息。其中有精心制作的插图清晰地说明了问题所在,并且没有明显的错别字。太棒了,加分! - Jeffrey L Whitledge
@Jeffrey L Whitledge:哇,谢谢(+1 给你)!最后我必须要编辑的一个事情就是问题标题……我发誓之前的标题不是我发布时写的。 - BoltClock
4个回答

32

示例代码

感谢我今天早上收到的一封电子邮件,促使我制作了一个演示此功能的工作样例应用程序。我已经完成了这个应用程序;您可以在 GitHub(或现已归档的CodePlex)上找到它。只需克隆存储库或下载并提取存档,然后在Visual Studio中打开它,构建并运行。

整个应用程序完全符合MIT许可证,但您可能会将其拆开并将其代码片段放置在自己的代码中,而不是完全使用应用程序代码 - 当然,许可证也不会阻止您这样做。此外,虽然我知道应用程序主窗口的设计与上面的线框图没有相似之处,但是问题所提出的想法是相同的。

希望这能帮助到某些人!

逐步解决方案

我终于解决了它。感谢Jeffrey L Whitledge指导我正确的方向!如果不是他的答案,我就无法找到解决方案。编辑[9/8]:现在我接受了这个答案,因为它更加完整;我会为Jeffrey的帮助提供一个很大的赏金。

为了后人着想,这是我如何做到的(在适当的情况下引用Jeffrey的答案):

获取鼠标点击的位置(可能是从wParam、lParam中获取?),并使用它创建一个Point(可能需要进行某种坐标转换?)。可以从WM_NCHITTEST消息的lParam中获取此信息。光标的x坐标是其低位字,y坐标是其高位字,如MSDN所述。由于坐标相对于整个屏幕,因此我需要在窗口上调用Visual.PointFromScreen()将坐标转换为相对于窗口空间。然后调用静态方法VisualTreeHelper.HitTest(Visual, Point),传递this和刚刚创建的Point。返回值将指示具有最高Z顺序的控件。我必须传递顶级Grid控件而不是this作为要测试的可视对象。同样,我必须检查结果是否为空,而不是检查它是否为窗口。如果为null,则光标未命中网格的任何子控件,换句话说,它命中了未占用的窗口框架区域。无论如何,关键是使用VisualTreeHelper.HitTest()方法。现在,话虽如此,如果您正在按照我的步骤进行操作,则可能有两个警告:
  1. 如果您不覆盖整个窗口,而只部分扩展窗口框架,则必须在未被窗口框架填充的矩形上放置控件作为客户端区域填充器。

    在我的情况下,选项卡控件的内容区域完全适合该矩形区域,如图所示。在您的应用程序中,您可能需要放置一个Rectangle形状或Panel控件并将其涂上适当的颜色。这样,控件就会被命中。

    关于客户端区域填充器的问题引出了下一个问题:

  2. 如果您的网格或其他顶层控件具有扩展窗口框架的背景纹理或渐变,则整个网格区域将响应命中,即使在背景的任何完全透明的区域上(请参见Hit Testing in the Visual Layer)。在这种情况下,您将希望忽略对网格本身的命中,并仅关注其中的控件。

因此:

// 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;
}

现在可以通过点击和拖动窗口的未占用区域来移动窗口。

但这还不是全部。回想一下第一张插图,窗口的非客户区包括边框也受到了HTCAPTION的影响,因此窗口不再可调整大小。

为了解决这个问题,我需要检查光标是否击中了客户区或非客户区。为了检查这一点,我需要使用DefWindowProc()函数并查看它是否返回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);

最后,这是我的最终窗口过程方法:

// 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;
    }
}

3
大脑崩溃,核心转储。你想出了一个很棒的解决方案。我祈求上帝永远不要处理类似的需求。+1。 - Marcote
@Markust:你的核心转储双关语让我度过了愉快的下午。 - BoltClock

16

这里有一个你可以尝试的方法:

获取鼠标点击的位置(可能从wParam、lParam中获取?),并使用它创建一个Point(可能需要进行某种坐标变换?)。

然后调用静态方法VisualTreeHelper.HitTest(Visual,Point),将this和刚刚创建的Point传递给它。返回值将指示具有最高Z-Order的控件。如果是你的窗口,则执行HTCAPTION操作。如果是其他控件,则不要进行操作。

祝好运!


这里有一个棘手的部分:WinForms的Control类有一个PointToClient()方法,可以执行所需的转换。但是WPF的Visual类没有该方法。可能是因为它是WPF。 - BoltClock
2
我将我的答案标记为被接受的,以便完整性。作为感谢的方式,请领取悬赏奖励! - BoltClock

6

我正在寻找一个解决方案,让我的WPF应用程序中的扩展Aero玻璃可以拖动。通过谷歌搜索,我找到了这篇文章。我已经阅读了你的答案,但决定继续搜索是否有更简单的方法。

我找到了一个代码量少得多的解决方案。

只需在您的控件后面创建一个透明的项目,并为它设置左鼠标按钮按下事件处理程序,该处理程序调用窗口的DragMove()方法。

这是我XAML中覆盖我的扩展Aero玻璃的部分:

<Grid DockPanel.Dock="Top">
    <Border MouseLeftButtonDown="Border_MouseLeftButtonDown" Background="Transparent" />
    <Grid><!-- My controls are in here --></Grid>
</Grid>

而代码后端(这是在 Window 类中,所以可以直接调用 DragMove()):

private void Border_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
    DragMove();
}

就是这样!对于您的解决方案,您需要添加多个这样的内容才能实现非矩形可拖动区域。


1
我也考虑过使用DragMove(),但是它的问题在于当窗口最大化时它无法工作,因此它真的无法与Aero Snap很好地兼容。 - BoltClock
你说得对。我刚刚测试了一下我的解决方案,虽然 Aero Snap 可以很好地实现最大化和左/右停靠,但无法从最大化状态“取消停靠”。 - Ross
1
再说,Aero Snap是Windows 7的一个功能,如果Aero Snap不起作用并不重要,使用DragMove()就可以了。 - BoltClock
你的解决方案是否适用于“双击最大化”? - Ross
是的,没错。因为我让窗口过程在单击玻璃区域时发送虚假的HTCAPTION,所以玻璃区域的行为与标题/标题栏完全相同。 - BoltClock

1

简单的方法是创建一个堆栈面板或者你想要的任何东西作为你的标题栏 XAML

 <StackPanel Name="titleBar" Background="Gray" MouseLeftButtonDown="titleBar_MouseLeftButtonDown" Grid.ColumnSpan="2"></StackPanel>

代码

  private void titleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
     {
         DragMove();
     }

这与两年半前Ross的答案有何不同(除了使用StackPanel而不是Border)? - BoltClock

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接