首页 / 资讯中心 / 文章详情

QHeaderView自定义绘制:实现Qt多级表头完整指南

QHeaderView自定义绘制:实现Qt多级表头完整指南 ★ FEATURED ARTICLE
简介面向需要实现复杂表格表头的Qt开发者围绕QTableView多级表头展开演示如何通过继承QHeaderView并重写关键绘制与尺寸计算方法呈现层次化列标题解决多层级数据在表格中的组织问题。资源包为RAR压缩格式共12个文件以4个C源文件、4个头文件为主配合2个UI界面文件和pro工程配置整体仅17KB是轻量级可直接参考的示例工程。已有842人学习下载适合需要扩展QTableView原生表头能力、理解QAbstractItemModel与QHeaderView协作机制的Qt程序员。示例中包含MultistageHeader、RbTableHeaderView等核心类实现以及界面设计与工程配置实现时需要在paintSection中遍历表头层级分别绘制文本与分隔线在sectionSizeFromContents中动态计算列宽并处理好刷新时机、模型索引映射等细节。读者可对照源码理解多级表头的绘制流程、尺寸计算与更新机制并据此改造应用到自己的项目中。1. 多级表头不是QTableView自带功能先搞懂你要改的是QHeaderView在做一个模拟项目X的数据管理窗口时我一度以为QT TableView实现多级表头只是设置一下setSpan就能搞定结果翻车翻得很彻底。QTableView本身只支持单层表头所有列标题都平铺在一条线上要实现“产品信息 | 销售数据”这种分组结构本质上是要替换掉默认的水平表头也就是从QHeaderView继承一个自定义类自己接管标题区和点击区。这篇笔记会一步一步把绘制原理、完整代码和三个常见坑都拆开适合正在做表格报表、需要表头分组的开发者。花十分钟看完能省掉你一下午的瞎试。2. 自定义QHeaderView的绘制原理分组数据结构与paintEvent重写2.1 为什么必须重写paintEvent而不是paintSectionQHeaderView和普通控件不一样它不是一个简单的空白画布而是由若干个section组成每个section对应一列。默认情况下绘制一个section会调用受保护的虚函数paintSection它会画背景、画标题、画排序箭头。很多人第一反应是重写paintSection往里面加自己的分组矩形。我试过这个方案效果很诡异当你拖动列宽时分组矩形会出现残影而且因为每个section都画一遍跨列的分组标题会被重复绘制两三次。原因在于paintSection的执行粒度是“一个section”不是“一个表头”。你要画一个横跨三列的矩形在第一个section里画了一部分第二个section里又画另一部分边界和重叠根本没法控制。更合适的方式是直接重写paintEvent把它当成一个完整的绘图事件来处理。QHeaderView内部的paintEvent默认会遍历所有可见section逐个调用paintSection我们绕开这个循环自己画整张表头。这样分组矩形可以一次性画完列标题的坐标也可以精确计算。另一个原因是滚动。当表格横向滚动时QHeaderView的viewport会移动paintEvent会频繁触发。如果只在paintSection里处理你很难拿到全局的可见范围画出来的分组会在滚动时错位。而paintEvent里可以通过sectionViewportPosition拿到每个section在viewport中的实际x坐标无论滚动到哪位置都是实时正确的。2.2 分组数据结构与表头高度如何定我们要解决的是两级表头第一行是分组名比如“产品信息”第二行是具体列标题比如“名称”“型号”“价格”。所以表头总高度要分成上下两块区域topHeight和bottomHeight。我一般把topHeight设成24bottomHeight设成28总高度52。这个比例在字体大小12pt时看起来最协调。具体高度可以按你的实际font来调但不要设置成单个固定数字最好用成员变量存起来方便后续在sizeHint里返回。分组数据结构用最简单的结构体struct GroupItem { QString title; int firstColumn; int lastColumn; };firstColumn和lastColumn是逻辑列索引注意不是物理位置。因为列可以被拖动、隐藏、增删逻辑索引和visible索引不一样。我们绘制时需要通过sectionViewportPosition把逻辑索引转换成viewport上的坐标。如果你直接用QHeaderView::sectionSize和sectionPosition在列被隐藏时会混淆。我建议在自定义表头里维护一个QListGroupItem提供setGroups方法。这个方法不负责修改模型只负责更新表头的分组配置然后调用update()重绘。这样表头本身只是一个渲染器数据来源仍然由QTableView的model管理职责清晰。2.3 绘制分组标题与列标题的坐标计算在paintEvent里我们有两步绘制。第一步画每个列标题位置是x sectionViewportPosition(i)y topHeight宽是sectionSize(i)高是height() - topHeight。这里直接用QPainter::drawText(bottomRect, Qt::AlignCenter, text)文字从model的headerData取。第二步画分组标题遍历m_groups取startPos sectionViewportPosition(group.firstColumn)取endPos sectionViewportPosition(group.lastColumn) sectionSize(group.lastColumn)然后这个矩形的宽度就是endPos - startPos。绘制时先填充背景色再画边框最后写标题。这里有个细节边框如果用drawRect(rect.adjusted(0, 0, -1, -1))避免右侧和底部超出viewport。分组矩形和列标题矩形共享一条边界线所以列标题的顶边线可以不画让分组矩形的底边线充当分隔线。如果两边都画会出现一条黑线叠在另一条上看起来特别脏。另外分组矩形内部是不需要画垂直线的否则会破坏分组的一体感。你可以在画完分组后再画一条竖直方向的分隔线但只在分组之间的边界上画而不是每个列之间画。也就是说列标题区域保留每列的竖线分组区域只保留分组之间的竖线。两种线可能不在同一个x坐标上这是正常的视觉上分组区域比较干净。3. 从零写一个MultiHeaderView完整代码与接入步骤3.1 MultiHeaderView类的头文件与成员我们把自定义表头类命名为MultiHeaderView继承QHeaderView。构造函数里要设置几个关键选项setSectionsClickable(true)保留点击能力但后面会重写鼠标事件setHighlightSections(false)避免默认选中高亮干扰绘制还要给表头一个固定高度。头文件关键部分class MultiHeaderView : public QHeaderView { Q_OBJECT public: explicit MultiHeaderView(Qt::Orientation orientation, QWidget *parent nullptr); // 设置分组列表格式{title, firstColumn, lastColumn} void setGroups(const QListGroupItem groups); protected: void paintEvent(QPaintEvent *event) override; void mousePressEvent(QMouseEvent *event) override; QSize sizeHint() const override; private: QListGroupItem m_groups; int m_topHeight; int m_bottomHeight; };注意Qt::Orientation参数通常只处理水平方向垂直方向仍然是默认行为。如果你在构造函数里不判断方向垂直表头也会被重绘那会导致行号的样式异常。我一般会在构造函数里加一句if (orientation Qt::Vertical) { setFixedHeight(28); return; }这样垂直表头保持默认只有水平表头走我们的自定义绘制逻辑。3.2 paintEvent的完整实现与逐段说明这是核心代码我直接贴出完整的paintEvent实现注释标出了每一段在干什么。void MultiHeaderView::paintEvent(QPaintEvent *event) { QPainter painter(viewport()); painter.fillRect(event-rect(), palette().button()); const int colCount count(); const QFontMetrics fm fontMetrics(); // 1. 先绘制底部列标题区每个列一格 for (int i 0; i colCount; i) { if (isSectionHidden(i)) continue; int x sectionViewportPosition(i); int w sectionSize(i); QRect bottomRect(x, m_topHeight, w, height() - m_topHeight); // 背景和边框 painter.fillRect(bottomRect, palette().button()); painter.setPen(palette().mid().color()); painter.drawRect(bottomRect.adjusted(0, 0, -1, -1)); // 列标题文本 QString text model()-headerData(i, Qt::Horizontal).toString(); QRect textRect bottomRect.adjusted(2, 0, -2, 0); painter.drawText(textRect, Qt::AlignCenter | Qt::TextSingleLine, fm.elidedText(text, Qt::ElideRight, textRect.width())); } // 2. 再绘制顶部组标题区横跨多列 for (const GroupItem group : m_groups) { if (group.firstColumn colCount || group.lastColumn colCount) continue; int startX sectionViewportPosition(group.firstColumn); int endX sectionViewportPosition(group.lastColumn) sectionSize(group.lastColumn); QRect topRect(startX, 0, endX - startX, m_topHeight); painter.fillRect(topRect, palette().alternateBase()); painter.setPen(palette().mid().color()); painter.drawRect(topRect.adjusted(0, 0, -1, -1)); QRect titleRect topRect.adjusted(4, 0, -4, 0); painter.drawText(titleRect, Qt::AlignCenter | Qt::TextSingleLine, fm.elidedText(group.title, Qt::ElideRight, titleRect.width())); } }逻辑说明代码先画列标题再画组标题后画的会覆盖先画在交叉区域的内容这样组标题和列标题之间有一条清晰的分隔线。sectionViewportPosition返回的是当前可见位置即使表格横向滚动坐标也是对的。isSectionHidden跳过隐藏列避免在隐藏列上画出空洞。参数说明event-rect()是Qt自动传入的需要重绘的区域用它填充背景性能更好不用全量重绘。painter.setPen的palette().mid()是比较靠中间的颜色适合做分隔线不会像黑色那样刺眼。fm.elidedText会在文字太长时加上省略号这是处理表头文字溢出的默认手段。3.3 鼠标事件与排序逻辑怎么处理默认情况下QHeaderView点击后会在整个section范围内响应包括我们画的顶部组标题区域。用户点击“产品信息”分组可能莫名其妙触发了“名称”列的排序这会让人困惑。解决办法是在mousePressEvent中做一个高度判断void MultiHeaderView::mousePressEvent(QMouseEvent *event) { if (event-pos().y() m_topHeight) return; // 点击组标题区域忽略 QHeaderView::mousePressEvent(event); }这样点击底部列标题区域时仍然走默认的sectionPress逻辑QTableView会正常发出排序请求。点击顶部组标题区域时事件被吞掉就不会触发排序。如果你希望点击组标题时收缩或展开分组可以在这里自己写逻辑然后调用update()。我们的示例不涉及这个只做最简单的忽略。3.4 在QTableView中接入并设置列标题使用自定义表头只需要三步。第一步创建MultiHeaderView并替换默认水平表头第二步给model设置列标题文本第三步设置分组。// 创建表格和model QStandardItemModel *model new QStandardItemModel(4, 6, this); model-setHorizontalHeaderLabels({A, B, C, D, E, F}); // 填一些假数据 for (int row 0; row 4; row) { for (int col 0; col 6; col) { model-setItem(row, col, new QStandardItem(QString(%1%2).arg(row).arg(col))); } } QTableView *table new QTableView(this); table-setModel(model); // 替换水平表头 MultiHeaderView *header new MultiHeaderView(Qt::Horizontal, table); table-setHorizontalHeader(header); // 设置分组第0~1列为“产品信息”第2~5列为“销售数据” QListGroupItem groups; groups.append({产品信息, 0, 1}); groups.append({销售数据, 2, 5}); header-setGroups(groups); // 让表头高度自适应 header-setFixedHeight(header-sizeHint().height());逻辑说明分组索引必须和model里的列索引一致。如果你的列在中间被隐藏隐藏列仍然占一个逻辑索引分组要按逻辑索引来指定。setFixedHeight的调用顺序应该在setGroups之后因为sizeHint会用到m_topHeight和m_bottomHeight。参数说明QStandardItemModel的列标题通过setHorizontalHeaderLabels设置这里不会有中文实际项目中一般用setHorizontalHeaderItem设置含对齐等属性的表头项。分组列表里firstColumn和lastColumn是闭区间所以“产品信息”包含0、1两列。4. 避坑指南多级表头的四个常见翻车点4.1 列宽调整后分组分隔线与列边界对不上现象拖动列宽时分组标题的右边界和列的右边界差了几个像素甚至出现错层。原因很多人会在paintEvent里缓存分组矩形的位置或者只调用一次update()但QHeaderView在列宽变化时是分多次重绘的。如果你把坐标存成成员变量第二次绘制用的还是旧坐标自然对不上。解决不要在成员变量中保存任何与坐标有关的东西。每次paintEvent都通过sectionViewportPosition和sectionSize实时计算。同时确保在colsResized信号中调用viewport()-update()强制整个表头重绘。我一般在构造时连接columnResized信号在槽函数里只加一句viewport()-update()。4.2 点击分组区域却意外触发了列排序现象用户点击“产品信息”这个组标题表格却按“A列”排序了体验非常奇怪。原因QHeaderView的点击命中机制是按section来算的它不知道你的表头已经分成了上下两半。只要鼠标落在这个section的矩形内就会触发sectionPressed、sectionClicked等信号。解决重写mousePressEvent对y m_topHeight的点击直接return不调用父类。这样组标题区域永远不产生section信号。如果你还想在组标题区域做折叠/展开可以在这里自己处理然后手动调用update()。4.3 隐藏某列后分组范围错乱现象调用hideColumn(2)后分组“销售数据”画出来的区域少了一截或者画到了前一组的位置上。原因隐藏列不会改变逻辑索引但sectionViewportPosition会跳过隐藏列导致连续的逻辑索引在viewport上并不是连续的。如果分组里恰好跨越了一个隐藏列分组的宽度就会变大而且中间还会出现断裂。解决最稳妥的办法是在隐藏列时同步更新分组列表把包含隐藏列的分组拆分成两个或者干脆在分组绘制时跳过隐藏列并且重新计算可见范围。我一般写一个辅助函数根据isSectionHidden动态计算分组的实际起始和结束位置如果中间有隐藏列就绘制两个分段矩形但这对视觉不太友好。另一个方案是禁止隐藏表头列或者隐藏后让分组只显示连续可见部分隐藏的部分自然看不到分组标题也跟着缩短。4.4 表头文字被裁剪或重叠现象分组标题很长时文字既靠左又靠右或者一部分被截掉有些列标题则叠在分组标题下方。原因调用drawText时没做省略号处理也没有预设矩形内边距。另外如果顶层矩形和底层矩形的高度比不对文字会互相压到。解决在绘制文字前调用fontMetrics().elidedText把超长文字替换为带省略号的版本。再给文本矩形加一点水平边距比如adjusted(4, 0, -4, 0)。高度方面建议m_topHeight至少比字体像素高度多6~8个像素底部同理。如果还是重叠把表头总高度从52加大到56或者缩小字体。5. 进阶动态增删分组与列宽自适应5.1 用sectionsResized信号触发分组重绘多级表头在运行过程中经常需要动态调整。比如用户双击列边缘拉宽某一列此时组标题位置会变但表头不一定自动重绘。虽然paintEvent每次都会实时计算但QHeaderView内部的缓存有时会阻止paintEvent被连续触发。我在MultiHeaderView构造函数里会这样连接信号connect(this, QHeaderView::geometriesChanged, this, [this]() { viewport()-update(); });geometriesChanged信号在列宽变化、列移动、section位置变化时都会触发。这样确保无论怎么拖动分组标题都能跟着刷新。别偷懒只连sectionResized因为列移动时不一定发那个信号。5.2 动态修改分组列表并刷新表头如果你需要在代码里增删分组注意setGroups必须和model的列状态保持一致。比如用户勾选了某个选项后要把“销售数据”拆成“线上”和“线下”两组。void MainWindow::updateHeaderByConfig(bool splitSales) { QListGroupItem newGroups; newGroups.append({产品信息, 0, 1}); if (splitSales) { newGroups.append({线上销售, 2, 3}); newGroups.append({线下销售, 4, 5}); } else { newGroups.append({销售数据, 2, 5}); } m_header-setGroups(newGroups); m_header-viewport()-update(); }这里调用setGroups时内部只是简单替换列表并调用update()不涉及任何信号发送。因为表头的分组配置是视图层的概念不应该逆向修改model。如果你的分组是跟着业务变化走的建议把分组配置放在一个独立的数据类里避免在UI代码里写死逻辑索引。5.3 结合setStretchLastSection做最后的列宽分配多级表头表格如果最后一列被拉伸到填满剩余空间分组矩形的宽度计算仍然有效因为sectionViewportPosition和sectionSize会返回拉伸后的实际尺寸。我习惯在业务代码里设置table-horizontalHeader()-setStretchLastSection(true);但要注意这个拉伸只针对最后一个section如果你的最后一个分组恰好包含多列只有最后一列会被拉伸其他列仍然固定宽度。如果想要整个表格平均分配可以用header-setSectionResizeMode(QHeaderView::Stretch);这样所有列都拉伸分组标题会跟着列宽自动变宽。缺点是无法手动调整列宽。折中方案是除了最后一列设为Stretch其他列设为Interactive这样既保留分组视觉也允许用户调整主要列宽。6. 验证多级表头画得对不对一个自检坐标的小技巧写完表头后肉眼观察是一种方式但有些错位用肉眼看不太出来特别是表格滚动之后再回来。我习惯在调试模式下写一段临时代码把分组矩形和列标题矩形的坐标打印到控制台。#ifdef QT_DEBUG void MultiHeaderView::debugDumpGeometry() { qDebug() Viewport width: viewport()-width(); for (int i 0; i count(); i) { qDebug() Column i pos sectionViewportPosition(i) size sectionSize(i); } for (const GroupItem g : m_groups) { qDebug() Group g.title startX sectionViewportPosition(g.firstColumn) endX sectionViewportPosition(g.lastColumn) sectionSize(g.lastColumn); } } #endif在拖动列宽或滚动后调用这个函数检查endX是否等于startX 实际宽度。如果出现endX小于startX多半是分组索引越界或者列被隐藏。还有一个更直观的验证方法临时设置setStyleSheet(QHeaderView::section { border: 1px solid red; })如果分组区域的边界线和红色列边界不一致一眼就能看出来。从那以后我每次做多级表头都会强制走一遍这个调试流程确认所有分组坐标与列坐标对齐再继续写业务逻辑。这个习惯帮我避开过好几次隐藏列导致的错位希望也对你有用。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站