普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月18日首页

QT中自定义标题栏

作者 寒鸦飞尽
2026年3月18日 10:56

QT中自定义标题栏

在 Qt 桌面开发里,自定义标题栏是一个很常见的需求。比如你想统一品牌风格、做更轻的窗口外观,或者需要让标题栏和业务界面融为一体,这时候系统默认标题栏往往就不够用了。

但真正动手之后会发现,这件事远不止“把标题栏画出来”这么简单。因为一旦把系统标题栏去掉,随之消失的还有一整套窗口能力:

  • 窗口拖动
  • 双击最大化/还原
  • 四边八角缩放
  • 最大化后的拖动恢复
  • Windows 下的系统阴影、圆角、细边框
  • 最大化时窗口位置偏移等细节问题

这篇文章结合一个实际 Qt 6 项目的实现,完整讲清楚如何在 QML + Qt + Win32/DWM 的组合下,做出一套可用、接近原生体验的自定义标题栏方案。

一、先说结论:自定义标题栏要分三层做

如果想把这件事做好,建议拆成三层:

  1. QML 负责界面
    • 标题栏布局
    • 最小化、最大化、关闭按钮
    • 鼠标 hover、图标切换
  2. Qt 负责窗口交互
    • startSystemMove()
    • startSystemResize()
    • 窗口状态切换
    • 普通状态与最大化状态之间的恢复逻辑
  3. Windows 原生层负责非客户区细节
    • 系统阴影
    • 圆角
    • 细边框
    • 非客户区裁剪
    • 最大化偏移修正

这样分层后,代码结构会清晰很多,而且不会把所有逻辑都塞进一个 QML 文件里。

二、去掉系统标题栏

第一步是把系统标题栏去掉,让窗口变成无边框窗口。

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window

Window {
    id: window

    flags: Qt.Window | Qt.FramelessWindowHint

    width: 1200
    height: 800
    minimumWidth: 800
    minimumHeight: 600
    visible: true
    title: ""

    color: "transparent"

    Rectangle {
        anchors.fill: parent
        color: "#f5f7fb"

        ColumnLayout {
            anchors.fill: parent
            spacing: 0

            WindowTitleBar {
                id: titleBar
                Layout.fillWidth: true
                title: "我是标题"
                logoSource: "qrc:/icons/logo.png"
                windowStateController: windowStateController
            }

            Item {
                Layout.fillWidth: true
                Layout.fillHeight: true
            }
        }
    }

    WindowStateController {
        id: windowStateController
        window: window
    }

    WindowResizeHandles {
        anchors.fill: parent
        windowStateController: windowStateController
    }
}

这里最关键的是:

flags: Qt.Window | Qt.FramelessWindowHint

只要加上 Qt.FramelessWindowHint,系统标题栏就没了。接下来窗口顶部就可以完全由我们自己接管。

三、实现一个自定义标题栏组件

接下来开始做标题栏 UI。这个标题栏需要具备几个最基本的能力:

  • 展示标题、Logo
  • 最小化按钮
  • 最大化 / 还原按钮
  • 关闭按钮
  • 鼠标按下时开始拖动窗口
  • 双击时最大化 / 还原

下面是一份比较完整的 WindowTitleBar.qml

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window

Item {
    id: root

    property var windowStateController
    property string title: "我是标题"
    property color backgroundColor: "#f7f8fa"
    property color borderColor: "#d9dde4"
    property color textColor: "#111827"
    property color buttonHoverColor: "#e9edf3"
    property color closeHoverColor: "#e81123"
    property alias logoSource: logo.source

    height: 40

    function toggleMaximize() {
        if (!windowStateController) {
            return;
        }
        windowStateController.toggleMaximize();
    }

    Rectangle {
        anchors.fill: parent
        color: root.backgroundColor
    }

    Rectangle {
        anchors.left: parent.left
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        height: 1
        color: root.borderColor
    }

    MouseArea {
        anchors.fill: parent
        acceptedButtons: Qt.LeftButton
        propagateComposedEvents: true

        onPressed: function(mouse) {
            if (mouse.button === Qt.LeftButton && windowStateController) {
                windowStateController.startMove(root, mouse.x, mouse.y);
            }
        }

        onDoubleClicked: function(mouse) {
            if (mouse.button === Qt.LeftButton) {
                root.toggleMaximize();
            }
        }
    }

    RowLayout {
        anchors.fill: parent
        anchors.leftMargin: 12
        spacing: 8

        Image {
            id: logo
            Layout.alignment: Qt.AlignVCenter
            Layout.preferredWidth: 18
            Layout.preferredHeight: 18
            sourceSize.width: 18
            sourceSize.height: 18
            fillMode: Image.PreserveAspectFit
            smooth: true
        }

        Text {
            Layout.alignment: Qt.AlignVCenter
            text: root.title
            color: root.textColor
            font.family: "Microsoft YaHei"
            font.pixelSize: 13
            elide: Text.ElideRight
        }

        Item {
            Layout.fillWidth: true
        }

        Rectangle {
            Layout.preferredWidth: 46
            Layout.preferredHeight: parent.height
            color: minimizeArea.containsMouse ? root.buttonHoverColor : "transparent"

            Image {
                anchors.centerIn: parent
                width: 18
                height: 18
                source: "qrc:/icons/minimize.png"
                fillMode: Image.PreserveAspectFit
            }

            MouseArea {
                id: minimizeArea
                anchors.fill: parent
                hoverEnabled: true
                onClicked: {
                    if (windowStateController) {
                        windowStateController.minimize();
                    }
                }
            }
        }

        Rectangle {
            Layout.preferredWidth: 46
            Layout.preferredHeight: parent.height
            color: maximizeArea.containsMouse ? root.buttonHoverColor : "transparent"

            Image {
                anchors.centerIn: parent
                width: 18
                height: 18
                source: windowStateController && windowStateController.maximized
                        ? "qrc:/icons/exitFullScreen.png"
                        : "qrc:/icons/fullScreen.png"
                fillMode: Image.PreserveAspectFit
            }

            MouseArea {
                id: maximizeArea
                anchors.fill: parent
                hoverEnabled: true
                onClicked: {
                    root.toggleMaximize();
                }
            }
        }

        Rectangle {
            Layout.preferredWidth: 46
            Layout.preferredHeight: parent.height
            color: closeArea.containsMouse ? root.closeHoverColor : "transparent"

            Image {
                anchors.centerIn: parent
                width: 18
                height: 18
                source: "qrc:/icons/close.png"
                fillMode: Image.PreserveAspectFit
            }

            MouseArea {
                id: closeArea
                anchors.fill: parent
                hoverEnabled: true
                onClicked: {
                    if (Window.window) {
                        Window.window.close();
                    }
                }
            }
        }
    }
}

四、拖动窗口时,尽量别自己算位移

很多人第一次做自定义标题栏,会在 MouseArea 里记录鼠标按下位置,然后自己计算 window.xwindow.y。这能跑,但体验一般,而且容易在多屏、贴边、最大化恢复时出 bug。

更稳妥的方式是直接使用 Qt 提供的系统能力:

window.startSystemMove()

这会把拖动行为交还给系统窗口管理器。吸附、贴边、拖动体验都会更接近原生。

所以在标题栏里,不应该自己移动窗口,而是交给一个统一的状态控制器处理。

五、抽一个 WindowStateController,统一管理窗口状态

无边框窗口最容易出问题的地方,就是最大化与还原之间的状态切换。

建议把这些逻辑统一放进一个 WindowStateController.qml。下面是完整实现:

import QtQuick
import QtQuick.Window

Item {
    id: root

    required property Window window
    visible: false
    width: 0
    height: 0

    property bool maximized: false
    property rect normalGeometry: Qt.rect(0, 0, 0, 0)

    function availableGeometry() {
        if (window && window.screen) {
            return window.screen.availableGeometry;
        }
        return Qt.rect(0, 0, Screen.width, Screen.height);
    }

    function rememberNormalGeometry() {
        if (!window || maximized || window.visibility === Window.Minimized || window.visibility === Window.FullScreen) {
            return;
        }

        normalGeometry = Qt.rect(window.x, window.y, window.width, window.height);
    }

    function applyGeometry(geometry) {
        if (!window) {
            return;
        }

        window.x = Math.round(geometry.x);
        window.y = Math.round(geometry.y);
        window.width = Math.round(geometry.width);
        window.height = Math.round(geometry.height);
    }

    function maximize() {
        if (!window) {
            return;
        }

        rememberNormalGeometry();
        maximized = true;
        window.showMaximized();
        applyGeometry(availableGeometry());
    }

    function restore() {
        if (!window) {
            return;
        }

        maximized = false;
        window.showNormal();
        if (normalGeometry.width > 0 && normalGeometry.height > 0) {
            applyGeometry(normalGeometry);
        }
    }

    function toggleMaximize() {
        if (maximized) {
            restore();
        } else {
            maximize();
        }
    }

    function minimize() {
        if (!window) {
            return;
        }

        window.showMinimized();
    }

    function startMove(item, mouseX, mouseY) {
        if (!window || !item) {
            return;
        }

        if (!maximized) {
            window.startSystemMove();
            return;
        }

        const globalPoint = item.mapToGlobal(mouseX, mouseY);
        const preservedWidth = window.width;
        const preservedHeight = window.height;

        maximized = false;
        window.showNormal();

        Qt.callLater(function() {
            window.width = preservedWidth;
            window.height = preservedHeight;
            window.x = Math.round(globalPoint.x - mouseX);
            window.y = Math.round(globalPoint.y - mouseY);
            window.startSystemMove();
        });
    }

    function startResize(item, mouseX, mouseY, edges) {
        if (!window || !item) {
            return;
        }

        if (!maximized) {
            window.startSystemResize(edges);
            return;
        }

        const globalPoint = item.mapToGlobal(mouseX, mouseY);
        const preservedWidth = window.width;
        const preservedHeight = window.height;

        maximized = false;
        window.showNormal();

        Qt.callLater(function() {
            window.width = preservedWidth;
            window.height = preservedHeight;

            if (edges & Qt.LeftEdge) {
                window.x = Math.round(globalPoint.x);
            } else if (edges & Qt.RightEdge) {
                window.x = Math.round(globalPoint.x - preservedWidth);
            }

            if (edges & Qt.TopEdge) {
                window.y = Math.round(globalPoint.y);
            } else if (edges & Qt.BottomEdge) {
                window.y = Math.round(globalPoint.y - preservedHeight);
            }

            window.startSystemResize(edges);
        });
    }

    Component.onCompleted: {
        rememberNormalGeometry();
    }

    Connections {
        target: window

        function onXChanged() {
            root.rememberNormalGeometry();
        }

        function onYChanged() {
            root.rememberNormalGeometry();
        }

        function onWidthChanged() {
            root.rememberNormalGeometry();
        }

        function onHeightChanged() {
            root.rememberNormalGeometry();
        }
    }
}

这段代码里有几个关键点。

1. 记录普通状态下的窗口尺寸

property rect normalGeometry: Qt.rect(0, 0, 0, 0)

最大化之前,先把正常窗口的位置和大小保存下来,还原时才能回到原来的状态。

2. 最大化后拖动,要先恢复再开始拖

这是最容易漏掉的细节。

如果窗口已经最大化,此时用户在标题栏按住往下拖,正确体验应该是:

  • 先从最大化状态恢复
  • 再让窗口跟着鼠标继续拖动

这就是 startMove() 里这段逻辑的意义:

if (!maximized) {
    window.startSystemMove();
    return;
}

而不是在最大化状态下直接调用 startSystemMove()

3. 最大化后从边缘缩放,也要先恢复

同理,最大化窗口通常不能直接以当前状态进入边缘缩放,所以这里先恢复,再启动系统缩放:

window.showNormal();
Qt.callLater(function() {
    ...
    window.startSystemResize(edges);
});

Qt.callLater() 在这里很有用,它能让窗口先完成一次状态切换,再去执行后续几何调整。

六、四边八角缩放:透明热区比手写缩放逻辑更稳

标题栏能拖了,接下来还要把缩放能力补回来。

最简单可控的做法,是在窗口四周加一层透明热区,把边和角都覆盖到。下面是一份完整的 WindowResizeHandles.qml

import QtQuick
import QtQuick.Window

Item {
    id: root

    property var windowStateController
    property int handleSize: 6
    property bool active: Window.window
                          && Window.window.visibility !== Window.FullScreen

    function startResize(edges, mouseArea, mouse) {
        if (root.active && windowStateController) {
            windowStateController.startResize(mouseArea, mouse.x, mouse.y, edges);
        }
    }

    MouseArea {
        id: topHandle
        anchors.left: parent.left
        anchors.right: parent.right
        anchors.top: parent.top
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeVerCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.TopEdge, topHandle, mouse); }
    }

    MouseArea {
        id: bottomHandle
        anchors.left: parent.left
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeVerCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.BottomEdge, bottomHandle, mouse); }
    }

    MouseArea {
        id: leftHandle
        anchors.left: parent.left
        anchors.top: parent.top
        anchors.bottom: parent.bottom
        width: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeHorCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.LeftEdge, leftHandle, mouse); }
    }

    MouseArea {
        id: rightHandle
        anchors.right: parent.right
        anchors.top: parent.top
        anchors.bottom: parent.bottom
        width: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeHorCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.RightEdge, rightHandle, mouse); }
    }

    MouseArea {
        id: topLeftHandle
        anchors.left: parent.left
        anchors.top: parent.top
        width: root.handleSize
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeFDiagCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.LeftEdge | Qt.TopEdge, topLeftHandle, mouse); }
    }

    MouseArea {
        id: topRightHandle
        anchors.right: parent.right
        anchors.top: parent.top
        width: root.handleSize
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeBDiagCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.RightEdge | Qt.TopEdge, topRightHandle, mouse); }
    }

    MouseArea {
        id: bottomLeftHandle
        anchors.left: parent.left
        anchors.bottom: parent.bottom
        width: root.handleSize
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeBDiagCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.LeftEdge | Qt.BottomEdge, bottomLeftHandle, mouse); }
    }

    MouseArea {
        id: bottomRightHandle
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        width: root.handleSize
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeFDiagCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.RightEdge | Qt.BottomEdge, bottomRightHandle, mouse); }
    }
}

这里的核心思路是:

  • 边缘和角分别放 MouseArea
  • 根据不同区域传不同的 Qt.Edge
  • 缩放时最终仍然走 window.startSystemResize(edges)

这比自己去改窗口宽高稳定得多。

七、只做到这里还不够:Windows 下往往会丢系统阴影

做到前面这些,窗口已经“能用了”,但视觉上通常还是不够像原生应用。最常见的问题是:

  • 没有系统阴影
  • 圆角丢失
  • 无边框后显得很薄、很飘
  • 最大化后可能有位置偏移

这些问题仅靠 QML 很难优雅解决,Windows 下最好下沉到原生层处理。

八、在 Windows 层接管非客户区

下面这部分是整个方案的关键。思路是:

  • 拿到 Qt 主窗口对应的 HWND
  • 自定义窗口过程
  • 拦截 WM_NCCALCSIZE
  • 让整个窗口区域作为客户区
  • 同时继续借助 DWM 提供阴影、圆角、边框

1. 自定义窗口过程

#ifdef Q_OS_WIN
#include <dwmapi.h>
#include <windows.h>

#ifndef DWMWA_BORDER_COLOR
#define DWMWA_BORDER_COLOR 34
#endif

namespace
{
    constexpr DWORD kDwmWindowCornerPreferenceAttribute = 33;
    constexpr DWORD kDwmWindowCornerPreferenceRound = 2;
    constexpr wchar_t kMainWindowOriginalWndProcProperty[] = L"WujieAgentMainWindowOriginalWndProc";
    constexpr COLORREF kThinBorderColor = RGB(0xD9, 0xDD, 0xE4);

    LRESULT CALLBACK mainWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
    {
        const auto originalProc = reinterpret_cast<WNDPROC>(GetPropW(hwnd, kMainWindowOriginalWndProcProperty));

        switch (message)
        {
        case WM_NCCALCSIZE:
            return 0;

        case WM_NCDESTROY:
            if (originalProc)
            {
                SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(originalProc));
                RemovePropW(hwnd, kMainWindowOriginalWndProcProperty);
            }
            break;

        default:
            break;
        }

        if (originalProc)
        {
            return CallWindowProcW(originalProc, hwnd, message, wParam, lParam);
        }
        return DefWindowProcW(hwnd, message, wParam, lParam);
    }
}
#endif

WM_NCCALCSIZE 返回 0 的作用,是告诉系统:不要再给我单独计算传统标题栏和边框的客户区了,我要把整个窗口都当作内容区来用。

这样 QML 标题栏才能真正贴到窗口最上方。

2. 安装新的窗口过程

#ifdef Q_OS_WIN
void installMainWindowProc(QWindow *window)
{
    if (!window)
    {
        return;
    }

    const HWND hwnd = reinterpret_cast<HWND>(window->winId());
    if (!hwnd)
    {
        return;
    }

    if (GetPropW(hwnd, kMainWindowOriginalWndProcProperty))
    {
        return;
    }

    const auto previousProc =
        reinterpret_cast<WNDPROC>(SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&mainWindowProc)));
    if (previousProc)
    {
        SetPropW(hwnd, kMainWindowOriginalWndProcProperty, reinterpret_cast<HANDLE>(previousProc));
    }
}
#endif

这里把原始 WndProc 存在窗口属性里,窗口销毁时再恢复,避免资源泄漏和消息链断裂。

九、补回系统阴影、圆角和细边框

仅仅接管 WM_NCCALCSIZE 还不够,还要主动调用 DWM 接口恢复视觉效果。

#ifdef Q_OS_WIN
void enableSystemShadow(QWindow *window)
{
    if (!window)
    {
        return;
    }

    const HWND hwnd = reinterpret_cast<HWND>(window->winId());
    if (!hwnd)
    {
        return;
    }

    BOOL compositionEnabled = FALSE;
    if (FAILED(DwmIsCompositionEnabled(&compositionEnabled)) || !compositionEnabled)
    {
        return;
    }

    const DWMNCRENDERINGPOLICY policy = DWMNCRP_ENABLED;
    DwmSetWindowAttribute(hwnd, DWMWA_NCRENDERING_POLICY, &policy, sizeof(policy));

    const COLORREF borderColor = kThinBorderColor;
    DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, &borderColor, sizeof(borderColor));

    const DWORD cornerPreference = kDwmWindowCornerPreferenceRound;
    DwmSetWindowAttribute(hwnd,
                          kDwmWindowCornerPreferenceAttribute,
                          &cornerPreference,
                          sizeof(cornerPreference));

    const MARGINS margins{1, 1, 1, 1};
    DwmExtendFrameIntoClientArea(hwnd, &margins);

    SetWindowPos(hwnd,
                 nullptr,
                 0,
                 0,
                 0,
                 0,
                 SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
}
#endif

这段代码分别做了几件事:

  • 启用非客户区渲染
  • 设置一圈很薄的边框颜色
  • 指定圆角风格
  • 把 DWM frame 延伸进客户区
  • 通知系统刷新窗口 frame

其中这一句非常关键:

const MARGINS margins{1, 1, 1, 1};
DwmExtendFrameIntoClientArea(hwnd, &margins);

它能帮助系统继续在窗口边缘绘制阴影和相关效果。没有它时,无边框窗口经常显得“糊在屏幕上”。

十、隐藏系统标题和图标,避免残留

有时候即便你已经用了无边框窗口,Windows 某些状态下仍可能残留标题、图标或系统边框表现。可以额外做一层清理:

#ifdef Q_OS_WIN
void hideWindowsCaptionIconAndTitle(QWindow *window)
{
    if (!window)
    {
        return;
    }

    const HWND hwnd = reinterpret_cast<HWND>(window->winId());
    if (!hwnd)
    {
        return;
    }

    const LONG exStyle = GetWindowLongW(hwnd, GWL_EXSTYLE);
    SetWindowLongW(hwnd, GWL_EXSTYLE, exStyle | WS_EX_DLGMODALFRAME);

    SendMessageW(hwnd, WM_SETICON, ICON_SMALL, 0);
    SendMessageW(hwnd, WM_SETICON, ICON_BIG, 0);
    SetWindowTextW(hwnd, L"");

    SetWindowPos(hwnd,
                 nullptr,
                 0,
                 0,
                 0,
                 0,
                 SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
}
#endif

这一步的目标不是“美化”,而是避免系统残留元素干扰我们自己的标题栏。

十一、修正最大化后的窗口偏移

无边框窗口在 Windows 上有一个很常见的问题:最大化之后,窗口左上角可能和工作区不完全对齐,出现 1~几像素的偏移。

可以在窗口可见性变化后做一次修正:

#ifdef Q_OS_WIN
void correctMaximizedWindowOffset(QWindow *window)
{
    if (!window || window->visibility() != QWindow::Maximized)
    {
        return;
    }

    QScreen *screen = window->screen();
    if (!screen)
    {
        return;
    }

    const QRect availableGeometry = screen->availableGeometry();
    const QPoint expectedTopLeft = availableGeometry.topLeft();

    if (window->position() == expectedTopLeft)
    {
        return;
    }

    const HWND hwnd = reinterpret_cast<HWND>(window->winId());
    if (!hwnd)
    {
        window->setPosition(expectedTopLeft);
        return;
    }

    SetWindowPos(hwnd,
                 nullptr,
                 expectedTopLeft.x(),
                 expectedTopLeft.y(),
                 0,
                 0,
                 SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
}
#endif

这段代码的思路很直接:

  • 读取当前屏幕的 availableGeometry
  • 取它的左上角作为最大化后应有的位置
  • 如果窗口没有对齐,就用 SetWindowPos 修正

十二、在应用启动时统一应用这些 tweak

最后,把这些原生能力统一挂到主窗口上。比如在 QQmlApplicationEngine 加载完成后执行:

void applyMainWindowTweaks(QQmlApplicationEngine &engine)
{
#ifdef Q_OS_WIN
    if (engine.rootObjects().isEmpty())
    {
        return;
    }

    auto *mainWindow = qobject_cast<QWindow *>(engine.rootObjects().constFirst());
    if (!mainWindow)
    {
        return;
    }

    mainWindow->setTitle(QString());
    installMainWindowProc(mainWindow);
    hideWindowsCaptionIconAndTitle(mainWindow);
    enableSystemShadow(mainWindow);

    QObject::connect(mainWindow, &QWindow::visibilityChanged, mainWindow, [mainWindow]() {
        if (mainWindow->isVisible())
        {
            hideWindowsCaptionIconAndTitle(mainWindow);

            if (mainWindow->visibility() == QWindow::Windowed)
            {
                enableSystemShadow(mainWindow);
            }

            QTimer::singleShot(0, mainWindow, [mainWindow]() {
                correctMaximizedWindowOffset(mainWindow);
            });
        }
    });

    QObject::connect(mainWindow, &QWindow::windowTitleChanged, mainWindow, [mainWindow](const QString &title) {
        if (!title.isEmpty())
        {
            mainWindow->setTitle(QString());
            hideWindowsCaptionIconAndTitle(mainWindow);

            if (mainWindow->visibility() == QWindow::Windowed)
            {
                enableSystemShadow(mainWindow);
            }
        }
    });
#else
    Q_UNUSED(engine);
#endif
}

这里有两个处理很有必要:

  • visibilityChanged 后重新应用阴影和标题栏隐藏逻辑
  • windowTitleChanged 后强制把系统标题清掉

因为某些状态切换后,Windows 可能会重新套回部分非客户区表现,不补这一层很容易出现“偶发性异常”。

十三、这套方案为什么比“纯 QML 手搓窗口管理”更稳

很多实现方案都会走到一个误区:既然标题栏自己画了,那拖动、缩放、最大化逻辑也都自己算。

理论上可行,实际上问题很多:

  • 鼠标位移要自己维护
  • 多显示器切换容易错
  • 最大化恢复点不好算
  • 贴边、吸附、系统手势体验差
  • Windows 阴影和边框很难补完整

而这套方案的核心原则是:

外观自己画,窗口行为尽量交给系统。

也就是:

  • UI 用 QML 自定义
  • 窗口拖拽用 startSystemMove()
  • 窗口缩放用 startSystemResize()
  • 阴影圆角边框交给 DWM
  • 自己只补系统默认标题栏拿走后丢失的那一层胶水逻辑

这样代码量不一定最少,但稳定性和体验会明显更好。

十四、落地时最容易踩的几个坑

1. 只去掉标题栏,不补缩放热区

结果就是窗口看起来没问题,但用户拉不动边缘。

2. 最大化后直接 startSystemMove()

这样拖动标题栏时会出现跳动,或者不能正确恢复到普通窗口状态。

3. 自己算拖拽位移,不用系统拖动

短期看能用,长期看问题最多,尤其是多屏和贴边。

4. 只写 QML,不处理 Win32/DWM

这会导致窗口没有阴影、圆角异常、边框层次不足。

5. 最大化后不修正 offset

某些机器上会出现窗口边缘错位,视觉上很明显。

十五、总结

Qt 自定义标题栏真正难的地方,从来不是“把按钮画出来”,而是把系统标题栏拿掉以后,如何把窗口该有的行为和质感补回来。

比较稳的一条路线是:

  • Qt.FramelessWindowHint 去掉系统标题栏
  • 用 QML 重建顶部标题栏 UI
  • startSystemMove()startSystemResize() 保留原生交互
  • 用独立控制器管理最大化、还原和普通窗口几何
  • 在 Windows 层通过 WM_NCCALCSIZE + DWM 补回阴影、圆角、细边框和位置修正

如果只是做一个“看起来像标题栏”的 UI,这件事并不难;但如果目标是做出一个真正可用、体验接近原生的桌面窗口,这些细节基本都绕不过去。

❌
❌