简介:面向Qt开发者的自定义选项卡按钮示例工程,解决默认QTabWidget样式单一、交互定制难的问题,适合有基础Qt控件使用经验的开发者学习界面自绘与事件处理。工程基于C++实现,演示通过继承QTabBar创建CustomTabBar,重写paintEvent()绘制选中/悬停/按下状态,并配合鼠标事件响应及QSS样式表,形成可切换皮肤的选项卡效果。包体共13个文件、仅8KB,包含3个cpp源文件、2个头文件、1个UI界面文件、1个Qt工程文件及4张按钮状态PNG图标,结构精简,适合快速阅读。示例中附带皮肤资源文件(qrc),直接打开pro工程即可运行,查看自定义选项卡按钮的实际交互效果。目前已有532人学习,从源码中可提取出自绘控件分层、事件过滤、资源管理等方法,可直接移植到项目中使用。
1. 自定义选项卡按钮:为什么 Qt 默认的 QTabBar 不够用
默认 QTabWidget 的选项卡在 Windows 上是灰色平面标签,在 macOS 上又是一套系统风格,设计稿里那种带关闭小叉、悬停高亮、圆角渐变背景的“浏览器式”页签,光靠改 setStyleSheet 很难做干净。更关键的是默认 QTabBar 根本不暴露 hover 状态和关闭按钮的点击区域,你无法在 QSS 里精确命中“鼠标悬停在关闭按钮上”这个子状态。所以项目只要提出“每个页签能单独关闭、不同状态不同配色”,第一反应就不该再堆样式,而是动底层控件。tabbuttondemo-master 这个示例正是把这条路线走通了:通过继承 QTabBar、重写 paintEvent、拦截鼠标事件,把普通选项卡改造成带关闭按钮的自绘按钮。下面按理论到实现拆开讲。
2. QTabBar 与 QTabWidget 的关系:为什么自定义要从控件继承入手
2.1 先理解 QTabWidget 的复合结构
QTabWidget 并不是一个把所有页面画在自己的绘制函数里的控件,它内部由一个 QTabBar 负责绘制顶部标签,一个 QStackedWidget 负责承载页面内容,二者通过信号和槽联动。QTabWidget 对外暴露的 setTabText、setTabIcon 等接口,其实都是转发给内部的 QTabBar。如果你想改的是“标签自身的绘制与行为”,那么正确目标是替换掉那个内部 QTabBar,而不是在 QTabWidget 层面做文章。
这里有一个容易踩坑的细节:QTabWidget::setTabBar 在 Qt 5/6 中是 protected 成员,不是公开接口。外部代码直接写tabWidget->setTabBar(new CustomTabBar);会编译报错,网上很多旧教程和博客都这么写,但那是 Qt 4 时代的行为。可靠的做法是子类化 QTabWidget,在子类构造函数里调用 setTabBar,或者退而求其次通过tabWidget->tabBar()拿到默认 QTabBar,再安装事件过滤器去拦截绘制和鼠标事件。前者更干净,tabbuttondemo 里采用的也是这个思路。
2.2 从示例工程文件看自定义控件的标准布局
解压 tabbuttondemo-master 后,可以看到一组典型的 Qt Widgets 工程文件,各文件职责如下表所示:
| 文件 | 作用 |
|---|---|
| tabbuttondemo.pro | qmake 工程文件,声明源文件、头文件、资源文件 |
| main.cpp | 程序入口,创建 QApplication 并实例化主窗口 |
| mainwindow.h / mainwindow.cpp | 主窗口类,持有 QTabWidget 并集成自定义 TabBar |
| mainwindow.ui | 主窗口 UI 布局文件,可用 Qt Designer 打开 |
| closeablebutton.h / closeablebutton.cpp | 关闭小按钮控件子类,处理皮肤切换 |
| skin.qrc / *.png | 图片资源,包含正常、悬停、按下三种关闭按钮图标 |
从文件布局可以看出,实现重点被拆分到两个类里:一个负责“可关闭页签”的 TabBar,一个负责“关闭按钮”的 Button。这种拆分是可复用的关键,关闭按钮不仅可以用在 TabBar 上,也可以放到 QToolBar 可停靠窗口的标题栏里,避免把绘制逻辑都堆积在 TabBar 里导致代码互相纠缠。
2.3 最小可编译的 CustomTabBar 骨架
在写具体绘图之前,先搭一个空壳类,验证继承路径正确。下面的代码可以直接加入 Qt 工程:
// customtabbar.h #include <QTabBar> #include <QPoint> class CustomTabBar : public QTabBar { Q_OBJECT public: explicit CustomTabBar(QWidget *parent = nullptr); protected: void paintEvent(QPaintEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void leaveEvent(QEvent *event) override; private: QRect closeButtonRect(int index) const; int m_hoverIndex = -1; int m_pressedIndex = -1; };构造函数中需要设置一些默认选项,否则自定义绘制会与默认行为打架:
// customtabbar.cpp #include "customtabbar.h" #include <QMouseEvent> CustomTabBar::CustomTabBar(QWidget *parent) : QTabBar(parent) { setMouseTracking(true); // 不按住鼠标也能收到 mouseMoveEvent setExpanding(false); // 不让标签自动撑满整个栏宽 setElideMode(Qt::ElideMiddle); setDrawBase(false); // 去掉默认的底部横线,留给 paintEvent 全权处理 }这里 setMouseTracking(true) 是重点。QTabBar 默认只在按下鼠标时才跟踪鼠标位置,不打开的话,单纯移动鼠标不会触发 mouseMoveEvent,hover 效果根本做不出来。setExpand(false) 则让标签宽度收缩到文本内容附近,否则列表多时看起来像一块块拉伸的马赛克。
3. 重写 paintEvent:用 QPainter 绘制选中、悬停、普通三种状态
3.1 为什么不用 QSS 直接画背景和文字
很多人在自定义选项卡第一步会选择QTabBar::tab:selected { background: ... },但这种做法的边界非常尴尬:QSS 无法精细控制“关闭按钮”这个子元素的 hover 状态,无法把文字区域和关闭按钮区域分成两个独立命中区域。还有在复杂背景图上,QSS 的 border-image 缩放规则会直接扭曲按钮圆角。因此更可控的做法是彻底重写 paintEvent,用 QPainter 按 row/column 逻辑逐格绘制。
paintEvent 的绘制顺序是:先绘制整个 TabBar 的底色,再遍历每个 tabRect,分别绘制背景、文字、图标、关闭按钮。关闭按钮直接画在 TabBar 的绘制区域里,而不是单独 new 一个 QWidget 坐落在上面,这样状态同步、坐标变换都省掉一大截。
3.2 分离背景绘制与内容绘制
一个完整的 paintEvent 至少要拆成两个内部函数:drawTabBackground 和 drawTabContent,否则绘制代码全塞在一个循环里会非常难维护。下面给出核心实现:
#include <QPainter> #include <QStyleOptionTab> void CustomTabBar::paintEvent(QPaintEvent *event) { Q_UNUSED(event); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); for (int i = 0; i < count(); ++i) { QRect rect = tabRect(i); // 背景状态优先取 hover 和 selected bool isSelected = (i == currentIndex()); bool isHovered = (i == m_hoverIndex); drawTabBackground(&painter, rect, isSelected, isHovered); drawTabContent(&painter, rect, i, isSelected, isHovered); } }drawTabBackground 只需要处理填充色和顶部高亮条,这里用 QLinearGradient 模拟浏览器页签的柔和过渡:
void CustomTabBar::drawTabBackground(QPainter *painter, const QRect &rect, bool selected, bool hovered) { QLinearGradient gradient(rect.topLeft(), rect.bottomLeft()); if (selected) { gradient.setColorAt(0, QColor("#fafafa")); gradient.setColorAt(1, QColor("#ececec")); painter->fillRect(rect, gradient); // 顶部 3px 蓝色高亮条,用于强调当前页签 painter->fillRect(rect.adjusted(0, 0, 0, -rect.height() + 3), QColor("#3b82f6")); } else if (hovered) { gradient.setColorAt(0, QColor("#e8e8e8")); gradient.setColorAt(1, QColor("#dedede")); painter->fillRect(rect, gradient); } else { gradient.setColorAt(0, QColor("#d6d6d6")); gradient.setColorAt(1, QColor("#cfcfcf")); painter->fillRect(rect, gradient); } painter->setPen(QColor(200, 200, 200)); painter->drawLine(rect.topRight(), rect.bottomRight()); // 右侧分隔线 }参数说明:这里 rect 来自tabRect(i),返回的是 QTabBar 坐标系下的控件区域。gradient.setColorAt的 0 和 1 分别对应矩形顶部和底部。rect.adjusted(0,0,0,-rect.height()+3)表示上下左右分别调整,实际得到一个只有 3px 高的长条,用来画选中指示线,避免另开一个 painter 去 fillRect 指定高度。
3.3 绘制文字与关闭按钮
drawTabContent 负责真正的内容信息。文字区域要预留右侧空间给关闭按钮,否则文字和叉号会重叠:
void CustomTabBar::drawTabContent(QPainter *painter, const QRect &rect, int index, bool selected, bool hovered) { QString text = tabText(index); QIcon icon = tabIcon(index); // 左侧图标 int iconWidth = 16; if (!icon.isNull()) { QRect iconRect = rect.adjusted(10, (rect.height() - iconWidth) / 2, -rect.width() + iconWidth + 10, 0); icon.paint(painter, iconRect, Qt::AlignCenter, selected ? QIcon::Normal : QIcon::Disabled); } // 文字区域,右侧预留 28px 给关闭按钮 QRect textRect = rect.adjusted(iconWidth + 16, 0, -28, 0); painter->setFont(font()); painter->setPen(selected ? QColor("#1f2937") : QColor("#6b7280")); painter->drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft | Qt::ElideRight, text); // 关闭按钮三态图标 QRect closeRect = closeButtonRect(index); if (closeRect.contains(m_hoverIndex, -1)) { // 这里简化为检测鼠标是否落在关闭区域内,真正判断要放到事件中 } QPixmap pix; if (m_pressedIndex == index) { pix.load(":/skin/closeTabPressed.png"); } else if (m_hoverIndex == index) { pix.load(":/skin/closeTabHot.png"); } else { pix.load(":/skin/closeTabNormal.png"); } painter->drawPixmap(closeRect, pix); }这里有一个需要注意的细节:关闭按钮的 hot 状态不应在 TabBar 级单独判断,因为 hot 指的是“鼠标悬停在这个小叉上”,而不是悬停在整个 tab 上。正确做法是在 mouseMoveEvent 中同时记录 hover 的 tab 下标和 hover 的关闭按钮标记,paintEvent 里直接读取该标记决定画哪张图。上面代码为了先跑通,简化成用m_hoverIndex == index判断,实际工程中需要拆开。
图标资源的加载也可以优化:构造函数中用 p() 一次性读取三张 QPixmap 存为成员变量,而不是每次 paintEvent 都调 load,后者在拖动窗口或频繁 hover 时会造成不必要的 IO 和内存拷贝。
3.4 关闭按钮命中区域计算
closeButtonRect 是 TabBar 中最容易被算错的部分。选项卡矩形包含内容边距,不能粗暴放到 rect.right() - 20 的位置。标准做法是基于 QTabBar::tabRect 返回的 rect,再向右偏移一个固定值,同时考虑文档模式下标签间的重叠间距:
QRect CustomTabBar::closeButtonRect(int index) const { QRect r = tabRect(index); int size = 16; int x = r.right() - size - 8; // 右侧 8px 留白 int y = r.top() + (r.height() - size) / 2; return QRect(x, y, size, size); }这个函数要标记为 const,因为事件处理和 paintEvent 都会调用它。坐标原点是 CustomTabBar 控件本身,而 mousePressEvent 收到的点的坐标同样是控件本地坐标,所以可以直接closeRect.contains(event->pos()),不需要做坐标系转换。真正复杂的是当 QTabBar 有 tab overlap(重叠)时,tabRect 之间会覆盖,此时还要再考虑 z-order。
4. 事件处理与关闭按钮:从鼠标移动到点击响应的完整链路
4.1 用 mouseMoveEvent 维护 hover 状态
有了关闭按钮的命中区域,剩下就是事件驱动绘制。QTabBar 默认的 hover 效果是内部实现的,外部无法直接获取。我们需要自己维护 m_hoverIndex,并在离开控件时清空它:
void CustomTabBar::mouseMoveEvent(QMouseEvent *event) { int tabIndex = tabAt(event->pos()); if (tabIndex != m_hoverIndex) { m_hoverIndex = tabIndex; update(); // 触发重绘 } // 判断是否悬停在关闭按钮上方 m_hoverOnClose = false; if (tabIndex >= 0) { m_hoverOnClose = closeButtonRect(tabIndex).contains(event->pos()); } QTabBar::mouseMoveEvent(event); // 保留默认行为,如 tooltip }update() 是尽量小代价地让整条 TabBar 重绘。如果担心性能,可以用update(tabRect(tabIndex))只更新对应区域,不过对少量选项卡来说全量重绘可接受。不要在这里调用 repaint(),repaint 会强制同步绘制,阻塞事件循环,鼠标快速滑过时会有明显卡顿。
4.2 mousePressEvent 里区分“切换页签”和“点击关闭”
按下事件需要先判断点是否落在关闭按钮内部,如果是,就设置 m_pressedIndex 并更新图标为按压缩态,但不调用基类的 mousePressEvent,因为此时不应该切换当前页签。如果不是,再把事件交给基类处理,保持默认切换逻辑:
void CustomTabBar::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { int tabIndex = tabAt(event->pos()); if (tabIndex >= 0 && closeButtonRect(tabIndex).contains(event->pos())) { m_pressedIndex = tabIndex; update(); return; // 吞掉事件,防止切换到该页 } } QTabBar::mousePressEvent(event); }release 是更关键的收尾点,因为用户可能按下关闭按钮后拖出区域再松开,这时不应触发关闭。需要用一个标志记录按下时是否在关闭区域内:
void CustomTabBar::mouseReleaseEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton && m_pressedIndex >= 0) { int tabIndex = tabAt(event->pos()); m_pressedIndex = -1; if (tabIndex >= 0 && closeButtonRect(tabIndex).contains(event->pos())) { emit closeRequested(tabIndex); // 自定义信号,交给 QTabWidget 处理 } update(); return; } QTabBar::mouseReleaseEvent(event); }这里 emit closeRequested(tabIndex) 是向外部传递“哪个页签需要关闭”的标准做法,比 TabBar 自己直接调用 removeTab 更解耦。TabBar 只负责视觉和命中,TabWidget 负责真正移除页面。
4.3 把自定义 TabBar 集成到主窗口
集成时注意 setTabBar 的权限问题。正确做法是定义一个带自定义 TabBar 的 TabWidget 子类,或者在主窗口直接 new 一个 QTabWidget 后通过其内部的 QTabBar 来初始化?但既然已经子类化了 QTabBar,推荐在主窗口中使用一个持有该 TabBar 的组合类。最简洁的落地代码是这样的:
// mainwindow.cpp #include "customtabbar.h" #include <QTabWidget> MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { QTabWidget *tabWidget = new QTabWidget(this); auto *customBar = new CustomTabBar(tabWidget); // setTabBar 是 protected,不能直接调用;使用下述替代方案: // 方法一:继承 QTabWidget // 方法二:直接对默认 tabBar 设置样式,并安装事件过滤器 // 这里演示方法一的等价实现,用在子类构造函数中: // this->setTabBar(customBar); // 实际工程里,我会这样写: // 因为 CustomTabBar 在外部无法 set,所以把 MainWindow 改成继承 QTabWidget, // 或在 MainWindow 构造函数里用一个 lambda 包装 class MainTabWidget : QTabWidget。 // 为清晰起见,这里给出最实用的两个步骤: tabWidget->setTabsClosable(true); // 打开内置关闭入口,但不使用默认图标 tabWidget->tabBar()->installEventFilter(this); // 后续通过事件过滤器屏蔽默认叉号 setCentralWidget(tabWidget); }上面这段代码演示的是两种思路的混合,实际工程中更推荐:直接再写一个class MainTabWidget : public QTabWidget,在构造函数里setTabBar(new CustomTabBar(this)),然后主窗口只使用这个类。这样能绕过 protected 限制,代码也清晰。closeRequested 信号则连接到 MainTabWidget 的槽函数,执行 removeTab。
5. 进阶:让自定义选项卡按钮适配真实产品环境
5.1 用 QSS 接管颜色变量,减少硬编码
自定义绘制越多,颜色越容易散落在代码里。我的习惯是:把颜色、圆角半径、图标大小全部放到一个 struct 里,然后用 QSS 属性读取,或者干脆维护一份全局 QSS 文件,在 paintEvent 中通过property取值。这样在不修改 C++ 代码的前提下,可以让设计师通过 QSS 微调皮肤。一个可行方案是定义动态属性:
tabBar->setProperty("tabSelectedColor", QColor("#fafafa")); tabBar->style()->unpolish(tabBar); tabBar->style()->polish(tabBar);然后在 paintEvent 里用property("tabSelectedColor").value<QColor>()读取。好处是回退逻辑清晰,坏处是 property 查询有微小开销,但相比绘制开销可以忽略。
5.2 状态优化:把 hover 命中拆细
我建议把 m_hoverIndex 扩展为两个字段:m_hoverTabIndex 和 m_hoverCloseFlag。paintEvent 在画关闭图标时优先看 m_hoverCloseFlag 是否对应当前 tab,是则画 closeTabHot.png,否则画普通图标。这样可以实现“鼠标在文字上时关闭叉不变色,鼠标移到叉上时叉才高亮”,这是浏览器级体验的关键,也是 QSS 做到不了的细节。
另外,当 currentIndex 改变时,需要主动 update,否则选中态切换可能延迟到下次鼠标动作。可以在 currentChanged 信号里连接一个 lambda 调 update(),或者重写 setCurrentIndex 方法。
5.3 验证与排错:怎么确认绘制和事件没有脱节
调试自定义 TabBar 最容易出三类问题:图片不显示、点击无反应、hover 残影。图片不显示先检查 skin.qrc 的路径前缀:/skin/closeTabNormal.png是否和代码一致,再用QFile::exists(":/skin/closeTabNormal.png")做运行时验证;点击无反应先看 mousePressEvent 是否被基类吞掉,以及 closeButtonRect 的坐标是否越界;hover 残影大多是离开 TabBar 后没有把 m_hoverIndex 重置为 -1,在 leaveEvent 里补上 update 即可。打印坐标的方法也很有用:在 mouseMoveEvent 里临时输出 tabAt 的返回值,确认 tabRect 和鼠标坐标处于同一参考系,这是排查事件与绘制错位的最后手段。
本文还有配套的精品资源,点击获取