前言
之前寫過很多關於無邊框窗口並給窗口添加特效的博客,按照時間線羅列如下:
- 如何在pyqt中實現窗口磨砂效果
- 如何在pyqt中實現win10亞克力效果
- 如何在pyqt中通過調用SetWindowCompositionAttribute實現Win10亞克力效果
- 如何在pyqt中在實現無邊框窗口的同時保留Windows窗口動畫效果(一)
- 如何在pyqt中給無邊框窗口添加DWM環繞陰影
- 如何在pyqt中在實現無邊框窗口的同時保留Windows窗口動畫效果(二)
里面有幾篇博客用了 C++ 的 dll,雖然也給出了不用 dll 的實現方法,但還是覺得先前寫的自定義無邊框窗口的解決方案有些混亂,所以這次就總結在這一篇博客里面,並且只用 Python 的 ctypes
和 pywin32
來解決無邊框窗口的問題。先來看看無邊框窗口的效果:
需要解決的問題
在pyqt中只要 self.setWindowFlags(Qt.FramelessWindowHint)
就可以實現邊框的去除,但是沒了邊框會帶來一系列問題:
- 窗口無法移動
- 窗口無法拉伸
- 窗口動畫消失
- 窗口陰影消失
下面我們會一個個地解決上述問題,並且給出 Windows 的 Aero 和 Acrylic 窗口特效的實現方法。
自定義標題欄
為了還原窗口的移動、最大化、最小化和關閉功能,我們需要實現一個標題欄 TitleBar
。注意下面只會給出關鍵代碼,完整代碼請移步 github。
窗口移動
要實現窗口移動,我們需要重寫標題欄的 mousePressEvent()
,並調用 win32api.SendMessage()
和 win32gui.ReleaseCapture()
,將鼠標按下並拖動的消息 win32con.SC_MOVE + win32con.HTCAPTION
發送給 Windows,讓它知道該拖動窗口了。下面是實現代碼:
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()
窗口最大化、最小化、還原和關閉
當我們雙擊標題欄時,窗口應該由正常大小變為最大化狀態,或者由最大化狀態還原為正常大小,為了實現這個功能,我們需要重寫標題欄的 mouseDoubleClickEvent()
。而普通的最大化、最小化和關閉功能只需將按鈕的點擊信號連接到槽函數即可,下面是具體代碼:
def __initWidget(self):
""" 初始化小部件 """
self.setFixedHeight(40)
self.setAttribute(Qt.WA_StyledBackground)
self.__setQss()
# 將按鈕的點擊信號連接到槽函數
self.minBt.clicked.connect(self.window().showMinimized)
self.maxBt.clicked.connect(self.__showRestoreWindow)
self.closeBt.clicked.connect(self.window().close)
def mouseDoubleClickEvent(self, event):
""" 雙擊最大化/還原窗口 """
self.__showRestoreWindow()
def __showRestoreWindow(self):
""" 復原窗口並更換最大化按鈕的圖標 """
if self.window().isMaximized():
self.window().showNormal()
# 更新標志位用於更換圖標
self.maxBt.setMaxState(False)
else:
self.window().showMaximized()
self.maxBt.setMaxState(True)
WindowEffect 類
為了給無邊框窗口添加陰影,並設置 Aero 和 Acrylic 窗口特效,我們需要實現 WindowEffect
類,它還將提供還原窗口動畫的功能。
窗口陰影
要給窗口添加上一層陰影有許多方法,比如:
- 在當前窗口外再嵌套一層窗口,並通過
self.setGraphicsEffect()
給當前窗口添加上QGraphicsDropShadowEffect
,優點是我們可以任意調節陰影的半徑、偏移量和顏色; - 重寫頂層窗口的
paintEvent()
,手動畫出一層陰影,不過這種方法畫出來陰影在拐角處看起來會有些不自然;
我們不會使用這兩種方法,而是通過調用 ~ctypes.WinDLL('dwmapi')
中的接口函數來還原原生的 DWM 窗口陰影。
接口函數
為了實現DWM
環繞陰影,需要調用dwmapi
中的兩個函數:
HRESULT DwmSetWindowAttribute (HWND hwnd, DWORD dwAttribute, LPCVOID pvAttribute, DWORD cbAttribute)
,用來設置窗口的桌面窗口管理器(DWM)非客戶端呈現屬性的值,可以參見文檔 DwmSetWindowAttribute函數;HRESULT DwmExtendFrameIntoClientArea (HWND hWnd, const MARGINS *pMarInset)
,用來將窗口框架擴展到工作區,參見文檔DwmExtendFrameIntoClientArea函數 和 DWM模糊概述;
在調用這兩個函數之前,我們需要先在WindowEffect
的構造函數中聲明一下他們的函數原型
self.dwmapi = WinDLL("dwmapi")
self.DwmExtendFrameIntoClientArea = self.dwmapi.DwmExtendFrameIntoClientArea
self.DwmSetWindowAttribute = self.dwmapi.DwmSetWindowAttribute
self.DwmExtendFrameIntoClientArea.restype = LONG
self.DwmSetWindowAttribute.restype = LONG
self.DwmSetWindowAttribute.argtypes = [c_int, DWORD, LPCVOID, DWORD]
self.DwmExtendFrameIntoClientArea.argtypes = [c_int, POINTER(MARGINS)]
結構體和枚舉類
從MSDN文檔可以得知,傳入 DwmExtendFrameIntoClientArea()
的第二個參數 pMarInset
是一個結構體 MARGIN
的指針,所以我們下面定義一下 MARGIN
,同時定義一些要用到的枚舉類:
# coding: utf-8
from ctypes import Structure, c_int
from enum import Enum
class DWMNCRENDERINGPOLICY(Enum):
DWMNCRP_USEWINDOWSTYLE = 0
DWMNCRP_DISABLED = 1
DWMNCRP_ENABLED = 2
DWMNCRP_LAS = 3
class DWMWINDOWATTRIBUTE(Enum):
DWMWA_NCRENDERING_ENABLED = 1
DWMWA_NCRENDERING_POLICY = 2
DWMWA_TRANSITIONS_FORCEDISABLED = 3
DWMWA_ALLOW_NCPAINT = 4
DWMWA_CAPTION_BUTTON_BOUNDS = 5
DWMWA_NONCLIENT_RTL_LAYOUT = 6
DWMWA_FORCE_ICONIC_REPRESENTATION = 7
DWMWA_FLIP3D_POLICY = 8
DWMWA_EXTENDED_FRAME_BOUNDS = 9
DWMWA_HAS_ICONIC_BITMAP = 10
DWMWA_DISALLOW_PEEK = 11
DWMWA_EXCLUDED_FROM_PEEK = 12
DWMWA_CLOAK = 13
DWMWA_CLOAKED = 14
DWMWA_FREEZE_REPRESENTATION = 25
DWMWA_LAST = 16
class MARGINS(Structure):
_fields_ = [
("cxLeftWidth", c_int),
("cxRightWidth", c_int),
("cyTopHeight", c_int),
("cyBottomHeight", c_int),
]
還原陰影
准備工作完成,我們來看一下 WindowEffect
中拿來給無邊框窗口添加環繞陰影的函數:
def addShadowEffect(self, hWnd):
""" 給窗口添加陰影
Parameter
----------
hWnd: int or `sip.voidptr`
窗口句柄
"""
hWnd = int(hWnd)
self.DwmSetWindowAttribute(
hWnd,
DWMWINDOWATTRIBUTE.DWMWA_NCRENDERING_POLICY.value,
byref(c_int(DWMNCRENDERINGPOLICY.DWMNCRP_ENABLED.value)),
4,
)
margins = MARGINS(-1, -1, -1, -1)
self.DwmExtendFrameIntoClientArea(hWnd, byref(margins))
這是這篇博客中首次出現窗口句柄 hWnd
,我們后面還會再用到它。簡單理解,一個 hWnd
就是一個頂層窗口的 ID
,具體介紹參見 窗口句柄。在 pyqt 中,通過 self.winId()
可以獲得 sip.voidptr
類型的 hWnd
,可以通過 int(self.windId())
將其轉換為整數。從上面的代碼也可以看出,hWnd
是很重要的,很多接口函數都將 hWnd
作為第一個參數。
窗口動畫
要想還原最大化和最小化時的窗口動畫,只需通過 win32gui.SetWindowLong()
重新設置一下窗口樣式即可:
def addWindowAnimation(self, hWnd):
""" 還原窗口動畫效果
Parameters
----------
hWnd : int or `sip.voidptr`
窗口句柄
"""
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,
)
Aero 和 Acrylic
為了吸引眼球,Win7 引入了Aero,Win10 引入了 Acrylic 亞克力效果,要想給我們的窗口也添加上這兩種效果,需要用到 ~ctypes.WinDLL('user32')
的一個接口函數 SetWindowCompositionAttribute()
。和添加窗口陰影相似,在調用這個函數之前,我們需要一些准備工作。
接口函數
我們先在 WindowEffect
的構造函數中聲明一下函數原型並初始化一些要作為參數的結構體:
# 調用api
self.user32 = WinDLL("user32")
self.SetWindowCompositionAttribute = self.user32.SetWindowCompositionAttribute
self.SetWindowCompositionAttribute.restype = c_bool
self.SetWindowCompositionAttribute.argtypes = [
c_int,
POINTER(WINDOWCOMPOSITIONATTRIBDATA),
]
# 初始化結構體
self.accentPolicy = ACCENT_POLICY()
self.winCompAttrData = WINDOWCOMPOSITIONATTRIBDATA()
self.winCompAttrData.Attribute = WINDOWCOMPOSITIONATTRIB.WCA_ACCENT_POLICY.value[
0
]
self.winCompAttrData.SizeOfData = sizeof(self.accentPolicy)
self.winCompAttrData.Data = pointer(self.accentPolicy)
結構體
下面是上述代碼用到的結構體和枚舉類:
# coding:utf-8
from ctypes import POINTER, Structure, c_int
from ctypes.wintypes import DWORD, HWND, ULONG, POINT, RECT, UINT
from enum import Enum
class WINDOWCOMPOSITIONATTRIB(Enum):
WCA_UNDEFINED = 0,
WCA_NCRENDERING_ENABLED = 1,
WCA_NCRENDERING_POLICY = 2,
WCA_TRANSITIONS_FORCEDISABLED = 3,
WCA_ALLOW_NCPAINT = 4,
WCA_CAPTION_BUTTON_BOUNDS = 5,
WCA_NONCLIENT_RTL_LAYOUT = 6,
WCA_FORCE_ICONIC_REPRESENTATION = 7,
WCA_EXTENDED_FRAME_BOUNDS = 8,
WCA_HAS_ICONIC_BITMAP = 9,
WCA_THEME_ATTRIBUTES = 10,
WCA_NCRENDERING_EXILED = 11,
WCA_NCADORNMENTINFO = 12,
WCA_EXCLUDED_FROM_LIVEPREVIEW = 13,
WCA_VIDEO_OVERLAY_ACTIVE = 14,
WCA_FORCE_ACTIVEWINDOW_APPEARANCE = 15,
WCA_DISALLOW_PEEK = 16,
WCA_CLOAK = 17,
WCA_CLOAKED = 18,
WCA_ACCENT_POLICY = 19,
WCA_FREEZE_REPRESENTATION = 20,
WCA_EVER_UNCLOAKED = 21,
WCA_VISUAL_OWNER = 22,
WCA_LAST = 23
class ACCENT_STATE(Enum):
""" 客戶區狀態枚舉類 """
ACCENT_DISABLED = 0,
ACCENT_ENABLE_GRADIENT = 1,
ACCENT_ENABLE_TRANSPARENTGRADIENT = 2,
ACCENT_ENABLE_BLURBEHIND = 3, # Aero效果
ACCENT_ENABLE_ACRYLICBLURBEHIND = 4, # 亞克力效果
ACCENT_INVALID_STATE = 5
class ACCENT_POLICY(Structure):
""" 設置客戶區的具體屬性 """
_fields_ = [
("AccentState", DWORD),
("AccentFlags", DWORD),
("GradientColor", DWORD),
("AnimationId", DWORD),
]
class WINDOWCOMPOSITIONATTRIBDATA(Structure):
_fields_ = [
("Attribute", DWORD),
# POINTER()接收任何ctypes類型,並返回一個指針類型
("Data", POINTER(ACCENT_POLICY)),
("SizeOfData", ULONG),
]
添加窗口特效
上述結構體中 AccentPolicy.AccentState
可以控制着窗口的多種效果,通過改變它的值,我們可以實現 Aero、Acrylic 等多種效果。對於這些效果的研究,可以參見 《使用 SetWindowCompositionAttribute 來控制程序的窗口邊框和背景》,里面介紹的十分詳盡。下面我們來看看 WindowEffect
中給窗口添加 Acrylic 和 Aero 效果的方法:
def setAcrylicEffect(self, hWnd, gradientColor: str = "F2F2F230", isEnableShadow: bool = True, animationId: int = 0):
""" 給窗口開啟Win10的亞克力效果
Parameter
----------
hWnd: int or `sip.voidptr`
窗口句柄
gradientColor: str
十六進制亞克力混合色,對應 RGBA 四個分量
isEnableShadow: bool
控制是否啟用窗口陰影
animationId: int
控制磨砂動畫
"""
# 亞克力混合色
gradientColor = (
gradientColor[6:]
+ gradientColor[4:6]
+ gradientColor[2:4]
+ gradientColor[:2]
)
gradientColor = DWORD(int(gradientColor, base=16))
# 磨砂動畫
animationId = DWORD(animationId)
# 窗口陰影
accentFlags = DWORD(0x20 | 0x40 | 0x80 |
0x100) if isEnableShadow else DWORD(0)
self.accentPolicy.AccentState = ACCENT_STATE.ACCENT_ENABLE_ACRYLICBLURBEHIND.value[
0
]
self.accentPolicy.GradientColor = gradientColor
self.accentPolicy.AccentFlags = accentFlags
self.accentPolicy.AnimationId = animationId
# 開啟亞克力
self.SetWindowCompositionAttribute(hWnd, pointer(self.winCompAttrData))
def setAeroEffect(self, hWnd):
""" 給窗口開啟Aero效果
Parameter
----------
hWnd: int or `sip.voidptr`
窗口句柄
"""
self.accentPolicy.AccentState = ACCENT_STATE.ACCENT_ENABLE_BLURBEHIND.value[0]
# 開啟Aero
self.SetWindowCompositionAttribute(hWnd, pointer(self.winCompAttrData))
雖然亞克力的視覺效果很不錯,但是在拖動窗口時會出現窗口卡頓問題,一種在本機的解決方案是去掉 高級系統設置 -> 性能 -> 拖動時顯示窗口內容 復選框的 √ :
FramelessWindow 類
最后我們還剩一個窗口拉伸問題,為了解決這個問題,我們需要定義一個無邊框窗口 FramelessWindow
類。在構造函數里面我們利用 WindowEffect
類給無邊框窗口加上了窗口陰影和窗口動畫,還有一點需要強調的是,我們不是簡單地用 self.setWindowFlags(Qt.FramelessWindowHint)
來取消邊框,而是多加了幾個標志位,目的是解決點擊任務欄圖標窗口無法最小化或者還原的問題。下面是無邊框窗口的構造函數:
class FramelessWindow(QWidget):
BORDER_WIDTH = 5
def __init__(self, parent=None):
super().__init__(parent)
self.monitor_info = None
self.titleBar = TitleBar(self)
self.windowEffect = WindowEffect()
# 取消邊框
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowSystemMenuHint |
Qt.WindowMinimizeButtonHint | Qt.WindowMaximizeButtonHint)
# 添加陰影和窗口動畫
self.windowEffect.addShadowEffect(self.winId())
self.windowEffect.addWindowAnimation(self.winId())
self.resize(500, 500)
self.titleBar.raise_()
窗口拉伸
為了實現窗口拉伸,我們需要在 nativeEvent()
中處理 WM_NCHITTEST
消息,來告訴 Windows 光標已經到了窗口的邊沿,該改變光標的樣式並允許我們拉伸窗口了。實現方式如下:
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()) % 65536
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
return QWidget.nativeEvent(self, eventType, message)
窗口最大化
至此,我們已經解決了羅列出來的所有問題,但是新的問題也接踵而來,那就是
如果還原窗口動畫,就會導致窗口最大化時尺寸超過正確的顯示器尺寸,甚至最大化之后又會有新的標題欄跑出來。
要解決這個問題必須在 nativeEvent
中處理另外兩個消息:WM_NCCALCSIZE
和 WM_GETMINMAXINFO
。
結構體
在處理這兩個消息的時候,我們會調用 win32api
和 win32gui
中的一些接口函數,所以需要先定義一些結構體:
class MINMAXINFO(Structure):
_fields_ = [
("ptReserved", POINT),
("ptMaxSize", POINT),
("ptMaxPosition", POINT),
("ptMinTrackSize", POINT),
("ptMaxTrackSize", POINT),
]
class PWINDOWPOS(Structure):
_fields_ = [
('hWnd', HWND),
('hwndInsertAfter', HWND),
('x', c_int),
('y', c_int),
('cx', c_int),
('cy', c_int),
('flags', UINT)
]
class NCCALCSIZE_PARAMS(Structure):
_fields_ = [
('rgrc', RECT*3),
('lppos', POINTER(PWINDOWPOS))
]
處理消息
下面是處理這兩個消息用到的代碼,注釋講解地很詳細:
def nativeEvent(self, eventType, message):
""" 處理windows消息 """
msg = MSG.from_address(message.__int__())
# 此處省略處理 WM_NCHITTEST 消息的代碼
if msg.message == win32con.WM_NCCALCSIZE:
if self.isWindowMaximized(msg.hWnd):
self.monitorNCCALCSIZE(msg)
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)
def monitorNCCALCSIZE(self, msg: MSG):
""" 調整窗口大小 """
monitor = win32api.MonitorFromWindow(msg.hWnd)
# 如果沒有保存顯示器信息就直接返回,否則接着調整窗口大小
if monitor is None and not self.monitor_info:
return
elif monitor is not None:
self.monitor_info = win32api.GetMonitorInfo(monitor)
# 調整窗口大小
params = cast(msg.lParam, POINTER(NCCALCSIZE_PARAMS)).contents
params.rgrc[0].left = self.monitor_info['Work'][0]
params.rgrc[0].top = self.monitor_info['Work'][1]
params.rgrc[0].right = self.monitor_info['Work'][2]
params.rgrc[0].bottom = self.monitor_info['Work'][3]
寫在最后
這樣整個自定義無邊框窗口的解決方案就介紹完了,更多代碼請移步 github,以上~~