Qt甘特图源码实战:QGraphicsView实现拖拽改期与依赖联动

发布时间:2026/10/17 3:36:26
Qt甘特图源码实战:QGraphicsView实现拖拽改期与依赖联动 简介这份源码资源面向具备一定 C 与 Qt 基础的开发者用于在 Qt 环境中实现甘特图这一项目管理可视化组件帮助解决任务时间表与进度直观展示的问题。压缩包共 12 个文件以 5 个 .h 头文件与 4 个 .cpp 源文件为核心另含 .pro 工程文件、说明文本及用户配置整体约 14KB结构紧凑便于快速阅读。源码围绕 QGraphicsView、QGraphicsScene 与 QPainter 构建绘制逻辑涉及任务数据模型、QDateTime 时间计算、鼠标事件交互与信号槽刷新等关键环节并包含约束求解与对话框模块可帮助读者理解自定义可视化控件的完整实现路径。目前已有 3107 人学习下载适合希望掌握 Qt 图形绘制、数据管理与交互设计的中级开发者参考借鉴。1. 从一份 Qt 甘特图源码说起排期可视化到底难在哪做过生产排程、研发项目管理或者施工进度看板的人都有一个共识把任务条画出来不难难的是拖拽改期之后所有依赖关系跟着重算还要保证缩放、滚动、跨天跨月渲染不闪烁。我手上这份 Qt 甘特图源码解决的正是这个场景——它用 QGraphicsView 图形视图框架搭了一套可交互的任务条时间轴支持任务拖拽移动、左右拉伸改工期、依赖箭头连线、时间轴按天/周/月缩放以及任务树与图表区的行同步。适合两类人一类是需要在桌面端嵌入排期控件的 Qt 开发者另一类是想拿一个完整图形视图项目练手、理解 Model/View 与场景坐标系怎么配合的工程师。源码结构清晰没有过度封装改起来不费劲。2. 技术选型与整体架构为什么是 QGraphicsView 而不是 QWidget 堆控件2.1 图形视图框架的天然优势甘特图本质上是一张二维时间-任务矩阵横轴是时间刻度纵轴是任务行。如果用传统 QWidget 堆叠每个任务条是一个子控件几百个任务时布局计算和重绘就会成为瓶颈而且拖拽时的坐标换算、层级遮挡、滚动同步都要自己处理。QGraphicsView 提供场景坐标系、图元拾取、变换矩阵和局部重绘正好对上甘特图的需求。这份源码的架构分三层数据层GanttModel持有任务列表每个任务包含 id、名称、开始时间、结束时间、进度、依赖 id 列表、层级深度。视图层GanttScene继承 QGraphicsScene负责把任务数据转成图元并管理依赖连线GanttView继承 QGraphicsView处理缩放、滚动和鼠标交互。图元层TaskItem继承 QGraphicsRectItem 表示任务条DependencyItem继承 QGraphicsPathItem 表示依赖箭头TimeHeaderItem负责顶部时间刻度。三层之间通过信号槽通信数据变更只通知场景刷新不直接操作图元避免视图和数据耦合。2.2 目录结构一览源码目录大致如下我按功能做了归类说明GanttChart/ ├── GanttChart.pro # qmake 工程文件 ├── main.cpp # 程序入口 ├── gantt/ │ ├── ganttmodel.h/.cpp # 任务数据模型 │ ├── ganttscene.h/.cpp # 场景管理图元与依赖 │ ├── ganttview.h/.cpp # 视图缩放与交互 │ ├── taskitem.h/.cpp # 任务条图元 │ ├── dependencyitem.h/.cpp # 依赖箭头图元 │ └── timeheaderitem.h/.cpp # 时间刻度图元 ├── widgets/ │ ├── mainwindow.h/.cpp # 主窗口工具栏与树表 │ └── tasktreewidget.h/.cpp # 左侧任务树 └── resources/ └── icons.qrc # 图标资源工程用 qmake 组织没有引入第三方库纯 Qt 官方模块core、gui、widgets。这意味着你拿到源码后不需要额外配依赖Qt 5.12 以上或 Qt 6 都能编译跨平台没有障碍。2.3 关键数据结构设计任务模型的核心结构决定了后续所有操作的复杂度。源码里用了一个扁平列表加父子索引的方式而不是嵌套树这样查找和更新都是 O(1) 或 O(n)实现简单// ganttmodel.h struct GanttTask { int id -1; // 唯一标识 QString name; // 任务名称 QDateTime startTime; // 开始时间 QDateTime endTime; // 结束时间 int progress 0; // 进度 0-100 QListint dependencies; // 依赖的任务 id int parentId -1; // 父任务 id-1 表示顶层 int depth 0; // 层级深度用于缩进 bool expanded true; // 是否展开子任务 }; class GanttModel : public QObject { Q_OBJECT public: int addTask(const GanttTask task); bool updateTask(int id, const GanttTask task); bool removeTask(int id); GanttTask task(int id) const; QListGanttTask allTasks() const; // 根据依赖关系计算某任务的最早可开始时间 QDateTime earliestStart(int id) const; signals: void taskChanged(int id); void taskAdded(int id); void taskRemoved(int id); };这里有几个设计点值得说。dependencies存的是 id 列表而不是指针避免对象生命周期问题parentId加depth的组合让树形展示和扁平存储兼得earliestStart是依赖约束的核心拖拽任务后调用它就能算出连锁调整量。参数上progress用整数百分比而不是浮点是为了避免进度条渲染时的精度抖动实际项目里够用。3. 核心模块拆解任务条渲染、拖拽改期与依赖连线怎么落地3.1 任务条图元的绘制与命中区域TaskItem是用户直接操作的对象它的绘制分三部分背景圆角矩形、进度填充条、任务名称文本。源码里重写了paint和boundingRect并在shape里返回一个更宽松的路径方便鼠标拾取// taskitem.cpp QRectF TaskItem::boundingRect() const { // 留出 2px 边距避免拖拽时边缘闪烁 return QRectF(0, 0, m_width, m_height).adjusted(-2, -2, 2, 2); } void TaskItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(option); Q_UNUSED(widget); painter-setRenderHint(QPainter::Antialiasing); // 1. 背景 QRectF body(0, 0, m_width, m_height); painter-setBrush(m_selected ? QColor(#4a90d9) : QColor(#5b9bd5)); painter-setPen(Qt::NoPen); painter-drawRoundedRect(body, 3, 3); // 2. 进度条按百分比从左往右填充 if (m_progress 0) { QRectF prog body; prog.setWidth(m_width * m_progress / 100.0); painter-setBrush(QColor(#2e6da4)); painter-drawRoundedRect(prog, 3, 3); } // 3. 文本超出宽度时省略 painter-setPen(Qt::white); QString text painter-fontMetrics().elidedText( m_name, Qt::ElideRight, static_castint(m_width) - 8); painter-drawText(body.adjusted(4, 0, -4, 0), Qt::AlignVCenter | Qt::AlignLeft, text); }逻辑说明boundingRect比实际矩形大 2px是为了让鼠标在任务条边缘也能命中减少“点不中”的挫败感。paint里先画背景再画进度进度条宽度按m_progress / 100.0计算注意这里用浮点除法否则整数除法会得到 0。文本用elidedText做省略避免长任务名撑破任务条。参数m_width由场景根据时间跨度和缩放比例算出不是固定值。3.2 拖拽改期的鼠标事件处理拖拽是甘特图最核心的交互。源码在TaskItem里处理鼠标按下、移动、释放三个阶段并区分“整体移动”和“边缘拉伸”两种模式// taskitem.cpp void TaskItem::mousePressEvent(QGraphicsSceneMouseEvent *event) { m_dragStartPos event-scenePos(); m_origStart m_startTime; m_origEnd m_endTime; // 判断是否点在左右边缘 8px 内决定拉伸还是移动 qreal localX event-pos().x(); if (localX kEdgeMargin) { m_dragMode ResizeLeft; setCursor(Qt::SizeHorCursor); } else if (localX m_width - kEdgeMargin) { m_dragMode ResizeRight; setCursor(Qt::SizeHorCursor); } else { m_dragMode Move; setCursor(Qt::ClosedHandCursor); } event-accept(); } void TaskItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { qreal dx event-scenePos().x() - m_dragStartPos.x(); // 像素转时间每像素代表多少秒由场景的 scaleFactor 决定 qint64 deltaSec static_castqint64(dx / m_pixelsPerSecond); if (m_dragMode Move) { m_startTime m_origStart.addSecs(deltaSec); m_endTime m_origEnd.addSecs(deltaSec); } else if (m_dragMode ResizeLeft) { m_startTime m_origStart.addSecs(deltaSec); if (m_startTime m_endTime) m_startTime m_endTime.addSecs(-60); } else if (m_dragMode ResizeRight) { m_endTime m_origEnd.addSecs(deltaSec); if (m_endTime m_startTime) m_endTime m_startTime.addSecs(60); } update(); // 触发重绘 event-accept(); } void TaskItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { setCursor(Qt::ArrowCursor); // 通知模型更新模型再触发依赖重算 emit taskTimeChanged(m_taskId, m_startTime, m_endTime); event-accept(); }逻辑说明kEdgeMargin是边缘判定阈值源码里设为 8px太小不好点太大容易误触。m_pixelsPerSecond是场景缩放后的像素时间比由GanttView在缩放时统一设置保证不同缩放下拖拽的时间粒度一致。ResizeLeft时如果开始时间超过结束时间强制保留 1 分钟最小工期避免出现负工期任务。释放时发信号而不是直接改模型是为了让模型有机会做依赖校验和连锁调整。3.3 依赖箭头的路径计算依赖连线用QPainterPath画折线从前置任务的右边缘中点出发到后置任务的左边缘中点结束中间走一个“Z”字形// dependencyitem.cpp void DependencyItem::updatePath() { if (!m_fromItem || !m_toItem) return; QPointF from m_fromItem-sceneBoundingRect().center(); from.setX(m_fromItem-sceneBoundingRect().right()); QPointF to m_toItem-sceneBoundingRect().center(); to.setX(m_toItem-sceneBoundingRect().left()); QPainterPath path(from); qreal midX (from.x() to.x()) / 2.0; // 先水平到中间再垂直到目标行再水平到目标 path.lineTo(midX, from.y()); path.lineTo(midX, to.y()); path.lineTo(to); // 箭头头部 QPainterPath arrow; arrow.moveTo(to); arrow.lineTo(to.x() - 8, to.y() - 4); arrow.lineTo(to.x() - 8, to.y() 4); arrow.closeSubpath(); path.addPath(arrow); setPath(path); }逻辑说明midX取两点横坐标中点让折线在中间转折视觉上更清晰。箭头用三角形路径追加到主路径上避免单独画图元。当任务条被拖动时GanttScene会调用所有相关DependencyItem的updatePath保证连线跟着走。参数上箭头尺寸 8x4 是经验值太小看不清太大遮挡任务条。4. 编译运行与最小可跑示例从源码到窗口弹出4.1 环境准备与编译步骤这份源码用 qmake 管理Qt 5.12 及以上或 Qt 6 均可。我一般用 Qt Creator 直接打开.pro文件选好 Kit 后点构建。命令行方式如下# 进入源码目录 cd GanttChart # 生成 Makefile注意替换成你的 Qt 安装路径 qmake GanttChart.pro # 编译Windows 用 mingw32-make 或 nmakeLinux/macOS 用 make make -j4 # 运行 ./GanttChart如果 qmake 不在 PATH 里用 Qt 安装目录下的完整路径比如~/Qt/5.15.2/gcc_64/bin/qmake。编译报错最常见的是找不到 Qt 模块检查.pro里的QT core gui widgets是否齐全。4.2 主窗口初始化与数据填充main.cpp很简洁创建应用和主窗口后进入事件循环。真正的初始化在MainWindow构造函数里它创建模型、场景、视图并塞入几条示例任务// mainwindow.cpp MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { m_model new GanttModel(this); m_scene new GanttScene(m_model, this); m_view new GanttView(m_scene, this); // 左侧任务树与右侧甘特图并排 QSplitter *splitter new QSplitter(Qt::Horizontal, this); m_tree new TaskTreeWidget(m_model, this); splitter-addWidget(m_tree); splitter-addWidget(m_view); splitter-setStretchFactor(1, 1); setCentralWidget(splitter); // 填充示例数据 GanttTask t1; t1.id 1; t1.name QStringLiteral(需求分析); t1.startTime QDateTime(QDate(2024,1,1), QTime(9,0)); t1.endTime QDateTime(QDate(2024,1,5), QTime(18,0)); t1.progress 100; m_model-addTask(t1); GanttTask t2; t2.id 2; t2.name QStringLiteral(接口设计); t2.startTime QDateTime(QDate(2024,1,6), QTime(9,0)); t2.endTime QDateTime(QDate(2024,1,12), QTime(18,0)); t2.progress 60; t2.dependencies 1; // 依赖任务 1 m_model-addTask(t2); m_scene-rebuild(); // 根据模型重建所有图元 m_view-fitToTasks(); // 自动缩放到合适比例 }逻辑说明QSplitter让任务树和甘特图可以拖动分隔条调整宽度setStretchFactor(1, 1)保证甘特图占据更多空间。示例数据里任务 2 依赖任务 1rebuild会据此生成依赖箭头。fitToTasks根据所有任务的时间范围计算缩放比例让初始视图刚好容纳全部任务。参数上时间用QDateTime而不是QDate是为了支持小时级排程如果你的场景只到天可以简化。4.3 时间轴缩放与滚动同步GanttView重写了wheelEvent按住 Ctrl 滚轮缩放普通滚轮垂直滚动Shift 滚轮水平滚动// ganttview.cpp void GanttView::wheelEvent(QWheelEvent *event) { if (event-modifiers() Qt::ControlModifier) { // Ctrl 滚轮缩放时间轴 qreal factor event-angleDelta().y() 0 ? 1.15 : 1.0 / 1.15; m_scaleFactor * factor; m_scaleFactor qBound(0.2, m_scaleFactor, 8.0); // 通知场景更新每像素时间比 m_scene-setPixelsPerSecond(m_basePixelsPerSecond * m_scaleFactor); event-accept(); } else { QGraphicsView::wheelEvent(event); // 默认滚动 } }逻辑说明m_scaleFactor限制在 0.2 到 8.0 之间防止缩放到极端值导致渲染异常。setPixelsPerSecond会触发场景重新计算所有任务条的宽度和位置以及时间刻度的密度。普通滚轮交给基类处理保持默认滚动行为。这里有个细节缩放后鼠标位置对应的场景点会偏移源码里没有做“以鼠标为中心缩放”如果你需要这个体验可以在缩放后调用centerOn补偿。5. 避坑与常见问题那些让我加班到凌晨的细节5.1 任务条拖拽后依赖箭头不更新现象拖动任务条任务条位置变了但依赖箭头还停在原地看起来像断开的线。原因TaskItem的itemChange没有通知DependencyItem或者场景没有在任务位置变化时遍历依赖项。解决在TaskItem::itemChange里捕获ItemPositionHasChanged发信号给场景场景再调用所有关联DependencyItem的updatePath。源码里是在mouseMoveEvent里直接调update()但依赖箭头需要显式刷新我一般会在场景里维护一个QMultiMapint, DependencyItem*的索引按任务 id 快速找到相关连线。5.2 缩放后拖拽时间粒度错乱现象放大时间轴后拖动任务条移动的时间量不对要么跳一大段要么几乎不动。原因m_pixelsPerSecond没有随缩放更新或者更新了但TaskItem里缓存的是旧值。解决把m_pixelsPerSecond作为场景的属性TaskItem每次拖拽时从场景读取而不是自己存一份。源码里是通过scene()-property(pixelsPerSecond)动态获取避免缓存不一致。5.3 大量任务时滚动卡顿现象任务超过 500 条后滚动和缩放明显掉帧。原因QGraphicsView 默认会渲染所有可见图元但任务条和依赖箭头的boundingRect如果设置过大会导致不必要的重绘区域。解决确保boundingRect精确返回实际占用区域不要为了省事返回一个超大矩形。另外可以开启setViewportUpdateMode(QGraphicsView::SmartViewportUpdate)让 Qt 只重绘变化区域。如果还卡考虑对不可见区域的任务条做懒加载但源码里没做到这一步属于进阶优化。5.4 依赖关系形成环导致死循环现象给任务 A 添加依赖 B又给 B 添加依赖 A程序卡死或栈溢出。原因earliestStart递归计算时没有检测环。解决在添加依赖前做一次深度优先搜索检测是否会形成环。源码里在GanttModel::addDependency里加了一个hasCycle辅助函数用访问标记数组实现。这个坑很隐蔽因为正常操作不会故意造环但用户拖拽时可能误操作。5.5 跨天任务的时间计算偏差现象任务从 1 月 1 日 18:00 到 1 月 2 日 9:00显示成跨了两天实际只跨了一晚。原因时间轴按天刻度渲染时没有考虑工作时间和非工作时间的区分。解决源码里简化处理按自然时间连续渲染。如果你的场景需要跳过周末和夜间需要在pixelsPerSecond换算时引入工作日历把非工作时间压缩掉。这是二次开发里最常见的改造点后面会细说。6. 二次开发改造思路从能跑到好用还差哪几步拿到这份源码跑通只是起点。真正用到项目里我一般会从三个方向改。第一接入真实数据源。源码里的示例数据是硬编码的实际项目里任务通常来自数据库或接口。改造点是给GanttModel加一个loadFromJson或loadFromSql方法把外部数据映射成GanttTask列表。注意时间字段的时区处理如果服务端返回 UTC 时间要在模型层统一转成本地时间再交给视图否则任务条位置会偏。我习惯在模型里加一个QDateTime::fromSecsSinceEpoch的转换入口所有外部时间都走这个口子。第二支持工作日历。这是甘特图从“能看”到“能用”的关键。改造思路是在GanttScene里维护一个WorkCalendar对象记录哪些日期是工作日、每天工作几个小时。pixelsPerSecond的换算不再用自然秒而是用“工作秒”。具体做法是写一个workingSecondsBetween(start, end)函数遍历日期区间累加工作时长。拖拽时的时间增量也要走这个函数否则拖过周末会出现任务条跳跃。这个改造工作量不小但一旦做好排程准确性会提升一个档次。第三增加导出与打印。项目评审时经常需要把甘特图导出成图片或 PDF。Qt 提供了QGraphicsScene::render到QPainter的能力可以渲染到QImage或QPdfWriter。我一般会加一个exportToPdf(const QString path)方法先临时把场景缩放到 A4 比例渲染完再恢复。注意导出时要隐藏选中高亮和鼠标光标否则图里会带上一堆交互痕迹。下面是一个导出 PDF 的最小实现可以直接抄// ganttscene.cpp bool GanttScene::exportToPdf(const QString filePath) { QPdfWriter writer(filePath); writer.setPageSize(QPageSize(QPageSize::A4)); writer.setResolution(300); // 300 DPI打印清晰 QPainter painter(writer); painter.setRenderHint(QPainter::Antialiasing); // 计算场景内容边界留出边距 QRectF contentRect itemsBoundingRect().adjusted(-20, -20, 20, 20); // 把场景渲染到 PDF 页面自动缩放适应 render(painter, QRectF(), contentRect, Qt::KeepAspectRatio); painter.end(); return true; }逻辑说明QPdfWriter的setResolution(300)决定输出精度300 DPI 打印够用再高文件会很大。itemsBoundingRect返回所有图元的包围盒加 20px 边距避免内容贴边。render的第四个参数Qt::KeepAspectRatio保证内容按比例缩放不会拉伸变形。参数上页面尺寸可以换成 A3 或自定义看你的打印需求。改造完之后验证方法很简单拿一份真实项目排期数据导进去拖几个任务看依赖是否联动缩放几次看时间刻度是否对齐导出 PDF 看是否清晰。我踩过最深的坑是时区问题——服务端返回的时间没带时区本地解析后偏了 8 小时任务条全跑到前一天去了。从那以后我每次接外部时间数据都强制在模型入口打日志确认时区再交给视图渲染。希望这份源码和改造思路帮到你少走几个我走过的弯路。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询