Qt 无边框窗口自己实现拖拽与缩放
flyfish
版本
Qt Creator 17.0.1
Desktop Qt 6.9.3 MSVC2022 64bit
Windows 平台
当给 Qt 窗口加上 Qt::FramelessWindowHint 去掉系统标题栏和边框后,窗口就再也不能拖拽、也不能拉伸缩放了。本文用一个最小演示工程,讲清楚如何用 nativeEvent() 拦截 Windows 的 WM_NCHITTEST 消息,把拖拽和缩放的职责优雅地还给了操作系统。
文末附完整可运行代码。
一、为什么需要自己实现
普通桌面窗口的标题栏拖拽、边缘缩放,其实都是 Windows 在干活,程序一行没写。Windows 是怎么知道的?靠一条叫 WM_NCHITTEST(Non-Client Hit Test,非客户区命中测试)的消息:每次鼠标在窗口上移动或按下,Windows 都会问窗口一句------
鼠标现在点在你身上哪个部位?标题栏?左边框?右上角?还是普通内容?
窗口回一个命中代码(HTCAPTION=标题栏、HTLEFT=左边框、HTBOTTOMRIGHT=右下角......)。Windows 收到后,自己执行对应的原生交互:拖拽、缩放、光标变化,一气呵成。
而无边框窗口(FramelessWindowHint)把所有区域都默认当成了普通内容区(HTCLIENT),于是既拖不了,也缩不了------这份告诉 Windows 哪里是边框的活,就得自己写了。
二、思路:把活还给系统
监听鼠标 press/move/release,自己记起点、算位移、setGeometry 改窗口大小。这条路能用,但很脆------光标要自己画、跟手要自己调。
更优雅的做法只有一句话:
拦截
WM_NCHITTEST,手动返回正确的HT*命中代码,然后return true。
| 命中代码 | 含义 | Windows 收到后的行为 |
|---|---|---|
HTCAPTION |
标题栏区域 | 当作标题栏 → 拖拽移动 |
HTLEFT |
左边框 | 向左/右拉伸缩宽 |
HTRIGHT |
右边框 | 向右/左拉伸缩宽 |
HTTOP |
上边框 | 向上/下拉伸缩高 |
HTBOTTOM |
下边框 | 向下/上拉伸缩高 |
HTTOPLEFT / HTTOPRIGHT / HTBOTTOMLEFT / HTBOTTOMRIGHT |
四个角 | 双向缩放 |
HTCLIENT |
普通客户区 | 当作内容,不拖不缩,事件交给程序自己处理 |
剩下的缩放、光标全交给 Windows。
在 Qt 里,拦截原生消息的入口是 QWidget::nativeEvent():
cpp
bool nativeEvent(const QByteArray &eventType, void *message, qintptr *result) override;
message 在 Windows 下是 MSG*;
只处理 WM_NCHITTEST,把结果写进 result,返回 true 表示这事我定了,按我的来;
其它消息一律 return false 放行给 Qt。
代码实现
重写 nativeEvent 拦截系统的 WM_NCHITTEST 消息,亲手告诉 Windows:我窗口里哪块是边框、哪块是标题栏。剩下的拖拽、缩放、光标样式,全交给系统原生逻辑处理。
头文件
头文件特别干净,就是重写 nativeEvent 这一个虚函数,再加两个常量控制热区尺寸,剩下一个关闭按钮纯是演示用的,跟机制没关系。
#pragma once
#include <QMainWindow>
#include <QPushButton>
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
protected:
// 拦截系统原生消息,拖拽/缩放的唯一入口
bool nativeEvent(const QByteArray &eventType, void *message, qintptr *result) override;
private:
static constexpr int RESIZE_BORDER = 6; // 边缘缩放热区宽度(逻辑像素)
static constexpr int TITLE_H = 30; // 顶部可拖拽标题条高度
QPushButton *m_closeBtn = nullptr; // 演示用关闭按钮
};
两个常量可以自己调:
RESIZE_BORDER 就是窗口边缘多少像素内算缩放热区,6px 是比较舒服的数值,太窄不好抓,太宽容易误触
TITLE_H 就是顶部多高的区域算标题栏,能拖着走
构造函数
第一步肯定是去掉系统自带的标题栏和边框,用 Qt::FramelessWindowHint 这个标志位就行。
#include "mainwindow.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#ifdef Q_OS_WIN
# include <windows.h>
# include <windowsx.h> // 解析鼠标坐标要用
#endif
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent, Qt::FramelessWindowHint) // 去掉系统边框和标题栏
{
setMinimumSize(420, 320); // 一定要设最小尺寸!无边框下窗口能缩到没影
// 中央部件铺满整个窗口
auto *central = new QWidget(this);
central->setStyleSheet("background-color:#2d8cf0;");
setCentralWidget(central);
auto *root = new QVBoxLayout(central);
root->setContentsMargins(0, 0, 0, 0); // 边距必须设0!不然边缘热区被布局吃掉
root->setSpacing(0);
// ---- 顶部标题栏 ----
auto *title = new QWidget(central);
title->setFixedHeight(TITLE_H);
title->setStyleSheet("background-color:#1c6fd6;");
auto *titleRow = new QHBoxLayout(title);
titleRow->setContentsMargins(12, 0, 6, 0);
auto *titleText = new QLabel(QStringLiteral("拖动顶部移动 · 边缘拖动缩放"), title);
titleText->setStyleSheet("color:#ffffff;");
titleRow->addWidget(titleText);
titleRow->addStretch();
m_closeBtn = new QPushButton(QStringLiteral("×"), title);
m_closeBtn->setFixedSize(28, 24);
m_closeBtn->setStyleSheet("color:#ffffff;background:transparent;border:none;font-size:16px;");
connect(m_closeBtn, &QPushButton::clicked, this, &QWidget::close);
titleRow->addWidget(m_closeBtn);
root->addWidget(title);
// ---- 内容区(纯提示用,实际项目换掉就行)----
auto *body = new QLabel(QStringLiteral(
"抓住顶部蓝色条拖动 = 移动窗口\n"
"鼠标移到窗口四边/四角拖动 = 缩放窗口"), central);
body->setAlignment(Qt::AlignCenter);
body->setStyleSheet("color:#ffffff;");
root->addWidget(body);
root->addStretch();
}
- 一定要设最小尺寸。无边框模式下没有系统边框限制,理论上能把窗口缩到 0 甚至负尺寸,设个 minimumSize 兜底准没错。
- 布局边距必须全设 0。如果中央布局有边距,窗口最边缘那几像素就会被内部留白吃掉,鼠标移到窗口最边上的时候命中不到控件,缩放热区直接失效。
nativeEvent 命中测试
bool MainWindow::nativeEvent(const QByteArray &eventType, void *message, qintptr *result)
{
#ifdef Q_OS_WIN
Q_UNUSED(eventType)
MSG *msg = static_cast<MSG *>(message);
// 只拦截命中测试消息,其他消息不管
if (msg->message == WM_NCHITTEST) {
const HWND hwnd = reinterpret_cast<HWND>(winId());
// ========== 重点:高DPI坐标换算 ==========
// lParam 里的是物理屏幕像素,Qt 用的是逻辑像素
// 不做换算的话,125%/150% 缩放的屏幕上热区会整体偏移
RECT wr{};
GetWindowRect(hwnd, &wr);
const UINT dpi = GetDpiForWindow(hwnd);
const double dpr = (dpi == 0) ? 1.0 : dpi / 96.0;
const int cx = int((GET_X_LPARAM(msg->lParam) - wr.left) / dpr);
const int cy = int((GET_Y_LPARAM(msg->lParam) - wr.top) / dpr);
const int w = width();
const int h = height();
const int b = RESIZE_BORDER;
// 判定四边热区
const bool left = cx < b;
const bool right = cx >= w - b;
const bool top = cy < b;
const bool bottom = cy >= h - b;
// ========== 缩放热区:先角后边,顺序不能乱 ==========
// 角落同时属于横边和竖边,必须优先判定,不然会识别成单边
if (left && top) { *result = HTTOPLEFT; return true; }
if (right && top) { *result = HTTOPRIGHT; return true; }
if (right && bottom) { *result = HTBOTTOMRIGHT; return true; }
if (left && bottom) { *result = HTBOTTOMLEFT; return true; }
if (left) { *result = HTLEFT; return true; }
if (right) { *result = HTRIGHT; return true; }
if (top) { *result = HTTOP; return true; }
if (bottom) { *result = HTBOTTOM; return true; }
// ========== 标题栏拖拽 ==========
// 排除关闭按钮区域,不然点按钮也会触发拖拽
const int btnLeft = m_closeBtn->mapTo(this, QPoint()).x();
if (cy < TITLE_H && cx < btnLeft) {
*result = HTCAPTION;
return true;
}
}
return false; // 其余区域交给 Qt 正常处理
#else
// 非 Windows 平台直接放行,不影响编译
Q_UNUSED(eventType); Q_UNUSED(message); Q_UNUSED(result);
return false;
#endif
}
几个关键细节
1. 高 DPI 坐标换算
WM_NCHITTEST 的 lParam 给的是物理屏幕坐标 ,也就是显示器的真实像素;而 Qt 里的 width()、控件位置都是逻辑像素 ,受系统缩放比例影响。
比如 150% 缩放的屏幕,逻辑 1px = 物理 1.5px,直接拿来对比的话,热区会整体往右下角飘,越靠边偏差越大。
解决方法就是拿窗口 DPI 算出缩放比 dpr,把物理坐标转换成相对窗口的逻辑坐标再判断。
2. 缩放判定:先角后边
四个角落同时属于两条边,如果先判断边,那左上角会被判定成"左边"而不是"左上角",拖拽的时候只会横向缩放,对角缩放就废了。
所以必须先判断四个角,再判断四条边,顺序不能反。
命中对应区域后,把结果设成对应的 HT* 常量,然后 return true。Windows 收到这个结果就知道"哦,这地方是边框",然后自动帮你处理:光标变成对应方向的双向箭头、拖拽缩放,全是原生逻辑。
3. 标题栏排除按钮
返回 HTCAPTION 之后,Windows 就会把这块区域当标题栏,拖着就能动。
但标题栏上如果有按钮,一定要把按钮区域排除出去。不然点按钮的时候,系统会认为你点的是标题栏,直接触发拖拽,按钮的点击事件就没反应了。
这里用 mapTo 把按钮的坐标映射到窗口坐标系,拿到按钮的左边界,判断的时候避开就行。
