简介:这份资源面向使用 C# 开发 Windows 窗体应用的开发者,尤其是需要在 DataGridView 中嵌入自定义编辑控件的进阶学习者。它提供了一套完整的自定义列实现源码,弥补了原生 DataGridView 仅支持文本框、下拉框和复选框三种列类型的不足,重点演示了如何通过派生 DataGridViewColumn 与 DataGridViewCell 来嵌套时间控件,并借助编辑控件接口在单元格进入编辑模式时显示日历选择器。资源包共 24 个文件,约 25KB,包含 9 个 cs 源码文件、3 个 exe 可执行程序、2 个 resx 与 2 个 resources 资源文件,以及 sln 解决方案、csproj 工程文件和配置项,源码与工程结构完整,可直接编译运行。目前已有 1652 人学习下载。读者可从中掌握自定义单元格绘制、编辑控件托管与列类型扩展的完整思路,对照 CalendarColumn、CalendarCell、CalendarEditingControl 等核心类理解控件生命周期,并作为自身项目中扩展 DataGridView 功能的参考模板。
1. 从一次翻车说起:为什么我要拆这份 DataGridView 自定义列源码
去年帮一个做仓储管理的朋友改 WinForms 项目,需求很简单——在 DataGridView 里加一列进度条,显示每个订单的发货完成度。我心想这有什么难的,直接DataGridViewProgressBarColumn走起。结果翻车了:单元格重绘闪烁、滚动时数值错位、编辑状态下进度条直接消失。折腾了一下午才意识到,DataGridView 的自定义列远不是继承一个DataGridViewColumn就完事,绘制、编辑、值转换、事件响应每一环都有坑。
这份源码就是围绕「自定义列」这个核心场景展开的,提供了一套可直接编译运行的 C# WinForms 示例,覆盖进度条列、按钮列、下拉框列等常见自定义列类型。适合两类人:一是正在用 WinForms 做业务系统、被 DataGridView 原生列类型卡住的开发者;二是想搞懂 DataGridView 列模型底层机制、准备自己写控件库的人。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序,把这份源码拆开讲。
2. 项目定位与技术选型:WinForms 下 DataGridView 列模型的完整实现
2.1 为什么是自定义列而不是模板列
WinForms 的 DataGridView 自带文本列、复选框列、图片列、按钮列、下拉框列、链接列这几种。业务系统里稍微复杂一点的需求——进度条、评分星级、颜色选择器、多标签展示——原生列就覆盖不了了。常见的替代方案有两种:一是用DataGridViewCellPainting事件在 CellFormatting 阶段手绘,二是直接继承DataGridViewColumn写自定义列。
第一种方案的问题是逻辑散落在 Form 代码里,复用性差,多个表格要复制粘贴。第二种方案把绘制、编辑、值处理封装到列和单元格类里,用的时候只需要dataGridView.Columns.Add(new MyColumn()),干净得多。这份源码走的是第二条路,也是我推荐的做法。
2.2 技术栈与运行环境
项目基于 .NET Framework 4.x + WinForms,用 Visual Studio 打开.sln直接编译即可。没有引入第三方 UI 库,所有绘制逻辑基于 GDI+ 原生 API。核心类继承关系如下:
| 类名 | 继承自 | 职责 |
|---|---|---|
| CustomColumnBase | DataGridViewColumn | 列定义,绑定 CellTemplate |
| ProgressBarCell | DataGridViewCell | 进度条绘制与值处理 |
| ButtonCell | DataGridViewCell | 按钮绘制与点击响应 |
| ComboBoxCell | DataGridViewCell | 下拉编辑与显示 |
| CellEditingControl | Control, IDataGridViewEditingControl | 编辑态控件宿主 |
这个继承体系是 DataGridView 自定义列的标准骨架。列类本身不干活,它只负责指定CellTemplate,真正的绘制和交互都在 Cell 类里。编辑态则需要额外实现IDataGridViewEditingControl接口,让 DataGridView 知道怎么把你的控件嵌进单元格。
2.3 目录结构一览
源码包解压后大致是这样的结构:
DataGridViewCustomColumns/ ├── Columns/ │ ├── CustomColumnBase.cs │ ├── ProgressBarColumn.cs │ ├── ButtonColumn.cs │ └── ComboBoxColumn.cs ├── Cells/ │ ├── ProgressBarCell.cs │ ├── ButtonCell.cs │ └── ComboBoxCell.cs ├── EditingControls/ │ └── ComboBoxEditingControl.cs ├── Demo/ │ └── MainForm.cs └── DataGridViewCustomColumns.slnColumns放列定义,Cells放单元格逻辑,EditingControls放编辑态控件,Demo是一个可直接运行的示例窗体。这个分层方式值得借鉴——列、单元格、编辑控件三者职责分离,改一个不会牵连另一个。
3. 核心模块拆解:从列定义到单元格绘制的完整链路
3.1 列定义:CellTemplate 是关键
先看列类怎么写。以进度条列为例:
public class ProgressBarColumn : DataGridViewColumn { public ProgressBarColumn() : base(new ProgressBarCell()) { // 默认宽度调大一点,进度条太窄看不清 this.Width = 120; this.DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleCenter; } // 必须重写 Clone,否则设计器复制列时会丢配置 public override object Clone() { var col = (ProgressBarColumn)base.Clone(); return col; } // 告诉 DataGridView 这个列的值类型是什么 public override Type ValueType { get { return typeof(int); } } }这里有两个容易忽略的点。第一,构造函数里必须传CellTemplate,否则 DataGridView 不知道用哪个 Cell 类来渲染。第二,Clone()必须重写,不然在设计器里复制粘贴列、或者用Columns.Add动态添加时,配置会丢失。我见过有人忘了重写 Clone,结果运行时列样式全变成默认值,排查了半天。
ValueType重写为int是因为进度条的值是 0-100 的整数。如果你的自定义列存的是自定义对象,这里要返回对应的类型,DataGridView 在排序和格式化时会用到它。
3.2 单元格绘制:Paint 方法的正确姿势
Cell 类的核心是Paint方法。进度条单元格的绘制逻辑:
protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates cellState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) { // 先画背景和边框,这部分交给基类 base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); // 解析进度值,做边界保护 int progress = 0; if (value != null && int.TryParse(value.ToString(), out int v)) progress = Math.Max(0, Math.Min(100, v)); // 计算进度条区域,留 2px 内边距 Rectangle barRect = new Rectangle( cellBounds.X + 2, cellBounds.Y + 2, cellBounds.Width - 4, cellBounds.Height - 4); // 画底槽 using (var backBrush = new SolidBrush(Color.FromArgb(230, 230, 230))) graphics.FillRectangle(backBrush, barRect); // 画进度 int fillWidth = (int)(barRect.Width * progress / 100.0); if (fillWidth > 0) { Rectangle fillRect = new Rectangle(barRect.X, barRect.Y, fillWidth, barRect.Height); using (var fillBrush = new SolidBrush(Color.FromArgb(0, 120, 215))) graphics.FillRectangle(fillBrush, fillRect); } // 画百分比文字 string text = progress + "%"; TextRenderer.DrawText(graphics, text, cellStyle.Font, cellBounds, cellStyle.ForeColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); }几个关键点。第一,base.Paint只传Background | Border,不要传ContentForeground,否则基类会再画一遍文字,和你的绘制重叠。第二,进度值一定要做Math.Max/Min边界保护,数据源里出现 -1 或 200 时进度条会画到单元格外面去。第三,用TextRenderer.DrawText而不是Graphics.DrawString,前者在 WinForms 里渲染更清晰,尤其是小字号。
3.3 编辑态处理:IDataGridViewEditingControl 接口
下拉框列需要编辑态。DataGridView 的编辑机制是:进入编辑时,它向 Cell 要一个编辑控件,这个控件必须实现IDataGridViewEditingControl。核心实现:
public class ComboBoxEditingControl : ComboBox, IDataGridViewEditingControl { private DataGridView dataGridView; private bool valueChanged; // 告诉 DataGridView 编辑值变了,触发提交 public object EditingControlFormattedValue { get { return this.Text; } set { this.Text = value?.ToString() ?? ""; } } public object GetEditingControlFormattedValue(DataGridViewDataErrorContexts context) { return this.Text; } // 把编辑值写回单元格 public void ApplyCellStyleToEditingControl(DataGridViewCellStyle style) { this.Font = style.Font; this.BackColor = style.BackColor; } public bool EditingControlWantsInputKey(Keys keyData, bool dataGridViewWantsInputKey) { // 方向键和回车交给 ComboBox 自己处理 switch (keyData & Keys.KeyCode) { case Keys.Left: case Keys.Right: case Keys.Up: case Keys.Down: case Keys.Enter: return true; default: return !dataGridViewWantsInputKey; } } public void PrepareEditingControlForEdit(bool selectAll) { if (selectAll) this.SelectAll(); } public bool RepositionEditingControlOnValueChange => false; public DataGridView EditingControlDataGridView { get => dataGridView; set => dataGridView = value; } public int EditingControlRowIndex { get; set; } public bool EditingControlValueChanged { get => valueChanged; set => valueChanged = value; } public Cursor EditingPanelCursor => base.Cursor; }EditingControlWantsInputKey是最容易出问题的地方。如果这里返回 false,按方向键会直接跳出编辑态而不是在 ComboBox 里切换选项。我一般会把方向键、回车、Tab 都返回 true,让编辑控件优先处理。
3.4 值转换与格式化:GetFormattedValue 的时机
Cell 的GetFormattedValue决定了显示什么。进度条列如果数据源存的是 0.0-1.0 的浮点数,显示时要转成百分比:
protected override object GetFormattedValue(object value, int rowIndex, ref DataGridViewCellStyle cellStyle, TypeConverter valueTypeConverter, TypeConverter formattedValueTypeConverter, DataGridViewDataErrorContexts context) { if (value == null) return "0%"; if (value is double d) return (d * 100).ToString("F0") + "%"; return value.ToString() + "%"; }注意GetFormattedValue在每次重绘时都会调用,不要在里面做耗时操作。如果需要缓存,用 Cell 的Tag属性或者外部字典。
4. 跑起来:编译、运行与第一个自定义列
4.1 环境准备与编译
用 Visual Studio 2019 或更高版本打开.sln,目标框架选 .NET Framework 4.7.2 或 4.8。直接 F5 运行,Demo 窗体会加载一个包含三种自定义列的表格。如果编译报错找不到System.Windows.Forms,检查项目引用里是否添加了该程序集。
命令行编译的话:
msbuild DataGridViewCustomColumns.sln /p:Configuration=Release编译产物在bin/Release/下,是一个 exe,双击即可运行。
4.2 在现有项目里接入自定义列
把Columns、Cells、EditingControls三个文件夹复制到你的项目,然后:
// 初始化表格 var grid = new DataGridView(); grid.Dock = DockStyle.Fill; grid.AllowUserToAddRows = false; grid.RowTemplate.Height = 32; // 添加进度条列 var progressCol = new ProgressBarColumn(); progressCol.HeaderText = "发货进度"; progressCol.Name = "colProgress"; progressCol.DataPropertyName = "Progress"; // 绑定数据源字段 grid.Columns.Add(progressCol); // 添加按钮列 var btnCol = new ButtonColumn(); btnCol.HeaderText = "操作"; btnCol.Name = "colAction"; btnCol.Text = "查看详情"; grid.Columns.Add(btnCol); // 绑定数据 grid.DataSource = orderList; // orderList 里每项有 Progress 字段 this.Controls.Add(grid);DataPropertyName必须和数据源的属性名一致,否则列显示空白。按钮列不需要绑定字段,它的值通过CellContentClick事件处理。
4.3 按钮列的点击响应
按钮列的点击不是通过CellClick,而是CellContentClick:
grid.CellContentClick += (sender, e) => { if (e.RowIndex < 0) return; if (grid.Columns[e.ColumnIndex].Name == "colAction") { var order = grid.Rows[e.RowIndex].DataBoundItem as Order; if (order != null) { MessageBox.Show($"订单号:{order.OrderNo}"); } } };CellContentClick只在点击单元格内容区域时触发,点空白处不触发。如果按钮列和文本列混用,一定要判断ColumnIndex,否则点任何列都会弹窗。
5. 避坑与常见问题:那些让我加班到凌晨的细节
5.1 滚动时进度条闪烁或错位
现象:表格滚动时进度条位置偏移,或者出现残影。
原因:Paint方法里没有正确使用cellBounds,或者用了Graphics.DrawString导致重绘区域计算错误。
解决:所有绘制坐标基于cellBounds计算,不要用rowIndex去推算 Y 坐标。另外在 Form 构造函数里开启双缓冲:
this.SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true);5.2 编辑态下拉框按方向键直接退出编辑
现象:ComboBox 编辑时按上下键,编辑态直接关闭,值没变。
原因:EditingControlWantsInputKey返回了 false,DataGridView 抢走了按键。
解决:在EditingControlWantsInputKey里对方向键、回车、Tab 返回 true,让编辑控件优先处理。参考 3.3 节的代码。
5.3 设计器里列配置丢失
现象:在设计器里设置了列宽、HeaderText,重新打开设计器全变默认值。
原因:没有重写Clone()方法,或者CellTemplate没有正确设置。
解决:每个自定义列类都必须重写Clone(),并在构造函数里传入 CellTemplate。如果列有自定义属性,还要考虑序列化问题。
5.4 数据源为空时绘制异常
现象:表格没有数据时,进度条列绘制报NullReferenceException。
原因:Paint方法里直接用了value.ToString(),没有判空。
解决:所有从value取值的地方都加判空,给一个默认值。进度条默认 0%,按钮默认空文本。
5.5 高 DPI 下文字模糊
现象:在 4K 屏幕上,进度条里的百分比文字发虚。
原因:WinForms 默认 DPI 感知是系统级,GDI+ 绘制没有按 DPI 缩放。
解决:在 app.manifest 里开启 PerMonitorV2 DPI 感知,或者用TextRenderer替代Graphics.DrawString,后者对 DPI 处理更好。
6. 二次开发:从改一个列到写一套控件库
6.1 改造思路:抽象出通用基类
源码里的CustomColumnBase和CustomCellBase是很好的扩展起点。如果你要加一个「星级评分列」,不需要从零写,继承基类后只需要实现三个方法:
public class StarRatingCell : CustomCellBase { protected override void OnPaintContent(Graphics g, Rectangle bounds, object value) { int stars = value == null ? 0 : Math.Min(5, Math.Max(0, (int)value)); for (int i = 0; i < 5; i++) { var color = i < stars ? Color.Gold : Color.LightGray; using (var brush = new SolidBrush(color)) g.FillPolygon(brush, GetStarPoints(bounds.X + i * 20, bounds.Y + 4, 16)); } } protected override object OnGetFormattedValue(object value) { return value ?? 0; } protected override Type OnGetValueType() => typeof(int); }把绘制、格式化、值类型三个钩子抽出来,新增列类型的工作量从半天降到半小时。我一般会再抽一个OnGetPreferredSize钩子,让单元格能根据内容自动算高度。
6.2 验证方法:用单元测试覆盖值转换
自定义列最容易出 bug 的地方是值转换和边界处理。建议对GetFormattedValue和Paint里的数值计算写单元测试:
[TestMethod] public void ProgressBar_ValueOutOfRange_ClampedTo100() { var cell = new ProgressBarCell(); var result = cell.GetFormattedValue(200, 0, ref style, null, null, DataGridViewDataErrorContexts.Formatting); Assert.AreEqual("100%", result); } [TestMethod] public void ProgressBar_NullValue_ReturnsZeroPercent() { var cell = new ProgressBarCell(); var result = cell.GetFormattedValue(null, 0, ref style, null, null, DataGridViewDataErrorContexts.Formatting); Assert.AreEqual("0%", result); }GetFormattedValue是 protected 的,测试时需要把测试类放到同一程序集,或者用InternalsVisibleTo暴露。我一般会在项目里加一个[assembly: InternalsVisibleTo("Tests")]。
6.3 一个具体技巧:用双缓冲减少闪烁
如果自定义列绘制复杂(比如带渐变、阴影),即使开了 Form 双缓冲,单元格级别仍可能闪烁。可以在 Cell 的Paint里用离屏位图:
protected override void Paint(...) { using (var bmp = new Bitmap(cellBounds.Width, cellBounds.Height)) using (var g = Graphics.FromImage(bmp)) { // 所有绘制画到 bmp 上 DrawContent(g, new Rectangle(0, 0, bmp.Width, bmp.Height), value); // 一次性贴到屏幕 graphics.DrawImage(bmp, cellBounds.Location); } }这个技巧在进度条带渐变、或者单元格里有多个图形元素时特别管用。代价是每次重绘多一次位图分配,单元格数量大时要注意 GC 压力,可以缓存位图对象复用。
6.4 从单列到控件库的演进路径
如果你打算把自定义列做成一个可复用的控件库,建议按这个顺序推进:先把所有列类的公共逻辑抽到基类,再把绘制相关的工具方法(圆角矩形、渐变填充、文字居中)抽到静态帮助类,最后把列、单元格、编辑控件打包成一个独立的类库项目,输出 DLL。这样其他项目引用 DLL 就能用,不需要复制源码。
我自己的习惯是每加一个新列类型,先在 Demo 里跑通,确认绘制、编辑、数据绑定三条链路都没问题,再合并到类库。从那以后我每次改自定义列,都强制走一遍「空数据 → 边界值 → 高 DPI → 滚动」这四个场景,少一个都可能埋雷。希望帮到你。
本文还有配套的精品资源,点击获取