
简介面向QT桌面应用开发者这份《QT的TableView实现多级表头》工程包演示了如何基于QAbstractItemModel与QHeaderView扩展实现多级表头针对性解决表格数据分组展示、层级列头组织等常见需求。包内包含自定义表头类RbTableHeaderView及多级表头初始化代码并在配套界面文件中提供布局同时给出了按键槽函数中触发表头刷新更新的排错思路适合需要梳理模型与视图交互逻辑的初中级开发者参考。资源共十二个文件以cpp、h源码为主附带两个ui界面文件和Qt工程文件压缩包仅17KB代码精简便于快速阅读。已有842人在CSDN学习下载。通过这套示例可以掌握paintSection重绘、节尺寸计算以及数据模型联动等关键写法并结合条件术语界面与委托模块理解表头与单元格编辑控件的协作方式相关代码可直接应用到项目中的分组表头或合并表头场景。1. 多级表头不是开箱即用先干掉两个认知误区接过一个报表项目第一眼看到设计稿里“企业收入”下面挂两列、“企业支出”下面再挂两列的二级表头大多数从 QTableView 入手的开发者会惯性以为“给 Header 多塞一行文本就行”。等真动手才发现QHeaderView 默认只有一个水平标题行无论怎么 setText 都只能画一条平铺的标题带。这个需求在 QT 的 TableView 体系里没有现成开关必须自己接管表头绘制逻辑。常见的第一个误区是去 model 里做文章在数据行里插一行当表头或者试图用 setSpan 合并单元格。这些做法要么污染数据要么在排序、拖列之后直接错位。第二个误区是以为自定义 QHeaderView 只重写 paintSection 就够了结果发现跨列绘制的图形被裁剪得只剩一个列宽。这篇文章围绕“多级表头”给出一套能落地的实现方案适合桌面端报表、数据看板这类需要视觉分组、又不希望动业务数据的场景。先讲清楚为什么默认画不出来再给完整的绘制代码和参数说明。2. 三种实现思路与取舍为什么最终选了重写 paintEvent多级表头的本质是把“组标题”和“列标题”两套信息画在同一个水平表头区域里。数据模型仍然是扁平的二维表视觉上的层级关系全部交给表头绘制层去表达。这里先拆开讲三种常见思路每种的边界条件不一样选错了后面全是坑。2.1 默认 QHeaderView 的限制在哪里QTableView 的水平表头是 QHeaderView 的一个实例它内部按“一个 section 对应一个逻辑列”来组织。默认绘制流程里每个 section 只负责自己的矩形区域模型通过 headerData 提供的字符串被绘制成一个标题行。也就是说QHeaderView 的模型接口和绘制逻辑都是为“单行标题”设计的model 里每个逻辑列只有一个 DisplayRole 的标题字符串表头高度默认跟字体行高走绘制时不会把一块区域拆成上下两行合并、跨列的语义在 QHeaderView 中不存在setSpan 是 QTableView 数据区的能力管不到表头。这不是 bug是 Qt 把表头抽象得太简洁。想实现多级表头就得绕过这套默认绘制自己决定每个像素画什么。绕行的方式不同维护成本差异很大。2.2 思路一paintSection 逐列补画最直觉的改法是继承 QHeaderView重写 paintSection。这个虚函数在表头绘制时被逐个 section 调用你可以在里面拿到当前逻辑列、绘制矩形和 QPainter。很多网上示例就是这么干的在 paintSection 里判断当前列是不是某一组的首列是的话就往右多画一点组标题。我实际试过之后发现这条路有个硬伤QHeaderView 的 paintEvent 在遍历各列时会先对每个 section 的矩形设置裁剪区再调用 paintSection。也就是说你在 paintSection 里画一个超出当前列宽度的组标题矩形超出部分会被 viewport 的裁剪直接切掉。表现出来的效果是组标题文字只在第一列露了个头后面全是空白。如果硬要在 paintSection 里跨列画就得自己 save 后重新 setClipRect再把裁剪区扩大成整个组的矩形这等于和 QHeaderView 内部的裁剪逻辑对着干一个不留神就会把旁边列的内容也盖掉。结论paintSection 适合做单列的装饰修改不适合做跨列的组标题。2.3 思路二TableView 上方叠加独立表头控件第二种思路是放弃修改 QHeaderView直接在 QTableView 的 viewport 上方叠一个自定义 QWidget用独立 painter 画组标题然后监听水平滚动条的 valueChanged 信号去同步位置。视觉上确实自由了画什么都行不受 QHeaderView 内部逻辑限制。但这个方案把简单问题复杂化了。QTableView 内部由 QAbstractScrollArea 管理滚动叠加控件要处理滚动同步、列宽改变、列移动、隐藏列、缩放因子等多组信号。任何一个没同步上表头和数据区就会错位。实际工程里A同学用它做过一版光是同步滚动条的偏移就花了两个晚上最后还因为 DPI 缩放导致 1px 偏差被测试打回。除非你的表头交互极其复杂、放弃用 QHeaderView 的一切行为否则不建议走这条路。2.4 思路三重写 paintEvent 双层绘制我最终采用的做法是重写 paintEvent完全接管 QHeaderView 的绘制把表头区域从高度上分成两段。上半段按组画跨列矩形下半段按列画普通标题。这样做的好处是绘制逻辑完全由自己控制不跟 QHeaderView 内部的 section 裁剪纠缠坏处是默认样式行为悬停高亮、点击排序指示需要自己处理一部分。与 paintSection 相比paintEvent 拿到的 painter 是 viewport 级别的坐标系整块表头区域都能画没有按列裁剪的限制。与叠加控件相比所有绘制都在 QHeaderView 自己的 paintEvent 里完成天然跟着 QTableView 的滚动、重绘流程走不需要联动信号。三种思路对比下来取舍点很明确方案跨列绘制与滚动联动实现复杂度适用场景paintSection 逐列补画受裁剪限制需绕自动低单列表头装饰叠加独立控件完全自由需手动同步高极端自定义表头重写 paintEvent完全自由自动中多级表头、报表分组我选了重写 paintEvent接下来就是完整的落地代码。3. 用代码落地双层绘制二级表头这一章给出一个可以直接抄进项目的实现。核心思路是组信息用自定义结构体管理表头高度分成“组标题区 列标题区”绘制时先画下半段列标题再画上半段组标题。数据侧完全不用动QStandardItemModel 的列头照常设置。3.1 分组映射先定义组与列的从属关系多级表头第一步不是写绘制代码而是把“哪个组跨哪几列”这件事定下来。我习惯用一个结构体表达组信息struct GroupHeaderItem { QString name; // 组名称例如“企业收入” int firstLogicalCol; // 组起始的逻辑列索引 int span; // 跨列数 };逻辑列索引指的是 model 里列的序号跟视觉上从左到右的顺序可能不一致。比如用户把第 3 列拖到了第 1 列的位置逻辑列还是 3但视觉位置变了。多级表头场景下这种组内列顺序被打乱的问题很麻烦我在第 4 章会专门讲这里先按“列不允许手动移动”来设计。组映射数据应该在创建表头时一次性传入QListGroupHeaderItem groups; groups.append({QStringLiteral(企业收入), 0, 2}); groups.append({QStringLiteral(企业支出), 2, 2});这段代码定义了两个组第一组覆盖逻辑列 0 和 1第二组覆盖逻辑列 2 和 3正好对应四列数据的二级表头。组名是“企业收入 / 企业支出”二级列名则由 model 的 headerData 提供。3.2 自定义 GroupHeaderView构造与高度配置接下来声明一个继承 QHeaderView 的类提供两个对外接口setGroupItems 传入组映射setGroupHeight 设置组标题区的高度。class GroupHeaderView : public QHeaderView { Q_OBJECT public: explicit GroupHeaderView(Qt::Orientation orientation, QWidget *parent nullptr); void setGroupItems(const QListGroupHeaderItem items); void setGroupHeight(int h); protected: void paintEvent(QPaintEvent *event) override; private: void drawColumnTitle(QPainter painter, int logicalIndex, const QRect rect); void drawGroupTitle(QPainter painter, const GroupHeaderItem group); QListGroupHeaderItem m_groups; int m_groupHeight 28; // 组标题区高度可按设计稿调整 };构造函数里要明确表头总高度。我一般把总高度设为组标题区和列标题区之和例如组高 28px、列标题区高 26px总高 54pxGroupHeaderView::GroupHeaderView(Qt::Orientation orientation, QWidget *parent) : QHeaderView(orientation, parent) { setFixedHeight(54); // 总高 m_groupHeight 列标题区高度 setSectionsClickable(true); setHighlightSections(true); }注意 setFixedHeight 这里不能省。QHeaderView 默认会根据字体高度计算自己的尺寸不显式固定高度的话设置 m_groupHeight 后总高度可能被字体行高原样覆盖表头依然只有一行。setMinimumHeight 和 setMaximumHeight 同时设置也能达到类似效果但我觉得 setFixedHeight 最省事。3.3 核心绘制逻辑先画列标题再画分组标题paintEvent 是整篇文章最关键的地方。代码分成两步第一步把每列的标题画在表头下半段第二步把组标题画在上半段。这样两套信息互不重叠。void GroupHeaderView::paintEvent(QPaintEvent *event) { QPainter painter(viewport()); painter.setRenderHint(QPainter::Antialiasing, false); const int totalHeight height(); const int columnAreaHeight totalHeight - m_groupHeight; // 第一步画每个逻辑列的标题区域从 m_groupHeight 到表头底部 if (model()) { const int columnCount model()-columnCount(); for (int logical 0; logical columnCount; logical) { if (isSectionHidden(logical)) continue; QRect rect(sectionViewportPosition(logical), m_groupHeight, sectionSize(logical), columnAreaHeight); drawColumnTitle(painter, logical, rect); } } // 第二步画组标题区域从表头顶部到 m_groupHeight for (const GroupHeaderItem group : m_groups) drawGroupTitle(painter, group); }这里有一个细节值得说明sectionViewportPosition 返回的是该逻辑列在表头可视区域中的 x 坐标sectionSize 返回的是当前实际列宽。用这两个接口取值列宽被用户拖拽后绘制位置仍然是准的不用自己维护一份宽度缓存。drawColumnTitle 负责画下半段的单个列标题走 QStyle 接口而不是手绘文本这样悬停高亮、排序指示等默认行为还能保留一部分void GroupHeaderView::drawColumnTitle(QPainter painter, int logicalIndex, const QRect rect) { QStyleOptionHeader opt; initStyleOption(opt); opt.rect rect; opt.section logicalIndex; opt.text model()-headerData(logicalIndex, orientation(), Qt::DisplayRole).toString(); opt.textAlignment Qt::AlignCenter; style()-drawControl(QStyle::CE_Header, opt, painter, this); }QStyleOptionHeader 来自 Qt 样式系统initStyleOption 会填充表头的默认状态。这里把绘制矩形传入样式引擎就会在这个范围内画背景、边框和文字。好处是系统自带的配色、圆角、高亮效果不用自己手工模拟。drawGroupTitle 是跨列绘制的重点。它要计算整个组从第一列到最后一列的总宽度再把组名画在矩形正中void GroupHeaderView::drawGroupTitle(QPainter painter, const GroupHeaderItem group) { if (group.span 0) return; const int startX sectionViewportPosition(group.firstLogicalCol); int totalWidth 0; for (int c group.firstLogicalCol; c group.firstLogicalCol group.span; c) { if (!isSectionHidden(c)) totalWidth sectionSize(c); } if (totalWidth 0) return; QRect rect(startX, 0, totalWidth, m_groupHeight); QStyleOptionHeader opt; initStyleOption(opt); opt.rect rect; opt.text group.name; opt.textAlignment Qt::AlignCenter; style()-drawControl(QStyle::CE_Header, opt, painter, this); }这段代码里的宽度累加逻辑有个隐形好处如果组内某一列被隐藏它不会计入 totalWidth组标题的宽度会跟着收缩不会画到隐藏列的位置上去。这个细节在普通实现里容易被漏掉而在实际业务中“部分列被 setColumnHidden 隐藏、组标题还按原跨度画”是非常常见的翻车点。3.4 接入 QTableView模型与视图的配置自定义表头类写完接入 QTableView 就简单了。先建一个四列的数据模型设置好二级列名然后把 GroupHeaderView 设为水平表头QStandardItemModel *model new QStandardItemModel(8, 4, this); model-setHeaderData(0, Qt::Horizontal, QStringLiteral(主营收入)); model-setHeaderData(1, Qt::Horizontal, QStringLiteral(其他收入)); model-setHeaderData(2, Qt::Horizontal, QStringLiteral(主营支出)); model-setHeaderData(3, Qt::Horizontal, QStringLiteral(其他支出)); auto *header new GroupHeaderView(Qt::Horizontal, ui-tableView); header-setGroupItems(groups); header-setGroupHeight(28); ui-tableView-setHorizontalHeader(header); ui-tableView-setModel(model);注意 setModel 和 setHorizontalHeader 的顺序没有严格要求但建议先 setHorizontalHeader 再 setModel。原因是 QTableView 在设置 model 时会触发一次表头的 reset 和重绘如果表头还没挂上去这次初始重绘就浪费了反过来先挂表头setModel 后表头立即按新模型刷新视觉上不会闪一下空表头。参数上还可以按需调整组高如果设成 36、表头总高设成 62组标题区的视觉比重更大适合组名较长的场景。二级标题文字默认居中如果想靠左改 drawColumnTitle 里的 opt.textAlignment 为 Qt::AlignLeft | Qt::AlignVCenter。4. 避坑清单列偏移、隐藏列、列移动的三类翻车现场多级表头代码看着不长真正上线前要过的坑一个不少。这里记录的每一条都是我或同事实际踩过的按“现象、原因、解决”整理方便你对照排查。4.1 组标题被裁剪跨列区域显示不全现象组标题文字只在第一列范围内显示其余列上面一片空白或者文字被截断。原因如果用的是重写 paintSection 的方案QHeaderView 的 paintEvent 在调用 paintSection 前会给每个 section 设置裁剪区。任何画到当前列矩形以外的内容都会被裁剪跨列组标题自然只剩第一列那点空间。解决改用第 3 章的 paintEvent 双层绘制方案。在 paintEvent 里拿到的 painter 是 viewport 级别的坐标系裁剪范围是整块表头跨列绘制不受限。经验是设计多级表头时直接把 paintSection 从候选方案里划掉省得白费力气。4.2 隐藏列后组标题宽度没跟着变现象调用 setColumnHidden 隐藏列后组标题的矩形仍然覆盖到隐藏列的位置视觉上出现一块空白或者组名没居中。原因绘制组标题时用 group.span 直接乘以某个默认列宽或者没有逐列判断 isSectionHidden。隐藏列不计入宽度累加但组标题矩形起点还按第一列的 sectionViewportPosition 来终点算多了。解决严格按 3.3 节的宽度累加逻辑遍历组内每一列时都判断 isSectionHidden。注意 sectionSize 在列隐藏时返回的可能是 0但保险起见显式判断一次更稳妥。另外一个隐藏副作用是当组内所有列都被隐藏时totalWidth 为 0绘制函数里要提前 return否则会产生一个宽度为 0 的样式绘制控制台偶尔会报出奇怪的布局警告。4.3 拖动列宽或移动列后组标题文字错位现象用户横向拖拽表头列宽组标题区域没有对齐到新的列边界如果开启了列移动组内某列被拖到别的组后组标题仍然画在原位置跟数据完全对不上。原因组标题的 x 坐标和宽度是绘制时临时计算的列宽变化后 sectionViewportPosition 和 sectionSize 返回值已经改变但如果绘制代码里用了缓存坐标就会错位。更严重的是列移动Qt 允许通过 setSectionsMovable(true) 让用户拖动列拖完后视觉列序和逻辑列序不一致而 GroupHeaderItem 里记录的是逻辑列索引组边界会跨到别的列上。解决绘制时不要缓存任何坐标和宽度全部实时调用 sectionViewportPosition 和 sectionSize。列移动这块我的建议是最好多级表头场景下直接禁用列移动header-setSectionsMovable(false); header-setSectionsClickable(true);如果产品非要支持列移动那就得在 sectionMoved 信号里重建 GroupHeaderItem 映射把组关系从逻辑索引改成视觉索引复杂度和收益不成正比一般不建议做。4.4 排序状态下表头与列数据错位现象开启 setSortingEnabled(true) 后点某列排序数据行重排了但二级表头的组关系看起来跟内容对不上。原因排序是 model 层的行为表头绘制时拿到的 headerData 会跟着 model 的重排结果走但 GroupHeaderItem 里的 firstLogicalCol 是固定的。如果 model 的排序把列顺序也变了或者用了 QSortFilterProxyModel逻辑列索引与视觉位置的对应关系就会被打乱。解决最稳的方案是让排序只发生在行方向列顺序保持稳定这样组映射不受影响。写法上在 table view 初始化后调用ui-tableView-setSortingEnabled(true); header-setSortIndicatorShown(false);同时确保代理 model 或自定义 model 的 sort 方法只重排行不重排列。列方向一旦重排组映射必须跟着重建否则多级表头的视觉分组就是错的。4.5 表头总高度设置不生效组标题区还是被压缩现象构造函数里写了 setFixedHeight(54)运行时表头高度还是只有一行或者组标题区被压缩到几乎看不见。原因QHeaderView 的默认尺寸策略是 Preferred会结合字体高度和内容尺寸重新计算建议高度。某些布局里调用 setFixedHeight 的时机太早setModel 之后 QHeaderView 内部会触发一次 sizeHint 刷新把高度又拉回去了。解决把 setFixedHeight 放到 setModel 之后执行或者在构造函数之后、第一次 show 之前再调一次。还有一种做法是重写 sizeHint 返回固定高度QSize GroupHeaderView::sizeHint() const { return QSize(QHeaderView::sizeHint().width(), 54); }这个写法更可靠任何内部刷新都不会覆盖 sizeHint 的返回值。5. 进阶动态组跨度与暗色样式下的收尾技巧多级表头跑通之后还有两个高频需求值得顺手解决。第一个是列数不固定组跨度需要根据 model 动态算第二个是应用换暗色主题后表头绘制要和整体皮肤匹配。先说动态组跨度。固定分组适合列数确定的报表但如果数据源来自动态配置列的个数和分组关系是运行时才知道的就要在 setModel 后重新组装 GroupHeaderItemvoid GroupHeaderView::rebuildGroupsFromModel() { if (!model()) return; m_groups.clear(); const int colCount model()-columnCount(); // 示例每两列一组组名从 model 一个特殊 role 读取 for (int i 0; i colCount; i 2) { const int span qMin(2, colCount - i); const QString name model()-headerData( i, Qt::Horizontal, Qt::UserRole 1).toString(); m_groups.append({name, i, span}); } viewport()-update(); }这个函数放在 model 重建或列结构变化之后调用。组名没有额外数据源时可以先按空字符串生成再通过一个接口单独修正。注意调用完一定要执行 viewport()-update()否则表头不会触发重绘组标题还停留在旧的映射关系上。再说暗色主题。前面所有绘制都走 QStyle 的 drawControl正常情况下会跟随应用全局样式自动变色。但如果你在项目中用了大量自定义 QSSQHeaderView 的样式分支经常会被 QSS 规则覆盖或漏掉。常见的现象是整体界面是深色表头却还是浅色底。排查方式很简单先确认 qApp 设置的 palette 的 Window 和 Base 颜色是否符合预期再用下面这段覆盖表头的调色板QPalette pal header-palette(); pal.setColor(QPalette::Window, QColor(45, 45, 48)); pal.setColor(QPalette::Base, QColor(45, 45, 48)); pal.setColor(QPalette::Text, QColor(220, 220, 220)); header-setPalette(pal);QStyleOptionHeader 在绘制时会读取调色板里的 Window、Text、Highlight 等角色调色板覆盖之后组标题和列标题都会跟着变色。如果连调色板都压不住那就只能绕过样式系统在 drawGroupTitle 和 drawColumnTitle 里直接用 painter 填色画边框和文本这是最后的手段。验证方法上我会造一批随机数据组跨度分别设 1 列、2 列、3 列各跑一遍重点检查三件事组标题文字是否居中、隐藏中间列后组矩形是否收缩、连续拖动列宽后是否有残影。这三项过了多级表头基本就能稳定交付。从那以后我每次做多级表头都先画一张组映射表把逻辑列、视觉列、隐藏标记列出来再动绘制代码。这个习惯帮我省掉了大半的排查时间希望帮到你。本文还有配套的精品资源点击获取