如何在pyqt中自定義無邊框窗口


前言

之前寫過很多關於無邊框窗口並給窗口添加特效的博客,按照時間線羅列如下:

  1. 如何在pyqt中實現窗口磨砂效果
  2. 如何在pyqt中實現win10亞克力效果
  3. 如何在pyqt中通過調用SetWindowCompositionAttribute實現Win10亞克力效果
  4. 如何在pyqt中在實現無邊框窗口的同時保留Windows窗口動畫效果(一)
  5. 如何在pyqt中給無邊框窗口添加DWM環繞陰影
  6. 如何在pyqt中在實現無邊框窗口的同時保留Windows窗口動畫效果(二)

里面有幾篇博客用了 C++ 的 dll,雖然也給出了不用 dll 的實現方法,但還是覺得先前寫的自定義無邊框窗口的解決方案有些混亂,所以這次就總結在這一篇博客里面,並且只用 Python 的 ctypespywin32 來解決無邊框窗口的問題。先來看看無邊框窗口的效果:

無邊框窗口

需要解決的問題

在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_NCCALCSIZEWM_GETMINMAXINFO

結構體

在處理這兩個消息的時候,我們會調用 win32apiwin32gui 中的一些接口函數,所以需要先定義一些結構體:

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,以上~~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM