前言
导航栏(Navigation Bar)是应用"骨架"里最显眼的部分:用户靠它知道自己在哪、能去哪。一个设计良好的导航栏应该做到三件事------清楚标出当前位置 、点击立刻响应 、状态永远和内容区一致。
Qt 里做导航栏,最省事的是 QToolBar,最好看的是用按钮 + QStackedWidget 手搓,最"原生"的是用 QTabBar 或 QListWidget 当导航项。但不管用哪种,核心机制都是同一个:一组互斥的按钮(当前只有一个被选中)驱动一个堆叠内容区。
本文会把这个机制讲透:QButtonGroup 的互斥原理、QStackedWidget 的联动、QSS 定制的要点,以及实现中最典型的几个坑。
一、导航栏的几种形态
先做技术选型。按"官方组件 vs 自绘"排开:
| 形态 | 实现方式 | 外观可控性 | 开发成本 | 适用场景 |
|---|---|---|---|---|
| 工具栏 | QToolBar + QAction |
中(QSS 受限) | 低 | 传统桌面软件、菜单式功能 |
| 标签栏 | QTabBar 独立使用 |
中 | 低 | 浏览器式顶部标签导航 |
| 列表导航 | QListWidget |
高 | 低 | 侧边栏式导航(VSCode 风) |
| 自绘按钮组 | QPushButton + QButtonGroup |
极高 | 中 | 需要图标+文字+角标等定制 |
本文重点讲最后一种------自绘按钮组,因为它是"外观问题最多、也最能学到东西"的方案,其他几种掌握原理后都是它的简化版。QToolBar 若只是要个能用的工具栏,addToolBar() + addAction() 两行就够,它会自动停靠到 QMainWindow 顶部;但它的样式受 QStyle 影响很大,跨平台表现不一致,深度定制比较痛苦,这正是很多人转向自绘的原因。
二、核心原理:互斥按钮组
导航栏"当前选中项唯一"这个约束,用 QButtonGroup 一个属性就能实现:
cpp
auto *group = new QButtonGroup(this);
group->setExclusive(true); // 关键:互斥
group->addButton(btnHome, 0); // 第二个参数是 id
group->addButton(btnMsg, 1);
group->addButton(btnSet, 2);
QButtonGroup 本身不是 widget(不可见),它只是一组按钮的管理器,职责有三:
- 互斥 :
setExclusive(true)后,点一个自动取消其他; - ID 映射 :给每个按钮绑一个 int id,用
idClicked(int)拿到; - 统一信号:不用给每个按钮单独连信号,只连组的信号即可。
这里有个必须提醒的 API 差异:
| 信号 | Qt5 | Qt6 |
|---|---|---|
| 按钮点击(带 id) | buttonClicked(int) |
idClicked(int) |
| 按钮按下 | buttonPressed(int) |
idPressed(int) |
| 按钮释放 | buttonReleased(int) |
idReleased(int) |
| 按钮切换 | buttonToggled(QAbstractButton*, bool) |
idToggled(int, bool) |
Qt6 把"传指针"的重载去掉了,统一改成"传 id"。Qt5 代码里写 &QButtonGroup::buttonClicked,Qt6 会编译不过,得换成 idClicked。这是 Qt5 迁移到 Qt6 时的高频报错点。
按钮必须设 checkable
❌ 错误写法:
cpp
auto *btn = new QPushButton(QStringLiteral("首页"));
group->addButton(btn, 0);
// 按钮不可选中,互斥机制无从谈起,永远看不出"当前在哪"
✅ 正确写法:
cpp
auto *btn = new QPushButton(QStringLiteral("首页"));
btn->setCheckable(true); // 必须!否则不会被 checked
btn->setChecked(true); // 默认选中首页
group->addButton(btn, 0);
setCheckable(true) 是导航按钮和普通按钮的唯一区别。忘了这一句,症状是"点了没反应、看不出选中状态",这是导航栏最经典的坑。
三、实战:顶部导航 + 内容区联动
实现一个带 QSS 样式的导航栏,顶部横向排列,点击切换下方内容区。
cmake
cmake_minimum_required(VERSION 3.16)
project(NavbarDemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON) # 有 .qrc 资源文件时必须
find_package(Qt6 REQUIRED COMPONENTS Widgets)
add_executable(NavbarDemo main.cpp mainwindow.cpp mainwindow.h resources.qrc)
target_link_libraries(NavbarDemo PRIVATE Qt6::Widgets)
qmake 版本:
bash
QT += widgets
CONFIG += c++17
TARGET = NavbarDemo
SOURCES += main.cpp mainwindow.cpp
HEADERS += mainwindow.h
RESOURCES += resources.qrc
头文件
cpp
// mainwindow.h
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
#include <QWidget>
class QButtonGroup;
class QStackedWidget;
class MainWindow : public QWidget
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
private:
void buildNavBar();
void buildContent();
QWidget *m_navBar = nullptr;
QButtonGroup *m_group = nullptr;
QStackedWidget *m_stack = nullptr;
};
#endif // MAINWINDOW_H
实现
cpp
// mainwindow.cpp
#include "mainwindow.h"
#include <QButtonGroup>
#include <QPushButton>
#include <QStackedWidget>
#include <QLabel>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QDebug>
namespace {
struct NavItem { QString text; QString iconPath; QString pageTip; };
const NavItem kNavItems[] = {
{QStringLiteral("首页"), QStringLiteral(":/icons/home.png"), QStringLiteral("欢迎回到首页")},
{QStringLiteral("消息"), QStringLiteral(":/icons/msg.png"), QStringLiteral("你还没有新消息")},
{QStringLiteral("统计"), QStringLiteral(":/icons/chart.png"), QStringLiteral("这里是数据统计页")},
{QStringLiteral("设置"), QStringLiteral(":/icons/setting.png"), QStringLiteral("应用偏好设置")},
};
constexpr int kNavCount = int(sizeof(kNavItems) / sizeof(kNavItems[0]));
} // namespace
MainWindow::MainWindow(QWidget *parent) : QWidget(parent)
{
setWindowTitle(QStringLiteral("导航栏示例"));
resize(860, 560);
buildNavBar();
buildContent();
auto *root = new QVBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
root->addWidget(m_navBar);
root->addWidget(m_stack, 1);
// 默认选中第一项,并同步内容区
if (auto *first = m_group->button(0)) {
first->setChecked(true);
m_stack->setCurrentIndex(0);
}
}
void MainWindow::buildNavBar()
{
m_navBar = new QWidget;
m_navBar->setObjectName(QStringLiteral("navBar"));
m_navBar->setAttribute(Qt::WA_StyledBackground, true); // 让 QSS 背景生效
m_navBar->setFixedHeight(52); // 固定高度防抖动
auto *navLayout = new QHBoxLayout(m_navBar);
navLayout->setContentsMargins(12, 0, 12, 0);
navLayout->setSpacing(4);
m_group = new QButtonGroup(this);
m_group->setExclusive(true); // 互斥,保证同时只有一个选中
for (int i = 0; i < kNavCount; ++i) {
auto *btn = new QPushButton(kNavItems[i].text, m_navBar);
btn->setCheckable(true); // 关键:可选中
btn->setIcon(QIcon(kNavItems[i].iconPath));
btn->setIconSize(QSize(18, 18));
btn->setCursor(Qt::PointingHandCursor);
btn->setFixedHeight(34);
m_group->addButton(btn, i); // 绑定 id
navLayout->addWidget(btn);
}
navLayout->addStretch(); // 把按钮推到左边
// 只连组的信号,不用逐个按钮连接
connect(m_group, &QButtonGroup::idClicked, this, [this](int id) {
if (id >= 0 && id < m_stack->count())
m_stack->setCurrentIndex(id);
});
m_navBar->setStyleSheet(R"(
QWidget#navBar { background-color: #1f2430; border-bottom: 1px solid #2c3346; }
QPushButton {
color: #b8c0d0; background: transparent; border: none;
border-radius: 6px; padding: 0 16px; font-size: 14px;
}
QPushButton:hover { background-color: #2a3145; color: #ffffff; }
QPushButton:checked { background-color: #3d6fff; color: #ffffff; font-weight: bold; }
)");
}
void MainWindow::buildContent()
{
m_stack = new QStackedWidget;
for (int i = 0; i < kNavCount; ++i) {
auto *page = new QWidget;
auto *label = new QLabel(kNavItems[i].pageTip, page);
label->setAlignment(Qt::AlignCenter);
label->setStyleSheet(QStringLiteral("font-size:20px;color:#666;"));
auto *layout = new QVBoxLayout(page);
layout->addWidget(label);
m_stack->addWidget(page);
}
}
入口
cpp
// main.cpp
#include "mainwindow.h"
#include <QApplication>
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
MainWindow w;
w.show();
return app.exec();
}
资源文件 resources.qrc 里用 <qresource prefix="/icons"> 声明前缀,代码里就写 :/icons/home.png。注意 : 是资源系统前缀,/icons 是 qresource 的 prefix,不要写成 :/qresource/icons/...,那是目录名不是 prefix。
四、进阶:让导航栏和内容区"双向同步"
上面是单向的(点按钮 → 切页面)。但内容区也可能主动切页(比如"设置"页里点按钮跳到"关于"页),这时按钮选中状态必须跟着变。
❌ 错误写法(只连了单向):
cpp
connect(m_group, &QButtonGroup::idClicked, m_stack, &QStackedWidget::setCurrentIndex);
// 内容区自己 setCurrentIndex 时,按钮状态不会更新
✅ 正确写法,回连 currentChanged:
cpp
// 反向:内容区变了 → 同步按钮选中状态
connect(m_stack, &QStackedWidget::currentChanged, this, [this](int index) {
if (auto *btn = m_group->button(index))
btn->setChecked(true); // 这不会再次触发 idClicked
});
为什么 setChecked(true) 不会造成死循环?因为 idClicked 只在用户点击 时发出,程序调用 setChecked() 不会触发 clicked。这正是 clicked 与 toggled 的区别------如果组信号用的是 idToggled,上面这段就会形成回环。推荐用 idClicked 而不是 idToggled,可以天然避免这种回环。
| 信号 | 触发条件 | 程序 setChecked 会触发吗 |
|---|---|---|
clicked / idClicked |
用户点击 | ❌ 不会 |
toggled / idToggled |
选中状态变化 | ✅ 会 |
五、样式定制要点
QSS 里导航按钮的状态伪类务必都写上,否则交互反馈很差:
| 伪类 | 触发时机 | 建议 |
|---|---|---|
:hover |
鼠标悬停 | 轻微变色 |
:checked |
当前选中 | 强调色(主色) |
:pressed |
按下瞬间 | 比 hover 再深一点 |
:disabled |
禁用 | 半透明 |
一条经验:导航按钮不要用 border-bottom 做下划线指示器 ,不同 DPI 下会糊;改用 border-radius + 背景色块。
高 DPI 方面,Qt5 要在 QApplication 构造之前 设 AA_EnableHighDpiScaling 和 AA_UseHighDpiPixmaps;Qt6 这两个属性已默认开启 ,且 AA_EnableHighDpiScaling 被标记废弃,再写会有编译警告,所以 Qt6 什么都不用做。图标本身推荐用 SVG(矢量图),或提供 @2x 位图让 Qt 自动挑选。
常见坑点
坑点 1:按钮忘了 setCheckable
前面讲过,再强调一次,因为这是最高频的坑。
❌ 错误写法:
cpp
auto *btn = new QPushButton(QStringLiteral("首页"));
m_group->addButton(btn, 0);
// 点击后没有 :checked 状态
✅ 正确写法:
cpp
btn->setCheckable(true);
m_group->addButton(btn, 0);
坑点 2:QButtonGroup 的父对象设错导致泄漏
QButtonGroup 虽不可见,但它继承自 QObject,必须纳入对象树管理。父对象一销毁,它跟着销毁,按钮的组关系也自动解除。
❌ 错误写法:
cpp
auto *group = new QButtonGroup; // 没有父对象 → 内存泄漏
✅ 正确写法,把它挂到导航栏所属的窗口上:
cpp
auto *group = new QButtonGroup(this); // this 是导航栏所属的窗口
坑点 3:信号槽连接失败------编译通过但运行时没反应
这是新手最迷惑的问题。Qt5 起推荐用函数指针语法 (新式语法),它能在编译期检查参数匹配:
cpp
// ✅ 编译期检查,写错直接编译不过
connect(m_group, &QButtonGroup::idClicked, this, &MainWindow::onNavClicked);
而老式的字符串语法是运行时才报错:
cpp
// ❌ 运行时才失败,且只在控制台打印一行警告
connect(m_group, SIGNAL(buttonClicked(int)), this, SLOT(onNavClicked(int)));
如果你在 Qt6 里用字符串语法连接 buttonClicked(int),会因为信号名已改而连接失败,但程序照常运行、只是"点了没反应"。排查方法 :看控制台有没有 QObject::connect: No such signal ... 的输出。
坑点 4:跨线程更新导航栏导致崩溃
导航状态多半是"后台任务完成 → 更新角标/切页"。工作线程里绝对不能碰 UI。
❌ 错误写法:
cpp
void Worker::run() {
m_mainWindow->switchToPage(2); // 工作线程直接操作 UI → 崩溃
}
✅ 正确写法:
cpp
// Worker 声明信号
signals:
void pageSwitchRequested(int index);
// Worker::run() 里
emit pageSwitchRequested(2);
// 主线程里(接收者属于主线程,跨线程自动排队)
connect(worker, &Worker::pageSwitchRequested, this, [this](int idx){
m_group->button(idx)->setChecked(true);
m_stack->setCurrentIndex(idx);
});
补充:跨线程时 Qt::AutoConnection 会自动判定为 QueuedConnection,但前提是接收者对象(上面的 this)归属主线程。如果你把 lambda 的上下文对象写成了 worker 自己,连接类型就会判成直连,照样崩。
坑点 5:图标资源路径写错
❌ 错误写法:
cpp
btn->setIcon(QIcon(":/qresource/icons/home.png")); // 多了目录名
btn->setIcon(QIcon("resources/icons/home.png")); // 少了冒号,走文件系统
✅ 正确写法:
cpp
btn->setIcon(QIcon(QStringLiteral(":/icons/home.png")));
// 前缀 ":" + qresource 的 prefix "/icons" + 文件名
另外,.qrc 文件改了之后必须重新构建 (qmake 需重跑 qmake,CMake 用 CMAKE_AUTORCC 时自动处理),否则资源不更新,这也是"改了图标没变化"的常见原因。
坑点 6:导航栏高度不固定导致内容区跳动
setFixedHeight(52) 是必要的。如果不设,导航栏高度会随其中最高的按钮变化,而按钮又可能因字体/图标尺寸变化,最终导致内容区上下抖动。给导航栏一个固定高度,配合按钮用 setFixedHeight,布局就稳了。
总结
导航栏的实现可以拆成四个零件,缺一不可:
| 零件 | 作用 | 关键 API |
|---|---|---|
| 容器 | 承载按钮 | QWidget + setFixedHeight |
| 按钮组 | 互斥 + id 映射 | QButtonGroup::setExclusive(true) |
| 按钮 | 可选中 | setCheckable(true) |
| 内容区 | 显示对应页面 | QStackedWidget::setCurrentIndex |
把它们串起来的是信号槽:idClicked 驱动 setCurrentIndex,currentChanged 反向同步按钮选中。记住 idClicked 不会被程序触发,天然避免回环,所以优先用它而不是 idToggled。
最后提醒两个跨版本差异:Qt6 里 QButtonGroup 的信号统一改成了 idClicked / idToggled 系列,Qt5 的 buttonClicked(int) 已不存在;高 DPI 缩放属性在 Qt6 里默认开启、无需手动设置。把这两点记住,Qt5 和 Qt6 的代码就不会互相"水土不服"。