乐于分享
好东西不私藏

Qt 自定义树形菜单插件:后台管理侧边栏菜单的 QWidget 实现

Qt 自定义树形菜单插件:后台管理侧边栏菜单的 QWidget 实现

在后台管理类软件中,左侧树形菜单是非常常见的交互形式。它通常具备几个特点:一级菜单纵向排列,父级菜单可以展开或收起,子菜单按层级缩进显示,菜单内容超过容器高度时出现滚动条,点击叶子菜单后再进入具体页面。

Qt 本身有 QTreeWidgetQTreeViewQListView 等控件,但如果我们希望菜单更接近后台管理系统的侧边栏样式,并且希望完全控制绘制、交互、圆角、高亮、滚动条、层级缩进和点击回调,那么直接基于 QWidget + QPainter + QScrollArea 自定义一个树形菜单插件会更灵活。

本文实现的插件叫 MenuPluginTreeWidget,它是一个独立的树形菜单组件。外部只需要传入多维菜单数据,插件内部递归解析数据,并按展开状态动态生成可见菜单项。父节点点击时展开或收起,叶子节点点击时发出完整路径回调。

效果如下:

一、插件目标

这个插件主要解决以下问题:

  • 支持理论无限级菜单,只要数据结构可以嵌套,插件就可以递归渲染。
  • 父级菜单有子节点时,在菜单项前方绘制三角形箭头。
  • 点击父级菜单时向下展开,再次点击时收起。
  • 如果父级菜单收起,它下面所有已展开的后代菜单都会一起收起。
  • 菜单项高度始终固定,不会因为展开内容过多而被压缩。
  • 内容超过容器高度时,使用 QScrollArea 自动显示垂直滚动条。
  • 点击叶子菜单时发出完整菜单路径,方便业务层定位具体功能。
  • 菜单项自绘,视觉状态包括 hover、pressed、selected。
  • 插件和业务窗口解耦,可以在不同窗口中复用。

二、为什么不用 QTreeWidget

QTreeWidget 很适合做传统树控件,例如文件树、对象树、属性树。但后台管理侧边栏菜单并不完全等同于普通树控件。

普通树控件更偏数据展示,后台菜单更偏导航交互。它通常要求:

  • 菜单项高度固定,视觉更像 ListView。
  • 鼠标悬浮和选中状态有明显背景色。
  • 箭头、缩进、文字布局完全可控。
  • 滚动条样式和整体容器风格统一。
  • 只暴露业务需要的叶子菜单点击回调。
  • 父节点只负责展开/收起,不直接触发业务页面跳转。

因此这里没有直接套 QTreeWidget,而是把菜单项拆成自绘控件 TreeMenuItemWidget,再由 MenuPluginTreeWidget 管理整棵菜单树。

三、源码

下面直接贴出完整源码,方便复制到 Qt 工程中使用。

1. menuplugintreewidget.h

#pragma once#include<QList>#include<QPointer>#include<QString>#include<QWidget>class QEnterEvent;class QEvent;class QMouseEvent;class QPaintEvent;class QScrollArea;class QVBoxLayout;// 树菜单中的单行菜单项控件。// 负责绘制层级缩进、菜单文字、悬浮/按下/选中状态,以及有子节点时的展开箭头。class TreeMenuItemWidget : public QWidget{    Q_OBJECTpublic:    // 创建一行菜单项。    // depth 用于计算左侧缩进;hasChildren 用于判断是否绘制三角箭头。    explicitTreeMenuItemWidget(const QString &text, int depth, bool hasChildren, QWidget *parent = nullptr);    // 设置展开状态。    // 这里只影响箭头方向,真正的子菜单显示和隐藏由 MenuPluginTreeWidget 负责重建。    voidsetExpanded(bool expanded);    // 设置选中状态。    // 叶子菜单被点击后会保持选中高亮,表示当前激活菜单。    voidsetSelected(bool selected);    // 返回当前菜单项显示的文字。    QString text()const;signals:    // 鼠标左键在菜单项内部释放后触发。    voidclicked();protected:    // 鼠标进入菜单项时打开悬浮状态。    voidenterEvent(QEnterEvent *event)override;    // 鼠标离开菜单项时清理悬浮和按下状态。    voidleaveEvent(QEvent *event)override;    // 鼠标左键按下时打开按下状态。    voidmousePressEvent(QMouseEvent *event)override;    // 鼠标释放时判断是否为有效点击。    voidmouseReleaseEvent(QMouseEvent *event)override;    // 自绘菜单项背景、文字、缩进和箭头。    voidpaintEvent(QPaintEvent *event)override;    // 返回菜单项建议尺寸,保证高度固定,宽度随文字和层级变化。    QSize sizeHint()constoverride;private:    QString m_text;      // 菜单显示文字。    int m_depth;         // 当前菜单层级,顶级为 0。    bool m_hasChildren;  // 是否有子菜单。    bool m_expanded;     // 当前菜单项是否展开。    bool m_hovered;      // 鼠标是否悬浮在当前菜单项上。    bool m_pressed;      // 鼠标是否按下当前菜单项。    bool m_selected;     // 当前菜单项是否为选中的叶子菜单。};// 后台管理风格的递归树菜单插件。// 接收多维菜单数据,按展开状态渲染可见菜单项,内容超出容器高度时通过 QScrollArea 显示滚动条。class MenuPluginTreeWidget : public QWidget{    Q_OBJECTpublic:    // 对外传入的菜单数据结构。    // key 是菜单文字;values 是子菜单列表,可以递归嵌套任意层级。    struct MenuDef {        QString key;        QList<MenuDef> values = {};    };    // 初始化树菜单插件。    // 内部会创建滚动区域、内容容器、垂直布局和滚动条样式。    explicitMenuPluginTreeWidget(QWidget *parent = nullptr);    // 析构插件。    // 释放可见菜单项控件和内部菜单树数据。    ~MenuPluginTreeWidget() override;    // 设置菜单数据。    // 每次调用都会替换旧菜单树,并重新渲染当前可见菜单项。    voidsetMenus(const QList<MenuDef> &menus);signals:    // 点击叶子菜单时触发。    // path 是完整菜单路径,例如 "系统管理 / 用户中心 / 用户列表"。    voidactionTriggered(const QString &path);private:    // 插件内部使用的树节点结构。    // 只保存数据和展开状态,不承担界面绘制职责。    struct TreeNode {        QString text;        QList<TreeNode *> children;        TreeNode *parent = nullptr;        bool expanded = false;        // 递归释放所有子节点。        ~TreeNode();    };    // 将外部 MenuDef 数据递归转换为内部 TreeNode 数据。    QList<TreeNode *> buildNodes(const QList<MenuDef> &defs, TreeNode *parent)const;    // 根据当前树节点展开状态,重新生成所有可见菜单项。    voidrebuildVisibleItems();    // 延迟重建可见菜单项。    // 避免在 clicked 信号还没返回时同步删除当前 sender,防止频繁点击崩溃。    voidscheduleRebuildVisibleItems();    // 追加一个可见节点;如果该节点已展开,则继续递归追加它的子节点。    voidappendVisibleNode(TreeNode *node, int depth);    // 清空当前布局中的所有菜单项控件。    voidclearLayout();    // 清空可见菜单项和内部菜单树数据。    voidclearMenuData();    // 递归收起指定节点下的所有后代节点。    voidcollapseChildren(TreeNode *node);    // 根据节点的 parent 指针向上回溯,生成完整菜单路径。    QString buildPath(TreeNode *node)const;    // 处理菜单项点击。    // 父节点点击时展开/收起;叶子节点点击时选中并发出 actionTriggered。    voidhandleNodeClicked(TreeNode *node, TreeMenuItemWidget *itemWidget);    // 清除上一个选中菜单项的选中状态。    voidclearSelectedItem();    QList<TreeNode *> m_roots;                    // 完整菜单树的根节点列表。    QScrollArea *m_scrollArea;                    // 滚动区域,内容超过高度时显示滚动条。    QWidget *m_contentWidget;                     // 滚动区域内部的内容容器。    QVBoxLayout *m_contentLayout;                 // 承载所有可见菜单项的垂直布局。    QPointer<TreeMenuItemWidget> m_selectedItem;  // 当前选中的叶子菜单项,QPointer 可避免悬空访问。    bool m_rebuildPending;                        // 是否已有延迟重建任务,防止重复投递。};

2. menuplugintreewidget.cpp

#include"menuplugintreewidget.h"#include<QApplication>#include<QEnterEvent>#include<QEvent>#include<QFontMetrics>#include<QFrame>#include<QLayout>#include<QLayoutItem>#include<QMouseEvent>#include<QPainter>#include<QPainterPath>#include<QScrollArea>#include<QSizePolicy>#include<QStringList>#include<QTimer>#include<QtAlgorithms>#include<QVBoxLayout>// 创建一个自绘菜单项。// 菜单项高度固定为 42,展开更多节点时不会压缩已有菜单项高度。TreeMenuItemWidget::TreeMenuItemWidget(const QString &text, int depth, bool hasChildren, QWidget *parent)    : QWidget(parent)    , m_text(text)    , m_depth(depth)    , m_hasChildren(hasChildren)    , m_expanded(false)    , m_hovered(false)    , m_pressed(false)    , m_selected(false){    // 鼠标追踪和点击事件处理。    setMouseTracking(true);    // 设置鼠标手型    setCursor(Qt::PointingHandCursor);    // 菜单项不接收焦点,避免菜单项内文本框获取焦点导致菜单项高亮。    setFocusPolicy(Qt::NoFocus);    // 菜单项固定高度    setFixedHeight(42);    // 菜单项自绘,横向尽量拉伸占满空间,纵向保持固定高度    setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);}// 设置展开状态。// 该状态只用于绘制箭头方向,子节点是否显示由插件重建可见列表决定。voidTreeMenuItemWidget::setExpanded(bool expanded){    if (m_expanded == expanded) {        return;    }    m_expanded = expanded;    update();}// 设置选中状态。// 叶子菜单点击后保持选中,用于显示当前激活菜单。voidTreeMenuItemWidget::setSelected(bool selected){    if (m_selected == selected) {        return;    }    m_selected = selected;    update();}// 返回菜单项文字。QString TreeMenuItemWidget::text()const{    return m_text;}// 鼠标进入时开启悬浮状态并重绘。voidTreeMenuItemWidget::enterEvent(QEnterEvent *event){    QWidget::enterEvent(event);    m_hovered = true;    update();}// 鼠标离开时清理悬浮和按下状态,避免颜色残留。voidTreeMenuItemWidget::leaveEvent(QEvent *event){    QWidget::leaveEvent(event);    m_hovered = false;    m_pressed = false;    update();}// 鼠标左键按下时开启按下状态。voidTreeMenuItemWidget::mousePressEvent(QMouseEvent *event){    if (event->button() == Qt::LeftButton) {        m_pressed = true;        update();    }    QWidget::mousePressEvent(event);}// 鼠标释放时转换成 clicked 信号。// 如果按下后移出菜单项再释放,则不触发点击。voidTreeMenuItemWidget::mouseReleaseEvent(QMouseEvent *event){    const bool triggerClick = event->button() == Qt::LeftButton && rect().contains(event->pos());    m_pressed = false;    update();    // 点击信号    if (triggerClick) {        emit clicked();    }    QWidget::mouseReleaseEvent(event);}// 绘制菜单项完整界面。// 包括背景高亮、层级缩进、展开箭头和菜单文字。voidTreeMenuItemWidget::paintEvent(QPaintEvent *event){    Q_UNUSED(event);    QPainter painter(this);    painter.setRenderHint(QPainter::Antialiasing, true);    // 高亮背景保留左右边距,让视觉效果更接近后台侧边栏菜单。    const QRectF bgRect = rect().adjusted(8.03.0-8.0-3.0);    const bool highlighted = m_selected || m_pressed || m_hovered;    if (highlighted) {        const QColor bgColor = m_selected            ? QColor(QStringLiteral("#dbeafe"))            : (m_pressed ? QColor(QStringLiteral("#e0f2fe")) : QColor(QStringLiteral("#f1f5f9")));        QPainterPath bgPath;        bgPath.addRoundedRect(bgRect, 10.010.0);        painter.fillPath(bgPath, bgColor);    }    QFont menuFont = font();    menuFont.setPointSize(10);    menuFont.setWeight(m_selected ? QFont::DemiBold : QFont::Medium);    painter.setFont(menuFont);    const int baseIndent = 16;// 菜单项左侧缩进    const int depthIndent = 20// 菜单项层级缩进    const int arrowSize = 8// 菜单项箭头大小    const int arrowX = baseIndent + m_depth * depthIndent; // 菜单项箭头 X 坐标    const int centerY = rect().center().y();    const int textX = arrowX + 20;    const QColor textColor = m_selected ? QColor(QStringLiteral("#1d4ed8")) : QColor(QStringLiteral("#334155"));    // 只有存在子节点时才绘制三角形箭头。    // 展开状态箭头向下,收起状态箭头向右。    if (m_hasChildren) {        QPolygonF arrow;        if (m_expanded) {            arrow << QPointF(arrowX, centerY - 3)                  << QPointF(arrowX + arrowSize, centerY - 3)                  << QPointF(arrowX + arrowSize / 2.0, centerY + 4);        } else {            arrow << QPointF(arrowX + 2, centerY - 5)                  << QPointF(arrowX + 2, centerY + 5)                  << QPointF(arrowX + arrowSize, centerY);        }        painter.setBrush(QColor(QStringLiteral("#64748b")));        painter.setPen(Qt::NoPen);        painter.drawPolygon(arrow);    }    painter.setPen(textColor);    QFontMetrics fm(menuFont);    const QRect textRect(textX, 0, qMax(20, width() - textX - 14), height());    const QString text = fm.elidedText(m_text, Qt::ElideRight, textRect.width());    painter.drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft, text);}// 返回菜单项建议尺寸。// 高度固定,宽度根据文字长度和层级缩进计算。QSize TreeMenuItemWidget::sizeHint()const{    QFont menuFont = font();    menuFont.setPointSize(10);    menuFont.setWeight(QFont::Medium);    QFontMetrics fm(menuFont);    const int width = 52 + m_depth * 20 + fm.horizontalAdvance(m_text);    return QSize(qMax(220, width), 42);}// 递归释放所有子节点。MenuPluginTreeWidget::TreeNode::~TreeNode(){    qDeleteAll(children);    children.clear();}// 构建树菜单插件容器。// 使用 QScrollArea 提供类似 ListView 的滚动能力,同时保持每个菜单项固定高度。MenuPluginTreeWidget::MenuPluginTreeWidget(QWidget *parent)    : QWidget(parent)    , m_scrollArea(new QScrollArea(this))    , m_contentWidget(new QWidget(m_scrollArea))    , m_contentLayout(new QVBoxLayout(m_contentWidget)) //m_contentWidget->setLayout(m_contentLayout);    , m_rebuildPending(false){    // 根布局    auto *rootLayout = new QVBoxLayout(this);    rootLayout->setContentsMargins(0000);    rootLayout->setSpacing(0);    m_contentLayout->setContentsMargins(0808);    m_contentLayout->setSpacing(2);    // 强制内容容器按菜单项真实总高度计算尺寸,避免展开后压缩菜单项。    m_contentLayout->setSizeConstraint(QLayout::SetMinAndMaxSize);    m_contentLayout->addStretch();// 填充剩余空间,让菜单项靠上显示。    // contentWidget 是 QScrollArea 内部真正承载菜单项的容器。    // 显式设置透明,避免某些平台下显示默认黑色背景。    m_contentWidget->setObjectName(QStringLiteral("treeMenuContent"));    m_contentWidget->setAutoFillBackground(false);    m_contentWidget->setAttribute(Qt::WA_StyledBackground, true);    m_contentWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum);    m_scrollArea->setWidget(m_contentWidget);    m_scrollArea->setWidgetResizable(true);    m_scrollArea->setFrameShape(QFrame::NoFrame);    m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);    m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);    // QScrollArea 内部还有 viewport,也需要显式设置透明背景。    m_scrollArea->viewport()->setAutoFillBackground(false);    m_scrollArea->viewport()->setAttribute(Qt::WA_StyledBackground, true);    m_scrollArea->setStyleSheet(QStringLiteral(R"(        QScrollArea {            background: transparent;            border: none;        }        QScrollArea > QWidget,        QWidget#treeMenuContent {            background: transparent;            border: none;        }        QScrollBar:vertical {            width: 8px;            background: transparent;            margin: 8px 2px 8px 2px;        }        QScrollBar::handle:vertical {            background: #cbd5e1;            border-radius: 4px;            min-height: 28px;        }        QScrollBar::handle:vertical:hover {            background: #94a3b8;        }        QScrollBar::add-line:vertical,        QScrollBar::sub-line:vertical {            height: 0px;        }        QScrollBar::add-page:vertical,        QScrollBar::sub-page:vertical {            background: transparent;        }    )"));    rootLayout->addWidget(m_scrollArea);    setAutoFillBackground(false); // 避免某些平台下显示默认黑色背景。}// 析构时释放菜单树数据和当前可见菜单项。MenuPluginTreeWidget::~MenuPluginTreeWidget(){    clearMenuData();}// 设置外部菜单数据。// 先清理旧数据,再递归构建内部树节点,最后渲染当前可见菜单项。voidMenuPluginTreeWidget::setMenus(const QList<MenuDef> &menus){    clearMenuData();    m_roots = buildNodes(menus, nullptr);    rebuildVisibleItems();}// 将外部 MenuDef 数据递归转换成内部 TreeNode 数据。// parent 指针用于叶子节点点击时向上回溯完整路径。QList<MenuPluginTreeWidget::TreeNode *> MenuPluginTreeWidget::buildNodes(const QList<MenuDef> &defs, TreeNode *parent)const{    QList<TreeNode *> nodes;    for (const MenuDef &def : defs) {        if (def.key.isEmpty()) {            continue;        }        auto *node = new TreeNode;        node->text = def.key;        node->parent = parent;        node->children = buildNodes(def.values, node);        nodes.append(node);    }    return nodes;}// 根据当前 expanded 状态重建可见菜单项。// 未展开的子树不会创建 QWidget,只有当前可见节点会被渲染。voidMenuPluginTreeWidget::rebuildVisibleItems(){    m_rebuildPending = false;    clearLayout();    for (TreeNode *node : m_roots) {        appendVisibleNode(node, 0);    }    m_contentLayout->addStretch();}// 将重建任务延迟到下一轮事件循环。// 这样可以避免在 clicked 信号派发过程中删除当前被点击的菜单项。voidMenuPluginTreeWidget::scheduleRebuildVisibleItems(){    if (m_rebuildPending) {        return;    }    m_rebuildPending = true;    QTimer::singleShot(0this, [this]() {        rebuildVisibleItems();    });}// 追加一个可见节点。// 如果该节点已经展开,则递归追加它的子节点,depth + 1 用于增加缩进。voidMenuPluginTreeWidget::appendVisibleNode(TreeNode *node, int depth){    if (node == nullptr) {        return;    }    auto *itemWidget = new TreeMenuItemWidget(node->text, depth, !node->children.isEmpty(), m_contentWidget);    itemWidget->setExpanded(node->expanded);    m_contentLayout->addWidget(itemWidget);    // 菜单项只负责发出 clicked,具体展开/收起或回调由插件统一处理。    connect(itemWidget, &TreeMenuItemWidget::clicked, this, [this, node, itemWidget]() {        handleNodeClicked(node, itemWidget);    });    if (!node->expanded) {        return;    }    for (TreeNode *child : node->children) {        appendVisibleNode(child, depth + 1);    }}// 清空当前布局中的菜单项控件。// 使用 deleteLater,而不是直接 delete,避免事件仍在派发时销毁控件导致崩溃。voidMenuPluginTreeWidget::clearLayout(){    clearSelectedItem();    while (QLayoutItem *item = m_contentLayout->takeAt(0)) {        if (QWidget *widget = item->widget()) {            widget->hide();            widget->deleteLater();        }        delete item;    }}// 清空可见菜单项和内部菜单树数据。voidMenuPluginTreeWidget::clearMenuData(){    clearLayout();    qDeleteAll(m_roots);    m_roots.clear();}// 递归收起指定节点的所有后代节点。// 父节点收起后,下面所有已展开子节点都要同步复位。voidMenuPluginTreeWidget::collapseChildren(TreeNode *node){    if (node == nullptr) {        return;    }    for (TreeNode *child : node->children) {        child->expanded = false;        collapseChildren(child);    }}// 通过 parent 指针向上回溯,生成完整菜单路径。QString MenuPluginTreeWidget::buildPath(TreeNode *node)const{    QStringList parts;    for (TreeNode *current = node; current != nullptr; current = current->parent) {        parts.prepend(current->text);    }    return parts.join(QStringLiteral(" / "));}// 处理菜单项点击。// 父节点点击时展开或收起;叶子节点点击时选中并发出 actionTriggered。voidMenuPluginTreeWidget::handleNodeClicked(TreeNode *node, TreeMenuItemWidget *itemWidget){    if (node == nullptr) {        return;    }    // 有子节点时展开或收起    if (!node->children.isEmpty()) {        if (node->expanded) {            node->expanded = false;            // 父节点收起时重置后代展开状态,避免再次展开时残留旧状态。            collapseChildren(node);        } else {            node->expanded = true;        }        scheduleRebuildVisibleItems();        return;    }    //没有子节点的时候,选中该节点并发出 actionTriggered 信号,携带完整菜单路径。    clearSelectedItem();    if (itemWidget != nullptr) {        itemWidget->setSelected(true);        m_selectedItem = itemWidget;    }    emit actionTriggered(buildPath(node));}// 清除当前选中菜单项。// m_selectedItem 是 QPointer,如果控件已被 deleteLater 释放,会自动变为空指针。voidMenuPluginTreeWidget::clearSelectedItem(){    if (m_selectedItem != nullptr) {        m_selectedItem->setSelected(false);        m_selectedItem.clear();    }}

3. mainwindow4.h

#pragma once#include<QMainWindow>class QLabel;class MenuPluginTreeWidget;class MainWindow4 : public QMainWindow{public:    explicitMainWindow4(QWidget *parent = nullptr);private:    voidbuildUi();    voidhandleAction(const QString &path);    static QString buildStyleSheet();    QLabel *m_titleLabel;    QLabel *m_currentPathLabel;    MenuPluginTreeWidget *m_treeMenu;};

4. mainwindow4.cpp

#include"mainwindow4.h"#include"menuplugintreewidget.h"#include<QFrame>#include<QHBoxLayout>#include<QLabel>#include<QStatusBar>#include<QVBoxLayout>#include<QWidget>MainWindow4::MainWindow4(QWidget *parent)    : QMainWindow(parent)    , m_titleLabel(nullptr)    , m_currentPathLabel(nullptr)    , m_treeMenu(nullptr){    buildUi();    setStyleSheet(buildStyleSheet());    statusBar()->showMessage(QStringLiteral("树形菜单演示已准备就绪"), 3000);}voidMainWindow4::buildUi(){    resize(1120720);    setWindowTitle(QStringLiteral("树形菜单演示"));    auto *centralWidget = new QWidget(this);    auto *rootLayout = new QHBoxLayout(centralWidget);    rootLayout->setContentsMargins(22222222);    rootLayout->setSpacing(18);    auto *sideBar = new QFrame(centralWidget);    sideBar->setObjectName(QStringLiteral("sideBar"));    sideBar->setFixedWidth(286);    auto *sideLayout = new QVBoxLayout(sideBar);    sideLayout->setContentsMargins(12141214);    sideLayout->setSpacing(12);    auto *logoLabel = new QLabel(QStringLiteral("树形菜单容器"), sideBar);    logoLabel->setObjectName(QStringLiteral("logoLabel"));    auto *descLabel = new QLabel(QStringLiteral("自定义递归树菜单"), sideBar);    descLabel->setObjectName(QStringLiteral("descLabel"));    m_treeMenu = new MenuPluginTreeWidget(sideBar);    m_treeMenu->setMenus({        {QStringLiteral("Dashboard"), {            {QStringLiteral("Overview")},            {QStringLiteral("Workplace")},            {QStringLiteral("Analysis")}        }},        {QStringLiteral("System Management"), {            {QStringLiteral("User Center"), {                {QStringLiteral("User List")},                {QStringLiteral("Role List")},                {QStringLiteral("Permission Matrix"), {                    {QStringLiteral("Menu Permission")},                    {QStringLiteral("Button Permission")},                    {QStringLiteral("Data Permission")}                }}            }},            {QStringLiteral("Department")},            {QStringLiteral("Post Management")},            {QStringLiteral("Dictionary"), {                {QStringLiteral("Dict Type")},                {QStringLiteral("Dict Data")}            }}        }},        {QStringLiteral("Content Center"), {            {QStringLiteral("Article"), {                {QStringLiteral("Article List")},                {QStringLiteral("Category")},                {QStringLiteral("Tags")}            }},            {QStringLiteral("Banner")},            {QStringLiteral("Comments")}        }},        {QStringLiteral("Operations"), {            {QStringLiteral("Order Management"), {                {QStringLiteral("All Orders")},                {QStringLiteral("Refund Orders")},                {QStringLiteral("Invoice"), {                    {QStringLiteral("Invoice List")},                    {QStringLiteral("Invoice Settings")}                }}            }},            {QStringLiteral("Member Center"), {                {QStringLiteral("Member List")},                {QStringLiteral("Level Rules")},                {QStringLiteral("Growth Value")}            }},            {QStringLiteral("Marketing"), {                {QStringLiteral("Coupon")},                {QStringLiteral("Campaign")},                {QStringLiteral("Push Task")}            }}        }},        {QStringLiteral("Monitor"), {            {QStringLiteral("Online Users")},            {QStringLiteral("Server Status")},            {QStringLiteral("Job Log")},            {QStringLiteral("Login Log")},            {QStringLiteral("Operation Log")}        }},        {QStringLiteral("Settings"), {            {QStringLiteral("Basic Settings")},            {QStringLiteral("Security Settings")},            {QStringLiteral("Notification Settings")}        }}    });    connect(m_treeMenu, &MenuPluginTreeWidget::actionTriggered, this, &MainWindow4::handleAction);    sideLayout->addWidget(logoLabel);    sideLayout->addWidget(descLabel);    sideLayout->addWidget(m_treeMenu, 1);    auto *contentCard = new QFrame(centralWidget);    contentCard->setObjectName(QStringLiteral("contentCard"));    auto *contentLayout = new QVBoxLayout(contentCard);    contentLayout->setContentsMargins(34303430);    contentLayout->setSpacing(18);    m_titleLabel = new QLabel(QStringLiteral("Tree Menu Plugin"), contentCard);    m_titleLabel->setObjectName(QStringLiteral("titleLabel"));    auto *introLabel = new QLabel(        QStringLiteral("此演示使用了一个基于自定义 QWidget 的树形菜单。父项在点击时展开或折叠,而叶项会发出完整的菜单路径。"),        contentCard);    introLabel->setObjectName(QStringLiteral("introLabel"));    introLabel->setWordWrap(true);    m_currentPathLabel = new QLabel(QStringLiteral("当前操作:选择一个菜单项(叶子菜单项)"), contentCard);    m_currentPathLabel->setObjectName(QStringLiteral("pathLabel"));    m_currentPathLabel->setWordWrap(true);    auto *hintLabel = new QLabel(        QStringLiteral("提示:展开多个层级,然后再次点击顶部父项。该父项下的所有后代都将折叠。"),        contentCard);    hintLabel->setObjectName(QStringLiteral("hintLabel"));    hintLabel->setWordWrap(true);    contentLayout->addWidget(m_titleLabel);    contentLayout->addWidget(introLabel);    contentLayout->addWidget(m_currentPathLabel);    contentLayout->addWidget(hintLabel);    contentLayout->addStretch();    rootLayout->addWidget(sideBar);    rootLayout->addWidget(contentCard, 1);    setCentralWidget(centralWidget);}voidMainWindow4::handleAction(const QString &path){    m_currentPathLabel->setText(QStringLiteral("当前操作: %1").arg(path));    statusBar()->showMessage(QStringLiteral("触发: %1").arg(path), 2600);}QString MainWindow4::buildStyleSheet(){    return QStringLiteral(R"(        QMainWindow {            background: #edf2f7;        }        QFrame#sideBar {            background: #ffffff;            border: 1px solid #dbe3ee;            border-radius: 18px;        }        QFrame#contentCard {            background: #ffffff;            border: 1px solid #dbe3ee;            border-radius: 22px;        }        QLabel#logoLabel {            color: #0f172a;            font-size: 22px;            font-weight: 800;            padding: 6px 8px 0px 8px;        }        QLabel#descLabel {            color: #64748b;            font-size: 13px;            padding: 0px 8px 8px 8px;        }        QLabel#titleLabel {            color: #0f172a;            font-size: 30px;            font-weight: 800;        }        QLabel#introLabel,        QLabel#hintLabel {            color: #475569;            font-size: 15px;        }        QLabel#pathLabel {            color: #1d4ed8;            font-size: 16px;            font-weight: 700;            background: #eff6ff;            border: 1px solid #bfdbfe;            border-radius: 14px;            padding: 14px 16px;        }        QStatusBar {            background: #ffffff;            color: #64748b;            border-top: 1px solid #dbe3ee;        }    )");}

四、函数职责分析

TreeMenuItemWidget::TreeMenuItemWidget

构造单个菜单项控件。这里固定了菜单项高度,并设置鼠标追踪、手型光标、无焦点策略和尺寸策略。固定高度是树菜单体验的关键,否则展开层级变多时,布局可能会压缩 item 高度。

TreeMenuItemWidget::setExpanded

设置菜单项展开状态。这个函数不负责创建或销毁子菜单,只负责影响箭头方向。真正的可见菜单项由 MenuPluginTreeWidget 根据树节点状态统一重建。

TreeMenuItemWidget::setSelected

设置叶子菜单项选中状态。后台管理菜单通常需要显示当前页面对应的菜单项,所以叶子节点点击后会保持选中高亮。

TreeMenuItemWidget::text

返回当前菜单项文字。这个函数目前主要用于扩展和调试,后续如果要做搜索、日志、自动化测试,也可以复用这个接口。

TreeMenuItemWidget::enterEvent

鼠标进入菜单项时设置 hover 状态并触发重绘。由于菜单项是自绘控件,hover 视觉需要自己维护。

TreeMenuItemWidget::leaveEvent

鼠标离开菜单项时清理 hover 和 pressed 状态,避免鼠标移走后高亮或按压颜色残留。

TreeMenuItemWidget::mousePressEvent

鼠标左键按下时设置 pressed 状态,用于绘制按压反馈。这里不直接触发点击,因为有效点击应该在 release 时确认。

TreeMenuItemWidget::mouseReleaseEvent

鼠标释放时判断释放位置是否仍在菜单项内部。如果仍在内部,才发出 clicked 信号。这样可以避免按下后拖出控件再释放时误触发。

TreeMenuItemWidget::paintEvent

菜单项绘制的核心函数。它负责绘制悬浮、按下、选中背景,计算层级缩进,绘制父节点三角箭头,并绘制菜单文字。文字过长时使用 QFontMetrics::elidedText 做省略处理。

TreeMenuItemWidget::sizeHint

返回菜单项建议尺寸。高度固定为 42,宽度根据文字长度和层级缩进计算,保证 item 不被压缩,同时给布局提供合理的尺寸参考。

MenuPluginTreeWidget::TreeNode::~TreeNode

内部树节点析构函数。因为子节点使用裸指针保存,所以析构时用 qDeleteAll 递归释放子节点,避免内存泄漏。

MenuPluginTreeWidget::MenuPluginTreeWidget

插件构造函数。它创建 QScrollArea、内部 contentWidget 和 QVBoxLayout,设置滚动条策略,并处理透明背景问题。这里最重要的是让内容容器按真实高度撑开,超出高度时由滚动条处理,而不是压缩菜单项。

MenuPluginTreeWidget::~MenuPluginTreeWidget

插件析构函数。调用 clearMenuData 清理可见控件和内部树数据。

MenuPluginTreeWidget::setMenus

插件对外的核心入口。业务层通过这个函数传入多维菜单数据。函数内部先清理旧数据,再递归构建新的内部树,最后渲染当前可见菜单项。

MenuPluginTreeWidget::buildNodes

递归解析外部 MenuDef 数据,转换成插件内部的 TreeNode。同时记录每个节点的 parent 指针,方便叶子节点点击时回溯完整菜单路径。

MenuPluginTreeWidget::rebuildVisibleItems

根据当前展开状态重建所有可见菜单项。未展开的子树不会创建 QWidget,这样逻辑简单,也避免隐藏大量无用控件。

MenuPluginTreeWidget::scheduleRebuildVisibleItems

延迟重建菜单项。这个函数是稳定性的关键:如果点击父节点后立即清空布局,可能会在 clicked 信号还没返回时删除当前 sender,频繁点击时容易异常退出。使用 QTimer::singleShot(0, ...) 可以把重建延迟到下一轮事件循环。

MenuPluginTreeWidget::appendVisibleNode

递归追加可见节点。每个可见节点都会创建一个 TreeMenuItemWidget,并连接 clicked 信号。如果当前节点已展开,则继续追加它的子节点。

MenuPluginTreeWidget::clearLayout

清空当前布局中的菜单项控件。这里使用 hide + deleteLater,而不是直接 delete,是为了保证 Qt 事件派发期间不会销毁正在处理事件的控件。

MenuPluginTreeWidget::clearMenuData

清空可见控件和内部树数据。setMenus 重新设置菜单、插件析构时都会走这个函数。

MenuPluginTreeWidget::collapseChildren

递归收起指定节点下的所有后代。比如一级、二级、三级菜单都展开后,点击最顶层菜单收起时,它下面所有子菜单的展开状态都要复位。

MenuPluginTreeWidget::buildPath

从当前节点开始沿 parent 指针向上回溯,生成完整菜单路径。这样业务层拿到的不是单个叶子文字,而是完整路径,例如 System Management / User Center / User List

MenuPluginTreeWidget::handleNodeClicked

点击处理的核心函数。如果点击的是父节点,就切换展开或收起;如果点击的是叶子节点,就设置选中状态,并发出 actionTriggered 信号。

MenuPluginTreeWidget::clearSelectedItem

清理上一个选中的叶子菜单项。这里使用 QPointer 保存当前选中项,即使控件已经被 deleteLater 释放,指针也会自动变空,避免悬空访问。

MainWindow4::buildUi

Demo 的界面初始化函数。它创建左侧菜单容器和右侧内容区域,初始化多级菜单数据,并连接 actionTriggered 信号。

MainWindow4::handleAction

接收插件发出的叶子菜单路径,并展示到右侧内容区和状态栏中。实际项目中可以在这里切换页面、打开模块或触发业务逻辑。

MainWindow4::buildStyleSheet

Demo 的样式函数,主要用于设置窗口背景、侧边栏、内容卡片、文本和状态栏样式。插件自身的菜单项绘制不依赖这里,因此插件可以独立复用。

五、工程化思考

这个树菜单插件的关键点不是“把菜单画出来”,而是职责拆分:

  • MenuDef
     负责对外表达菜单数据。
  • TreeNode
     负责内部树结构和展开状态。
  • TreeMenuItemWidget
     负责单个菜单项绘制和鼠标状态。
  • MenuPluginTreeWidget
     负责递归解析、可见项重建、滚动容器和点击行为。
  • MainWindow4
     只负责传数据和接收回调。

这样设计后,菜单插件不会和具体业务页面绑定。如果后续菜单来自接口、配置文件、权限系统或插件系统,只需要把数据转换成 MenuDef,再调用 setMenus 即可。

六、后续可扩展方向

当前版本已经满足后台管理树菜单的基本需求,但还可以继续扩展:

  • 菜单项增加图标字段。
  • 支持禁用状态。
  • 支持展开/收起动画。
  • 支持只允许同级展开一个菜单。
  • 支持搜索过滤菜单。
  • 支持快捷键或徽标。
  • 支持从 JSON 配置加载菜单。
  • 支持右侧页面路由映射。

七、总结

MenuPluginTreeWidget 是一个完全自定义的 Qt 树形菜单插件。它没有依赖 QTreeWidget 的默认样式,而是通过 QWidget 自绘菜单项,通过 QScrollArea 提供滚动能力,通过递归数据结构支持多级菜单。

它更适合需要高度定制 UI 的后台管理系统,尤其是希望菜单样式、交互、滚动条、展开规则和回调路径都由自己掌控的场景。

业务侧使用时只需要两步:

 m_treeMenu = new MenuPluginTreeWidget(sideBar);    m_treeMenu->setMenus({        {QStringLiteral("Dashboard"), {            {QStringLiteral("Overview")},            {QStringLiteral("Workplace")},            {QStringLiteral("Analysis")}        }},        {QStringLiteral("System Management"), {            {QStringLiteral("User Center"), {                {QStringLiteral("User List")},                {QStringLiteral("Role List")},                {QStringLiteral("Permission Matrix"), {                    {QStringLiteral("Menu Permission")},                    {QStringLiteral("Button Permission")},                    {QStringLiteral("Data Permission")}                }}            }},            {QStringLiteral("Department")},            {QStringLiteral("Post Management")},            {QStringLiteral("Dictionary"), {                {QStringLiteral("Dict Type")},                {QStringLiteral("Dict Data")}            }}        }},        {QStringLiteral("Content Center"), {            {QStringLiteral("Article"), {                {QStringLiteral("Article List")},                {QStringLiteral("Category")},                {QStringLiteral("Tags")}            }},            {QStringLiteral("Banner")},            {QStringLiteral("Comments")}        }},        {QStringLiteral("Operations"), {            {QStringLiteral("Order Management"), {                {QStringLiteral("All Orders")},                {QStringLiteral("Refund Orders")},                {QStringLiteral("Invoice"), {                    {QStringLiteral("Invoice List")},                    {QStringLiteral("Invoice Settings")}                }}            }},            {QStringLiteral("Member Center"), {                {QStringLiteral("Member List")},                {QStringLiteral("Level Rules")},                {QStringLiteral("Growth Value")}            }},            {QStringLiteral("Marketing"), {                {QStringLiteral("Coupon")},                {QStringLiteral("Campaign")},                {QStringLiteral("Push Task")}            }}        }},        {QStringLiteral("Monitor"), {            {QStringLiteral("Online Users")},            {QStringLiteral("Server Status")},            {QStringLiteral("Job Log")},            {QStringLiteral("Login Log")},            {QStringLiteral("Operation Log")}        }},        {QStringLiteral("Settings"), {            {QStringLiteral("Basic Settings")},            {QStringLiteral("Security Settings")},            {QStringLiteral("Notification Settings")}        }}    });    connect(m_treeMenu, &MenuPluginTreeWidget::actionTriggered, this, &MainWindow4::handleAction);

这样,一个可复用、可扩展、支持多级展开和滚动的后台树形菜单插件就完成了。

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

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

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