QT中自定义标题栏
QT中自定义标题栏
在 Qt 桌面开发里,自定义标题栏是一个很常见的需求。比如你想统一品牌风格、做更轻的窗口外观,或者需要让标题栏和业务界面融为一体,这时候系统默认标题栏往往就不够用了。
但真正动手之后会发现,这件事远不止“把标题栏画出来”这么简单。因为一旦把系统标题栏去掉,随之消失的还有一整套窗口能力:
- 窗口拖动
- 双击最大化/还原
- 四边八角缩放
- 最大化后的拖动恢复
- Windows 下的系统阴影、圆角、细边框
- 最大化时窗口位置偏移等细节问题
这篇文章结合一个实际 Qt 6 项目的实现,完整讲清楚如何在 QML + Qt + Win32/DWM 的组合下,做出一套可用、接近原生体验的自定义标题栏方案。
一、先说结论:自定义标题栏要分三层做
如果想把这件事做好,建议拆成三层:
- QML 负责界面
- 标题栏布局
- 最小化、最大化、关闭按钮
- 鼠标 hover、图标切换
- Qt 负责窗口交互
startSystemMove()startSystemResize()- 窗口状态切换
- 普通状态与最大化状态之间的恢复逻辑
- 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.x 和 window.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,这件事并不难;但如果目标是做出一个真正可用、体验接近原生的桌面窗口,这些细节基本都绕不过去。