Windows平台Qt无边款窗口技术细节

Windows平台Qt无边款窗口技术细节

(本文只讨论带有窗口特效的无边框实现,通过自绘阴影、自定义鼠标事件的方式不具备参考价值)

无论是哪个GUI框架,在Windows平台实现无边框窗口,都是一个绕不过去的话题,毕竟标题栏总是与设计师的图格格不入。自Win7以来,Windows的桌面窗口管理器(DWM)给应用程序的窗口交互增加了一些功能,如:最小化动态效果、拖拽标题栏停靠顶部最大化等。通过 Qt::FramelessWindowHint 可以直接将默认边框去掉,但这些效果也随之消失,为了保留效果,只能通过Windows消息实现,即重写QWidget::nativeEvent。

本文的目的是为了整理这些年使用Qt实现无边框窗口技术细节,因为个人比较懒,所以暂时不提供一个兼容所有系统、所有Qt版本的实现方案,只讨论需要注意的细节。

1. 先去掉边框

首先,MSDN提供了一个简单的示例,可以用来参考。

当窗口尺寸改变时,系统使用 WM_NCCALCSIZE 消息来计算窗口客户区在桌面的区域。窗口区域是包含标题栏、边框在内的整个区域,客户区通常是程序显示内容的区域。默认情况下,系统会自动计算合适的客户区,客户区比窗口区要小。所以原则上只要将客户区与窗口区域保持一致即可。

重写QWidget::nativeEvent,WM_NCCALCSIZE 的 lParam 参数在 wParam 为 true 时是NCCALCSIZE_PARAMS ,其 rgrc 是一个RECT数组,第一个就是目标窗口区域,比如移动、缩放窗口。当该消息返回时,第一个应该是客户区的区域:

  • 当窗口处于正常显示状态(非最大化)

    此时窗口区域应该与客户区一致,所以 *result = 0,直接返回即可。

  • 当窗口处于最大化时

    默认的窗口边框一部分实际是处于当前屏幕之外的(可以通过spy++查看或者 GetWindowRect 获取),如果直接返回,就会导致客户区内容一部分显示在屏幕之外。所以需要手动修改,如何计算还是比较麻烦的

    个人在参考 Windows Terminal 的源码时看到一种方案,这里简单抄一了一下逻辑:

    扫描二维码关注公众号,回复: 14462113 查看本文章
    case WM_NCCALCSIZE:
    {
          
          
        *result = 0;
        if(!msg->wParam)
            return true;
        // 这部分代码参考windows terminal源码
        NCCALCSIZE_PARAMS &params = *reinterpret_cast<NCCALCSIZE_PARAMS *>(msg->lParam);
        const int originalTop = params.rgrc[0].top;
        const RECT originalRect = params.rgrc[0];
        const auto ret = ::DefWindowProc(msg->hwnd, WM_NCCALCSIZE, msg->wParam, msg->lParam);
        if(ret != 0)
        {
          
          
            *result = ret;
            return true;
        }
        params.rgrc[0].top = originalTop;
    
        bool isMaximized = GetWindowStyle(msg->hwnd) & WS_MAXIMIZE;
        if(isMaximized)
        {
          
          
            // 这里计算一个默认边框尺寸border,原则上应该根据dpi计算,参考windows terminal源码
            int border = GetSystemMetrics(SM_CXSIZEFRAME) + GetSystemMetrics(SM_CXPADDEDBORDER);
            RECT &rect = params.rgrc[0];
            rect.top += border;
    
            QMargins margins = calculateTaskBarMargins(msg->hwnd);
            rect.top += margins.top();
            rect.bottom -= margins.bottom();
            rect.left += margins.left();
            rect.right -= margins.right();
        }
        else
        {
          
          
            params.rgrc[0] = originalRect;
        }
        return true;
    }
    

    代码逻辑是,先保存初始的窗口区域,然后调用 DefWindowProc 执行默认计算,计算出默认的客户区区域。如果是最大化窗口,默认的计算已经将左、右、下的区域计算准确了, 只修改top。非最大化窗口,则修改为初始值。Windows Terminal 的无边框窗口方案实际很复杂,而且考虑了全屏状态(Qt::WindowFullScreen)。

窗口最大化时,还有一个比较麻烦的问题是,如果任务栏处于隐藏状态,由于窗口区域是整个屏幕,而客户区也刚好也覆盖了屏幕,导致鼠标移动至显示器边界,不会弹出任务栏。所以需要判断任务栏在屏幕的哪一个边,预留出2像素的边距(上面代码中calculateTaskBarMargins函数实现)。然而win8.1以前没有直接接口判断,这个可以自行查找方案。

需要注意的是,早期的Qt(具体没做测试,应该5.9以前)还有一些问题:

  • 早期Qt是通过 Qt::FramelessWindowHint 标志判断是否是无边框,win32的坐标又是全局的,导致Qt内部计算错误。所以会常见类似这样的写法:

    setWindowFlags(Qt::FramelessWindowHint | Qt::WindowSystemMenuHint | Qt::WindowMinMaxButtonsHint | Qt::WindowCloseButtonHint);
    HWND hwnd = (HWND)this->winId();
    DWORD style = ::GetWindowLong(hwnd, GWL_STYLE);
    ::SetWindowLong(hwnd, GWL_STYLE, style | WS_MAXIMIZEBOX | WS_THICKFRAME | WS_CAPTION);
    

    通过Qt::FramelessWindowHint关闭无边框,保证Qt内部计算准确,同时调用SetWindowLong使原生窗口样式保留样式基本样式。

  • 而且窗口最大化时,不会关心WM_NCCALCSIZE计算的客户区区域信息,所以需要手动调整整个窗口的显示区域,例如调用 setContentsMargins 设置边距。多屏幕场景,甚至会在相邻屏幕出现白边。有个私有接口似乎可以同时解决边距和白变问题,没有严格测试。

现在的版本应该已经不需要这样做了,Qt内部拦截了 WM_NCCALCSIZE ,根据返回值计算了实际的边框信息。

2. 恢复默认阴影

恢复默认阴影的方案比较统一,通过DWM API,将默认边框扩展到客户区。常见做法是:

const MARGINS shadow = {
    
    1, 1, 1, 1};
DwmExtendFrameIntoClientArea(HWND(winId()), &shadow);

具体在什么时机调用比较合适没有准确的说法,上面MSDN的示例里是在 WM_ACTIVATE 消息里执行,Windows Terminal 的源码又在多种情况下都有调用,只在构造函数里执行似乎也行。

3. 移动和缩放

通过 WM_NCHITTEST 消息实现缩放,WM_NCHITTEST 的 lParam 参数包含了鼠标坐标,配合窗口区域,来返回命中区域。

case WM_NCHITTEST:
{
    
    
    long x = GET_X_LPARAM(msg->lParam);
    long y = GET_Y_LPARAM(msg->lParam);
    QPoint pos = mapFromNative(QPoint(x, y));
    if(pos.y() < captionHeight)
    	*result = HTCAPTION;
    else
        *result = HTCLIENT;
    return true;
}

基本逻辑比较简单,根据坐标返回对应的区域即可,主要有以下几个:

描述
HTCAPTION 标题栏,实现拖拽移动窗口,自动最大化,自动布局等
HTCLIENT 客户区,返回该值的区域,会收到鼠标事件
HTSYSMENU 在该区域点击会弹出系统菜单,与在默认窗口左上角图标处点击一致
HTLEFT 左边框,向左缩放窗口
HTRIGHT 有边框,向右缩放窗口
HTTOP 上边框
HTTOPLEFT 左上缩放
HTTOPRIGHT 右上缩放
HTBOTTOM 下边框
HTBOTTOMLEFT 左下缩放
HTBOTTOMRIGHT 右下缩放

需要注意的是,从Win32坐标转换到Qt坐标,多屏场景下不能直接mapFromGlobal,参考 Win32屏幕坐标转换Qt坐标 进行转换。

4. 其他问题

  • Win11 系统,当鼠标在最大化按钮悬停时,会弹出snap layout选项,实现更丰富的布局。

    如果自定义了一个最大化按钮,该功能的实现需要在 WM_NCHITTEST,判断是否悬停在自定义的最大化按钮,并返回 HTMAXBUTTON。

    这样带来的问题是,QWidget 不会收到鼠标事件,不能点击,相对对应的悬停、按下等效果都失效。所以需要在 WM_NCLBUTTONDOWN、WM_NCLBUTTONUP、WM_NCMOUSEHOVER、WM_NCMOUSEMOVE等消息中,转换成WM_MOUSEMOVE等相关鼠标消息发送给按钮,或者直接修改样式达到状态改变的效果,(建议后者)。

    WM_NCLBUTTONDOWN等消息的 wParam 参数,就是 WM_NCHITTEST 的返回值,直接判断即可,具体可自行实现:

    case WM_NCLBUTTONDOWN:
    case WM_NCLBUTTONUP:
    case WM_NCLBUTTONDBLCLK:
        if(msg->wParam == HTMAXBUTTON)
        {
          
          
            *result = 0;
            // 处理鼠标事件
            return true;
        }
        break;
    case WM_NCMOUSEHOVER:
    case WM_NCMOUSELEAVE:
    case WM_NCMOUSEMOVE:
        if(msg->wParam == HTMAXBUTTON)
        {
          
          
            *result = 0;
            // 处理鼠标事件
            return true;
        }
    
  • win11系统 snap layout 特性仅在最大化时生效

    如果出现仅在最大化时才生效 snap layout,可能是因为 DwmExtendFrameIntoClientArea 设置阴影的时候,MARGINS 参数设置了四周的非客户区扩展都是1像素,此时系统认为顶部区域存在非客户区,优先认为鼠标位置没有在最大化按钮上。

    可以考虑 将 MARGINS 参数的top改为0,似乎只要四个边有一个不是0,就可以显示阴影

    (兴许也可以通过拦截某些win32消息解决,但这块确实没有什么资料。)

  • 缩放窗口时的画面闪烁问题:

    (再补充)

猜你喜欢

转载自blog.csdn.net/eiilpux17/article/details/124776295
今日推荐