Qt 竖向多级菜单插件封装:QPushButton + QMenu 的工程化实践

在前面的横向菜单实现中,我使用了 QMenuBar + QMenu 来完成顶部横向菜单。但在很多桌面应用中,左侧竖向菜单也很常见,比如后台管理工具、配置工具、桌面客户端侧边栏等。
这次我封装了一个新的竖向菜单插件:MenuPluginVerticalWidget。
它的核心目标是:
-
一级菜单使用 QPushButton 竖向排列。 -
二级及更深层级菜单使用 QMenu 实现。 -
菜单数据通过结构化数据传入。 -
插件内部递归解析菜单数据。 -
外部只关心点击结果,不关心菜单如何生成。 -
点击一级按钮后,按钮保持激活色;对应菜单关闭后恢复默认色。

菜单数据结构
插件内部复用了一个简单的菜单节点结构:
struct MenuNode{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;};
这个结构非常直观:
- text
表示菜单显示文字。 - children
表示子菜单。 - children
为空时,表示这是一个可点击菜单项。 - children
不为空时,表示这是一个菜单分组。
不过对外暴露时,我没有直接要求业务层传 MenuNode*,而是定义了一个更轻量的 MenuDef:
struct MenuDef {QString key;QList<MenuDef> values = {};};
{{QStringLiteral("File"), {{QStringLiteral("New Project")},{QStringLiteral("Open Project")},{QStringLiteral("Export"), {{QStringLiteral("PNG")},{QStringLiteral("PDF")}}}}}}
插件类设计
竖向菜单插件类定义如下:
#pragma once#include<QHash>#include<QWidget>#include"menunode.h"class QEvent;class QMenu;class QPushButton;class QVBoxLayout;class MenuPluginVerticalWidget : public QWidget{Q_OBJECTpublic:struct MenuDef {QString key;QList<MenuDef> values = {};};explicitMenuPluginVerticalWidget(QWidget *parent = nullptr);~MenuPluginVerticalWidget() override;voidsetMenus(const QList<MenuDef> &menus);signals:voidactionTriggered(const QString &path);private:QList<MenuNode *> buildMenusFromDef(const QList<MenuDef> &defs)const;QPushButton *createMenuButton(MenuNode *node);QMenu *createMenu(const QString &text, QWidget *parent)const;voidconfigurePopupMenu(QMenu *menu)const;voidrebuildMenus();voidclearMenuData();voidclearLayout();voidsetButtonMenuActive(QPushButton *button, bool active);voidbuildMenuLevel(QMenu *parentMenu, const QList<MenuNode *> &items, const QStringList &parentPath);QList<MenuNode *> m_menus;QHash<QObject *, QMenu *> m_buttonMenus;QHash<QMenu *, QPushButton *> m_menuButtons;QVBoxLayout *m_layout;};
#include"menupluginverticalwidget.h"#include<QAction>#include<QLayoutItem>#include<QMenu>#include<QPoint>#include<QPushButton>#include<QStyle>#include<QStyleFactory>#include<QVBoxLayout>namespace{QString joinPath(const QStringList &parts){return parts.join(QStringLiteral(" / "));}}MenuPluginVerticalWidget::MenuPluginVerticalWidget(QWidget *parent): QWidget(parent), m_layout(new QVBoxLayout(this)){m_layout->setContentsMargins(0, 0, 0, 0);m_layout->setSpacing(8);m_layout->addStretch();setStyleSheet(QStringLiteral(R"(QPushButton#verticalMenuButton {background: #ffffff;border: 1px solid #dbe2ea;border-radius: 10px;color: #334155;font-size: 14px;font-weight: 600;min-height: 38px;padding: 8px 14px;text-align: left;}QPushButton#verticalMenuButton:hover {background: #eef2f7;color: #0f172a;}QPushButton#verticalMenuButton:pressed {background: #dbeafe;color: #1d4ed8;}QPushButton#verticalMenuButton[menuActive="true"] {background: #dbeafe;color: #1d4ed8;}QMenu {background-color: #ffffff;border: 1px solid #e2e8f0;border-radius: 8px;padding: 6px;}QMenu::item {background: transparent;color: #334155;padding: 8px 36px 8px 12px;border-radius: 6px;}QMenu::item:selected {background-color: #eff6ff;color: #1d4ed8;}QMenu::separator {height: 1px;background: #e2e8f0;margin: 6px 10px;})"));}MenuPluginVerticalWidget::~MenuPluginVerticalWidget(){clearMenuData();}voidMenuPluginVerticalWidget::setMenus(const QList<MenuDef> &menus){clearMenuData();m_menus = buildMenusFromDef(menus);rebuildMenus();}QList<MenuNode *> MenuPluginVerticalWidget::buildMenusFromDef(const QList<MenuDef> &defs)const{QList<MenuNode *> nodes;for (const MenuDef &def : defs) {nodes.append(new MenuNode(def.key, buildMenusFromDef(def.values)));}return nodes;}QPushButton *MenuPluginVerticalWidget::createMenuButton(MenuNode *node){auto *button = new QPushButton(node->text, this);button->setObjectName(QStringLiteral("verticalMenuButton"));button->setCursor(Qt::PointingHandCursor);button->setFocusPolicy(Qt::NoFocus);button->setCheckable(false);button->setAutoDefault(false);button->setDefault(false);button->setProperty("menuActive", false);const QStringList rootPath{node->text};if (node->children.isEmpty()) {//没有子菜单,直接触发 actionconnect(button, &QPushButton::clicked, this, [this, rootPath]() {emit actionTriggered(joinPath(rootPath));});return button;}//创建菜单按钮auto *menu = createMenu(node->text, button);// 创建菜单项并连接信号buildMenuLevel(menu, node->children, rootPath);// 保存菜单和按钮的关联关系m_buttonMenus.insert(button, menu);// 保存菜单和按钮的关联关系m_menuButtons.insert(menu, button);// 菜单按钮点击时,显示菜单connect(button, &QPushButton::clicked, this, [this, button, menu]() {for (QPushButton *activeButton : m_menuButtons) {setButtonMenuActive(activeButton, false);}setButtonMenuActive(button, true);menu->popup(button->mapToGlobal(QPoint(button->width(), 0)));});// 菜单隐藏时取消菜单激活状态connect(menu, &QMenu::aboutToHide, this, [this, button]() {setButtonMenuActive(button, false);});return button;}QMenu *MenuPluginVerticalWidget::createMenu(const QString &text, QWidget *parent)const{auto *menu = new QMenu(text, parent);configurePopupMenu(menu);return menu;}voidMenuPluginVerticalWidget::configurePopupMenu(QMenu *menu)const{if (menu == nullptr) {return;}menu->setAttribute(Qt::WA_TranslucentBackground, true);menu->setWindowFlag(Qt::FramelessWindowHint, true);menu->setWindowFlag(Qt::NoDropShadowWindowHint, true);menu->setStyle(QStyleFactory::create(QStringLiteral("Fusion")));}voidMenuPluginVerticalWidget::rebuildMenus(){clearLayout();for (MenuNode *node : m_menus) {if (node == nullptr || node->text.isEmpty()) {continue;}m_layout->addWidget(createMenuButton(node));}m_layout->addStretch();}voidMenuPluginVerticalWidget::clearMenuData(){clearLayout();m_buttonMenus.clear();m_menuButtons.clear();qDeleteAll(m_menus);m_menus.clear();}voidMenuPluginVerticalWidget::clearLayout(){m_buttonMenus.clear();m_menuButtons.clear();while (QLayoutItem *item = m_layout->takeAt(0)) {if (QWidget *widget = item->widget()) {delete widget;}delete item;}}voidMenuPluginVerticalWidget::setButtonMenuActive(QPushButton *button, bool active){if (button == nullptr) {return;}button->setProperty("menuActive", active);button->style()->unpolish(button);// 先取消样式,让属性变化生效button->style()->polish(button);// 再重新应用样式button->update(); // 最后更新界面}voidMenuPluginVerticalWidget::buildMenuLevel(QMenu *parentMenu, const QList<MenuNode *> &items, const QStringList &parentPath){for (MenuNode *node : items) {if (node == nullptr || node->text.isEmpty()) {continue;}const QStringList currentPath = parentPath + QStringList{node->text};if (!node->children.isEmpty()) {auto *subMenu = createMenu(node->text, parentMenu);parentMenu->addMenu(subMenu);buildMenuLevel(subMenu, node->children, currentPath);continue;}auto *action = parentMenu->addAction(node->text);connect(action, &QAction::triggered, this, [this, currentPath]() {emit actionTriggered(joinPath(currentPath));});}}
这里的职责划分比较清晰:
- setMenus()
:外部入口,接收菜单数据。 - buildMenusFromDef()
:把外部数据转换成内部菜单节点。 - createMenuButton()
:创建一级竖向按钮。 - buildMenuLevel()
:递归创建多级 QMenu。 - actionTriggered()
:对外通知菜单点击路径。 - setButtonMenuActive()
:控制一级按钮激活状态。
一级菜单:QPushButton
竖向菜单的一级入口使用 QPushButton:
QPushButton *MenuPluginVerticalWidget::createMenuButton(MenuNode *node){auto *button = new QPushButton(node->text, this);button->setObjectName(QStringLiteral("verticalMenuButton"));button->setCursor(Qt::PointingHandCursor);button->setFocusPolicy(Qt::NoFocus);button->setCheckable(false);button->setAutoDefault(false);button->setDefault(false);button->setProperty("menuActive", false);const QStringList rootPath{node->text};if (node->children.isEmpty()) {connect(button, &QPushButton::clicked, this, [this, rootPath]() {emit actionTriggered(joinPath(rootPath));});return button;}auto *menu = createMenu(node->text, button);buildMenuLevel(menu, node->children, rootPath);m_buttonMenus.insert(button, menu);m_menuButtons.insert(menu, button);connect(button, &QPushButton::clicked, this, [this, button, menu]() {for (QPushButton *activeButton : m_menuButtons) {setButtonMenuActive(activeButton, false);}setButtonMenuActive(button, true);menu->popup(button->mapToGlobal(QPoint(button->width(), 0)));});connect(menu, &QMenu::aboutToHide, this, [this, button]() {setButtonMenuActive(button, false);});return button;}
这里分两种情况:
如果一级菜单没有子菜单,点击按钮后直接触发:
emit actionTriggered(joinPath(rootPath));
如果一级菜单有子菜单,则创建一个 QMenu,点击按钮后在按钮右侧弹出。
多级菜单递归生成
子菜单通过递归生成:
voidMenuPluginVerticalWidget::buildMenuLevel(QMenu *parentMenu,const QList<MenuNode *> &items,const QStringList &parentPath){for (MenuNode *node : items) {if (node == nullptr || node->text.isEmpty()) {continue;}const QStringList currentPath = parentPath + QStringList{node->text};if (!node->children.isEmpty()) {auto *subMenu = createMenu(node->text, parentMenu);parentMenu->addMenu(subMenu);buildMenuLevel(subMenu, node->children, currentPath);continue;}auto *action = parentMenu->addAction(node->text);connect(action, &QAction::triggered, this, [this, currentPath]() {emit actionTriggered(joinPath(currentPath));});}}
核心规则依然很简单:
有 children:创建 QMenu无 children:创建 QAction
菜单点击后会返回完整路径,例如:
File / Export / More Formats / JSON Data
这样业务层可以通过路径判断用户点击了哪个菜单项。
按钮激活状态
竖向菜单有一个细节:当点击一级按钮并打开二级菜单时,按钮应该保持高亮状态。否则用户很难判断当前打开的是哪个一级菜单。
但是 QPushButton:pressed 是瞬时状态,鼠标松开后就会恢复,不能满足需求。
所以这里使用动态属性:
button->setProperty("menuActive", active);
打开菜单时:
setButtonMenuActive(button, true);
菜单关闭时:
setButtonMenuActive(button, false);
具体实现:
voidMenuPluginVerticalWidget::setButtonMenuActive(QPushButton *button, bool active){if (button == nullptr) {return;}button->setProperty("menuActive", active);button->style()->unpolish(button);// 先取消样式,让属性变化生效button->style()->polish(button);// 再重新应用样式button->update(); // 最后更新界面}
QSS 中通过属性选择器控制样式:
QPushButton#verticalMenuButton[menuActive="true"] {background: #dbeafe;color: #1d4ed8;}
这样按钮高亮状态就不再依赖鼠标按压,而是由菜单是否打开决定。
样式设计
一级按钮样式:
QPushButton#verticalMenuButton {background: #ffffff;border: 1px solid #dbe2ea;border-radius: 10px;color: #334155;font-size: 14px;font-weight: 600;min-height: 38px;padding: 8px 14px;text-align: left;}QPushButton#verticalMenuButton:hover {background: #eef2f7;color: #0f172a;}QPushButton#verticalMenuButton:pressed {background: #dbeafe;color: #1d4ed8;}QPushButton#verticalMenuButton[menuActive="true"] {background: #dbeafe;color: #1d4ed8;}
菜单弹窗样式:
QMenu {background-color: #ffffff;border: 1px solid #e2e8f0;border-radius: 8px;padding: 6px;}QMenu::item {background: transparent;color: #334155;padding: 8px 36px 8px 12px;border-radius: 6px;}QMenu::item:selected {background-color: #eff6ff;color: #1d4ed8;}QMenu::separator {height: 1px;background: #e2e8f0;margin: 6px 10px;}
样式和行为是分开的:
-
QSS 负责视觉表现。 -
C++ 负责菜单树构建和状态切换。
最后是使用示例:MainWindow
demo代码如下:
#pragma once#include<QMainWindow>class QLabel;class MenuPluginVerticalWidget;class MainWindow2 : public QMainWindow{public:explicitMainWindow2(QWidget *parent = nullptr);private:voidbuildUi();voidhandleAction(const QString &path);static QString buildStyleSheet();QLabel *m_currentPathLabel;QLabel *m_exampleLabel;MenuPluginVerticalWidget *m_menuWidget;};
#include"mainwindow.h"#include"menupluginverticalwidget.h"#include<QFrame>#include<QHBoxLayout>#include<QLabel>#include<QStatusBar>#include<QVBoxLayout>#include<QWidget>MainWindow2::MainWindow2(QWidget *parent): QMainWindow(parent), m_currentPathLabel(nullptr), m_exampleLabel(nullptr), m_menuWidget(nullptr){buildUi();setStyleSheet(buildStyleSheet());statusBar()->showMessage(QStringLiteral(" 垂直菜单插件已就绪"), 3000);}voidMainWindow2::buildUi(){resize(1080, 640);setWindowTitle(QStringLiteral("Qt Vertical Menu Plugin Demo"));auto *centralWidget = new QWidget(this);auto *rootLayout = new QHBoxLayout(centralWidget);rootLayout->setContentsMargins(28, 20, 28, 28);rootLayout->setSpacing(18);auto *sidePanel = new QFrame(centralWidget);sidePanel->setObjectName(QStringLiteral("sidePanel"));auto *sideLayout = new QVBoxLayout(sidePanel);sideLayout->setContentsMargins(16, 16, 16, 16);sideLayout->setSpacing(14);auto *titleLabel = new QLabel(QStringLiteral("Vertical Menu"), sidePanel);titleLabel->setObjectName(QStringLiteral("sideTitle"));m_menuWidget = new MenuPluginVerticalWidget(sidePanel);m_menuWidget->setMenus({{QStringLiteral("File"), {{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("Edit"), {{QStringLiteral("Undo")},{QStringLiteral("Redo")},{QStringLiteral("Preferences"), {{QStringLiteral("Theme"), {{QStringLiteral("Light")},{QStringLiteral("Dark")}}},{QStringLiteral("Language"), {{QStringLiteral("Chinese")},{QStringLiteral("English")}}}}}}},{QStringLiteral("View"), {{QStringLiteral("Refresh View")},{QStringLiteral("Panels"), {{QStringLiteral("Assets Panel")},{QStringLiteral("Properties Panel")},{QStringLiteral("Developer Tools"), {{QStringLiteral("Log Console")},{QStringLiteral("Network Requests")}}}}}}},{QStringLiteral("Help"), {{QStringLiteral("Documentation")},{QStringLiteral("Shortcuts")},{QStringLiteral("About"), {{QStringLiteral("Version")},{QStringLiteral("License")}}}}}});connect(m_menuWidget, &MenuPluginVerticalWidget::actionTriggered, this, &MainWindow2::handleAction);sideLayout->addWidget(titleLabel);sideLayout->addWidget(m_menuWidget, 1);auto *contentCard = new QFrame(centralWidget);contentCard->setObjectName(QStringLiteral("contentCard"));auto *contentLayout = new QVBoxLayout(contentCard);contentLayout->setContentsMargins(28, 24, 28, 24);contentLayout->setSpacing(14);auto *sectionTitle = new QLabel(QStringLiteral("垂直菜单交互预览"), contentCard);sectionTitle->setObjectName(QStringLiteral("sectionTitle"));auto *introLabel = new QLabel(QStringLiteral("这个菜单插件使用QPushButton作为第一级菜单,每个按钮打开一个QMenu,嵌套菜单是递归构建的。"),contentCard);introLabel->setObjectName(QStringLiteral("mutedLabel"));introLabel->setWordWrap(true);m_currentPathLabel = new QLabel(QStringLiteral("当前操作:选择垂直菜单项"), contentCard);m_currentPathLabel->setObjectName(QStringLiteral("pathLabel"));m_currentPathLabel->setWordWrap(true);m_exampleLabel = new QLabel(QStringLiteral("示例路径: 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(sidePanel);rootLayout->addWidget(contentCard, 1);setCentralWidget(centralWidget);}voidMainWindow2::handleAction(const QString &path){m_currentPathLabel->setText(QStringLiteral("当前触发: %1").arg(path));m_exampleLabel->setText(QStringLiteral("最后触发: %1").arg(path));statusBar()->showMessage(QStringLiteral("已触发: %1").arg(path), 2500);}QString MainWindow2::buildStyleSheet(){return QStringLiteral(R"(QMainWindow {background: #f4f7fb;}QFrame#sidePanel,QFrame#contentCard {background: #ffffff;border: 1px solid #e2e8f0;border-radius: 18px;}QFrame#sidePanel {min-width: 220px;max-width: 280px;}QLabel#sideTitle {color: #0f172a;font-size: 20px;font-weight: 700;padding: 2px 4px 8px 4px;}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;})");}
总结
MenuPluginVerticalWidget 解决的是一个常见场景:左侧竖向一级菜单 + 右侧弹出多级菜单。
它的核心特点是:
-
使用 QPushButton 实现一级竖向菜单。 -
使用 QMenu 实现二级和多级菜单。 -
菜单数据通过嵌套结构传入。 -
插件内部递归生成菜单。 -
点击叶子菜单时向外发送完整路径。 -
一级按钮在菜单打开期间保持激活色。 -
菜单关闭后按钮恢复默认状态。
相比直接在 MainWindow 中手写菜单,这种方式更适合工程化使用。以后如果菜单来自配置文件、接口、数据库,只需要构造对应的 MenuDef 数据,然后传给插件即可,界面层不需要关心菜单是几级、如何递归生成。

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