乐于分享
好东西不私藏

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

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")}        }}    }}}
插件内部再把 MenuDef 转换成 MenuNode

插件类设计

竖向菜单插件类定义如下:

#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(0000);    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()) {//没有子菜单,直接触发 action        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;}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;            border1px solid #dbe2ea;            border-radius10px;            color#334155;            font-size14px;            font-weight600;            min-height38px;            padding8px 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;            border1px solid #e2e8f0;            border-radius8px;            padding6px;        }        QMenu::item {            background: transparent;            color#334155;            padding8px 36px 8px 12px;            border-radius6px;        }        QMenu::item:selected {            background-color#eff6ff;            color#1d4ed8;        }        QMenu::separator {            height1px;            background#e2e8f0;            margin6px 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(1080640);    setWindowTitle(QStringLiteral("Qt Vertical Menu Plugin Demo"));    auto *centralWidget = new QWidget(this);    auto *rootLayout = new QHBoxLayout(centralWidget);    rootLayout->setContentsMargins(28202828);    rootLayout->setSpacing(18);    auto *sidePanel = new QFrame(centralWidget);    sidePanel->setObjectName(QStringLiteral("sidePanel"));    auto *sideLayout = new QVBoxLayout(sidePanel);    sideLayout->setContentsMargins(16161616);    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(28242824);    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 数据,然后传给插件即可,界面层不需要关心菜单是几级、如何递归生成。

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

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

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