如何在pyqt中在实现无边框窗口的同时保留Windows窗口动画效果(一)
无边框窗体的实现思路
在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)中,对你有帮助的话就点个赞吧(~ ̄▽ ̄)~