QT无边框窗体——拦截windows消息实现

无边框其实就是去掉windows自带的标题栏,去掉标题栏之后手动实现标题栏的功能:
1、左键按住标题栏移动窗体
2、双击标题栏切换最大化和normal状态
3、贴靠窗口功能
4、四个边和四个角resize窗体大小
5、窗体阴影

窗体的客户区和非客户区

用一个超级老的图(来自msdn)来介绍客户区和非客户区,简单来说就是,客户区是下图白色区域,除此之外都是非客户区。

客户区好说,qml写的东西都是堆在客户区的,非客户区比较麻烦,但是win32提供了一些办法来自定义非客户区
https://docs.microsoft.com/en-us/windows/win32/dwm/customframe#related-topics
msdn上面的这个链接提供了非常详细的介绍,简单总结一下
1、客户区和非客户区的关系就类似于ps中的图层,客户区是在非客户的上边
2、我们可以通过win32的api设置非客户区四个边框的大小

这个图左右两个只有细微的差别,那就是看起来左边的客户区带边框,而右边的没有,实际上是有边的非客户区边框被重新设置过,做了加粗处理,客户区覆盖了边框(边框是非客户区的,客户区没有),如果把客户区设置为透明就能看出其中玄机:

窗体的样式

这个属于非客户的功能,窗体带不带标题栏、有没有最大化那几个按钮等都是Windows系统的窗体样式。
每个窗口都有一个或多个窗口样式。 窗口样式是一个命名常量(named constant),它定义了窗口类(并不是那个结构体的window class,而是指create函数创建的一个窗体)未指定的窗口外观和行为。 应用程序通常在创建窗口时设置窗口样式(create函数),也可以在创建window后使用SetWindowLong函数设置样式。
窗体样式的参考可以看这个链接: https://docs.microsoft.com/zh-cn/windows/win32/winmsg/window-styles
除此之外还有扩展样式: https://docs.microsoft.com/zh-cn/windows/win32/winmsg/extended-window-styles
setwindowlong就是设置样式的函数,比如去掉标题栏:

效果如下图

虽然从代码上我们是去掉了标题栏,但是很明显,标题栏还有一个高度,测量了一下是3个像素,所以直接设置窗体样式并不能实现我们的需求(需要别的操作,我看有人可以实现,我没深究这个方案)

与窗体相关的几个Windows的消息

WM_NCHITTEST
鼠标在窗体上的时候Windows发送该消息,通过“窗口过程”的返回值能让Windows了解鼠标是在哪,比如返回HTCAPTION就是在标题栏中。关于窗口过程这里不细讲。
假设我们拦截了这个消息,然后不管什么情况都返回HTCAPTION,那么Windows系统就认为你一直在标题栏上。用鼠标左键点按就可以移动窗体。
这个消息是resize和按住标题栏移动窗体的关键。
这个消息的详细定义可以看这里 https://docs.microsoft.com/en-us/windows/win32/inputdev/wm-nchittest
WM_NCCALCSIZE
当客户区大小和坐标需要被重新计算的时候Windows会发送这个消息,默认os会自动处理该消息,但是如果我们拦截该消息并返回一些特定的值比如数字0x00,那么就能更改客户区的大小和坐标。
实际上返回0x00就是让客户区完全覆盖窗体,效果可以看下图

红框就是客户区(客户区做了透明处理),右边的图完全覆盖了窗体(包括非客户区)。其实这就是创造无边框窗体的核心操作
这个消息的详细定义可以看这里 https://docs.microsoft.com/en-us/windows/win32/winmsg/wm-nccalcsize
WM_ACTIVATE
窗口被激活的时候os会发送这条消息
https://docs.microsoft.com/en-us/windows/win32/inputdev/wm-activate

qt中的消息过滤器

win32程序中可以在WndProc函数中处理消息,这非常方便,但是qt封装了系统相关的东西做到了跨平台,要在qt中截获windows的系统消息就没有win32程序那么方便,但是qt仍开放了接口
使用 QAbstractNativeEventFilter类 , 参考 https://doc.qt.io/qt-5/qabstractnativeeventfilter.html
除了构造函数,就一个成员函数nativeEventFilter

所有的native消息都会经过这个函数,如果重写该函数,抓取到某个消息后函数返回值为true,那么该消息就永远不会进入qt系统(被阻断了),如果返回false,那么这条消息就会正常进入qt系统被处理。
该函数的第三个参数就相当于win32中WndProc函数的返回值。比如在win32中处理WM_NCCALCSIZE消息的代码

在qt的消息过滤器中上图就等价于:

所以:我们继承QAbstractNativeEventFilter类就可以对Windows系统消息做自定义处理,当然,我们写的消息过滤器要在qt的main函数中注册到qt系统,否则qt不会识别的:

去掉Windows系统的非客户区

其实就是用客户区完全覆盖整个窗体,根据msdn消息说明
截取WM_NCCALCSIZE消息,并设置返回值为0
就这么简单,就可以实现了。不过首先还是要写一个类,继承QAbstractNativeEventFilter类 ,代码如下:

此时的窗口,用鼠标根本无法操作,也没有阴影,因为非客户区的所有可控因素都被客户区盖住了。

添加阴影

设置一下非客户区边框就可以了,利用WM_ACTIVATE消息

resize和移动窗体

利用WM_NCHITTEST消息
下边代码绝大多数是参考了 https://docs.microsoft.com/en-us/windows/win32/dwm/customframe#appendix-c-hittestnca-function msdn这个提供的代码

case WM_NCHITTEST:
		{
			//处理resize
			//标记只处理resize
			bool isResize = false;

			//鼠标点击的坐标
			POINT ptMouse = { GET_X_LPARAM(pMsg->lParam), GET_Y_LPARAM(pMsg->lParam) };
			//窗口矩形
			RECT rcWindow;
			GetWindowRect(pMsg->hwnd, &rcWindow);
			RECT rcFrame = { 0,0,0,0 };
			AdjustWindowRectEx(&rcFrame, WS_OVERLAPPEDWINDOW & ~WS_CAPTION, FALSE, NULL);
			USHORT uRow = 1;
			USHORT uCol = 1;
			bool fOnResizeBorder = false;

			//确认鼠标指针是否在top或者bottom
			if (ptMouse.y >= rcWindow.top && ptMouse.y < rcWindow.top + 1)
			{
				fOnResizeBorder = (ptMouse.y < (rcWindow.top - rcFrame.top));
				uRow = 0;
				isResize = true;
			}
			else if (ptMouse.y < rcWindow.bottom && ptMouse.y >= rcWindow.bottom - 5)
			{
				uRow = 2;
				isResize = true;
			}
			//确认鼠标指针是否在left或者right
			if (ptMouse.x >= rcWindow.left && ptMouse.x < rcWindow.left + 5)
			{
				uCol = 0; // left side
				isResize = true;
			}
			else if (ptMouse.x < rcWindow.right && ptMouse.x >= rcWindow.right - 5)
			{
				uCol = 2; // right side
				isResize = true;
			}
            if (ptMouse.x >= rcWindow.left && ptMouse.x <= rcWindow.right - 135 && ptMouse.y > rcWindow.top + 3 && ptMouse.y <= rcWindow.top + 30)
			{
                *result = HTCAPTION;
				return true;
			}

			LRESULT hitTests[3][3] =
			{
				{ HTTOPLEFT,    fOnResizeBorder ? HTTOP : HTCAPTION,    HTTOPRIGHT },
				{ HTLEFT,       HTNOWHERE,     HTRIGHT },
				{ HTBOTTOMLEFT, HTBOTTOM, HTBOTTOMRIGHT },
			};

			if (isResize == true)
			{
				*result = hitTests[uRow][uCol];
				return true;
			}
			else
			{
				return false;
			}

		}

关于移动窗体,这个可选的方案有很多,qt中可以使用mousearea实现,但是使用Windows消息更好用,因为mousearea实现的办法没有“贴边停靠”,而且双击标题栏的操作也要手动实现。
关于贴边停靠功能,我并没有找到什么可查询的资料准确定义怎么实现,我做了很多次尝试,总结一下基本上是有两个条件可以实现,首先是设置窗体样式,具体是哪几个样式我没细究,总之必须有某些样式,才能实现贴边停靠,第二个条件是鼠标按住标题栏贴近屏幕某个边缘。这两个条件缺一不可,这也是为什么移动窗体我是拦截系统消息而不是在qml中使用mousearea的原因。关于第一个条件“窗体样式”在我的代码中我猜测是在WM_ACTIVATE处理阴影的时候添加了合适的窗口样式,这里没有细究。

完整代码

#ifndef WINMSGFILTER_H
#define WINMSGFILTER_H
#include <QAbstractNativeEventFilter>
#include <QDebug>
#include <Windows.h>
#pragma comment(lib, "dwmapi")
#pragma comment(lib,"user32.lib")
#include <dwmapi.h>
#include <windowsx.h>

class WinMsgFilter :public QAbstractNativeEventFilter
{
public:
	WinMsgFilter();
	//过滤掉消息返回true,否则返回false
	bool nativeEventFilter(const QByteArray& eventType, void* message, long* result) override
	{
		MSG* pMsg = reinterpret_cast<MSG*>(message);

		switch (pMsg->message)
		{
			//去掉边框
		case WM_NCCALCSIZE:
		{
			*result = 0;
			return true;
			break;
		}
		//阴影
        case WM_ACTIVATE:
        {
            MARGINS margins = { 1,1,1,1 };
            HRESULT hr = S_OK;
            hr = DwmExtendFrameIntoClientArea(pMsg->hwnd, &margins);
            *result = hr;
            return true;
        }
		case WM_NCHITTEST:
		{
			//处理resize
			//标记只处理resize
			bool isResize = false;

			//鼠标点击的坐标
			POINT ptMouse = { GET_X_LPARAM(pMsg->lParam), GET_Y_LPARAM(pMsg->lParam) };
			//窗口矩形
			RECT rcWindow;
			GetWindowRect(pMsg->hwnd, &rcWindow);
			RECT rcFrame = { 0,0,0,0 };
			AdjustWindowRectEx(&rcFrame, WS_OVERLAPPEDWINDOW & ~WS_CAPTION, FALSE, NULL);
			USHORT uRow = 1;
			USHORT uCol = 1;
			bool fOnResizeBorder = false;

			//确认鼠标指针是否在top或者bottom,顺带说一下屏幕坐标原点是左上角,窗体坐标原点也是左上角
			if (ptMouse.y >= rcWindow.top && ptMouse.y < rcWindow.top + 1)
			{
				fOnResizeBorder = (ptMouse.y < (rcWindow.top - rcFrame.top));
				uRow = 0;
				isResize = true;
			}
			else if (ptMouse.y < rcWindow.bottom && ptMouse.y >= rcWindow.bottom - 5)
			{
				uRow = 2;
				isResize = true;
			}
			//确认鼠标指针是否在left或者right
			if (ptMouse.x >= rcWindow.left && ptMouse.x < rcWindow.left + 5)
			{
				uCol = 0; // left side
				isResize = true;
			}
			else if (ptMouse.x < rcWindow.right && ptMouse.x >= rcWindow.right - 5)
			{
				uCol = 2; // right side
				isResize = true;
			}
            //检测是不是在标题栏上,右边预留出了45*3 = 135的宽度,是留给关闭按钮、最大化、最小化的。
            if (ptMouse.x >= rcWindow.left && ptMouse.x <= rcWindow.right - 135 && ptMouse.y > rcWindow.top + 3 && ptMouse.y <= rcWindow.top + 30)
			{
                *result = HTCAPTION;
				return true;
			}

			LRESULT hitTests[3][3] =
			{
				{ HTTOPLEFT,    fOnResizeBorder ? HTTOP : HTCAPTION,    HTTOPRIGHT },
				{ HTLEFT,       HTNOWHERE,     HTRIGHT },
				{ HTBOTTOMLEFT, HTBOTTOM, HTBOTTOMRIGHT },
			};

			if (isResize == true)
			{
				*result = hitTests[uRow][uCol];
				return true;
			}
			else
			{
				return false;
			}

		}
		}

		//这里一定要返回false,否则是屏蔽所有消息了
		return false;
	}

};

#endif // WINMSGFILTER_H

不要忘了在main函数中注册消息拦截的类

demo演示

demo代码
https://gitee.com/feipeng8848/frameless-window-qml/tree/master

瑕疵

这并不是完美的解决方案,切换多显示器的时候会有下图这种边框出现(为了方便观察,我把window的颜色做成了红色)

在别人实现的无边框方案中,也出现了这种问题,目前还没解决 https://github.com/qtdevs/FramelessHelper/issues/10
测试过程中发现Window的height和width发生了改变(top和right也有变化),而qt中的window的height和width实际是“客户区”的size
当设置qml中window的宽和高分别是300*400,启动软件,实际软件宽高变成了316*439,被拉长了。
切换到另一显示器的时候,这个数字会被重置为300*400.也就是说在从一个屏幕移动到另一个屏幕的时候客户区发生了变化。

但是,重点来了,我们设置window的颜色是红色,却是全部覆盖。window的height、width、top、right等属性发生了改变。猜测这是qt的bug。

fix

收到了上边提到的项目(https://github.com/qtdevs/FramelessHelper/issues/10)作者回复,内容如下:

我尝试在nativeEventFilter函数中处理WM_MOVE消息,执行如下代码
SetWindowPos(msg->hwnd, nullptr, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOACTIVATE | SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_NOOWNERZORDER); RedrawWindow(msg->hwnd, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW | RDW_NOCHILDREN);
即可解决多个显示器之间切换问题,但是程序还是会有其他问题。
这个方法我在VS调试诊断中看到,使用这个方法CPU性能前后都是在25左右。
感觉是歪门邪道,但是目前确实解决了我的问题,大家可以参考一下。
确实解决了,还可以精简下只设置SetWindowPos就行:
RECT rcClient;
GetWindowRect(msg->hwnd, &rcClient);
SetWindowPos(msg->hwnd, NULL, rcClient.left, rcClient.top, rcClient.right - rcClient.left, rcClient.bottom - rcClient.top, SWP_FRAMECHANGED);

posted @ 2021-03-06 23:41  feipeng8848  阅读(3842)  评论(4编辑  收藏  举报