简介:本资源是一份面向C#初学者与项目管理工具开发者的甘特图实践代码包,聚焦于使用WinForms或WPF在Windows桌面端实现可交互的甘特图可视化组件。资源通过完整工程结构支撑从数据建模、时间轴计算、条形绘制到鼠标拖拽调整任务周期的全流程开发,适用于课程设计、小型项目管理系统原型开发及图形控件学习场景。压缩包共49个文件,含14个核心C#源码(.cs)、2个解决方案文件(.sln)、2个项目配置(.csproj)、5个本地化资源(.resx)及3个可执行程序(.exe),辅以缓存、配置、样式等配套文件,整体仅96KB,轻量易解压即用。目前已有304人学习下载,读者可直接运行exe体验交互逻辑,参考cs文件掌握Graphics绘图、DateTime时间映射、事件驱动拖拽及Grid布局适配等关键技术实现细节,是理解C#图形界面与项目进度可视化结合的典型小而全范例。
1. 用 C# 在 WinForms 或 WPF 中画出可拖拽、可缩放、带任务依赖线的甘特图,不是调用 Excel 插件,也不是套 HTML 控件——这是上位机调度系统、产线排程工具、项目管理桌面端最常卡住的环节
很多做工业上位机、MES 子模块或内部 OA 项目的技术人员,一接到「要展示工序时间轴」的需求,第一反应是导出 Excel 再用内置甘特图功能渲染。但实际交付时发现:Excel 无法嵌入主窗体、刷新卡顿、不能响应扫码枪触发的任务状态更新、也无法拖动调整工期——这些恰恰是产线看板和计划员操作的核心交互。CSharpGantt 这类开源实现之所以在 PUDN 上被高频下载(搜索site:www.pudn.com CSharpGantt可见大量带“可编辑”“支持依赖”“源码完整”的标注),正是因为它们绕开了 WebView2 或第三方控件授权问题,用纯 GDI+ 或 WPF DrawingContext 手写时间轴坐标系、任务条绘制逻辑与鼠标事件分发机制。它不追求媲美 Microsoft Project 的复杂度,但能稳定承载 50+ 并行任务、支持毫秒级重绘、允许双击弹出属性编辑窗——这正是 C# 桌面开发中「甘特图」的真实落地水位:不是炫技,而是让计划员在 1080p 屏幕上,用鼠标滚轮缩放查看一周排程,拖动一个任务条就自动更新下游依赖线,并实时写回数据库。本文就从零开始,复现这个最小可行闭环。
2. 用 GDI+ 在 WinForms Panel 上手绘甘特图:坐标系映射、时间轴刻度生成与任务条矩形计算
甘特图本质是二维坐标系下的矩形集合:横轴为时间(分钟/小时/天),纵轴为任务行号。C# 中最轻量、兼容性最强的实现方式,是继承Panel控件并重写OnPaint方法,全程使用Graphics对象绘制。这种方式不依赖第三方 NuGet 包,VS2015 及以上均可编译,且对扫码枪触发后 UI 刷新卡顿问题有天然优势——因为所有绘制都在单次Paint调用内完成,避免了DataGridView类控件频繁Invalidate()引发的重绘风暴。
2.1 定义任务数据模型与时间范围映射规则
首先需明确:甘特图不存储像素坐标,而存储业务时间。每个任务必须包含StartTime、EndTime、Title、RowNumber四个核心字段。时间范围(如2024-06-01 08:00到2024-06-07 18:00)决定横轴总宽度,需转换为像素密度(px/hour)。关键在于建立DateTime → int X的线性映射:
public class GanttTask { public string Title { get; set; } public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } public int RowNumber { get; set; } // 从 0 开始,对应纵轴第几行 } // 在 GanttPanel 类中定义映射方法 private double GetPixelsPerHour() { TimeSpan totalSpan = EndTime - StartTime; return (double)ClientSize.Width / totalSpan.TotalHours; } private int TimeToX(DateTime time) { TimeSpan offset = time - StartTime; return (int)(offset.TotalHours * GetPixelsPerHour()); }提示:
GetPixelsPerHour()必须在OnResize或时间范围变更后重新计算,否则缩放时坐标错乱。不要缓存该值为字段,而应在每次OnPaint中动态计算——因为用户可能拖动滚动条改变可视时间窗口,此时StartTime/EndTime已变,但控件尺寸未变。
2.2 绘制时间轴刻度与网格线:按天/小时两级刻度策略
真实产线排程中,用户既要看整周分布(按天),也要定位具体操作(按小时)。因此需分层绘制:主刻度(每天 00:00)加粗线+日期标签,次刻度(每小时)细线+时间标签。注意StringFormat设置居中对齐,避免文字重叠:
protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; // 绘制背景网格 using (Pen gridPen = new Pen(Color.LightGray, 0.5f)) { // 次刻度:每小时一条竖线 for (DateTime hour = StartTime.Date; hour <= EndTime; hour = hour.AddHours(1)) { int x = TimeToX(hour); if (x >= 0 && x <= ClientSize.Width) g.DrawLine(gridPen, x, RowHeight, x, ClientSize.Height); } // 主刻度:每天一条粗线 + 日期标签 using (SolidBrush dateBrush = new SolidBrush(Color.FromArgb(64, 64, 64))) using (StringFormat sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }) { for (DateTime day = StartTime.Date; day <= EndTime.Date; day = day.AddDays(1)) { int x = TimeToX(day); if (x >= 0 && x <= ClientSize.Width) { g.DrawLine(new Pen(Color.Black, 1.2f), x, RowHeight, x, ClientSize.Height); g.DrawString(day.ToString("MM/dd"), Font, dateBrush, x, RowHeight - 20, sf); } } } } }2.2.1 纵轴行高与任务条高度的硬约束
RowHeight不应固定为 30px。实际部署中,用户可能设置字体为微软雅黑 12 号,或要求显示任务编号+负责人+设备号三行文本。因此RowHeight必须动态计算:
private int CalculateRowHeight(Graphics g) { // 基于当前 Font 计算单行高度,再乘以行数(此处按 2 行预留) int lineHeight = (int)g.MeasureString("测试", Font).Height; return Math.Max(lineHeight * 2 + 8, 32); // 最小 32px,避免挤压 }2.3 绘制任务条与依赖箭头:GDI+ 路径绘制与抗锯齿控制
任务条是带圆角的矩形,依赖关系用贝塞尔曲线连接两个任务条中心点。关键点在于:所有绘制必须基于业务时间计算像素位置,而非直接存储 Rect。这样缩放时间范围时,任务条自动重绘,无需维护坐标缓存:
foreach (var task in Tasks.OrderBy(t => t.RowNumber)) { int x1 = TimeToX(task.StartTime); int x2 = TimeToX(task.EndTime); int y = task.RowNumber * RowHeight + RowHeight / 2; // 绘制任务条(圆角矩形) using (GraphicsPath path = new GraphicsPath()) { int cornerRadius = 4; path.AddArc(x1, y - 12, cornerRadius * 2, cornerRadius * 2, 180, 90); path.AddLine(x1 + cornerRadius, y - 12, x2 - cornerRadius, y - 12); path.AddArc(x2 - cornerRadius * 2, y - 12, cornerRadius * 2, cornerRadius * 2, 270, 90); path.AddLine(x2 - cornerRadius, y + 12, x1 + cornerRadius, y + 12); path.CloseFigure(); using (SolidBrush brush = new SolidBrush(GetTaskColor(task))) using (Pen borderPen = new Pen(Color.FromArgb(80, 0, 0, 0), 0.8f)) { g.FillPath(brush, path); g.DrawPath(borderPen, path); } } // 绘制任务标题(居中于任务条上方) using (SolidBrush titleBrush = new SolidBrush(Color.Black)) using (StringFormat sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }) { RectangleF titleRect = new RectangleF(x1, y - 24, x2 - x1, 20); g.DrawString(task.Title, Font, titleBrush, titleRect, sf); } }注意:
GraphicsPath是绘制圆角矩形的唯一可靠方式。DrawRoundedRectangle并非 GDI+ 原生方法,网上流传的扩展方法多存在抗锯齿失效问题。此处用AddArc+AddLine组合,确保缩放时圆角比例不变。
3. 实现任务拖拽与缩放:鼠标事件绑定、坐标反查与增量重绘优化
甘特图交互的核心是「拖拽调整工期」和「滚轮缩放时间粒度」。若直接在OnPaint中重绘全部任务,50 个任务下帧率会跌破 10fps。必须采用增量重绘(Invalidate(Rectangle))+ 坐标反查(ScreenPoint → DateTime)策略。
3.1 坐标反查:将鼠标点击位置映射回业务时间点
MouseClick事件中,需将屏幕坐标(e.X, e.Y)转换为DateTime和RowNumber。难点在于纵轴行号计算:e.Y / RowHeight只是近似值,必须结合RowHeight的实际像素高度做取整校验:
private (DateTime? time, int? row) ScreenPointToBusinessPoint(Point screenPoint) { if (screenPoint.X < 0 || screenPoint.X > ClientSize.Width || screenPoint.Y < RowHeight || screenPoint.Y > ClientSize.Height) return (null, null); // 横轴:像素 → DateTime double hoursOffset = (double)screenPoint.X / GetPixelsPerHour(); DateTime clickedTime = StartTime.AddHours(hoursOffset); // 纵轴:Y 坐标 → 行号(需容错:用户可能点在行间隙) int candidateRow = (screenPoint.Y - RowHeight / 2) / RowHeight; if (candidateRow >= 0 && candidateRow < Tasks.Count) { // 校验是否真在该行有效区域内(上下留 4px 误差) int rowTop = candidateRow * RowHeight + RowHeight / 2 - 12; int rowBottom = rowTop + RowHeight; if (screenPoint.Y >= rowTop - 4 && screenPoint.Y <= rowBottom + 4) return (clickedTime, candidateRow); } return (clickedTime, null); }3.2 拖拽任务条:捕获鼠标按下、移动、释放三阶段事件
拖拽逻辑必须区分「拖动整个任务」和「拖动任务条左右边」。通过HitTest判断鼠标初始位置相对于任务条的位置:
private GanttTask _draggingTask; private bool _isDraggingLeftEdge; private bool _isDraggingRightEdge; private DateTime _dragStartOriginalTime; private void GanttPanel_MouseDown(object sender, MouseEventArgs e) { var hit = ScreenPointToBusinessPoint(e.Location); if (hit.row == null) return; var task = Tasks.FirstOrDefault(t => t.RowNumber == hit.row.Value); if (task == null) return; // 判断是否点击在任务条左右边缘 10px 内 int x1 = TimeToX(task.StartTime); int x2 = TimeToX(task.EndTime); _isDraggingLeftEdge = Math.Abs(e.X - x1) <= 10; _isDraggingRightEdge = Math.Abs(e.X - x2) <= 10; if (_isDraggingLeftEdge || _isDraggingRightEdge) { _draggingTask = task; _dragStartOriginalTime = _isDraggingLeftEdge ? task.StartTime : task.EndTime; Capture = true; // 确保 MouseMove 在控件外也触发 } } private void GanttPanel_MouseMove(object sender, MouseEventArgs e) { if (_draggingTask == null) return; var hit = ScreenPointToBusinessPoint(e.Location); if (hit.time == null) return; if (_isDraggingLeftEdge) { _draggingTask.StartTime = hit.time.Value; // 确保工期不为负 if (_draggingTask.StartTime > _draggingTask.EndTime) _draggingTask.EndTime = _draggingTask.StartTime.AddMinutes(30); } else if (_isDraggingRightEdge) { _draggingTask.EndTime = hit.time.Value; if (_draggingTask.EndTime < _draggingTask.StartTime) _draggingTask.StartTime = _draggingTask.EndTime.AddMinutes(-30); } // 只重绘任务条所在区域,而非全屏 int x1 = Math.Min(TimeToX(_draggingTask.StartTime), TimeToX(_draggingTask.EndTime)); int x2 = Math.Max(TimeToX(_draggingTask.StartTime), TimeToX(_draggingTask.EndTime)); Invalidate(new Rectangle(x1 - 5, _draggingTask.RowNumber * RowHeight, x2 - x1 + 10, RowHeight)); } private void GanttPanel_MouseUp(object sender, MouseEventArgs e) { if (_draggingTask != null) { _draggingTask = null; _isDraggingLeftEdge = false; _isDraggingRightEdge = false; ReleaseCapture(); } }3.2.1 滚轮缩放时间范围:保持鼠标焦点时间点不动
用户滚轮缩放时,希望「鼠标指针下的时间点保持不动」,而非简单拉伸整个时间轴。这需要动态调整StartTime和EndTime,使TimeToX(mouseX)对应的DateTime不变:
private void GanttPanel_MouseWheel(object sender, MouseEventArgs e) { double zoomFactor = e.Delta > 0 ? 0.9 : 1.1; // 向上滚缩小,向下滚放大 TimeSpan oldSpan = EndTime - StartTime; TimeSpan newSpan = TimeSpan.FromHours(oldSpan.TotalHours * zoomFactor); // 计算鼠标位置对应的时间点 var mouseTime = ScreenPointToBusinessPoint(e.Location).time; if (mouseTime == null) return; // 以 mouseTime 为中心,重新计算起止时间 TimeSpan halfNewSpan = newSpan.Divide(2); StartTime = mouseTime.Value.Subtract(halfNewSpan); EndTime = mouseTime.Value.Add(halfNewSpan); // 强制重绘整个控件(因时间轴刻度完全变化) Invalidate(); }4. 用 WPF 重构:基于 Canvas 的矢量甘特图与依赖线自动布局
WinForms 方案在 100+ 任务时仍可能卡顿,且无法利用 GPU 加速。WPF 提供更现代的方案:用Canvas作为容器,每个任务条为Border元素,依赖线为Polyline,所有位置通过Canvas.Left/Top绑定到 ViewModel 中的X,Y,Width属性。优势在于:动画平滑、DPI 自适应、样式易定制,且天然支持触摸拖拽。
4.1 ViewModel 层设计:实现 INotifyPropertyChanged 与时间计算分离
WPF 甘特图的核心是将「时间→像素」转换逻辑从 UI 层剥离。ViewModel 不持有Graphics或Canvas,只暴露TaskItem集合及TimeScale(单位:px/hour):
public class TaskItem : INotifyPropertyChanged { private DateTime _startTime; private DateTime _endTime; private double _x; private double _width; public DateTime StartTime { get => _startTime; set { _startTime = value; OnPropertyChanged(); UpdatePosition(); } } public DateTime EndTime { get => _endTime; set { _endTime = value; OnPropertyChanged(); UpdatePosition(); } } public double X { get => _x; private set => SetProperty(ref _x, value); } public double Width { get => _width; private set => SetProperty(ref _width, value); } private void UpdatePosition() { // ViewModel 中不依赖 UI 控件,TimeScale 由父 ViewModel 提供 X = (StartTime - GanttViewModel.Instance.TimeRangeStart).TotalHours * GanttViewModel.Instance.TimeScale; Width = (EndTime - StartTime).TotalHours * GanttViewModel.Instance.TimeScale; } }4.2 XAML 中绑定任务条与依赖线:使用 ItemsControl + DataTemplate
ItemsControl绑定TaskItems集合,DataTemplate定义每个任务条的Border,其Canvas.Left绑定X,Width绑定Width。依赖线通过Polyline的Points绑定到DependencyPoints属性(类型为PointCollection):
<ItemsControl ItemsSource="{Binding TaskItems}"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <Canvas /> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemTemplate> <DataTemplate> <Border Background="{Binding Color}" CornerRadius="3" Width="{Binding Width}" Height="24" Canvas.Left="{Binding X}" Canvas.Top="{Binding Y}"> <TextBlock Text="{Binding Title}" VerticalAlignment="Center" HorizontalAlignment="Center" Foreground="White"/> </Border> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> <!-- 依赖线 --> <ItemsControl ItemsSource="{Binding DependencyLines}"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <Canvas /> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemTemplate> <DataTemplate> <Polyline Points="{Binding Points}" Stroke="DarkBlue" StrokeThickness="1.5" StrokeDashArray="2,2"/> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl>4.2.1 依赖线自动计算:贝塞尔曲线避开任务条重叠
依赖线不能是直线,否则多条线交叉时无法分辨流向。采用三次贝塞尔曲线,控制点设为起点右侧 30px + 下方 20px,终点左侧 30px + 上方 20px,确保曲线自然拱起:
public PointCollection CalculateDependencyCurve(TaskItem from, TaskItem to) { Point startPoint = new Point(from.X + from.Width, from.Y + 12); Point endPoint = new Point(to.X, to.Y + 12); // 控制点:起点向右下,终点向左上 Point control1 = new Point(startPoint.X + 30, startPoint.Y + 20); Point control2 = new Point(endPoint.X - 30, endPoint.Y - 20); // 使用三次贝塞尔公式生成 10 个采样点(简化版,实际可用 PathGeometry) var points = new PointCollection(); for (double t = 0; t <= 1; t += 0.1) { double x = Math.Pow(1 - t, 3) * startPoint.X + 3 * Math.Pow(1 - t, 2) * t * control1.X + 3 * (1 - t) * t * t * control2.X + t * t * t * endPoint.X; double y = Math.Pow(1 - t, 3) * startPoint.Y + 3 * Math.Pow(1 - t, 2) * t * control1.Y + 3 * (1 - t) * t * t * control2.Y + t * t * t * endPoint.Y; points.Add(new Point(x, y)); } return points; }5. 解决扫码枪触发后的 UI 卡顿:双缓冲、异步加载与任务条批量更新技巧
产线场景中,扫码枪每秒触发 3~5 次任务状态更新(如“工序A完成”),若每次更新都Invalidate()整个甘特图,UI 线程将被阻塞。必须采用「批量合并 + 后台计算 + UI 线程最小化操作」三重策略。
5.1 后台线程预计算:避免在 UI 线程执行 DateTime 运算
扫码事件触发后,不应直接修改TaskItem.StartTime,而应将更新请求放入队列,由后台线程批量处理:
private readonly ConcurrentQueue<(string taskId, DateTime newTime, bool isStart)> _updateQueue = new(); private readonly CancellationTokenSource _cts = new(); private async void ScanHandler(string barcode) { // 解析条码获取 taskId 和新时间 var (taskId, newTime, isStart) = ParseBarcode(barcode); _updateQueue.Enqueue((taskId, newTime, isStart)); // 启动后台处理(若未运行) if (!_isProcessingUpdates) { _isProcessingUpdates = true; _ = ProcessUpdatesAsync(); } } private async Task ProcessUpdatesAsync() { while (_updateQueue.TryDequeue(out var update)) { // 在后台线程查找并更新 TaskItem(不触碰 UI) var task = _taskItems.FirstOrDefault(t => t.Id == update.taskId); if (task != null) { if (update.isStart) task.StartTime = update.newTime; else task.EndTime = update.newTime; } } // 批量通知 UI 更新(仅一次 Dispatcher.Invoke) await Application.Current.Dispatcher.InvokeAsync(() => { foreach (var task in _taskItems.Where(t => t.IsDirty)) { task.NotifyPropertyChanged(nameof(TaskItem.X)); task.NotifyPropertyChanged(nameof(TaskItem.Width)); task.IsDirty = false; } }); _isProcessingUpdates = false; }5.2 双缓冲强制启用:WinForms 中绕过默认闪烁
WinForms 默认双缓冲对自绘控件无效。必须手动开启:
public GanttPanel() { InitializeComponent(); // 启用双缓冲 SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | ControlStyles.AllPaintingInWmPaint, true); // 禁用擦除背景,避免闪烁 SetStyle(ControlStyles.Opaque, true); }5.3 WPF 中的虚拟化:当任务数超 200 时启用 VirtualizingStackPanel
WPFItemsControl默认不虚拟化,200 个Border元素会显著拖慢渲染。需显式启用:
<ItemsControl.ItemsPanel> <ItemsPanelTemplate> <VirtualizingStackPanel /> </ItemsPanelTemplate> </ItemsControl.ItemsPanel>注意:虚拟化要求
Canvas替换为VirtualizingStackPanel,此时任务条不再绝对定位,而是按行堆叠。若需保留时间轴横向布局,应改用VirtualizingWrapPanel并设置Orientation="Horizontal",配合ItemWidth固定每个任务条宽度,再通过ScrollViewer水平滚动——这是处理超大任务集的唯一可行路径。
6. 甘特图右边进度条时间设置:同步滚动条与时间范围联动的 3 种实现模式
用户常要求「甘特图右侧加一个时间进度条,拖动它即可平移时间轴」。这不是装饰,而是解决宽时间范围(如一个月)下横向滚动困难的关键交互。实现模式有三种,按复杂度递增排列:
6.1 模式一:ScrollBar 直接绑定时间偏移(最简)
用HScrollBar控件,Minimum=0,Maximum=TotalHours,Value表示已偏移小时数。Scroll事件中重置StartTime/EndTime:
private void TimeScrollBar_Scroll(object sender, ScrollEventArgs e) { TimeSpan offset = TimeSpan.FromHours(e.NewValue); StartTime = BaseStartTime.Add(offset); EndTime = BaseEndTime.Add(offset); Invalidate(); }6.2 模式二:Slider + 时间刻度标记(推荐用于 WinForms)
TrackBar替代ScrollBar,添加刻度标签(如“6/01”、“6/02”…),需动态生成TickFrequency:
private void SetupTimeSlider() { TimeSpan totalSpan = BaseEndTime - BaseStartTime; timeSlider.Maximum = (int)totalSpan.TotalDays; timeSlider.TickFrequency = 1; // 每天一个刻度 timeSlider.SmallChange = 1; timeSlider.LargeChange = 7; // 大步进为一周 } private void TimeSlider_Scroll(object sender, EventArgs e) { int daysOffset = timeSlider.Value; StartTime = BaseStartTime.AddDays(daysOffset); EndTime = StartTime.AddDays(ViewDays); // ViewDays=7,固定视图宽度 Invalidate(); }6.3 模式三:WPF 中的 TimeRangeSlider 自定义控件(高阶)
创建UserControl,内部含Slider和ItemsControl显示日期标签。关键在于Slider.ValueChanged中触发TimeRangeChanged事件,并在父窗体中订阅:
<local:TimeRangeSlider x:Name="timeRangeSlider" Minimum="{Binding BaseStartTime}" Maximum="{Binding BaseEndTime}" ViewDuration="7" TimeRangeChanged="OnTimeRangeChanged"/>private void OnTimeRangeChanged(object sender, TimeRangeChangedEventArgs e) { // e.StartTime 和 e.EndTime 已根据 Slider 位置计算好 ganttView.StartTime = e.StartTime; ganttView.EndTime = e.EndTime; ganttView.InvalidateVisual(); // WPF 中触发重绘 }提示:无论哪种模式,都必须保证
StartTime/EndTime的变更能触发OnPaint或InvalidateVisual()。WinForms 中检查Invalidate()是否被其他逻辑屏蔽;WPF 中确认Binding的UpdateSourceTrigger为PropertyChanged,且INotifyPropertyChanged正确触发。
本文还有配套的精品资源,点击获取