乐于分享
好东西不私藏

Qt 自定义横向多级菜单插件:不使用 QMenuBar / QMenu,完全 QWidget 化实现

Qt 自定义横向多级菜单插件:不使用 QMenuBar / QMenu,完全 QWidget 化实现

在 Qt 桌面端开发中,QMenuBar + QMenu 是最常见的菜单实现方案。它的优点是稳定、简单、跨平台行为一致;但如果项目对交互、样式、圆角、阴影、透明背景、多级菜单停留区域、悬浮切换逻辑有更强控制要求,Qt 原生菜单就会逐渐暴露出一些限制。

例如:

  • QMenu
     的窗口背景、系统阴影、圆角抗锯齿在不同平台上表现不完全一致。
  • QMenuBar
     和 QMenu 的 hover、popup、leave 行为很多由 Qt 内部管理,想精确控制兄弟菜单切换、子菜单桥接区域、叶子菜单点击触发等逻辑时会比较被动。
  • 复杂业务中菜单数据往往来自配置、权限、接口或插件系统,不适合把菜单项硬编码在窗口页面里。
  • 如果后续想把菜单做成更接近 Web / Figma 设计稿的视觉效果,完全自绘会更灵活。

本文实现的是一个完全自定义的横向多级菜单插件 MenuPluginCustomHorizontalWidget。它没有使用 QMenuBar,也没有使用 QMenu,顶级菜单、弹出菜单、子菜单项全部基于 QWidget + QPainter + QLayout 实现。外部只需要传入多维菜单数据,插件内部递归生成菜单树,并通过统一的 actionTriggered(QString path) 信号向业务层回调。

先预览效果再继续往下看:

一、设计目标

这个插件主要解决几个问题:

  • 顶级菜单横向展示,行为类似传统 QMenuBar
  • 菜单弹出层完全自定义绘制,支持圆角、边框、透明背景和抗锯齿。
  • 支持任意层级子菜单。
  • 鼠标悬浮顶级菜单时,自动显示对应子菜单。
  • 鼠标从一个顶级菜单移动到另一个顶级菜单时,自动关闭旧菜单并打开新菜单。
  • 鼠标离开整个菜单链路后,自动关闭所有 popup。
  • 鼠标经过父菜单和子菜单之间的小间隙时,不立即关闭菜单,而是通过桥接区域提升交互容错。
  • 顶级菜单如果没有子菜单,悬浮只显示高亮,不触发业务回调,只有点击才触发回调。
  • 子菜单的叶子节点点击后,回调完整路径,例如 文件 / Export / More Formats / JSON Data

二、为什么不用 QMenuBar / QMenu

如果只是普通软件菜单,QMenuBar + QMenu 是正确选择。但在更复杂的产品 UI 中,我们经常需要更细粒度的控制:

  • 想让圆角 popup 彻底透明,不受系统菜单窗口背景和阴影影响。
  • 想控制鼠标从父级移动到子级时的“安全区域”,避免菜单闪烁关闭。
  • 想统一菜单项的绘制、hover、pressed、active 状态。
  • 想让没有子菜单的顶级菜单只在点击时回调,而不是悬浮误触发。
  • 想把菜单数据抽象成插件输入,而不是散落在 MainWindow 里。

因此这里选择自己实现菜单项、菜单弹窗和菜单管理器。工程上看,它的成本比直接使用 QMenu 高一些,但换来的是完全可控的交互和样式。

三、源码

下面源码可以直接复制到 Qt 工程中使用。示例包含四部分:

  • menunode.h
    :菜单树节点。
  • menuplugincustomhorizontalwidget.h
    :自定义横向菜单插件声明。
  • menuplugincustomhorizontalwidget.cpp
    :插件完整实现。
  • mainwindow3.h / mainwindow3.cpp
    :初始化入口示例。

1. menunode.h

#pragma once#include<QList>#include<QString>#include<QtAlgorithms>structMenuNode{    QString text;    QList<MenuNode *> children;explicitMenuNode(const QString &menuText = QString(), const QList<MenuNode *> &menuChildren = {})        : text(menuText)        , children(menuChildren)    {    }    ~MenuNode()    {qDeleteAll(children);        children.clear();    }MenuNode(const MenuNode &) = delete;    MenuNode &operator=(const MenuNode &) = delete;};

2. menuplugincustomhorizontalwidget.h

#pragma once#include <QHash>#include <QList>#include <QPointer>#include <QWidget>#include "menunode.h"classQCloseEvent;classQEnterEvent;classQEvent;classQHBoxLayout;classQMouseEvent;classQPaintEvent;classQVBoxLayout;classCustomMenuItemWidget : publicQWidget{    Q_OBJECTpublic:enumclassRole {        TopLevel,        PopupItem    };struct MenuItemLayoutMetrics    {int leftPadding;int rightPadding;int minWidth;int height;    };explicitCustomMenuItemWidget(const QString &text, Role role, bool hasChildren, QWidget *parent = nullptr);voidsetActive(bool active);voidsetHovered(bool hovered);QString text() const;signals:voiditemHovered();voiditemLeft();voiditemClicked();protected:voidenterEvent(QEnterEvent *eventoverride;voidleaveEvent(QEvent *eventoverride;voidmousePressEvent(QMouseEvent *eventoverride;voidmouseReleaseEvent(QMouseEvent *eventoverride;voidpaintEvent(QPaintEvent *eventoverride;QSize sizeHint() constoverride;MenuItemLayoutMetrics layoutMetrics() const;private:    QString m_text;    Role m_role;bool m_hasChildren;bool m_hovered;bool m_pressed;bool m_active;};classCustomMenuPopupWidget : publicQWidget{    Q_OBJECTpublic:explicitCustomMenuPopupWidget(const QList<MenuNode *> &items, const QStringList &parentPath, QWidget *parent = nullptr);voidpopupAt(const QPoint &globalPos);voidcloseTree();boolcontainsGlobalPosInTree(const QPoint &globalPosconst;voidsyncSubmenuForGlobalPos(const QPoint &globalPos);signals:voidactionTriggered(const QString &path);voidpopupClosed();voidpopupLeft();protected:voidcloseEvent(QCloseEvent *eventoverride;voidenterEvent(QEnterEvent *eventoverride;voidleaveEvent(QEvent *eventoverride;voidpaintEvent(QPaintEvent *eventoverride;private:voidbuildItems();voidshowChildMenu(CustomMenuItemWidget *itemWidget, MenuNode *node, const QStringList &path);voidcloseChildMenu();    QList<MenuNode *> m_items;    QStringList m_parentPath;    QVBoxLayout *m_layout;    QPointer<CustomMenuPopupWidget> m_childPopup;    QPointer<CustomMenuItemWidget> m_childAnchorItem;    QHash<CustomMenuItemWidget *, MenuNode *> m_itemNodes;};classMenuPluginCustomHorizontalWidget : publicQWidget{    Q_OBJECTpublic:struct MenuDef {        QString key;        QList<MenuDef> values = {};    };explicitMenuPluginCustomHorizontalWidget(QWidget *parent = nullptr);    ~MenuPluginCustomHorizontalWidget() override;voidsetMenus(const QList<MenuDef> &menus);signals:voidactionTriggered(const QString &path);protected:booleventFilter(QObject *watched, QEvent *eventoverride;private:    QList<MenuNode *> buildMenusFromDef(const QList<MenuDef> &defs) const;voidrebuildMenus();voidclearLayout();voidclearMenuData();voidshowRootMenu(CustomMenuItemWidget *itemWidget, MenuNode *node);voidclearActiveTopItem();boolcontainsGlobalPosInActiveChain(const QPoint &globalPosconst;boolcontainsGlobalPosInAnyTopItem(const QPoint &globalPosconst;voidcloseRootPopup();voidinstallApplicationEventFilter();voidremoveApplicationEventFilter();    CustomMenuItemWidget *topItemAtGlobalPos(const QPoint &globalPos) const;    MenuNode *nodeForTopItem(CustomMenuItemWidget *itemWidget) const;    QList<MenuNode *> m_menus;    QHBoxLayout *m_layout;    QHash<CustomMenuItemWidget *, MenuNode *> m_topItemNodes;    QPointer<CustomMenuPopupWidget> m_rootPopup;    QPointer<CustomMenuItemWidget> m_activeTopItem;bool m_applicationEventFilterInstalled;};

3. menuplugincustomhorizontalwidget.cpp

#include"menuplugincustomhorizontalwidget.h"#include<QApplication>#include<QCloseEvent>#include<QCursor>#include<QEnterEvent>#include<QEvent>#include<QHBoxLayout>#include<QLayoutItem>#include<QMouseEvent>#include<QPainter>#include<QPainterPath>#include<QStyleFactory>#include<QTimer>#include<QVBoxLayout>namespace{QString joinPath(const QStringList &parts){return parts.join(QStringLiteral(" / "));}QColor topTextColor(bool active){return active ? QColor(QStringLiteral("#1d4ed8")) : QColor(QStringLiteral("#334155"));}QRect bridgeRectBetween(const QRect &first, const QRect &second){if (first.isNull() || second.isNull()) {return {};    }return first.united(second).adjusted(-8-888);}}CustomMenuItemWidget::CustomMenuItemWidget(const QString &text, Role role, bool hasChildren, QWidget *parent)    : QWidget(parent)    , m_text(text)    , m_role(role)    , m_hasChildren(hasChildren)    , m_hovered(false)    , m_pressed(false)    , m_active(false){setMouseTracking(true);setCursor(Qt::PointingHandCursor);setFocusPolicy(Qt::NoFocus);}voidCustomMenuItemWidget::setActive(bool active){if (m_active == active) {return;    }    m_active = active;update();}voidCustomMenuItemWidget::setHovered(bool hovered){if (m_hovered == hovered) {return;    }    m_hovered = hovered;update();}QString CustomMenuItemWidget::text()const{return m_text;}voidCustomMenuItemWidget::enterEvent(QEnterEvent *event){    QWidget::enterEvent(event);    m_hovered = true;emit itemHovered();update();}voidCustomMenuItemWidget::leaveEvent(QEvent *event){    QWidget::leaveEvent(event);    m_hovered = false;    m_pressed = false;emit itemLeft();update();}voidCustomMenuItemWidget::mousePressEvent(QMouseEvent *event){if (event->button() == Qt::LeftButton) {        m_pressed = true;update();    }    QWidget::mousePressEvent(event);}voidCustomMenuItemWidget::mouseReleaseEvent(QMouseEvent *event){if (event->button() == Qt::LeftButton && rect().contains(event->pos())) {emit itemClicked();    }    m_pressed = false;update();    QWidget::mouseReleaseEvent(event);}voidCustomMenuItemWidget::paintEvent(QPaintEvent *event){Q_UNUSED(event);QPainter painter(this);    painter.setRenderHint(QPainter::Antialiasing, true);const QRectF bgRect = rect().adjusted(2.02.0-2.0-2.0);constbool highlighted = m_active || m_pressed || m_hovered;constbool useActiveBackground = m_role == Role::TopLevel && (m_pressed || m_active);const QColor background = highlighted        ? (useActiveBackground ? QColor(QStringLiteral("#dbeafe")) : QColor(QStringLiteral("#eef2f7")))        : QColor(Qt::transparent);if (highlighted) {        QPainterPath path;        path.addRoundedRect(bgRect, 8.08.0);        painter.fillPath(path, background);    }    QFont menuFont = font();    menuFont.setPointSize(10);    menuFont.setWeight(QFont::DemiBold);    painter.setFont(menuFont);    painter.setPen(m_role == Role::TopLevel ? topTextColor(highlighted) : QColor(QStringLiteral("#334155")));constauto metrics = layoutMetrics();    QRect textRect = rect().adjusted(metrics.leftPadding, 0, -metrics.rightPadding, 0);QFontMetrics fm(menuFont);const QString text = fm.elidedText(m_text, Qt::ElideRight, textRect.width());    painter.drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft, text);if (m_hasChildren && m_role == Role::PopupItem) {        QPolygonF arrow;const qreal centerY = rect().center().y();const qreal right = rect().right() - 14.0;        arrow << QPointF(right, centerY - 4.0)              << QPointF(right + 5.0, centerY)              << QPointF(right, centerY + 4.0);        painter.setBrush(QColor(QStringLiteral("#334155")));        painter.setPen(Qt::NoPen);        painter.drawPolygon(arrow);    }}QSize CustomMenuItemWidget::sizeHint()const{    QFont menuFont = font();    menuFont.setPointSize(10);    menuFont.setWeight(QFont::DemiBold);const QFontMetrics fm(menuFont);constauto metrics = layoutMetrics();constint reserve = 8;constint width = fm.horizontalAdvance(m_text) + metrics.leftPadding + metrics.rightPadding + reserve;returnQSize(qMax(metrics.minWidth, width), metrics.height);}CustomMenuItemWidget::MenuItemLayoutMetrics CustomMenuItemWidget::layoutMetrics()const{if (m_role == Role::TopLevel) {return {14, m_hasChildren ? 28 : 127240};    }return {12, m_hasChildren ? 28 : 1218036};}CustomMenuPopupWidget::CustomMenuPopupWidget(const QList<MenuNode *> &items, const QStringList &parentPath, QWidget *parent)    : QWidget(parent, Qt::Popup | Qt::FramelessWindowHint)    , m_items(items)    , m_parentPath(parentPath)    , m_layout(newQVBoxLayout(this)){setAttribute(Qt::WA_TranslucentBackground, true);setAttribute(Qt::WA_NoSystemBackground, true);setMouseTracking(true);setWindowFlag(Qt::FramelessWindowHint, true);setWindowFlag(Qt::NoDropShadowWindowHint, true);setStyle(QStyleFactory::create(QStringLiteral("Fusion")));    m_layout->setContentsMargins(8888);    m_layout->setSpacing(2);buildItems();}voidCustomMenuPopupWidget::popupAt(const QPoint &globalPos){adjustSize();move(globalPos);show();raise();}voidCustomMenuPopupWidget::closeTree(){closeChildMenu();close();}voidCustomMenuPopupWidget::closeEvent(QCloseEvent *event){closeChildMenu();emit popupClosed();    QWidget::closeEvent(event);}voidCustomMenuPopupWidget::enterEvent(QEnterEvent *event){    QWidget::enterEvent(event);}voidCustomMenuPopupWidget::leaveEvent(QEvent *event){    QWidget::leaveEvent(event);emit popupLeft();}voidCustomMenuPopupWidget::paintEvent(QPaintEvent *event){Q_UNUSED(event);QPainter painter(this);    painter.setRenderHint(QPainter::Antialiasing, true);    painter.setCompositionMode(QPainter::CompositionMode_Source);    painter.fillRect(rect(), Qt::transparent);    painter.setCompositionMode(QPainter::CompositionMode_SourceOver);const QRectF panelRect = rect().adjusted(1.01.0-1.0-1.0);    QPainterPath path;    path.addRoundedRect(panelRect, 12.012.0);    painter.fillPath(path, QColor(QStringLiteral("#ffffffff")));    painter.setPen(QPen(QColor(QStringLiteral("#dbe2ea")), 1.0, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));    painter.drawPath(path);}voidCustomMenuPopupWidget::buildItems(){for (MenuNode *node : m_items) {if (node == nullptr || node->text.isEmpty()) {continue;        }const QStringList currentPath = m_parentPath + QStringList{node->text};auto *itemWidget = newCustomMenuItemWidget(            node->text,            CustomMenuItemWidget::Role::PopupItem,            !node->children.isEmpty(),this);        m_layout->addWidget(itemWidget);        m_itemNodes.insert(itemWidget, node);connect(itemWidget, &CustomMenuItemWidget::itemHovered, this, [this, itemWidget, node, currentPath]() {if (node->children.isEmpty()) {closeChildMenu();return;            }showChildMenu(itemWidget, node, currentPath);        });connect(itemWidget, &CustomMenuItemWidget::itemClicked, this, [this, node, currentPath]() {if (!node->children.isEmpty()) {return;            }            emit actionTriggered(joinPath(currentPath));closeTree();        });    }}voidCustomMenuPopupWidget::showChildMenu(CustomMenuItemWidget *itemWidget, MenuNode *node, const QStringList &path){if (m_childAnchorItem == itemWidget && m_childPopup != nullptr && m_childPopup->isVisible()) {        itemWidget->setActive(true);return;    }closeChildMenu();    m_childAnchorItem = itemWidget;    m_childAnchorItem->setActive(true);    m_childPopup = newCustomMenuPopupWidget(node->children, path, this);connect(m_childPopup, &CustomMenuPopupWidget::actionTriggered, this, [this](const QString &triggerPath) {        emit actionTriggered(triggerPath);closeTree();    });connect(m_childPopup, &CustomMenuPopupWidget::popupLeft, this, &CustomMenuPopupWidget::popupLeft);const QPoint globalPos = itemWidget->mapToGlobal(QPoint(itemWidget->width() - 20));    m_childPopup->popupAt(globalPos);}voidCustomMenuPopupWidget::closeChildMenu(){if (m_childPopup != nullptr) {        m_childPopup->closeTree();        m_childPopup->deleteLater();        m_childPopup.clear();    }if (m_childAnchorItem != nullptr) {        m_childAnchorItem->setActive(false);    }    m_childAnchorItem.clear();}boolCustomMenuPopupWidget::containsGlobalPosInTree(const QPoint &globalPos)const{if (!isVisible()) {returnfalse;    }const QRect popupRect(mapToGlobal(QPoint(00)), size());if (popupRect.contains(globalPos)) {returntrue;    }if (m_childPopup != nullptr && m_childAnchorItem != nullptr && m_childPopup->isVisible()) {const QRect anchorRect(m_childAnchorItem->mapToGlobal(QPoint(00)), m_childAnchorItem->size());const QRect childRect(m_childPopup->mapToGlobal(QPoint(00)), m_childPopup->size());if (bridgeRectBetween(anchorRect, childRect).contains(globalPos)) {returntrue;        }    }return m_childPopup != nullptr && m_childPopup->containsGlobalPosInTree(globalPos);}voidCustomMenuPopupWidget::syncSubmenuForGlobalPos(const QPoint &globalPos){if (!isVisible()) {return;    }if (m_childPopup != nullptr && m_childPopup->containsGlobalPosInTree(globalPos)) {        m_childPopup->syncSubmenuForGlobalPos(globalPos);return;    }const QRect popupRect(mapToGlobal(QPoint(00)), size());if (!popupRect.contains(globalPos)) {closeChildMenu();return;    }    CustomMenuItemWidget *hoveredItem = nullptr;    MenuNode *hoveredNode = nullptr;for (auto it = m_itemNodes.constBegin(); it != m_itemNodes.constEnd(); ++it) {        CustomMenuItemWidget *itemWidget = it.key();        MenuNode *node = it.value();if (itemWidget == nullptr || node == nullptr) {continue;        }const QRect itemRect(itemWidget->mapToGlobal(QPoint(00)), itemWidget->size());constbool containsCursor = itemRect.contains(globalPos);        itemWidget->setHovered(containsCursor);if (containsCursor) {            hoveredItem = itemWidget;            hoveredNode = node;        }    }if (hoveredItem == nullptr || hoveredNode == nullptr) {closeChildMenu();return;    }if (hoveredNode->children.isEmpty()) {closeChildMenu();return;    }showChildMenu(hoveredItem, hoveredNode, m_parentPath + QStringList{hoveredNode->text});}MenuPluginCustomHorizontalWidget::MenuPluginCustomHorizontalWidget(QWidget *parent)    : QWidget(parent)    , m_layout(newQHBoxLayout(this))    , m_applicationEventFilterInstalled(false){    m_layout->setContentsMargins(8484);    m_layout->setSpacing(6);    m_layout->addStretch();setAutoFillBackground(false);}MenuPluginCustomHorizontalWidget::~MenuPluginCustomHorizontalWidget(){removeApplicationEventFilter();clearMenuData();}voidMenuPluginCustomHorizontalWidget::setMenus(const QList<MenuDef> &menus){clearMenuData();    m_menus = buildMenusFromDef(menus);rebuildMenus();}QList<MenuNode *> MenuPluginCustomHorizontalWidget::buildMenusFromDef(const QList<MenuDef> &defs)const{    QList<MenuNode *> nodes;for (const MenuDef &def : defs) {        nodes.append(newMenuNode(def.key, buildMenusFromDef(def.values)));    }return nodes;}voidMenuPluginCustomHorizontalWidget::rebuildMenus(){clearLayout();for (MenuNode *node : m_menus) {if (node == nullptr || node->text.isEmpty()) {continue;        }auto *itemWidget = newCustomMenuItemWidget(            node->text,            CustomMenuItemWidget::Role::TopLevel,            !node->children.isEmpty(),this);        m_layout->addWidget(itemWidget);        m_topItemNodes.insert(itemWidget, node);connect(itemWidget, &CustomMenuItemWidget::itemHovered, this, [this, itemWidget, node]() {if (node == nullptr || node->children.isEmpty()) {closeRootPopup();                itemWidget->setHovered(true);return;            }showRootMenu(itemWidget, node);        });connect(itemWidget, &CustomMenuItemWidget::itemLeft, this, [this]() {            QTimer::singleShot(120this, [this]() {const QPoint cursorPos = QCursor::pos();if (!containsGlobalPosInActiveChain(cursorPos) && !containsGlobalPosInAnyTopItem(cursorPos)) {closeRootPopup();                }            });        });connect(itemWidget, &CustomMenuItemWidget::itemClicked, this, [this, itemWidget, node]() {if (node != nullptr && node->children.isEmpty()) {                emit actionTriggered(node->text);closeRootPopup();return;            }showRootMenu(itemWidget, node);        });    }    m_layout->addStretch();}voidMenuPluginCustomHorizontalWidget::clearLayout(){closeRootPopup();clearActiveTopItem();    m_topItemNodes.clear();while (QLayoutItem *item = m_layout->takeAt(0)) {if (QWidget *widget = item->widget()) {delete widget;        }delete item;    }}voidMenuPluginCustomHorizontalWidget::clearMenuData(){clearLayout();qDeleteAll(m_menus);    m_menus.clear();}voidMenuPluginCustomHorizontalWidget::showRootMenu(CustomMenuItemWidget *itemWidget, MenuNode *node){if (itemWidget == nullptr || node == nullptr) {return;    }if (m_activeTopItem == itemWidget && m_rootPopup != nullptr && m_rootPopup->isVisible()) {return;    }clearActiveTopItem();if (node->children.isEmpty()) {closeRootPopup();return;    }closeRootPopup();    itemWidget->setActive(true);    m_activeTopItem = itemWidget;    m_rootPopup = newCustomMenuPopupWidget(node->children, {node->text}, this);const QPointer<CustomMenuPopupWidget> popup = m_rootPopup;connect(m_rootPopup, &CustomMenuPopupWidget::actionTriggered, this, &MenuPluginCustomHorizontalWidget::actionTriggered);connect(m_rootPopup, &CustomMenuPopupWidget::popupClosed, this, [this, popup]() {if (m_rootPopup == popup) {clearActiveTopItem();removeApplicationEventFilter();        }    });connect(m_rootPopup, &CustomMenuPopupWidget::popupLeft, this, [this]() {        QTimer::singleShot(120this, [this]() {const QPoint cursorPos = QCursor::pos();if (!containsGlobalPosInActiveChain(cursorPos) && !containsGlobalPosInAnyTopItem(cursorPos)) {closeRootPopup();            }        });    });connect(m_rootPopup, &QObject::destroyed, this, [this, popup]() {if (m_rootPopup == popup) {clearActiveTopItem();            m_rootPopup.clear();removeApplicationEventFilter();        }    });    m_rootPopup->popupAt(itemWidget->mapToGlobal(QPoint(0, itemWidget->height() + 2)));installApplicationEventFilter();}voidMenuPluginCustomHorizontalWidget::clearActiveTopItem(){if (m_activeTopItem != nullptr) {        m_activeTopItem->setActive(false);        m_activeTopItem.clear();    }}boolMenuPluginCustomHorizontalWidget::containsGlobalPosInActiveChain(const QPoint &globalPos)const{if (m_activeTopItem != nullptr) {const QRect topItemRect(m_activeTopItem->mapToGlobal(QPoint(00)), m_activeTopItem->size());if (topItemRect.contains(globalPos)) {returntrue;        }if (m_rootPopup != nullptr && m_rootPopup->isVisible()) {const QRect rootPopupRect(m_rootPopup->mapToGlobal(QPoint(00)), m_rootPopup->size());if (bridgeRectBetween(topItemRect, rootPopupRect).contains(globalPos)) {returntrue;            }        }    }return m_rootPopup != nullptr && m_rootPopup->containsGlobalPosInTree(globalPos);}boolMenuPluginCustomHorizontalWidget::containsGlobalPosInAnyTopItem(const QPoint &globalPos)const{returntopItemAtGlobalPos(globalPos) != nullptr;}CustomMenuItemWidget *MenuPluginCustomHorizontalWidget::topItemAtGlobalPos(const QPoint &globalPos)const{for (auto it = m_topItemNodes.constBegin(); it != m_topItemNodes.constEnd(); ++it) {        CustomMenuItemWidget *topItem = it.key();if (topItem == nullptr || !topItem->isVisible()) {continue;        }const QRect topItemRect(topItem->mapToGlobal(QPoint(00)), topItem->size());if (topItemRect.contains(globalPos)) {return topItem;        }    }returnnullptr;}MenuNode *MenuPluginCustomHorizontalWidget::nodeForTopItem(CustomMenuItemWidget *itemWidget)const{return m_topItemNodes.value(itemWidget, nullptr);}voidMenuPluginCustomHorizontalWidget::closeRootPopup(){if (m_rootPopup != nullptr) {        CustomMenuPopupWidget *popup = m_rootPopup;        m_rootPopup.clear();        popup->closeTree();        popup->deleteLater();    }removeApplicationEventFilter();clearActiveTopItem();}voidMenuPluginCustomHorizontalWidget::installApplicationEventFilter(){if (m_applicationEventFilterInstalled) {return;    }    qApp->installEventFilter(this);    m_applicationEventFilterInstalled = true;}voidMenuPluginCustomHorizontalWidget::removeApplicationEventFilter(){if (!m_applicationEventFilterInstalled) {return;    }    qApp->removeEventFilter(this);    m_applicationEventFilterInstalled = false;}boolMenuPluginCustomHorizontalWidget::eventFilter(QObject *watched, QEvent *event){if (m_rootPopup == nullptr) {return QWidget::eventFilter(watched, event);    }if (event->type() == QEvent::MouseMove || event->type() == QEvent::HoverMove || event->type() == QEvent::Enter) {const QPoint cursorPos = QCursor::pos();        CustomMenuItemWidget *topItem = topItemAtGlobalPos(cursorPos);if (topItem != nullptr && topItem != m_activeTopItem) {            MenuNode *topNode = nodeForTopItem(topItem);if (topNode != nullptr && !topNode->children.isEmpty()) {showRootMenu(topItem, topNode);            } else {closeRootPopup();                topItem->setHovered(true);            }return QWidget::eventFilter(watched, event);        }if (m_rootPopup != nullptr) {            m_rootPopup->syncSubmenuForGlobalPos(cursorPos);        }if (!containsGlobalPosInActiveChain(cursorPos) && !containsGlobalPosInAnyTopItem(cursorPos)) {closeRootPopup();        }    }return QWidget::eventFilter(watched, event);}
4.mainwindo3.h
#pragma once#include<QMainWindow>classQLabel;classMenuPluginCustomHorizontalWidget;classMainWindow3 : public QMainWindow{public:explicitMainWindow3(QWidget *parent = nullptr);private:voidbuildUi();voidhandleAction(const QString &path);static QString buildStyleSheet();    QLabel *m_currentPathLabel;    QLabel *m_exampleLabel;    MenuPluginCustomHorizontalWidget *m_menuWidget;};

5. mainwindow3.cpp

#include"mainwindow3.h"#include"menuplugincustomhorizontalwidget.h"#include<QFrame>#include<QLabel>#include<QStatusBar>#include<QVBoxLayout>#include<QWidget>MainWindow3::MainWindow3(QWidget *parent)    : QMainWindow(parent)    , m_currentPathLabel(nullptr)    , m_exampleLabel(nullptr)    , m_menuWidget(nullptr){buildUi();setStyleSheet(buildStyleSheet());statusBar()->showMessage(QStringLiteral("Custom horizontal menu is ready"), 3000);}voidMainWindow3::buildUi(){resize(1080640);setWindowTitle(QStringLiteral("Custom Horizontal Menu Demo"));auto *centralWidget = newQWidget(this);auto *rootLayout = newQVBoxLayout(centralWidget);    rootLayout->setContentsMargins(28202828);    rootLayout->setSpacing(18);auto *menuPanel = newQFrame(centralWidget);    menuPanel->setObjectName(QStringLiteral("menuPanel"));auto *menuLayout = newQVBoxLayout(menuPanel);    menuLayout->setContentsMargins(14101410);    m_menuWidget = newMenuPluginCustomHorizontalWidget(menuPanel);    m_menuWidget->setMenus({        {QStringLiteral("文件"), {            {QStringLiteral("New Project")},            {QStringLiteral("Open Project")},            {QStringLiteral("Export"), {                {QStringLiteral("Export as PNG")},                {QStringLiteral("Export as PDF")},                {QStringLiteral("More Formats"), {                    {QStringLiteral("CSV Data")},                    {QStringLiteral("JSON Data")},                    {QStringLiteral("Image Assets"), {                        {QStringLiteral("Thumbnail")},                        {QStringLiteral("Full Resolution")}                    }}                }}            }},            {QStringLiteral("Exit")}        }},        {QStringLiteral("编辑"), {            {QStringLiteral("Undo")},            {QStringLiteral("Redo")},            {QStringLiteral("Preferences"), {                {QStringLiteral("Theme"), {                    {QStringLiteral("Light")},                    {QStringLiteral("Dark")}                }},                {QStringLiteral("Language"), {                    {QStringLiteral("Chinese")},                    {QStringLiteral("English")}                }}            }}        }},        {QStringLiteral("视图"), {            {QStringLiteral("Refresh View")},            {QStringLiteral("Panels"), {                {QStringLiteral("Assets Panel")},                {QStringLiteral("Properties Panel")},                {QStringLiteral("Developer Tools"), {                    {QStringLiteral("Log Console")},                    {QStringLiteral("Network Requests")}                }}            }}        }},        {QStringLiteral("帮助"), {            {QStringLiteral("Documentation")},            {QStringLiteral("Shortcuts")},            {QStringLiteral("About"), {                {QStringLiteral("Version")},                {QStringLiteral("License")}            }}        }},        {QStringLiteral("更多>>")}    });connect(m_menuWidget, &MenuPluginCustomHorizontalWidget::actionTriggered, this, &MainWindow3::handleAction);    menuLayout->addWidget(m_menuWidget);auto *contentCard = newQFrame(centralWidget);    contentCard->setObjectName(QStringLiteral("contentCard"));auto *contentLayout = newQVBoxLayout(contentCard);    contentLayout->setContentsMargins(28242824);    contentLayout->setSpacing(14);auto *sectionTitle = newQLabel(QStringLiteral("Custom Menu Interaction Preview"), contentCard);    sectionTitle->setObjectName(QStringLiteral("sectionTitle"));auto *introLabel = newQLabel(QStringLiteral("This menu does not use QMenuBar or QMenu. Top-level and popup entries are custom QWidget items painted manually."),        contentCard);    introLabel->setObjectName(QStringLiteral("mutedLabel"));    introLabel->setWordWrap(true);    m_currentPathLabel = newQLabel(QStringLiteral("Current action: select a custom menu item"), contentCard);    m_currentPathLabel->setObjectName(QStringLiteral("pathLabel"));    m_currentPathLabel->setWordWrap(true);    m_exampleLabel = newQLabel(QStringLiteral("Example path: File / Export / More Formats / JSON Data"), contentCard);    m_exampleLabel->setObjectName(QStringLiteral("mutedLabel"));    m_exampleLabel->setWordWrap(true);    contentLayout->addWidget(sectionTitle);    contentLayout->addWidget(introLabel);    contentLayout->addSpacing(6);    contentLayout->addWidget(m_currentPathLabel);    contentLayout->addWidget(m_exampleLabel);    contentLayout->addStretch();    rootLayout->addWidget(menuPanel);    rootLayout->addWidget(contentCard, 1);setCentralWidget(centralWidget);}voidMainWindow3::handleAction(const QString &path){    m_currentPathLabel->setText(QStringLiteral("Current action: %1").arg(path));    m_exampleLabel->setText(QStringLiteral("Last trigger: %1").arg(path));statusBar()->showMessage(QStringLiteral("Triggered: %1").arg(path), 2500);}QString MainWindow3::buildStyleSheet(){returnQStringLiteral(R"(        QMainWindow {            background: #f4f7fb;        }        QFrame#menuPanel,        QFrame#contentCard {            background: #ffffff;            border: 1px solid #e2e8f0;            border-radius: 18px;        }        QLabel#sectionTitle {            color: #0f172a;            font-size: 24px;            font-weight: 700;        }        QLabel#pathLabel {            color: #1d4ed8;            font-size: 16px;            font-weight: 600;            background: #eff6ff;            border: 1px solid #bfdbfe;            border-radius: 12px;            padding: 12px 14px;        }        QLabel#mutedLabel {            color: #475569;            font-size: 14px;        }        QStatusBar {            background: #ffffff;            color: #475569;            border-top: 1px solid #e2e8f0;        }    )");}

四、核心设计分析

1. MenuNode

MenuNode 是菜单树节点,字段非常简单:text 表示菜单显示文字,children 表示子菜单。它的析构函数中调用 qDeleteAll(children),负责递归释放子节点,避免外部调用方手动管理整棵树的生命周期。

这个结构的好处是菜单数据和 UI 控件解耦。业务层只描述“菜单是什么”,插件内部负责“菜单如何显示、如何交互、如何回调”。

2. MenuPluginCustomHorizontalWidget::MenuDef

MenuDef 是给外部使用的轻量数据结构,使用方式更接近配置:

{QStringLiteral("文件"), {    {QStringLiteral("New Project")},    {QStringLiteral("Export"), {        {QStringLiteral("Export as PNG")},        {QStringLiteral("Export as PDF")}    }}}}

外部不需要关心 MenuNode 的指针和释放问题,只需要传入一个多维数组风格的数据即可。

3. setMenus

setMenus 是插件对外最重要的入口。它先清理旧数据,再通过 buildMenusFromDef 把外部传入的 MenuDef 递归转换成内部 MenuNode 树,最后调用 rebuildMenus 重新生成顶级菜单控件。

这个函数让插件具备动态更新能力。后续如果菜单来自权限系统、接口、配置文件,只需要重新调用 setMenus 即可。

4. CustomMenuItemWidget

CustomMenuItemWidget 是菜单项控件,顶级菜单和弹出菜单项都复用它。区别由 Role 决定:

  • TopLevel
    :横向顶级菜单项。
  • PopupItem
    :弹出层中的菜单项。

它没有使用 QPushButtonQAction 或 QMenu,而是直接处理鼠标事件,并在 paintEvent 中自绘背景、文字和右侧箭头。

这样做的最大价值是状态完全可控:hoveredpressedactive 可以分别处理。比如有子菜单的项可以在子菜单打开时保持 active,高亮颜色也可以和普通 hover 保持一致。

5. paintEvent

paintEvent 是视觉定制的核心。这里通过 QPainterPath::addRoundedRect 绘制圆角背景,通过 QPainter::Antialiasing 提升边缘质量。

顶级菜单、弹出菜单、普通 hover、pressed、active 都可以在这里统一调整颜色。相比 QSS 控制 QMenu,这种方式不会受到系统菜单窗口背景、阴影和平台样式差异的影响。

6. sizeHint

sizeHint 根据 QFontMetrics::horizontalAdvance 动态计算文本宽度,所以顶级菜单文字变长时不会被固定宽度截断。

这点很关键。菜单项不应该假设 FileEdit 这类短文本一定存在,实际项目经常会出现“文件查看菜单”这种更长的中文文案。

7. CustomMenuPopupWidget

CustomMenuPopupWidget 是自定义弹出菜单窗口。它使用 Qt::Popup | Qt::FramelessWindowHint 创建无边框 popup,并通过:

  • WA_TranslucentBackground
  • WA_NoSystemBackground
  • Qt::NoDropShadowWindowHint
  • 自定义 paintEvent

尽量规避系统背景、默认阴影和圆角黑边问题。

它内部继续使用 CustomMenuItemWidget 构建菜单项,因此顶级菜单和子菜单的绘制逻辑保持统一。

8. buildItems

buildItems 根据当前层级的 MenuNode 列表生成 popup 菜单项。每个菜单项会连接两个信号:

  • itemHovered
    :如果有子菜单,则打开下一级 popup;如果没有子菜单,则关闭已打开的子 popup。
  • itemClicked
    :只有叶子节点点击才触发 actionTriggered

这个逻辑避免了一个常见问题:鼠标从 Export 移动到兄弟项 Exit 时,Export 的子菜单必须立刻关闭,否则界面状态会残留。

9. showChildMenu

showChildMenu 负责打开下一级子菜单。它会先判断当前子菜单是否已经打开,避免重复创建;如果切换到了新的子菜单项,就先关闭旧子菜单,再创建新的 CustomMenuPopupWidget

子菜单的位置通过当前菜单项的全局坐标计算,默认从右侧弹出。这种实现方式可以自然支持多级菜单递归。

10. containsGlobalPosInTree

这是整个交互体验中很重要的函数。它判断鼠标是否仍然位于当前菜单树中。

除了判断鼠标是否在 popup 自身矩形内,还额外判断父菜单项和子 popup 之间的桥接区域。这样鼠标从父菜单移动到子菜单时,即使经过中间 1 到 2 像素的空隙,也不会马上关闭菜单。

传统菜单中这种细节很容易被忽略,但它直接影响用户感觉是否“顺滑”。

11. syncSubmenuForGlobalPos

这个函数用于同步当前鼠标所在位置对应的子菜单状态。

它解决了几个关键场景:

  • 鼠标进入有子菜单的项,打开对应子菜单。
  • 鼠标移动到兄弟菜单项,关闭旧子菜单。
  • 鼠标移动到叶子项,关闭原来打开的子菜单。
  • 鼠标进入更深层子菜单时,递归交给子菜单处理。

也就是说,popup 内部的多级菜单状态不是靠点击触发,而是靠鼠标位置持续同步。

12. rebuildMenus

rebuildMenus 负责生成顶级横向菜单。每一个顶级菜单都是 CustomMenuItemWidget

这里有一个重要设计:顶级菜单如果没有子菜单,悬浮只关闭其他兄弟菜单 popup,并显示自身 hover 高亮,不会触发回调。只有点击时才 emit actionTriggered

这个行为更符合用户直觉。因为顶级菜单中的叶子项本质上是一个 action,hover 只能表达“我当前指向它”,不能代表“我要执行它”。

13. showRootMenu

showRootMenu 负责打开一级 popup。它会先清理旧的 active 状态,再关闭旧 popup,然后把当前顶级菜单设置为 active,创建根 popup。

这里使用 QPointer 保存 popup,是为了避免 Qt 对象异步销毁后还访问悬空指针。对于 popup 这种频繁创建、关闭、deleteLater 的控件,QPointer 是比较稳妥的选择。

14. eventFilter

eventFilter 是顶级菜单交互的兜底机制。因为 popup 是独立窗口,鼠标在顶级菜单和 popup 之间移动时,不能只依赖单个控件的 enter/leave 事件。

这里在根 popup 打开后安装应用级事件过滤器,持续观察鼠标位置:

  • 如果鼠标移动到另一个有子菜单的顶级菜单,立即切换 popup。
  • 如果鼠标移动到没有子菜单的顶级菜单,关闭旧 popup,并显示当前菜单 hover。
  • 如果鼠标还在当前菜单链路或桥接区域内,不关闭。
  • 如果鼠标完全离开所有顶级菜单和 popup 链路,关闭整棵菜单树。

这也是自定义菜单能做到接近原生菜单体验的关键。

五、工程化价值

这个插件和直接在 MainWindow 里写菜单代码最大的区别,是职责边界清晰:

  • MainWindow3
     只负责准备菜单数据和接收回调。
  • MenuPluginCustomHorizontalWidget
     负责顶级菜单管理。
  • CustomMenuPopupWidget
     负责 popup 和多级子菜单。
  • CustomMenuItemWidget
     负责单个菜单项绘制和鼠标状态。
  • MenuNode
     负责菜单树数据结构。

这样后续如果要加权限过滤、图标、快捷键、禁用态、分割线、异步菜单加载,都可以继续在插件内部扩展,而不会污染业务窗口代码。

七、总结

这套实现没有依赖 QMenuBar 和 QMenu,而是完整接管了菜单数据解析、菜单项绘制、popup 创建、多级子菜单、hover 同步、关闭判断和 action 回调。

它的核心优势是灵活、可控、工程化程度高。对于普通菜单,Qt 原生控件足够;但如果项目追求更强的 UI 定制能力、更稳定的圆角透明效果、更精细的鼠标交互体验,那么完全自定义菜单插件是一个值得采用的方案。

最终,业务侧只需要传入多维菜单数据:

m_menuWidget->setMenus({    {QStringLiteral("文件"), {        {QStringLiteral("New Project")},        {QStringLiteral("Export"), {            {QStringLiteral("Export as PNG")},            {QStringLiteral("Export as PDF")}        }}    }},    {QStringLiteral("更多>>")}});

然后监听统一信号:

connect(m_menuWidget, &MenuPluginCustomHorizontalWidget::actionTriggered,        this, &MainWindow3::handleAction);

这样菜单插件就可以像一个独立组件一样被复用到不同窗口和不同项目中。

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

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

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