乐于分享
好东西不私藏

Qt 自定义网格拖拽控件实战:基于 MenuPluginGridWidget 的网格管理方案

Qt 自定义网格拖拽控件实战:基于 MenuPluginGridWidget 的网格管理方案

在 Qt 桌面项目中,如果只是做一个静态的网格布局,直接使用 QGridLayout 就已经足够。但一旦需求升级为:

  • 网格卡片可复用
  • 网格项由外部数据驱动
  • 网格项支持自定义 UI
  • 支持拖拽调整顺序
  • 支持不同的重排策略
  • 保持布局与业务解耦

那么单纯依赖 QGridLayout 就不够了。

在当前工程中,gridwidget.h 已经完成了对 MenuPluginGridWidget 的引用,而 MenuPluginGridWidget 则承担了整个 grid 场景下的核心管理职责。它并不是一个简单的“显示控件”,而是一个完整的网格拖拽容器组件。

先看看效果:

本文就结合当前代码,说明这套方案是如何工作的。

一、gridwidget 的角色:示例窗口,而不是业务耦合层

gridwidget.h

#pragma once#include <QWidget>#include "widget/gridWidget/menuplugingridwidget.h"#include "ui_gridwidget.h"classQLabel;classMenuPluginGridWidget;classQComboBox;namespace Ui {	classGridWidgetClass;}classGridWidget : publicQWidget{	Q_OBJECTpublic:	GridWidget(QWidget *parent = nullptr);	~GridWidget();private:	Ui::GridWidgetClass ui;	void updateStatusText();	QLabel* m_statusLabel;	QComboBox* m_modeComboBox;	MenuPluginGridWidget* m_gridWidget;};

gridwidget.cpp

#include "gridwidget.h"#include <QHBoxLayout>#include <QVBoxLayout>#include <QLabel>#include <QComboBox>namespace {	struct DemoGridItemData {		QString title;		QString subtitle;		QString tag;		QString footer;		QString color;	};} // namespaceQ_DECLARE_METATYPE(DemoGridItemData)// //把你的自定义类型注册到 Qt 的元类型系统(Meta-Type System)中 以便它可以被 QVariant 使用。这对于在 Qt 的信号和槽机制中传递自定义类型非常有用。GridWidget::GridWidget(QWidget *parent)	: QWidget(parent)	, m_statusLabel(nullptr)	, m_modeComboBox(nullptr)	, m_gridWidget(nullptr){	qRegisterMetaType<DemoGridItemData>("DemoGridItemData"); // 把你的自定义类型注册到 Qt 的元类型系统(Meta-Type System)中 以便它可以被 QVariant 使用。	//创建布局	auto* rootLayout = new QVBoxLayout(this);	rootLayout->setContentsMargins(12, 12, 12, 12);	rootLayout->setSpacing(8);	auto* titleLabel = new QLabel(QStringLiteral("QGridLayout定义网格拖拽插件"), this);	titleLabel->setObjectName(QStringLiteral("titleLabel"));	rootLayout->addWidget(titleLabel);	auto* descLabel = new QLabel(		QStringLiteral("插件只负责网格排版、滚动和拖拽。数据模型与 item 布局都由外部提供,外部通过 QVariant 列表和回调工厂返回完整的 item 视图。"),		this);	descLabel->setObjectName(QStringLiteral("descLabel"));	descLabel->setWordWrap(true);	rootLayout->addWidget(descLabel);	//工具栏	auto* toolPanel = new QFrame(this);	toolPanel->setObjectName(QStringLiteral("toolPanel"));	auto* toolLayout = new QHBoxLayout(toolPanel);	toolLayout->setContentsMargins(16, 12, 16, 12);	toolLayout->setSpacing(12);	auto* modeLabel = new QLabel(QStringLiteral("拖拽模式"), toolPanel);	modeLabel->setObjectName(QStringLiteral("modeLabel"));	m_modeComboBox = new QComboBox(toolPanel);	m_modeComboBox->addItem(QStringLiteral("交换位置"), static_cast<int>(MenuPluginGridWidget::RearrangeMode::Swap));	m_modeComboBox->addItem(QStringLiteral("插入前移"), static_cast<int>(MenuPluginGridWidget::RearrangeMode::InsertShift));	m_statusLabel = new QLabel(toolPanel);	m_statusLabel->setObjectName(QStringLiteral("statusLabel"));	m_statusLabel->setText(QStringLiteral("状态说明"));	toolLayout->addWidget(modeLabel);	toolLayout->addWidget(m_modeComboBox);	toolLayout->addWidget(m_statusLabel, 1);	rootLayout->addWidget(toolPanel);	//网格内容	auto* gridCard = new QFrame(this);	gridCard->setObjectName(QStringLiteral("gridCard"));	auto* gridLayout = new QVBoxLayout(gridCard);	gridLayout->setContentsMargins(18, 18, 18, 18);	gridLayout->setSpacing(0);	m_gridWidget = new MenuPluginGridWidget(gridCard);	m_gridWidget->setColumnCount(4);	m_gridWidget->setGridSpacing(16, 16);    m_gridWidget->setItemWidgetFactory([](const QVariant& item, QWidget* parent) -> QWidget* {        if (!item.canConvert<DemoGridItemData>()) {            return nullptr;        }        const DemoGridItemData data = item.value<DemoGridItemData>();        auto* content = new QWidget(parent);        auto* layout = new QVBoxLayout(content);        layout->setContentsMargins(0, 0, 0, 0);        layout->setSpacing(10);        auto* headerLayout = new QHBoxLayout();        headerLayout->setContentsMargins(0, 0, 0, 0);        headerLayout->setSpacing(8);        auto* badge = new QLabel(data.tag, content);        badge->setObjectName(QStringLiteral("gridBadge"));        auto* titleLabel = new QLabel(data.title, content);        titleLabel->setObjectName(QStringLiteral("gridCustomTitle"));        titleLabel->setWordWrap(true);        headerLayout->addWidget(badge, 0);        headerLayout->addWidget(titleLabel, 1);        auto* subtitleLabel = new QLabel(data.subtitle, content);        subtitleLabel->setObjectName(QStringLiteral("gridCustomSubtitle"));        subtitleLabel->setWordWrap(true);        auto* footerLabel = new QLabel(data.footer, content);        footerLabel->setObjectName(QStringLiteral("gridCustomFooter"));        footerLabel->setWordWrap(true);        layout->addLayout(headerLayout);        layout->addWidget(subtitleLabel);        layout->addStretch();        layout->addWidget(footerLabel);        content->setStyleSheet(QStringLiteral(R"(            QLabel#gridBadge {                min-width: 48px;                padding: 4px 10px;                color: #ffffff;                font-size: 12px;                font-weight: 700;                background: %1;            }            QLabel#gridCustomTitle {                color: #0f172a;                font-size: 17px;                font-weight: 800;            }            QLabel#gridCustomSubtitle {                color: #475569;                font-size: 13px;            }            QLabel#gridCustomFooter {                color: %1;                font-size: 12px;                font-weight: 700;            }        )").arg(data.color));        return content;        });    QList<QVariant> items;    const QList<DemoGridItemData> demoItems = {        {QStringLiteral("数据大屏"), QStringLiteral("整合今日核心指标、趋势图和异常提醒。"), QStringLiteral("监控"), QStringLiteral("实时更新 · 12个数据源"), QStringLiteral("#2563eb")},        {QStringLiteral("系统管理"), QStringLiteral("管理角色、菜单、组织结构和权限边界。"), QStringLiteral("平台"), QStringLiteral("支持多租户隔离"), QStringLiteral("#7c3aed")},        {QStringLiteral("用户中心"), QStringLiteral("查看用户档案、状态、标签与活跃行为。"), QStringLiteral("用户"), QStringLiteral("用户画像实时同步"), QStringLiteral("#0891b2")},        {QStringLiteral("订单管理"), QStringLiteral("覆盖订单检索、售后进度与履约处理。"), QStringLiteral("交易"), QStringLiteral("今日待处理 37 笔"), QStringLiteral("#ea580c")},        {QStringLiteral("内容中心"), QStringLiteral("文章、专题、素材和审核流统一管理。"), QStringLiteral("内容"), QStringLiteral("支持草稿与版本回滚"), QStringLiteral("#16a34a")},        {QStringLiteral("营销活动"), QStringLiteral("优惠券、活动页、推送计划和复盘面板。"), QStringLiteral("增长"), QStringLiteral("活动转化率 14.8%"), QStringLiteral("#dc2626")},        {QStringLiteral("监控告警"), QStringLiteral("服务状态、链路日志与故障播报统一展示。"), QStringLiteral("运维"), QStringLiteral("过去24小时告警 5 条"), QStringLiteral("#0f766e")},        {QStringLiteral("基础设置"), QStringLiteral("站点参数、主题、通知和第三方接入配置。"), QStringLiteral("配置"), QStringLiteral("最近修改于 09:20"), QStringLiteral("#4f46e5")},        {QStringLiteral("报表中心"), QStringLiteral("按日周月维度导出业务报表与统计结果。"), QStringLiteral("分析"), QStringLiteral("支持异步导出"), QStringLiteral("#9333ea")},        {QStringLiteral("设备管理"), QStringLiteral("跟踪设备在线状态、版本和升级策略。"), QStringLiteral("设备"), QStringLiteral("在线设备 124 台"), QStringLiteral("#0284c7")},        {QStringLiteral("任务中心"), QStringLiteral("统一编排定时任务、审批任务和回调任务。"), QStringLiteral("任务"), QStringLiteral("待执行 8 个任务"), QStringLiteral("#ca8a04")},        {QStringLiteral("知识库"), QStringLiteral("沉淀文档、FAQ 和内部培训资料。"), QStringLiteral("文档"), QStringLiteral("最近新增 12 篇内容"), QStringLiteral("#1d4ed8")},        {QStringLiteral("渠道管理"), QStringLiteral("维护渠道配置、分佣策略和渠道数据看板。"), QStringLiteral("渠道"), QStringLiteral("覆盖 18 个分发渠道"), QStringLiteral("#be123c")},        {QStringLiteral("财务结算"), QStringLiteral("对账、开票、付款单和结算流程闭环管理。"), QStringLiteral("财务"), QStringLiteral("本月结算中 6 单"), QStringLiteral("#0f766e")}    };    for (const DemoGridItemData& item : demoItems) {        items.append(QVariant::fromValue(item));    }    m_gridWidget->setItems(items);    gridLayout->addWidget(m_gridWidget);    connect(m_modeComboBox, &QComboBox::currentIndexChanged, this, [this](int index) {        const auto mode = static_cast<MenuPluginGridWidget::RearrangeMode>(            m_modeComboBox->itemData(index).toInt());        m_gridWidget->setRearrangeMode(mode);        updateStatusText();        });    connect(m_gridWidget, &MenuPluginGridWidget::orderChanged, this, [this](const QList<QVariant>& items) {        QString title = QStringLiteral("-");        if (!items.isEmpty() && items.first().canConvert<DemoGridItemData>()) {            title = items.first().value<DemoGridItemData>().title;        }        const QString message = QStringLiteral("当前顺序已更新,第一项为:%1").arg(title);        m_statusLabel->setText(message);    });	rootLayout->addWidget(gridCard, 1);	//设置根布局	setLayout(rootLayout);}//刷新状态栏void GridWidget::updateStatusText() {    const auto mode = static_cast<MenuPluginGridWidget::RearrangeMode>(m_modeComboBox->currentData().toInt());    m_gridWidget->setRearrangeMode(mode);    if (mode == MenuPluginGridWidget::RearrangeMode::Swap) {        m_statusLabel->setText(QStringLiteral("当前为交换模式:拖到目标网格后,两个网格直接互换位置。"));    }    else {        m_statusLabel->setText(QStringLiteral("当前为插入前移模式:拖动时会显示插入线,释放后插入到对应位置。"));    }}GridWidget::~GridWidget(){}

从 gridwidget.h 可以看到,gridwidget本身非常轻量:

  • 只持有状态文字 m_statusLabel
  • 一个模式切换框 m_modeComboBox
  • 一个核心网格控件 m_gridWidget

也就是说,gridwidget的职责并不是实现网格逻辑,而是:

  1. 初始化演示界面
  2. 准备演示数据
  3. 配置 MenuPluginGridWidget
  4. 监听排序变化并反馈状态

这种职责划分很重要。因为真正的“网格管理能力”被封装在 MenuPluginGridWidget 内部,窗口层只是使用者。

二、如何接入 MenuPluginGridWidget

在 gridwidget.cpp 中,核心接入逻辑主要分成三步。

1. 创建控件实例

m_gridWidget = new MenuPluginGridWidget(gridCard);m_gridWidget->setColumnCount(4);m_gridWidget->setGridSpacing(16, 16);

这里完成了三件事:

  • 创建网格控件
  • 设置每行列数为 4
  • 设置水平、垂直间距

这说明 MenuPluginGridWidget 并不是写死布局,而是允许外部控制网格参数。

2. 通过工厂回调定义每个卡片的显示方式

m_gridWidget->setItemWidgetFactory([](const QVariant &item, QWidget *parent) -> QWidget * {    ...});

这是整套方案最关键的地方。

MenuPluginGridWidget 不直接关心“每个格子长什么样”,而是把渲染责任交给外部。
也就是说:

  • 插件只负责“管理网格”
  • 外部负责“生成每个卡片内容”

这就是一个很典型的“容器控件 + 外部渲染工厂”的设计。

当前示例里,外部通过 DemoGridItemData 提供:

  • title
  • subtitle
  • tag
  • footer
  • color

然后在工厂函数中将这些数据组装成一个卡片视图。

这种做法的好处是非常明显的:

  • 网格控件可以复用在不同业务中
  • 不同页面可以复用同一个网格管理器
  • 外观变化不影响拖拽逻辑
  • 数据结构变化不影响网格核心实现

3. 通过 QVariant 传递数据

示例中用了:
Q_DECLARE_METATYPE(DemoGridItemData)qRegisterMetaType<DemoGridItemData>("DemoGridItemData");
然后把数据封装进 QVariant 列表,再传给:
m_gridWidget->setItems(items);

这说明 MenuPluginGridWidget 的数据输入模型是泛型化的。
它不依赖具体业务对象,而是统一接收 QVariant。

这套设计使控件的工程化程度明显提高,因为它不需要知道:

  • 这是订单卡片
  • 这是用户卡片
  • 这是报表卡片
  • 还是监控面板卡片

它只负责把这些“项”放进网格并支持重排。

三、MenuPluginGridWidget 的核心定位:网格容器管理器

menuplugingridwidget.h

#pragma once#include <functional>#include <QFrame>#include <QList>#include <QPointer>#include <QVariant>#include <QWidget>class QGridLayout;class QLabel;class QMouseEvent;class QResizeEvent;class QScrollArea;class QScrollBar;class QVBoxLayout;class GridTileWidget : public QFrame{    Q_OBJECTpublic:    explicitGridTileWidget(QWidget* parent = nullptr);    voidsetContentWidget(QWidget* contentWidget);    QWidget* contentWidget()const;    voidsetDragging(bool dragging);    voidsetDropTarget(bool dropTarget);signals:    voidpressed(const QPoint& globalPos);    voidmoved(const QPoint& globalPos);    voidreleased(const QPoint& globalPos);protected:    voidmousePressEvent(QMouseEvent* event)override;    voidmouseMoveEvent(QMouseEvent* event)override;    voidmouseReleaseEvent(QMouseEvent* event)override;private:    voidapplyVisualState();    QVBoxLayout* m_contentLayout;    QPointer<QWidget> m_contentWidget;    bool m_dragging;    bool m_dropTarget;};class MenuPluginGridWidget : public QWidget{    Q_OBJECTpublic:    using GridItemData = QVariant;    enum class RearrangeMode {        Swap,        InsertShift    };    using ItemWidgetFactory = std::function<QWidget* (const QVariant& item, QWidget* parent)>;    using TileSizeProvider = std::function<QSize(const QSize& defaultSize, const QVariant& item, int index)>;    explicitMenuPluginGridWidget(QWidget* parent = nullptr);    voidsetItems(const QList<QVariant>& items);    QList<QVariant> items()const;    voidsetColumnCount(int columns);    intcolumnCount()const;    voidsetRearrangeMode(RearrangeMode mode);    RearrangeMode rearrangeMode()const;    voidsetGridSpacing(int horizontalSpacing, int verticalSpacing);    voidsetItemWidgetFactory(const ItemWidgetFactory& factory);    voidclearItemWidgetFactory();    voidsetTileSizeProvider(const TileSizeProvider& provider);    voidclearTileSizeProvider();    QSize defaultTileSize()const;signals:    voidorderChanged(const QList<QVariant>& items);protected:    voidresizeEvent(QResizeEvent* event)override;private:    voidrebuildGrid();    voidclearGrid();    voidupdateTileSizes();    intindexOfTile(GridTileWidget* tile)const;    GridTileWidget* tileAtGlobalPos(const QPoint& globalPos)const;    voidbeginDrag(GridTileWidget* tile, const QPoint& globalPos);    voidupdateDrag(const QPoint& globalPos);    voidfinishDrag(const QPoint& globalPos);    voidclearDragState();    voidapplyReorder(int fromIndex, int toIndex);    QWidget* createDragPreview(GridTileWidget* tile)const;    voidupdatePreviewPosition(const QPoint& globalPos);    intinsertionIndexForGlobalPos(const QPoint& globalPos)const;    voidupdateInsertIndicator(int insertIndex);    QWidget* buildItemWidget(const QVariant& item, QWidget* parent)const;    QSize tileSizeForIndex(int index)const;    voidrestoreScrollPosition(int verticalValue, int horizontalValue);    QList<QVariant> m_items;    QList<GridTileWidget*> m_tiles;    QScrollArea* m_scrollArea;    QWidget* m_contentWidget;    QGridLayout* m_gridLayout;    int m_columnCount;    RearrangeMode m_mode;    ItemWidgetFactory m_itemWidgetFactory;    TileSizeProvider m_tileSizeProvider;    QPointer<GridTileWidget> m_dragTile;    QPointer<GridTileWidget> m_dropTargetTile;    QPointer<QWidget> m_dragPreview;    QPointer<QWidget> m_insertIndicator;    QPoint m_dragOffset;    int m_dragSourceIndex;    int m_previewInsertIndex;};
menuplugingridwidget.cpp

#include "menuplugingridwidget.h"#include <QFrame>#include <QGridLayout>#include <QHBoxLayout>#include <QLabel>#include <QLayout>#include <QMouseEvent>#include <QResizeEvent>#include <QScrollArea>#include <QScrollBar>#include <QSizePolicy>#include <QTimer>#include <QVBoxLayout>#include <limits>GridTileWidget::GridTileWidget(QWidget* parent)    : QFrame(parent)    , m_contentLayout(new QVBoxLayout(this))    , m_dragging(false)    , m_dropTarget(false){    m_contentLayout->setContentsMargins(14, 14, 14, 14);    m_contentLayout->setSpacing(0);    setObjectName(QStringLiteral("gridTile"));    setFrameShape(QFrame::NoFrame);    setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);    setCursor(Qt::OpenHandCursor);    applyVisualState();}void GridTileWidget::setContentWidget(QWidget* contentWidget){    if (m_contentWidget == contentWidget) {        return;    }    if (m_contentWidget != nullptr) {        m_contentLayout->removeWidget(m_contentWidget);        m_contentWidget->deleteLater();        m_contentWidget.clear();    }    if (contentWidget == nullptr) {        return;    }    contentWidget->setParent(this);    m_contentLayout->addWidget(contentWidget);    m_contentWidget = contentWidget;    m_contentLayout->activate();    if (m_contentWidget->layout() != nullptr) {        m_contentWidget->layout()->activate();    }    m_contentWidget->show();    m_contentWidget->updateGeometry();    m_contentWidget->update();    updateGeometry();    update();}QWidget* GridTileWidget::contentWidget() const{    return m_contentWidget;}void GridTileWidget::setDragging(bool dragging){    m_dragging = dragging;    applyVisualState();}void GridTileWidget::setDropTarget(bool dropTarget){    m_dropTarget = dropTarget;    applyVisualState();}void GridTileWidget::mousePressEvent(QMouseEvent* event){    if (event->button() == Qt::LeftButton) {        setCursor(Qt::ClosedHandCursor);        emit pressed(event->globalPosition().toPoint());    }    QFrame::mousePressEvent(event);}void GridTileWidget::mouseMoveEvent(QMouseEvent* event){    if (event->buttons() & Qt::LeftButton) {        emit moved(event->globalPosition().toPoint());    }    QFrame::mouseMoveEvent(event);}void GridTileWidget::mouseReleaseEvent(QMouseEvent* event){    if (event->button() == Qt::LeftButton) {        setCursor(Qt::OpenHandCursor);        emit released(event->globalPosition().toPoint());    }    QFrame::mouseReleaseEvent(event);}void GridTileWidget::applyVisualState(){    if (m_dragging) {        setStyleSheet(QStringLiteral(R"(            QFrame#gridTile {                background: #eff6ff;                border: 1px solid #93c5fd;            }        )"));        return;    }    if (m_dropTarget) {        setStyleSheet(QStringLiteral(R"(            QFrame#gridTile {                background: #dbeafe;                border: 2px solid #1d4ed8;            }        )"));        return;    }    setStyleSheet(QStringLiteral(R"(        QFrame#gridTile {            background: #ffffff;            border: 1px solid #dbe3ee;        }    )"));}MenuPluginGridWidget::MenuPluginGridWidget(QWidget* parent)    : QWidget(parent)    , m_scrollArea(new QScrollArea(this))    , m_contentWidget(new QWidget(this))    , m_gridLayout(new QGridLayout(m_contentWidget))    , m_columnCount(4)    , m_mode(RearrangeMode::Swap)    , m_dragSourceIndex(-1)    , m_previewInsertIndex(-1){    auto* rootLayout = new QVBoxLayout(this);    rootLayout->setContentsMargins(0, 0, 0, 0);    rootLayout->setSpacing(0);    m_scrollArea->setFrameShape(QFrame::NoFrame);    m_scrollArea->setWidgetResizable(true);    m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);    m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);    m_scrollArea->setWidget(m_contentWidget);    m_contentWidget->setObjectName(QStringLiteral("gridScrollContent"));    m_contentWidget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);    m_gridLayout->setContentsMargins(0, 0, 0, 0);    m_gridLayout->setHorizontalSpacing(14);    m_gridLayout->setVerticalSpacing(14);    m_gridLayout->setSizeConstraint(QLayout::SetMinAndMaxSize);    rootLayout->addWidget(m_scrollArea);    setStyleSheet(QStringLiteral(R"(        QScrollArea {            background: transparent;            border: none;        }        QScrollArea > QWidget > QWidget#gridScrollContent {            background: transparent;        }    )"));}void MenuPluginGridWidget::setItems(const QList<GridItemData>& items){    m_items = items;    rebuildGrid();}QList<MenuPluginGridWidget::GridItemData> MenuPluginGridWidget::items() const{    return m_items;}void MenuPluginGridWidget::setColumnCount(int columns){    m_columnCount = qMax(1, columns);    rebuildGrid();}int MenuPluginGridWidget::columnCount() const{    return m_columnCount;}void MenuPluginGridWidget::setRearrangeMode(RearrangeMode mode){    m_mode = mode;}MenuPluginGridWidget::RearrangeMode MenuPluginGridWidget::rearrangeMode() const{    return m_mode;}void MenuPluginGridWidget::setGridSpacing(int horizontalSpacing, int verticalSpacing){    m_gridLayout->setHorizontalSpacing(qMax(0, horizontalSpacing));    m_gridLayout->setVerticalSpacing(qMax(0, verticalSpacing));    updateTileSizes();}void MenuPluginGridWidget::setItemWidgetFactory(const ItemWidgetFactory& factory){    m_itemWidgetFactory = factory;    rebuildGrid();}void MenuPluginGridWidget::clearItemWidgetFactory(){    m_itemWidgetFactory = ItemWidgetFactory();    rebuildGrid();}void MenuPluginGridWidget::setTileSizeProvider(const TileSizeProvider& provider){    m_tileSizeProvider = provider;    updateTileSizes();}void MenuPluginGridWidget::clearTileSizeProvider(){    m_tileSizeProvider = TileSizeProvider();    updateTileSizes();}QSize MenuPluginGridWidget::defaultTileSize() const{    const int viewportWidth = m_scrollArea->viewport() != nullptr        ? m_scrollArea->viewport()->width()        : width();    const QMargins margins = m_gridLayout->contentsMargins();    const int spacing = m_gridLayout->horizontalSpacing();    const int usableWidth = qMax(        0,        viewportWidth - margins.left() - margins.right() - spacing * qMax(0, m_columnCount - 1));    const int tileWidth = qMax(1, usableWidth / qMax(1, m_columnCount));    return QSize(tileWidth, tileWidth);}void MenuPluginGridWidget::resizeEvent(QResizeEvent* event){    QWidget::resizeEvent(event);    updateTileSizes();}void MenuPluginGridWidget::rebuildGrid(){    const int verticalScrollValue = m_scrollArea->verticalScrollBar() != nullptr        ? m_scrollArea->verticalScrollBar()->value()        : 0;    const int horizontalScrollValue = m_scrollArea->horizontalScrollBar() != nullptr        ? m_scrollArea->horizontalScrollBar()->value()        : 0;    clearGrid();    for (int index = 0; index < m_items.size(); ++index) {        const GridItemData& data = m_items.at(index);        auto* tile = new GridTileWidget(m_contentWidget);        tile->setContentWidget(buildItemWidget(data, tile));        m_tiles.append(tile);        connect(tile, &GridTileWidget::pressed, this, [this, tile](const QPoint& globalPos) {            beginDrag(tile, globalPos);            });        connect(tile, &GridTileWidget::moved, this, [this](const QPoint& globalPos) {            updateDrag(globalPos);            });        connect(tile, &GridTileWidget::released, this, [this](const QPoint& globalPos) {            finishDrag(globalPos);            });        const int row = index / m_columnCount;        const int column = index % m_columnCount;        m_gridLayout->addWidget(tile, row, column);    }    updateTileSizes();    QTimer::singleShot(0, this, [this]() {        updateTileSizes();        for (GridTileWidget* tile : m_tiles) {            if (tile == nullptr) {                continue;            }            if (tile->layout() != nullptr) {                tile->layout()->activate();            }            if (tile->contentWidget() != nullptr) {                if (tile->contentWidget()->layout() != nullptr) {                    tile->contentWidget()->layout()->activate();                }                tile->contentWidget()->updateGeometry();                tile->contentWidget()->update();            }            tile->updateGeometry();            tile->update();        }        m_contentWidget->updateGeometry();        m_contentWidget->update();        });    QTimer::singleShot(0, this, [this, verticalScrollValue, horizontalScrollValue]() {        restoreScrollPosition(verticalScrollValue, horizontalScrollValue);        });}void MenuPluginGridWidget::clearGrid(){    clearDragState();    m_tiles.clear();    while (QLayoutItem* item = m_gridLayout->takeAt(0)) {        if (QWidget* widget = item->widget()) {            widget->deleteLater();        }        delete item;    }}void MenuPluginGridWidget::updateTileSizes(){    if (m_tiles.isEmpty()) {        m_contentWidget->setMinimumHeight(0);        return;    }    for (int index = 0; index < m_tiles.size(); ++index) {        GridTileWidget* tile = m_tiles.at(index);        if (tile == nullptr) {            continue;        }        tile->setFixedSize(tileSizeForIndex(index));    }    m_contentWidget->setMinimumWidth(m_scrollArea->viewport()->width());    m_contentWidget->setMinimumHeight(m_gridLayout->sizeHint().height());    m_contentWidget->updateGeometry();}int MenuPluginGridWidget::indexOfTile(GridTileWidget* tile) const{    return m_tiles.indexOf(tile);}GridTileWidget* MenuPluginGridWidget::tileAtGlobalPos(const QPoint& globalPos) const{    for (GridTileWidget* tile : m_tiles) {        if (tile == nullptr || tile == m_dragTile) {            continue;        }        const QRect rect(tile->mapToGlobal(QPoint(0, 0)), tile->size());        if (rect.contains(globalPos)) {            return tile;        }    }    return nullptr;}void MenuPluginGridWidget::beginDrag(GridTileWidget* tile, const QPoint& globalPos){    if (tile == nullptr || m_items.size() <= 1) {        return;    }    m_dragTile = tile;    m_dragSourceIndex = indexOfTile(tile);    m_previewInsertIndex = m_dragSourceIndex;    m_dragTile->setDragging(true);    const QPoint tileTopLeft = tile->mapToGlobal(QPoint(0, 0));    m_dragOffset = globalPos - tileTopLeft;    m_dragPreview = createDragPreview(tile);    updatePreviewPosition(globalPos);}void MenuPluginGridWidget::updateDrag(const QPoint& globalPos){    if (m_dragTile == nullptr || m_dragPreview == nullptr) {        return;    }    updatePreviewPosition(globalPos);    GridTileWidget* target = tileAtGlobalPos(globalPos);    if (m_dropTargetTile == target) {        return;    }    if (m_dropTargetTile != nullptr) {        m_dropTargetTile->setDropTarget(false);    }    m_dropTargetTile = target;    if (m_dropTargetTile != nullptr) {        m_dropTargetTile->setDropTarget(true);    }    if (m_mode == RearrangeMode::InsertShift && m_insertIndicator != nullptr) {        m_insertIndicator->hide();    }}void MenuPluginGridWidget::finishDrag(const QPoint& globalPos){    if (m_dragTile == nullptr) {        return;    }    if (m_mode == RearrangeMode::InsertShift) {        const int fromIndex = m_dragSourceIndex;        GridTileWidget* target = tileAtGlobalPos(globalPos);        const int targetIndex = indexOfTile(target);        if (fromIndex >= 0 && fromIndex < m_items.size() && targetIndex >= 0 && fromIndex != targetIndex) {            const GridItemData moving = m_items.takeAt(fromIndex);            const int finalIndex = targetIndex > fromIndex ? targetIndex : targetIndex + 1;            m_items.insert(qBound(0, finalIndex, m_items.size()), moving);            emit orderChanged(m_items);        }        rebuildGrid();        return;    }    GridTileWidget* target = tileAtGlobalPos(globalPos);    if (target != nullptr) {        const int fromIndex = indexOfTile(m_dragTile);        const int toIndex = indexOfTile(target);        if (fromIndex >= 0 && toIndex >= 0 && fromIndex != toIndex) {            applyReorder(fromIndex, toIndex);            emit orderChanged(m_items);        }    }    else {        rebuildGrid();        return;    }    clearDragState();}void MenuPluginGridWidget::clearDragState(){    if (m_dragTile != nullptr) {        m_dragTile->setDragging(false);        m_dragTile.clear();    }    if (m_dropTargetTile != nullptr) {        m_dropTargetTile->setDropTarget(false);        m_dropTargetTile.clear();    }    if (m_dragPreview != nullptr) {        m_dragPreview->hide();        m_dragPreview->deleteLater();        m_dragPreview.clear();    }    if (m_insertIndicator != nullptr) {        m_insertIndicator->hide();        m_insertIndicator->deleteLater();        m_insertIndicator.clear();    }    m_dragSourceIndex = -1;    m_previewInsertIndex = -1;}void MenuPluginGridWidget::applyReorder(int fromIndex, int toIndex){    if (fromIndex < 0 || fromIndex >= m_items.size() || toIndex < 0 || toIndex >= m_items.size()) {        return;    }    if (m_mode == RearrangeMode::Swap) {        m_items.swapItemsAt(fromIndex, toIndex);    }    else {        const GridItemData moving = m_items.takeAt(fromIndex);        m_items.insert(toIndex, moving);    }    rebuildGrid();}QWidget* MenuPluginGridWidget::createDragPreview(GridTileWidget* tile) const{    auto* preview = new QLabel(nullptr, Qt::ToolTip | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);    preview->setAttribute(Qt::WA_TransparentForMouseEvents, true);    const QPixmap originalPixmap = tile->grab();    const QSize originalSize = tile->size();    const QSize previewSize(qMax(96, originalSize.width() - 40), qMax(96, originalSize.height() - 40));    const QPixmap scaledPixmap = originalPixmap.scaled(        previewSize,        Qt::IgnoreAspectRatio,        Qt::SmoothTransformation);    auto* previewLabel = qobject_cast<QLabel*>(preview);    previewLabel->setPixmap(scaledPixmap);    previewLabel->setScaledContents(true);    preview->setFixedSize(previewSize);    preview->setStyleSheet(QStringLiteral(R"(        QLabel {            background: rgba(255, 255, 255, 242);            border: 1px solid #1d4ed8;        }    )"));    preview->show();    preview->raise();    return preview;}void MenuPluginGridWidget::updatePreviewPosition(const QPoint& globalPos){    if (m_dragPreview == nullptr) {        return;    }    const QPoint previewOffset(        qBound(18, m_dragOffset.x(), qMax(18, m_dragPreview->width() - 18)),        qBound(18, m_dragOffset.y(), qMax(18, m_dragPreview->height() - 18)));    m_dragPreview->move(globalPos - previewOffset);    m_dragPreview->raise();}int MenuPluginGridWidget::insertionIndexForGlobalPos(const QPoint& globalPos) const{    QList<GridTileWidget*> visibleTiles;    visibleTiles.reserve(m_tiles.size());    for (GridTileWidget* tile : m_tiles) {        if (tile != nullptr && tile != m_dragTile) {            visibleTiles.append(tile);        }    }    if (visibleTiles.isEmpty()) {        return 0;    }    GridTileWidget* nearestTile = nullptr;    int nearestVisualIndex = -1;    qint64 nearestDistance = std::numeric_limits<qint64>::max();    for (int visualIndex = 0; visualIndex < visibleTiles.size(); ++visualIndex) {        GridTileWidget* tile = visibleTiles.at(visualIndex);        const QRect rect(tile->mapToGlobal(QPoint(0, 0)), tile->size());        const QPoint center = rect.center();        const qint64 dx = qint64(globalPos.x()) - center.x();        const qint64 dy = qint64(globalPos.y()) - center.y();        const qint64 distance = dx * dx + dy * dy;        if (distance < nearestDistance) {            nearestDistance = distance;            nearestTile = tile;            nearestVisualIndex = visualIndex;        }        if (!rect.contains(globalPos)) {            continue;        }        return globalPos.x() < rect.center().x() ? visualIndex : visualIndex + 1;    }    if (nearestTile != nullptr && nearestVisualIndex >= 0) {        const QRect nearestRect(nearestTile->mapToGlobal(QPoint(0, 0)), nearestTile->size());        return globalPos.x() < nearestRect.center().x() ? nearestVisualIndex : nearestVisualIndex + 1;    }    return qBound(0, m_previewInsertIndex, visibleTiles.size());}void MenuPluginGridWidget::updateInsertIndicator(int insertIndex){    if (m_mode != RearrangeMode::InsertShift) {        return;    }    if (m_insertIndicator == nullptr) {        auto* line = new QFrame(m_contentWidget);        line->setAttribute(Qt::WA_TransparentForMouseEvents, true);        line->setStyleSheet(QStringLiteral(R"(            QFrame {                background: #1d4ed8;            }        )"));        line->setFixedWidth(4);        line->show();        line->raise();        m_insertIndicator = line;    }    QList<GridTileWidget*> visibleTiles;    visibleTiles.reserve(m_tiles.size());    for (GridTileWidget* tile : m_tiles) {        if (tile != nullptr && tile != m_dragTile) {            visibleTiles.append(tile);        }    }    if (visibleTiles.isEmpty()) {        m_insertIndicator->hide();        return;    }    int indicatorX = 0;    int indicatorY = 0;    int indicatorHeight = 0;    const int spacing = qMax(8, m_gridLayout->horizontalSpacing());    if (insertIndex <= 0) {        GridTileWidget* firstTile = visibleTiles.first();        indicatorX = firstTile->x() - 7;        indicatorY = firstTile->y() + 8;        indicatorHeight = qMax(40, firstTile->height() - 16);    }    else if (insertIndex >= visibleTiles.size()) {        GridTileWidget* lastTile = visibleTiles.last();        indicatorX = lastTile->x() + lastTile->width() + spacing / 2 - 2;        indicatorY = lastTile->y() + 8;        indicatorHeight = qMax(40, lastTile->height() - 16);    }    else {        GridTileWidget* targetTile = visibleTiles.at(insertIndex);        GridTileWidget* previousTile = visibleTiles.at(insertIndex - 1);        const bool targetStartsNewVisualRow =            targetTile->y() > previousTile->y() && targetTile->x() <= previousTile->x();        if (targetStartsNewVisualRow) {            indicatorX = previousTile->x() + previousTile->width() + spacing / 2 - 2;            indicatorY = previousTile->y() + 8;            indicatorHeight = qMax(40, previousTile->height() - 16);        }        else {            indicatorX = targetTile->x() - 7;            indicatorY = targetTile->y() + 8;            indicatorHeight = qMax(40, targetTile->height() - 16);        }    }    indicatorX = qBound(0, indicatorX, qMax(0, m_contentWidget->width() - 4));    m_insertIndicator->setGeometry(indicatorX, indicatorY, 4, indicatorHeight);    m_insertIndicator->show();    m_insertIndicator->raise();}QWidget* MenuPluginGridWidget::buildItemWidget(const QVariant& item, QWidget* parent) const{    if (!m_itemWidgetFactory) {        return nullptr;    }    QWidget* widget = m_itemWidgetFactory(item, parent);    if (widget != nullptr) {        widget->setParent(parent);    }    return widget;}QSize MenuPluginGridWidget::tileSizeForIndex(int index) const{    const QSize squareSize = defaultTileSize();    if (!m_tileSizeProvider || index < 0 || index >= m_items.size()) {        return squareSize;    }    const QSize customSize = m_tileSizeProvider(squareSize, m_items.at(index), index);    if (!customSize.isValid()) {        return squareSize;    }    return QSize(qMax(1, customSize.width()), qMax(1, customSize.height()));}void MenuPluginGridWidget::restoreScrollPosition(int verticalValue, int horizontalValue){    if (m_scrollArea->verticalScrollBar() != nullptr) {        m_scrollArea->verticalScrollBar()->setValue(verticalValue);    }    if (m_scrollArea->horizontalScrollBar() != nullptr) {        m_scrollArea->horizontalScrollBar()->setValue(horizontalValue);    }}

从 menuplugingridwidget.h 可以看到,MenuPluginGridWidget 对外暴露的能力包括:

  • setItems
  • setColumnCount
  • setRearrangeMode
  • setGridSpacing
  • setItemWidgetFactory
  • setTileSizeProvider
  • orderChanged
     信号

这已经说明它不是一个普通 widget,而是一个完整的网格管理组件。

它主要负责五类事情:

  1. 管理网格项数据
  2. 负责网格布局和滚动容器
  3. 负责拖拽开始、移动、释放过程
  4. 负责根据策略调整项顺序
  5. 对外发出排序变化结果

四、为什么它不是直接用 QGridLayout 就结束了

很多人第一次做这类需求时,会直接把若干 QWidget 丢进 QGridLayout。
这种方式只能解决“摆放”,解决不了“管理”。

MenuPluginGridWidget 内部其实是:

  • QScrollArea
  • m_contentWidget
  • QGridLayout

也就是说,它底层仍然使用 QGridLayout 完成静态排布,但外层又加了一层管理逻辑。

这样做的原因在于:

1. QGridLayout 本身不带拖拽排序能力

Qt 的布局系统负责计算位置,但不会帮你完成拖拽重排。

2. 需要统一处理滚动区域

代码里:

m_scrollArea->setWidgetResizable(true);m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);

这让网格在内容较多时具备滚动能力,而不是简单溢出窗口。

3. 需要重建布局

当元素顺序变化后,控件通过 rebuildGrid() 整体重建网格,而不是局部硬改布局坐标。
这种方式虽然朴素,但非常稳定,也更容易维护。

五、GridTileWidget:拖拽交互的最小单元

MenuPluginGridWidget 没有直接让内容 widget 自己处理拖拽,而是包了一层 GridTileWidget。

GridTileWidget 的作用有三个:

  1. 承载内容 widget
  2. 捕获鼠标按下、移动、释放事件
  3. 维护拖拽中的视觉状态

它提供了几个重要接口:

  • setContentWidget
  • setDragging
  • setDropTarget

以及三个信号:

  • pressed
  • moved
  • released

这说明它本质上是一个“可拖拽卡片容器”,而不是业务内容本身。

这个拆分非常合理,因为:

  • 内容卡片不需要知道拖拽逻辑
  • 拖拽事件统一由外层容器接管
  • 视觉状态和业务显示分离

六、拖拽流程是如何完成的

整个拖拽流程在 MenuPluginGridWidget 内部是完整闭环的。

1. 按下时开始拖拽

beginDrag(tile, globalPos);

这里会记录:

  • 当前拖拽源项
  • 拖拽源索引
  • 鼠标相对偏移
  • 生成拖拽预览层

2. 移动时更新预览和目标项

updateDrag(globalPos);

这个阶段会做两件事:

  • 移动拖拽预览层
  • 找出当前鼠标所在的目标 tile,并更新高亮状态

如果是插入模式,还会控制插入指示器。

3. 释放时完成重排

finishDrag(globalPos);

释放后根据当前模式决定如何排序:

  • Swap
    :交换位置
  • InsertShift
    :插入后整体平移

最后发出:

emit orderChanged(m_items);

由外部拿到最新排序后的数据。

七、两种重排模式是这套控件的亮点

当前控件定义了:

enum classRearrangeMode{    Swap,    InsertShift};

这意味着拖拽并不是单一行为,而是可配置的。

1. Swap 交换模式

拖到目标项上后,两个卡片直接互换位置。

适合场景:

  • 仪表盘模块重排
  • 首页卡片换位
  • 快捷入口排序

2. InsertShift 插入前移模式

拖动项插入目标位置,其余项顺序平移。

适合场景:

  • 任务卡片排序
  • 内容块列表重排
  • 类似列表插入式编辑

gridwidget 里通过 QComboBox 切换这两种模式,说明这套控件不仅支持拖拽,还支持拖拽策略切换。

八、网格项尺寸并不是固定写死的

MenuPluginGridWidget 提供了:

  • defaultTileSize()
  • setTileSizeProvider(...)

这说明它的尺寸策略也是可插拔的。

默认情况下,它根据:

  • 当前 viewport 宽度
  • 列数
  • 间距
  • layout margins

自动计算一个方形 tile 尺寸。

如果外部有更复杂需求,比如:

  • 第一张卡片更大
  • 某些卡片横向跨列
  • 不同类型卡片有不同高度

就可以通过 TileSizeProvider 扩展,而不需要修改网格内部实现。

这种设计使控件从“固定演示控件”升级成了“可配置网格框架”。

九、为什么这套方案工程上是成立的

这套实现最值得肯定的地方,不是“能拖”,而是它做到了结构分层清晰。

1. 数据与视图分离

网格接收的是 QVariant 列表,而不是具体业务控件。

2. 视图生成外包

每个卡片长什么样,由 ItemWidgetFactory 决定。

3. 拖拽逻辑内聚

拖拽开始、更新、结束都在控件内部,不污染业务页面。

4. 排序结果对外输出

通过 orderChanged 把最终数据顺序通知外部,方便保存或同步。

5. 支持后续扩展

例如继续增加:

  • 占位动画
  • 键盘重排
  • 多选拖拽
  • 删除区
  • 固定卡片不可拖动
  • 跨行跨列 tile

都具备良好的演化空间。

十、Gridwidget这个示例说明了什么

gridwidget实际上已经很好地说明了 MenuPluginGridWidget 的使用方式:

  1. 定义自己的业务数据结构 DemoGridItemData
  2. 注册为元类型并封装进 QVariant
  3. 把数据列表传给 setItems
  4. 把卡片渲染逻辑通过 setItemWidgetFactory 传进去
  5. 用 orderChanged 监听最终顺序变化
  6. 用 setRearrangeMode 控制重排策略

这是一种非常标准的“容器控件使用模式”。

换句话说,gridwidget只是 demo,但它已经可以作为真实业务页面接入该控件的模板。

十一、总结

在这个工程里,gridwiget.h/.cpp 和 MenuPluginGridWidget 的配合,本质上实现的是一个“可复用的网格拖拽容器”。

它不是简单把若干 widget 丢进 QGridLayout,而是进一步解决了以下问题:

  • 网格项如何由外部数据驱动
  • 网格内容如何自定义渲染
  • 拖拽如何统一管理
  • 排序如何支持不同策略
  • 排序结果如何回传业务层
  • 布局、滚动、拖拽如何在同一组件内闭环

因此,MenuPluginGridWidget 的价值不在于“做了一个能拖的网格”,而在于它把 Qt 中原本分散的布局能力、滚动能力、事件处理能力和业务扩展能力组织成了一个完整的组件化方案。

▼关注我获取更多基础编程知识 ▼
一个热爱编程、分享的 Bug 战士

👆🏻👆🏻👆🏻扫码关注👆🏻👆🏻👆🏻

▼点个「」赞,是我持续更新的动力 ▼

相关学习资料