無邊框其實就是去掉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);