
在 Qt 桌面项目中,如果只是做一个静态的网格布局,直接使用 QGridLayout 就已经足够。但一旦需求升级为:
网格卡片可复用 网格项由外部数据驱动 网格项支持自定义 UI 支持拖拽调整顺序 支持不同的重排策略 保持布局与业务解耦
那么单纯依赖 QGridLayout 就不够了。
在当前工程中,gridwidget.h 已经完成了对 MenuPluginGridWidget 的引用,而 MenuPluginGridWidget 则承担了整个 grid 场景下的核心管理职责。它并不是一个简单的“显示控件”,而是一个完整的网格拖拽容器组件。
先看看效果:

本文就结合当前代码,说明这套方案是如何工作的。
一、gridwidget 的角色:示例窗口,而不是业务耦合层
gridwidget.h
#pragmaonce#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的职责并不是实现网格逻辑,而是:
初始化演示界面 准备演示数据 配置 MenuPluginGridWidget 监听排序变化并反馈状态
这种职责划分很重要。因为真正的“网格管理能力”被封装在 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");
m_gridWidget->setItems(items);这说明 MenuPluginGridWidget 的数据输入模型是泛型化的。
它不依赖具体业务对象,而是统一接收 QVariant。
这套设计使控件的工程化程度明显提高,因为它不需要知道:
这是订单卡片 这是用户卡片 这是报表卡片 还是监控面板卡片
它只负责把这些“项”放进网格并支持重排。
三、MenuPluginGridWidget 的核心定位:网格容器管理器
menuplugingridwidget.h
#pragmaonce#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;};
#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,而是一个完整的网格管理组件。
它主要负责五类事情:
管理网格项数据 负责网格布局和滚动容器 负责拖拽开始、移动、释放过程 负责根据策略调整项顺序 对外发出排序变化结果
四、为什么它不是直接用 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 的作用有三个:
承载内容 widget 捕获鼠标按下、移动、释放事件 维护拖拽中的视觉状态
它提供了几个重要接口:
- 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 的使用方式:
定义自己的业务数据结构 DemoGridItemData 注册为元类型并封装进 QVariant 把数据列表传给 setItems 把卡片渲染逻辑通过 setItemWidgetFactory 传进去 用 orderChanged 监听最终顺序变化 用 setRearrangeMode 控制重排策略
这是一种非常标准的“容器控件使用模式”。
换句话说,gridwidget只是 demo,但它已经可以作为真实业务页面接入该控件的模板。
十一、总结
在这个工程里,gridwiget.h/.cpp 和 MenuPluginGridWidget 的配合,本质上实现的是一个“可复用的网格拖拽容器”。
它不是简单把若干 widget 丢进 QGridLayout,而是进一步解决了以下问题:
网格项如何由外部数据驱动 网格内容如何自定义渲染 拖拽如何统一管理 排序如何支持不同策略 排序结果如何回传业务层 布局、滚动、拖拽如何在同一组件内闭环
因此,MenuPluginGridWidget 的价值不在于“做了一个能拖的网格”,而在于它把 Qt 中原本分散的布局能力、滚动能力、事件处理能力和业务扩展能力组织成了一个完整的组件化方案。

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