Qt 无边框窗口自己实现拖拽与缩放

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();
}
  1. 一定要设最小尺寸。无边框模式下没有系统边框限制,理论上能把窗口缩到 0 甚至负尺寸,设个 minimumSize 兜底准没错。
  2. 布局边距必须全设 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_NCHITTESTlParam 给的是物理屏幕坐标 ,也就是显示器的真实像素;而 Qt 里的 width()、控件位置都是逻辑像素 ,受系统缩放比例影响。

比如 150% 缩放的屏幕,逻辑 1px = 物理 1.5px,直接拿来对比的话,热区会整体往右下角飘,越靠边偏差越大。

解决方法就是拿窗口 DPI 算出缩放比 dpr,把物理坐标转换成相对窗口的逻辑坐标再判断。

2. 缩放判定:先角后边

四个角落同时属于两条边,如果先判断边,那左上角会被判定成"左边"而不是"左上角",拖拽的时候只会横向缩放,对角缩放就废了。

所以必须先判断四个角,再判断四条边,顺序不能反。

命中对应区域后,把结果设成对应的 HT* 常量,然后 return true。Windows 收到这个结果就知道"哦,这地方是边框",然后自动帮你处理:光标变成对应方向的双向箭头、拖拽缩放,全是原生逻辑。

3. 标题栏排除按钮

返回 HTCAPTION 之后,Windows 就会把这块区域当标题栏,拖着就能动。

但标题栏上如果有按钮,一定要把按钮区域排除出去。不然点按钮的时候,系统会认为你点的是标题栏,直接触发拖拽,按钮的点击事件就没反应了。

这里用 mapTo 把按钮的坐标映射到窗口坐标系,拿到按钮的左边界,判断的时候避开就行。

下载地址

相关推荐
狮子雨恋21 分钟前
anaconda python环境和QT python环境不一致如何解决
开发语言·python·qt
独立开发之道31 分钟前
【three.js教程】Three.js 矩阵变换:position/quaternion/scale 和 matrix 到底怎么配合
开发语言·javascript·矩阵
纪念 22931 分钟前
数据结构排序(四)
开发语言·数据结构
IvanCodes34 分钟前
Python 基础语法(四):循环结构与流程控制
开发语言·python
OPEN-F34 分钟前
C++工程化:编译链接、调试与性能优化
开发语言·c++
坐吃山猪1 小时前
【多线程】什么时候必须/推荐使用Thread类
java·开发语言
一只小阿乐1 小时前
java 快速上手开发 2
java·开发语言·前端
张宇Joaquin1 小时前
灵渠测试工具-urma_perftest
java·开发语言
高彬1 小时前
SAP ECC6.0 对接 C# web Services服务
开发语言·前端·c#·sap