☰
Qt自绘甘特图:基于QGraphicsView的排期控件实现与避坑指南
2026/10/15 14:54:11 网站建设 项目流程

简介:这是一份面向Qt开发者的甘特图组件源码包,定位为“qt甘特图另一个版本”,用于在桌面应用中直观展示项目任务时间线与进度,解决项目管理中的可视化调度需求。压缩包共27个文件,总大小约1.69MB,包含5个cpp与5个h源码文件、2个ui界面文件、2个exe可执行程序,以及sln/vcxproj工程配置、qrc资源文件和ico图标等,既能直接编译运行,也可对照工程梳理实现细节。已有860人学习下载,适合需要扩展Qt图形界面、实现自定义甘特图的中高级开发者参考。该版本着重呈现图形视图框架下的图形项绘制、模型视图分离、时间轴坐标转换与交互事件处理等核心逻辑,同时保留了完整的工程结构与构建脚本,便于研究揣摩或直接集成进现有项目中,是一份兼顾完整性与实用性的参考资源。

1. Qt 甘特图“另一个版本”到底在做什么

排期、计划、资源调度这类需求落到 Qt 界面上,第一反应就是找现成控件。可一旦涉及拖拽调时间、按周按月缩放、任务条颜色随状态变化,现成控件的黑匣子就让人头疼:样式改不动,交互差一截,导出报表还要绕一圈。于是越来越多团队选择自己在 Qt 里重写一版甘特图——这就是标题里“另一个版本”的含义。它不是复刻某个开源库,而是用 QGraphicsView 这套能力,按自己的业务约束做一套真正可控的排期视图。本文要讲的就是这条落地路径,适合有 Qt 基础、需要把甘特图嵌进业务系统的人;读完你会得到一条从选型到出图的完整路线,以及五个高频坑的排查记录。

2. 画布选型与坐标系:为什么值得用 QGraphicsView 重写一版

2.1 三种常见画法的取舍:表格、自绘、场景视图

做甘特图,第一件事是选承载画布。常见做法有三种:QTableView、QWidget 直接自绘、QGraphicsView 场景视图。我一般会直接推荐第三种,但先把前两种为什么不够用说清楚。

QTableView 最大的优点是自带滚动条、行高、选中态,适合做只读的排期表格;但要做跨行任务条、双时间轴、拖拽调整任务前后顺序,QTableView 的单元格模型会变得非常拧巴,合并单元格、自定义委托写到最后基本是绕着框架走。QWidget 自绘的好处是轻、性能完全可控,坏处是滚动、命中测试、缩放、局部刷新全都得自己写,一个坐标换算失误就能让任务条满屏乱飞。QGraphicsView 则内置了场景索引、Item 事件机制和视图变换能力,任务条是 Item,拖拽命中是 Item 的事件,缩放是视图层的变换逻辑,分工清楚,代码维护成本低不少。

另外一个现实条件是跨平台。QGraphicsView 方案不依赖第三方图形库,Windows、Linux 上编译没有额外负担,这对要部署到不同系统的团队很重要。下面这张表可以快速对照:

承载方式适合场景主要限制
QTableView只读排期表、轻量展示跨行任务条、双时间轴难画,自定义交互受限
QWidget 自绘高度定制、规模极小滚动、命中、缩放全自研,工程量集中在基础能力
QGraphicsView可拖拽、可缩放、样式可变的甘特图学习曲线略陡,但模型清晰

选 QGraphicsView 还有一个长期收益:任务条这类元素天然就是 QGraphicsItem,选中、悬浮、右键菜单、拖拽都能直接挂在 Item 上,业务层不需要在视图坐标和模型数据之间来回换算。下面先把坐标系搭起来。

2.2 建立时间与像素的换算关系:先定原点再谈绘制

甘特图的核心不是画矩形,而是时间到坐标的换算。换算关系一旦混乱,后面所有缩放、拖拽都会跟着错。我习惯在项目里单独放一个常量文件,把名称列宽度、行高、时间轴高度这些基础参数集中管理,而不是散落在各个类里。

// gantt_const.h const int kLeftMargin = 160; // 左侧任务名称列宽度,单位:像素 const int kHeaderHeight = 56; // 顶部时间轴高度,单位:像素 const int kRowHeight = 36; // 单行任务行高度,单位:像素 // 时间转场景X坐标:以场景左上角为原点,X轴向右为时间轴 qreal timeToX(const QDateTime& origin, const QDateTime& time, qreal pxPerSec) { return kLeftMargin + origin.secsTo(time) * pxPerSec; } // 场景X坐标转时间:用于鼠标点击、拖拽命中后的时间反算 QDateTime xToTime(const QDateTime& origin, qreal x, qreal pxPerSec) { return origin.addSecs(qint64((x - kLeftMargin) / pxPerSec)); } // 行号转Y坐标:顶部先留出时间轴区域 int rowToY(int row) { return kHeaderHeight + row * kRowHeight; }

这段代码里,origin是场景左上角对应的时间基准点,一般取项目起始周或者某个月初,用它而不是直接用本地时间戳,是为了让所有任务条的 X 坐标都是相对值,拖动整个时间范围时只需要改origin一个参数。pxPerSec是缩放等级,代表每秒钟占多少像素;当 1 像素代表 1 小时时,pxPerSec约等于 0.000278,这个值越小时间轴拉得越宽。xToTime里减掉kLeftMargin是必须的,因为左侧 160 像素是名称列,不属于时间区域,漏掉这一项拖拽时会出现固定偏移。

2.3 最小可运行的视图骨架:把场景和原点绑定

坐标系定义好之后,接下来做一个最小的视图骨架。这个骨架不需要先画任务条,先把视图和场景的关系跑通,后续所有功能都挂在这条主线上。

class GanttView : public QGraphicsView { Q_OBJECT public: explicit GanttView(QWidget* parent = nullptr); void setOrigin(const QDateTime& origin); void setPxPerSec(qreal pxPerSec); protected: void wheelEvent(QWheelEvent* event) override; private: void refreshAllItems(); void updateSceneRect(); QDateTime m_origin; qreal m_pxPerSec = 1.0 / 3600.0; // 初始:1像素代表1小时 QGraphicsScene* m_scene; }; void GanttView::setOrigin(const QDateTime& origin) { m_origin = origin; refreshAllItems(); // 所有任务条重新计算几何位置 updateSceneRect(); // 场景范围跟着数据走 }

refreshAllItems()的常见实现是遍历场景里所有TaskBarItem,逐个调用其刷新接口;在任务量低于几千条时,这种全量刷新完全够用,不需要一开始就做增量更新。updateSceneRect()则是用任务条的包络矩形重新设置场景范围,否则滚动条范围不会跟着任务数量变化。这个骨架把“数据变化 -> 刷新视图”的路径固定下来,后面缩放、拖拽都走同一套流程,不会出现多个入口各改各的导致状态不一致。

3. 核心数据模型与绘制:让任务条成为场景里的“活对象”

3.1 任务数据结构:一个结构体撑起整条数据链

甘特图的任务条要承载的信息比普通矩形多:开始时间、结束时间、所属行、颜色、进度、显示名。这些信息如果分散在好几个地方,后期加筛选、排序时会非常痛苦。我习惯用一个结构体把任务的定义固定住,再让 Item 直接持有这个结构体。

struct TaskEntry { qint64 id; // 任务唯一ID,关联业务数据表 int row; // 所属行索引,决定任务条Y坐标 QDateTime start; // 计划开始时间 QDateTime end; // 计划结束时间 QString name; // 任务条显示名称 QColor barColor; // 任务条颜色 qreal progress = 0.0; // 完成进度,0.0 ~ 1.0 };

这个结构体定义里,id是必须的,因为 Item 的指针不能脱离主线程安全地到处传递,而 ID 可以通过信号槽传出去,业务层拿到 ID 再去查自己的数据表。row决定任务条放在哪一行,换行操作在甘特图里其实就是改这个字段。progress用 0.0 到 1.0 的浮点数,绘制时直接乘以任务条宽度即可,避免在绘制层再做一次百分比换算。

3.2 任务条 Item 的实现:boundingRect 与 paint 的分工

任务条在这里用QGraphicsObject而不是普通QGraphicsItem,因为任务条需要对外发信号,比如拖拽后通知业务层“这个任务时间变了”。QGraphicsObject同时继承QObject和QGraphicsItem,正好满足这个需求。

class TaskBarItem : public QGraphicsObject { Q_OBJECT public: TaskBarItem(const TaskEntry& task, const QDateTime& origin, qreal pxPerSec); QRectF boundingRect() const override; void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override; void refresh(const QDateTime& origin, qreal pxPerSec); void setProgress(qreal progress); signals: void taskTimeChanged(qint64 id, QDateTime start, QDateTime end); private: TaskEntry m_task; QRectF m_rect; // 当前任务条像素矩形 qreal m_pxPerSec = 0.0; }; void TaskBarItem::refresh(const QDateTime& origin, qreal pxPerSec) { prepareGeometryChange(); // 关键:几何变化前必须先调用 m_pxPerSec = pxPerSec; qreal x1 = timeToX(origin, m_task.start, pxPerSec); qreal x2 = timeToX(origin, m_task.end, pxPerSec); m_rect = QRectF(x1, 0, qMax(x2 - x1, 2.0), kRowHeight - 8); update(); } void TaskBarItem::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) { Q_UNUSED(option); Q_UNUSED(widget); painter->setRenderHint(QPainter::Antialiasing, true); painter->setBrush(m_task.barColor); painter->setPen(QPen(Qt::darkGray, 1)); painter->drawRoundedRect(m_rect, 4, 4); if (m_task.progress > 0.0) { QRectF progressRect = m_rect; progressRect.setWidth(m_rect.width() * m_task.progress); painter->save(); painter->setClipRect(progressRect); painter->setBrush(m_task.barColor.lighter(120)); painter->drawRoundedRect(m_rect, 4, 4); painter->restore(); } if (!m_task.name.isEmpty()) { QFontMetrics fm(painter->font()); QString elided = fm.elidedText(m_task.name, Qt::ElideRight, qMax(0, qRound(m_rect.width()) - 8)); painter->setPen(Qt::white); painter->drawText(m_rect.adjusted(4, 0, -4, 0), Qt::AlignVCenter | Qt::AlignLeft, elided); } }

prepareGeometryChange()是这里最容易漏的一行。QGraphicsScene 内部缓存了每个 Item 的 boundingRect,几何变化前不调用它,场景还按旧矩形做碰撞检测和重绘,表现就是缩放后任务条位置错乱、背景残留。m_rect的 Y 坐标固定为 0,行偏移交给父节点处理,这样每个任务条的局部坐标系里,Y 方向永远是 0,计算简单也不容易出偏差。任务名绘制前用QFontMetrics::elidedText做省略,避免超长文本画出任务条边界,这条对中文任务名尤其重要,后面避坑章会展开。

3.3 场景构建与刷新策略:千级任务不需要过度优化

把 TaskBarItem 写好后,建场景就简单了。常见做法是按数据列表创建 Item,挂到场景里,行偏移用item->setPos(0, rowToY(row))。

void GanttPanel::rebuildTasks(const QVector<TaskEntry>& tasks, const QDateTime& origin, qreal pxPerSec) { m_scene->clear(); m_taskItems.clear(); for (const auto& task : tasks) { auto* bar = new TaskBarItem(task, origin, pxPerSec); bar->setPos(0, rowToY(task.row)); m_scene->addItem(bar); m_taskItems.insert(task.id, bar); connect(bar, &TaskBarItem::taskTimeChanged, this, &GanttPanel::onTaskTimeChanged); } m_scene->setSceneRect(m_scene->itemsBoundingRect()); }

这里用QHash<qint64, TaskBarItem*>维护 ID 到 Item 的映射,后台要更新某个任务进度时直接按 ID 找 Item,不需要遍历场景。全量重建的场景适合任务数量在几千条以内的排期表;如果任务量到几万条,就要改成只创建可视区域内的 Item,但那属于另一层优化,初期不建议碰。flush 策略上,我一般只在拖拽结束、批量导入后调用一次updateSceneRect(),而不是每次拖拽移动都调整场景范围,否则滚动条会跟着乱跳。

4. 交互能力:拖拽、缩放与滚动同步的实现

4.1 拖拽任务条:改数据而不是改坐标

甘特图拖拽任务条,最容易踩的坑是直接移动 Item 的坐标。但在这个方案里,任务条的 X 坐标是由开始时间换算出来的,Y 坐标由行号决定,直接改setPos会让数据和显示脱节。正确做法是拖拽时改 TaskEntry 里的时间字段,再让 Item 自己刷新几何。

void TaskBarItem::mousePressEvent(QGraphicsSceneMouseEvent* event) { m_dragStartTime = m_task.start; m_dragEndTime = m_task.end; m_lastSceneX = event->scenePos().x(); event->accept(); } void TaskBarItem::mouseMoveEvent(QGraphicsSceneMouseEvent* event) { qreal dx = event->scenePos().x() - m_lastSceneX; qint64 dSec = qint64(dx / m_pxPerSec); // 吸附到半小时粒度,避免任务时间出现秒级碎片 const qint64 snapSec = 30 * 60; qint64 snapped = (dSec / snapSec) * snapSec; QDateTime newStart = m_dragStartTime.addSecs(snapped); QDateTime newEnd = m_dragEndTime.addSecs(snapped); if (newStart >= newEnd) return; emit taskTimeChanged(m_task.id, newStart, newEnd); } void TaskBarItem::mouseReleaseEvent(QGraphicsSceneMouseEvent* event) { Q_UNUSED(event); // 拖拽结束,由业务层决定落库、记录历史 }

m_lastSceneX记录的是按下时的场景 X 坐标,后续每次移动都用event->scenePos().x()减去它,得到纯像素位移。这一步不能用event->pos()算,因为pos()是 Item 局部坐标,一旦任务条处于不同行或者有父节点偏移,结果就会叠加额外误差。吸附粒度snapSec是业务参数,做排期系统时半小时比较合适,做分钟级调度系统就改成 60 秒。taskTimeChanged信号把时间变化抛给业务层,业务层可以在这里做工期校验、依赖关系检查,再决定是否真正更新数据。

4.2 滚轮缩放:以鼠标所在时间为锚点

缩放时间轴如果只是简单改pxPerSec,视觉表现是鼠标指针下方的时间点会飘走,放大后你要看的那段任务反而跑到视口外面。解决方法是缩放时以鼠标当前指向的时间为锚点,缩放前后这个时间点仍然留在鼠标下方。

void GanttView::wheelEvent(QWheelEvent* event) { if (event->modifiers() & Qt::ControlModifier) { const qreal factor = event->angleDelta().y() > 0 ? 1.25 : 0.8; QPointF scenePos = mapToScene(event->position().toPoint()); QDateTime anchorTime = xToTime(m_origin, scenePos.x(), m_pxPerSec); m_pxPerSec = qBound(kMinPxPerSec, m_pxPerSec * factor, kMaxPxPerSec); // 重新计算origin,使anchorTime在缩放后仍对准原scenePos.x() QDateTime newOrigin = anchorTime.addSecs(-(scenePos.x() - kLeftMargin) / m_pxPerSec); setOrigin(newOrigin); event->accept(); } else { QGraphicsView::wheelEvent(event); } }

参数kMinPxPerSec和kMaxPxPerSec是缩放上下限。我一般把下限设为 1 像素代表 1 天,上限设为 1 像素代表 1 分钟,这样既不会缩到整个任务条消失,也不会放大到时间轴像素密度失去意义。factor用 1.25 和 0.8 这种等比缩放,比固定加减小数更平滑。注意xToTime和timeToX都用了kLeftMargin,缩放时如果不减这个偏移,锚点时间会整体向左偏 160 像素对应的时长。

4.3 顶部时间轴与主体区域:滚动条同步

甘特图通常要有固定的顶部时间轴,往下滚动时时间轴不消失,横向滚动时时间轴跟着移动。常见做法是拆成两个 QGraphicsView:上面一个只显示时间轴,下面一个显示任务条区域,两个视图共享横向滚动位置。

void GanttPanel::connectScrollSync() { connect(m_bodyView->horizontalScrollBar(), &QScrollBar::valueChanged, this, &GanttPanel::syncHeaderScroll); connect(m_headerView->horizontalScrollBar(), &QScrollBar::valueChanged, this, &GanttPanel::syncBodyScroll); } void GanttPanel::syncHeaderScroll(int value) { if (m_syncing) return; m_syncing = true; m_headerView->horizontalScrollBar()->setValue(value); m_syncing = false; } void GanttPanel::syncBodyScroll(int value) { if (m_syncing) return; m_syncing = true; m_bodyView->horizontalScrollBar()->setValue(value); m_syncing = false; }

m_syncing这个标志位是必须的,否则两个滚动条互相发信号会形成死循环。上面视图的垂直滚动条直接禁用,场景高度固定为kHeaderHeight;下面视图正常滚动。这种双视图结构还有一个好处:顶部时间轴可以独立绘制刻度(第 6 章会展开),不影响任务区域的刷新性能。

5. 自绘甘特图的 5 个高频坑:坐标、刷新与中文渲染排查记录

5.1 缩放后任务条错乱、背景残留:prepareGeometryChange 被漏掉

现象:滚轮缩放到一半,任务条和背景网格错开,原本的位置留下残影;继续拖动滚动条,残影区域还会被奇怪地选中。原因:pxPerSec变了,Item 的m_rect也变了,但没在修改几何前告诉场景。场景仍用旧 boundingRect 做碰撞检测和重绘判断。解决:所有涉及m_rect变化的操作,一律先调用prepareGeometryChange(),再赋值,最后update()。这个规则要写进团队的代码规范里,靠记忆不靠谱。

5.2 拖拽任务条时间不跟手、越拖越偏:坐标换算混用了局部坐标和场景坐标

现象:按住任务条拖动,任务条跳动幅度明显大于鼠标移动量,松手后任务位置和鼠标落点对不上。原因:在mouseMoveEvent里用了event->pos()计算位移,而pos()是 Item 局部坐标,X 方向虽然通常和场景坐标一致,但只要任务条有父节点、或者缩放后 Item 做过坐标变换,叠加的偏移就会放大。解决:拖拽位移统一用event->scenePos().x() - m_lastSceneX,m_lastSceneX在mousePressEvent里记录。换算到时间时,不要忘记xToTime内部减kLeftMargin,这个 160 像素的固定偏移是“不跟手”最常见的来源。

5.3 中文任务名显示成方块或被截断:字体与省略策略缺失

现象:任务名里中文变成方框,或者窄任务条上文字被硬生生截断,边界处半个字特别难看。原因:没有给 painter 设置合适的字体族,Qt 用了默认字体 fallback;同时绘制文本时没有做省略处理,drawText把超长文本直接画出矩形边界。解决:初始化时统一设置QFont,指定中文字体族(比如常见的宋体/黑体类族名),setStyleStrategy(QFont::PreferAntialias)保证小字号清晰;绘制前用QFontMetrics::elidedText按任务条宽度裁切,再调用drawText。这条在 Linux 环境下尤其明显,因为默认字体通常不含中文字形。

5.4 任务行数多了滚不到最下面:sceneRect 没有跟随数据更新

现象:任务加到一百多行,垂直滚动条拖到底,最后几行还是看不到;或者新增一条持续时间很长的任务,横向滚动条范围没有变大。原因:sceneRect还停留在初始值,而 QGraphicsView 的滚动范围完全由sceneRect决定,数据增加后场景范围没有扩大。解决:每次增删任务、批量导入、缩放结束后,调用一次updateSceneRect(),用itemsBoundingRect()重新计算范围,并和kLeftMargin、kHeaderHeight做下限合并。注意不要在mouseMoveEvent里频繁调用,否则滚动条会在拖拽过程中不停跳动。

void GanttView::updateSceneRect() { QRectF rect = m_scene->itemsBoundingRect(); rect.setLeft(qMin(rect.left(), qreal(kLeftMargin))); rect.setTop(qMin(rect.top(), qreal(kHeaderHeight))); m_scene->setSceneRect(rect); }

5.5 后台刷新进度时崩溃、界面卡成幻灯片:跨线程直接操作 Item

现象:定时器或者工作线程拉取到新进度后,直接调用item->update()或修改m_task,程序偶发崩溃,界面明显停顿。原因:QGraphicsScene 和 QGraphicsItem 都不是线程安全的,工作线程不能直接碰 Item。解决:后台线程只发信号,把qint64 id和进度值传回主线程,在主线程槽里找到对应 Item 并更新。如果单个循环里要更新几百个 Item,先把更新操作攒到列表中,最后再统一updateSceneRect(),减少场景范围的重复计算。

6. 把时间轴刻度做成自适应:一个值得长期保留的优化技巧

6.1 分级刻度算法:按缩放等级自动切换时/天/周/月

时间轴刻度是甘特图最容易“怎么调都不对”的地方。缩到整月视图时还画小时刻度线,密密麻麻挤在一起;放到分钟级视图时又只画天刻度,时间轴等于没做。常见做法是做一套分级算法:根据pxPerSec算出某种刻度单位下的像素间距,间距小于 80 像素就升级到更大的单位,直到刻度线疏密合适。

void GanttTimeAxis::paint(QPainter* painter, const QDateTime& origin, qreal pxPerSec, const QRectF& viewRect) { const struct { int sec; const char* name; } units[] = { {3600, "时"}, {86400, "天"}, {7 * 86400, "周"}, {30 * 86400, "月"} }; int unitSec = units[0].sec; for (const auto& u : units) { if (pxPerSec * u.sec >= 80.0) { // 主刻度间隔至少80像素 unitSec = u.sec; break; } } // 对齐到单位整数点,避免出现“10:37”这种难看刻度 qint64 sec = origin.toSecsSinceEpoch(); qint64 aligned = (sec / unitSec + 1) * unitSec; for (QDateTime t = QDateTime::fromSecsSinceEpoch(aligned); t < origin.addSecs(qint64(viewRect.right() / pxPerSec)); t = t.addSecs(unitSec)) { qreal x = timeToX(origin, t, pxPerSec); painter->drawLine(QPointF(x, 0), QPointF(x, kHeaderHeight)); painter->drawText(QRectF(x + 2, 0, 90, 20), formatUnitLabel(t, unitSec)); } }

80.0是刻度间距下限,低于这个数值文字会重叠。这个阈值也跟字号有关,字号放大时要调大。对齐到单位整数点这一步,能避免出现“10:37”这种非整点刻度线;跨月、跨年场景下,周起点对齐到周一、月起点对齐到 1 号,会让排期阅读舒服很多。formatUnitLabel根据单位决定输出“14:00”还是“3/14”,逻辑简单但值得单独抽函数,方便后续做多语言。

6.2 上线前的三个自检点

换过多个项目之后,我总结出三个最值得验证的检查点,适用于任何 QGraphicsView 自绘甘特图。第一个是拖拽吸附:把任务从上午拖到下午,松手后时间是否落在半小时整数边界上;第二个是极限缩放:缩到最小和最大级别,任务条是否还能看清、刻度是否还有可读性;第三个是滚动条稳定性:拖拽过程中滚动条不跳、缩放后滚轮能从最左到最右。这三个点过了,基本就能上线。

这套方案最值钱的一点,是把“另一个版本”变成“自己的版本”——数据结构和交互策略都由业务定义,不依赖外部控件的维护节奏。当年我第一版把origin写死成项目开始时间,换月份视图时整体错位,排查半天才明白对齐函数不应该是写死常量。后来把刻度对齐、坐标换算、刷新时机这三件事抽成独立模块,再接手其他排期项目时,半天就能完成整套迁移。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询