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 的源码时看到一种方案,这里简单抄一了一下逻辑:
case WM_NCCALCSIZE: { *result = 0; if(!msg->wParam) return true; // 这部分代码参考windows terminal源码 NCCALCSIZE_PARAMS ¶ms = *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消息解决,但这块确实没有什么资料。)
-
缩放窗口时的画面闪烁问题:文章来源:https://www.toymoban.com/news/detail-474450.html
(再补充)文章来源地址https://www.toymoban.com/news/detail-474450.html
到了这里,关于Windows平台Qt无边款窗口技术细节的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!