
简介本资源是一份面向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 原生方法网上流传的扩展方法多存在抗锯齿失效问题。此处用AddArcAddLine组合确保缩放时圆角比例不变。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/hourpublic 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 DataTemplateItemsControl绑定TaskItems集合DataTemplate定义每个任务条的Border其Canvas.Left绑定XWidth绑定Width。依赖线通过Polyline的Points绑定到DependencyPoints属性类型为PointCollectionItemsControl ItemsSource{Binding TaskItems} ItemsControl.ItemsPanel ItemsPanelTemplate Canvas / /ItemsPanelTemplate /ItemsControl.ItemsPanel ItemsControl.ItemTemplate DataTemplate Border Background{Binding Color} CornerRadius3 Width{Binding Width} Height24 Canvas.Left{Binding X} Canvas.Top{Binding Y} TextBlock Text{Binding Title} VerticalAlignmentCenter HorizontalAlignmentCenter ForegroundWhite/ /Border /DataTemplate /ItemsControl.ItemTemplate /ItemsControl !-- 依赖线 -- ItemsControl ItemsSource{Binding DependencyLines} ItemsControl.ItemsPanel ItemsPanelTemplate Canvas / /ItemsPanelTemplate /ItemsControl.ItemsPanel ItemsControl.ItemTemplate DataTemplate Polyline Points{Binding Points} StrokeDarkBlue StrokeThickness1.5 StrokeDashArray2,2/ /DataTemplate /ItemsControl.ItemTemplate /ItemsControl4.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 时启用 VirtualizingStackPanelWPFItemsControl默认不虚拟化200 个Border元素会显著拖慢渲染。需显式启用ItemsControl.ItemsPanel ItemsPanelTemplate VirtualizingStackPanel / /ItemsPanelTemplate /ItemsControl.ItemsPanel注意虚拟化要求Canvas替换为VirtualizingStackPanel此时任务条不再绝对定位而是按行堆叠。若需保留时间轴横向布局应改用VirtualizingWrapPanel并设置OrientationHorizontal配合ItemWidth固定每个任务条宽度再通过ScrollViewer水平滚动——这是处理超大任务集的唯一可行路径。6. 甘特图右边进度条时间设置同步滚动条与时间范围联动的 3 种实现模式用户常要求「甘特图右侧加一个时间进度条拖动它即可平移时间轴」。这不是装饰而是解决宽时间范围如一个月下横向滚动困难的关键交互。实现模式有三种按复杂度递增排列6.1 模式一ScrollBar 直接绑定时间偏移最简用HScrollBar控件Minimum0MaximumTotalHoursValue表示已偏移小时数。Scroll事件中重置StartTime/EndTimeprivate 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 时间刻度标记推荐用于 WinFormsTrackBar替代ScrollBar添加刻度标签如“6/01”、“6/02”…需动态生成TickFrequencyprivate 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); // ViewDays7固定视图宽度 Invalidate(); }6.3 模式三WPF 中的 TimeRangeSlider 自定义控件高阶创建UserControl内部含Slider和ItemsControl显示日期标签。关键在于Slider.ValueChanged中触发TimeRangeChanged事件并在父窗体中订阅local:TimeRangeSlider x:NametimeRangeSlider Minimum{Binding BaseStartTime} Maximum{Binding BaseEndTime} ViewDuration7 TimeRangeChangedOnTimeRangeChanged/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正确触发。本文还有配套的精品资源点击获取