無邊框窗體的實現思路
在pyqt中只要 self.setWindowFlags(Qt.FramelessWindowHint)
就可以實現邊框的去除,但是沒了標題欄也意味着窗口大小無法改變、窗口無法拖拽和窗口陰影的消失。網上有很多介紹pyqt自定義標題欄的方法,幾乎都是通過處理 mousePressEvent
、 mouseReleaseEvent
以及 mouseMoveEvent
來實現的,在移動的過程中是可以看到窗口的內容的。在沒有給窗口打開Windows的亞克力效果時這種方法還能湊合着用,如果給窗口加上了亞克力效果,移動窗口就會非常卡。
在《PYQT5 實現 無frame窗口的拖動和放縮》中,作者重寫了nativeEvent
,將qt的消息轉換為Windows的 MSG,確實可以直接還原去除邊框前的移動窗口和窗口放縮效果,但還是無法還原Windows的窗口最大化和最小化動畫。然后這篇博客《Qt無邊框窗體(Windows)》中,作者很詳細地介紹了C++的實現方法,看完這篇博客之后開始着手用 ctypes
和 pywin32
模塊來翻譯作者提供的那些代碼。從代碼中可以看到作者把 msg->lParam
強轉為各種結構體,里面存着窗口的信息。有些結構體用繼承 ctypes.Structure
之后再加上 ctype.wintypes
的一些數據類型是可以實現的,但是有的變量類型比如 PWINDOWPOS
在 ctype.wintypes
里面找不到。對於這種情況就用VS2019把C++代碼轉成dll來直接調用。
更完整的且不依賴於 C++ dll 的無邊框解決方案請移步《如何在pyqt中自定義無邊框窗口》。
效果
- 窗口拖動和貼邊最大化
- 窗口拉伸
具體實現過程
自定義標題欄
對於窗口移動,只要在自定義標題欄的 mousePressEvent
中調用 win32gui.ReleaseCapture()
和 win32api.SendMessage(hWnd,message,wParam,lParam)
就可以實現,具體代碼如下(用到的資源文件和其他按鈕的代碼放在了文末的鏈接中,可以自取):
import sys
from ctypes.wintypes import HWND
from win32.lib import win32con
from win32.win32api import SendMessage
from win32.win32gui import ReleaseCapture
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIcon, QPixmap, QResizeEvent
from PyQt5.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget
from effects.window_effect import WindowEffect
from .title_bar_buttons import BasicButton, MaximizeButton
class TitleBar(QWidget):
""" 定義標題欄 """
def __init__(self, parent):
super().__init__(parent)
self.resize(1360, 40)
# self.win = parent
# 實例化無邊框窗口函數類
self.windowEffect = WindowEffect()
self.setAttribute(Qt.WA_TranslucentBackground)
# 實例化小部件
self.title = QLabel('Groove 音樂', self)
self.createButtons()
# 初始化界面
self.initWidget()
self.adjustButtonPos()
def createButtons(self):
""" 創建各按鈕 """
self.minBt = BasicButton([
{'normal': r'resource\images\titleBar\透明黑色最小化按鈕_57_40.png',
'hover': r'resource\images\titleBar\綠色最小化按鈕_hover_57_40.png',
'pressed': r'resource\images\titleBar\黑色最小化按鈕_pressed_57_40.png'},
{'normal': r'resource\images\titleBar\白色最小化按鈕_57_40.png',
'hover': r'resource\images\titleBar\綠色最小化按鈕_hover_57_40.png',
'pressed': r'resource\images\titleBar\黑色最小化按鈕_pressed_57_40.png'}], self)
self.closeBt = BasicButton([
{'normal': r'resource\images\titleBar\透明黑色關閉按鈕_57_40.png',
'hover': r'resource\images\titleBar\關閉按鈕_hover_57_40.png',
'pressed': r'resource\images\titleBar\關閉按鈕_pressed_57_40.png'},
{'normal': r'resource\images\titleBar\透明白色關閉按鈕_57_40.png',
'hover': r'resource\images\titleBar\關閉按鈕_hover_57_40.png',
'pressed': r'resource\images\titleBar\關閉按鈕_pressed_57_40.png'}], self)
self.returnBt = BasicButton([
{'normal': r'resource\images\titleBar\黑色返回按鈕_60_40.png',
'hover': r'resource\images\titleBar\黑色返回按鈕_hover_60_40.png',
'pressed': r'resource\images\titleBar\黑色返回按鈕_pressed_60_40.png'},
{'normal': r'resource\images\titleBar\白色返回按鈕_60_40.png',
'hover': r'resource\images\titleBar\白色返回按鈕_hover_60_40.png',
'pressed': r'resource\images\titleBar\白色返回按鈕_pressed_60_40.png'}], self, iconSize_tuple=(60, 40))
self.maxBt = MaximizeButton(self)
self.button_list = [self.minBt, self.maxBt,
self.closeBt, self.returnBt]
def initWidget(self):
""" 初始化小部件 """
self.setFixedHeight(40)
self.setStyleSheet("QWidget{background-color:transparent}\
QLabel{font:14px 'Microsoft YaHei Light'; padding:10px 15px 10px 15px;}")
# 隱藏抬頭
self.title.hide()
# 將按鈕的點擊信號連接到槽函數
self.minBt.clicked.connect(self.window().showMinimized)
self.maxBt.clicked.connect(self.showRestoreWindow)
self.closeBt.clicked.connect(self.window().close)
def adjustButtonPos(self):
""" 初始化小部件位置 """
self.title.move(self.returnBt.width(), 0)
self.closeBt.move(self.width() - 57, 0)
self.maxBt.move(self.width() - 2 * 57, 0)
self.minBt.move(self.width() - 3 * 57, 0)
def resizeEvent(self, e: QResizeEvent):
""" 尺寸改變時移動按鈕 """
self.adjustButtonPos()
def mouseDoubleClickEvent(self, event):
""" 雙擊最大化窗口 """
self.showRestoreWindow()
def mousePressEvent(self, event):
""" 移動窗口 """
# 判斷鼠標點擊位置是否允許拖動
if self.isPointInDragRegion(event.pos()):
ReleaseCapture()
SendMessage(self.window().winId(), win32con.WM_SYSCOMMAND,
win32con.SC_MOVE + win32con.HTCAPTION, 0)
event.ignore()
# 也可以通過調用windowEffect.dll的接口函數來實現窗口拖動
# self.windowEffect.moveWindow(HWND(int(self.parent().winId())))
def showRestoreWindow(self):
""" 復原窗口並更換最大化按鈕的圖標 """
if self.window().isMaximized():
self.window().showNormal()
# 更新標志位用於更換圖標
self.maxBt.setMaxState(False)
else:
self.window().showMaximized()
self.maxBt.setMaxState(True)
def isPointInDragRegion(self, pos) -> bool:
""" 檢查鼠標按下的點是否屬於允許拖動的區域 """
x = pos.x()
condX = (60 < x < self.width() - 57 * 3)
return condX
def setWhiteIcon(self, isWhiteIcon):
""" 設置圖標顏色 """
for button in self.button_list:
button.setWhiteIcon(isWhiteIcon)
WindowEffect 類
對於還原窗口動畫,調用 win32gui.GetWindowLong()
和win32gui.SetWindowLong
重新設置一下窗口動畫即可,這個函數定義在了WindowEffect
中,這個類可以用來實現窗口的各種效果,包括 Win7 的 AERO
效果、Win10的亞克力效果、DWM繪制的窗口陰影和移動窗口等,不過要想成功使用這個類必須在Visual Studio里面裝好C++,不然調用windowEffect.dll
時候會報錯找不到相關的依賴項(免安裝MSVC的無邊框窗口解決方案見《如何在pyqt中自定義無邊框窗口》)。WindowEffect
的代碼如下所示,其中 setWindowAnimation(hWnd)
用來還原了窗口動畫:
# coding:utf-8
from ctypes import c_bool, cdll
from ctypes.wintypes import DWORD, HWND,LPARAM
from win32 import win32gui
from win32.lib import win32con
class WindowEffect():
""" 調用windowEffect.dll來設置窗口外觀 """
dll = cdll.LoadLibrary('dll\\windowEffect.dll')
def setAcrylicEffect(self,
hWnd: HWND,
gradientColor: str = 'FF000066',
accentFlags: bool = False,
animationId: int = 0):
""" 開啟亞克力效果,gradientColor對應16進制的rgba四個分量 """
# 設置陰影
if accentFlags:
accentFlags = DWORD(0x20 | 0x40 | 0x80 | 0x100)
else:
accentFlags = DWORD(0)
# 設置和亞克力效果相疊加的背景顏色
gradientColor = gradientColor[6:] + gradientColor[4:6] + \
gradientColor[2:4] + gradientColor[:2]
gradientColor = DWORD(int(gradientColor, base=16))
animationId = DWORD(animationId)
self.dll.setAcrylicEffect(hWnd, accentFlags, gradientColor,
animationId)
def setAeroEffect(self, hWnd: HWND):
""" 開啟Aero效果 """
self.dll.setAeroEffect(hWnd)
def setShadowEffect(self,
class_amended: c_bool,
hWnd: HWND,
newShadow=True):
""" 去除窗口自帶陰影並決定是否添加新陰影 """
class_amended = c_bool(
self.dll.setShadowEffect(class_amended, hWnd, c_bool(newShadow)))
return class_amended
def addShadowEffect(self, shadowEnable: bool, hWnd: HWND):
""" 直接添加新陰影 """
self.dll.addShadowEffect(c_bool(shadowEnable), hWnd)
def setWindowFrame(self, hWnd: HWND, left: int, top, right, buttom):
""" 設置客戶區的邊框大小 """
self.dll.setWindowFrame(hWnd, left, top, right, buttom)
def setWindowAnimation(self, hWnd):
""" 打開窗口動畫效果 """
style = win32gui.GetWindowLong(hWnd, win32con.GWL_STYLE)
win32gui.SetWindowLong(
hWnd, win32con.GWL_STYLE, style | win32con.WS_MAXIMIZEBOX
| win32con.WS_CAPTION
| win32con.CS_DBLCLKS
| win32con.WS_THICKFRAME)
def adjustMaximizedClientRect(self, hWnd: HWND, lParam: int):
""" 窗口最大化時調整大小 """
self.dll.adjustMaximizedClientRect(hWnd, LPARAM(lParam))
def moveWindow(self,hWnd:HWND):
""" 移動窗口 """
self.dll.moveWindow(hWnd)
無邊框窗口
正如第二篇博客所說的那樣,如果還原窗口動畫,就會導致窗口最大化時尺寸超過正確的顯示器尺寸,甚至最大化之后又會有新的標題欄跑出來,要解決這個問題必須在 nativeEvent
中處理兩個消息,一個是WM_NCCALCSIZE
,另一個則是 WM_GETMINMAXINFO
。處理WM_GETMINMAXINFO
時用到的結構體 MINMAXINFO
如下所示:
class MINMAXINFO(Structure):
_fields_ = [
("ptReserved", POINT),
("ptMaxSize", POINT),
("ptMaxPosition", POINT),
("ptMinTrackSize", POINT),
("ptMaxTrackSize", POINT),
]
這個結構體中每個參數的定義都可以在MSDN的文檔中找到,里面介紹的很詳細。處理 WM_NCCALCSIZE
時需要將msg.lParam
轉換為結構體NCCALCSIZE_PARAMS
的指針,這個是結構NCCALCSIZE_PARAMS
的第二個成員的類型。為了偷懶,我在動態鏈接庫中加了這個函數的接口里面,adjustMaximizedClientRect(HWND hWnd, LPARAM lParam)
具體代碼如下:
void adjustMaximizedClientRect(HWND hWnd, LPARAM lParam)
{
auto monitor = ::MonitorFromWindow(hWnd, MONITOR_DEFAULTTONULL);
if (!monitor) return;
MONITORINFO monitor_info{};
monitor_info.cbSize = sizeof(monitor_info);
if (!::GetMonitorInfoW(monitor, &monitor_info)) return;
auto& params = *reinterpret_cast<NCCALCSIZE_PARAMS*>(lParam);
params.rgrc[0] = monitor_info.rcWork;
}
這個函數拿來在窗口最大化時重新設置窗口大小和坐標,下面給出整個無邊框窗體的代碼,里面的重點就是重寫 nativeEvent
:
# coding:utf-8
import sys
from ctypes import POINTER,cast,Structure
from ctypes.wintypes import HWND, MSG, POINT
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication, QWidget
from PyQt5.QtWinExtras import QtWin
from win32 import win32api, win32gui
from win32.lib import win32con
from effects.window_effect import WindowEffect
from my_title_bar import TitleBar
class Window(QWidget):
BORDER_WIDTH = 5
def __init__(self, parent=None):
super().__init__(parent)
self.titleBar = TitleBar(self)
self.windowEffect = WindowEffect()
self.hWnd = HWND(int(self.winId()))
self.__initWidget()
def __initWidget(self):
""" 初始化小部件 """
self.resize(800, 600)
self.setWindowFlags(Qt.FramelessWindowHint)
# 還原窗口動畫
self.windowEffect.setWindowAnimation(self.winId())
# 打開亞克力效果
self.setStyleSheet('QWidget{background:transparent}') # 必須用qss開實現背景透明,不然會出現界面卡頓
self.windowEffect.setAcrylicEffect(self.hWnd,'F2F2F260')
def isWindowMaximized(self, hWnd: int) -> bool:
""" 判斷窗口是否最大化 """
# 返回指定窗口的顯示狀態以及被恢復的、最大化的和最小化的窗口位置,返回值為元組
windowPlacement = win32gui.GetWindowPlacement(hWnd)
if not windowPlacement:
return False
return windowPlacement[1] == win32con.SW_MAXIMIZE
def nativeEvent(self, eventType, message):
""" 處理windows消息 """
msg = MSG.from_address(message.__int__())
if msg.message == win32con.WM_NCHITTEST:
# 處理鼠標拖拽消息
xPos = win32api.LOWORD(msg.lParam) - self.frameGeometry().x()
yPos = win32api.HIWORD(msg.lParam) - self.frameGeometry().y()
w, h = self.width(), self.height()
lx = xPos < self.BORDER_WIDTH
rx = xPos + 9 > w - self.BORDER_WIDTH
ty = yPos < self.BORDER_WIDTH
by = yPos > h - self.BORDER_WIDTH
# 左上角
if (lx and ty):
return True, win32con.HTTOPLEFT
# 右下角
elif (rx and by):
return True, win32con.HTBOTTOMRIGHT
# 右上角
elif (rx and ty):
return True, win32con.HTTOPRIGHT
# 左下角
elif (lx and by):
return True, win32con.HTBOTTOMLEFT
# 頂部
elif ty:
return True, win32con.HTTOP
# 底部
elif by:
return True, win32con.HTBOTTOM
# 左邊
elif lx:
return True, win32con.HTLEFT
# 右邊
elif rx:
return True, win32con.HTRIGHT
# 處理窗口最大化消息
elif msg.message == win32con.WM_NCCALCSIZE:
if self.isWindowMaximized(msg.hWnd):
self.windowEffect.adjustMaximizedClientRect(
HWND(msg.hWnd), msg.lParam)
return True, 0
elif msg.message == win32con.WM_GETMINMAXINFO:
if self.isWindowMaximized(msg.hWnd):
window_rect = win32gui.GetWindowRect(msg.hWnd)
if not window_rect:
return False, 0
# 獲取顯示器句柄
monitor = win32api.MonitorFromRect(window_rect)
if not monitor:
return False, 0
# 獲取顯示器信息
monitor_info = win32api.GetMonitorInfo(monitor)
monitor_rect = monitor_info['Monitor']
work_area = monitor_info['Work']
# 將lParam轉換為MINMAXINFO指針
info = cast(msg.lParam, POINTER(MINMAXINFO)).contents
# 調整窗口大小
info.ptMaxSize.x = work_area[2] - work_area[0]
info.ptMaxSize.y = work_area[3] - work_area[1]
info.ptMaxTrackSize.x = info.ptMaxSize.x
info.ptMaxTrackSize.y = info.ptMaxSize.y
# 修改左上角坐標
info.ptMaxPosition.x = abs(
window_rect[0] - monitor_rect[0])
info.ptMaxPosition.y = abs(
window_rect[1] - monitor_rect[1])
return True, 1
return QWidget.nativeEvent(self, eventType, message)
def resizeEvent(self, e):
""" 改變標題欄大小 """
super().resizeEvent(e)
self.titleBar.resize(self.width(), 40)
# 更新最大化按鈕圖標
if self.isWindowMaximized(int(self.winId())):
self.titleBar.maxBt.setMaxState(True)
if __name__ == '__main__':
app = QApplication(sys.argv)
demo = Window()
demo.show()
sys.exit(app.exec_())
寫在最后
以上就是Windows上的無邊框窗體解決方案,代碼我放在了百度網盤(提取碼:1yhl)中,對你有幫助的話就點個贊吧(~ ̄▽ ̄)~