WinForms自定义DataGridView列:状态选择列的完整实现 简介这是一个面向C# WinForms开发者的DataGridView自定义列实现源码包重点解决在DataGridView中嵌入时间选择控件进行单元格编辑的需求。系统自带的文本框、下拉框、复选框列均无法直接提供日历选择该方案通过自定义列、单元格和编辑控件完整实现了点击单元格即可弹出时间选择器的交互逻辑。资源共24个文件包含9个C#源文件、项目解决方案及资源文件并提供3个可运行的exe便于直接查看效果压缩包整体仅25KB小巧精炼。目前已有1650人学习下载。源码中CalendarColumn、CalendarCell、CalendarEditingControl三个类协同工作覆盖了编辑控件显示、值传递与界面绘制等关键环节同时保留了DataGridViewTextBoxColumn、ComboBoxColumn、CheckBoxColumn的对照用法读者可据此掌握通过派生DataGridViewColumn、DataGridViewCell等类型自定义单元格外观与编辑行为的通用方法适合希望深入扩展DataGridView控件的中高级WinForms开发者参考。 在WinForms的项目里折腾DataGridView八成的人都卡在“默认列类型不够用”这堵墙上。项目里要做个设备状态管理界面下拉框、文本框、进度条这些标准列都还好说但老板要的是“单元格里一个彩色圆点加状态文字点进去能弹自定义面板选择状态”这种需求用内置列类型根本拼不出来。我当时翻遍MSDN才搞明白DataGridView想要放自定义控件列核心不是在那个控件本身下功夫而是要在列、单元格、编辑控件三者之间搭好一套协议。这篇就把我当时踩坑摸索出来的完整套路掰开讲源码直接可用适合正在搞C#上位机界面、或者被DataGridView各种诡异行为折磨的读者。1. 内置列不够用时的三种对策1.1 内置列类型的边界DataGridView自带的那一票列类型覆盖面其实不算窄TextBox列负责文本编辑ComboBox列做下拉选择Button列做点击操作CheckBox列处理布尔值Image列显示图片Link列做超链接。做普通信息管理、数据录入、简单查询结果展示这六个基本能覆盖七八成需求。真正让人头疼的是带业务语义的交互列。比如我做上位机配置界面时状态列要显示设备当前处于“待机/运行/告警/离线”哪种状态同时颜色要跟着变化双击进入编辑时要弹出带状态说明和颜色的自定义面板。用ComboBox列只能做到下拉选文本状态颜色得专门在CellFormatting里画编辑面板又只能干瞪眼。再比如某些列需要多控件组合一个文本框加一个单位后缀内置列更是完全没戏。这时候就有人想走捷径不搞自定义列直接在DataGridView上面叠一层控件根据鼠标坐标定位显示。这种做法Demo能跑但数据量大时移动、滚动、排序、键盘导航全部要自己处理格子一错位就是一场灾难。DataGridView本身提供的列扩展机制才是正路。1.2 自定义列的技术选型自定义列按改造深度可以分成三层。最轻的一层是只改Cell的绘制逻辑不碰编辑控件比如进度条列、状态灯列就是在Paint方法里自己画这种实现简单也够用。中间一层是给单元格换编辑控件复用默认的TextBox或ComboBox单元格但把编辑时弹出的东西换成自己的比如让TextBox列挂一个自定义编辑器。最重的一层是完整自定义列从Column、Cell到EditingControl全部自己写表面看代码量最大但灵活性最高能实现任何交互。我最终选择的是最重的一层原因很简单状态列这个需求横跨绘制和编辑两头单元格里要画圆点和文字进入编辑状态时要弹出带颜色的选择面板只有把三层全部接管才能控制所有细节。另外完整自定义列还有一个隐藏福利可以在设计器里直接拖拽使用别的同事拿到代码后拉一个列出来就能配不需要再写一堆初始化逻辑。2. 动手前先搞懂三个类是怎么配合的2.1 Column、Cell、EditingControl的分工自定义列的底层逻辑其实是一整套对象模型。DataGridViewColumn决定列的整体行为包括列头文字、宽度、排序模式、单元格模板。DataGridViewColumn里面有个CellTemplate属性它给的就是这个列所有单元格的模板实例实际显示时DataGridView会从这个模板Clone出一个个DataGridViewCell来干活。DataGridViewCell负责具体的显示、点击、键盘响应和编辑状态启动。编辑状态又是另一条线当用户开始编辑单元格时DataGridView会调用Cell的InitializeEditingControl方法把EditingControl创建出来、放到单元格的位置上。此时这个编辑控件是独立于DataGridView控件体系之外的普通控件它必须实现IDataGridViewEditingControl接口才能被DataGridView“感知”和控制。编辑控件在工作时会把值同步给CellCell再把值交给行的DataBoundItem整个链路才算闭合。这条链路看着绕其实类比一下很好懂Column是工厂负责定义流水线Cell是流水线上每个工位的工人负责具体执行EditingControl是工人临时拿起来的工具箱。工厂统领全局工人干活工具箱只在干活时出现干完就收走。你要做自定义列就得让这三者的类型互相咬合漏一环都会出奇怪的问题。2.2 编辑控件的宿主协议IDataGridViewEditingControlIDataGridViewEditingControl这个接口是DataGridView和编辑控件之间的通信协议。接口里有几个属性和方法必须实现EditingControlDataGridView和EditingControlRowIndexDataGridView通过这两个属性告诉编辑控件自己是谁、当前编辑的是第几行。EditingControlFormattedValue编辑控件对外暴露的格式化值DataGridView显示时用。EditingControlValueChanged编辑控件内容变化时把属性设为true通知DataGridView值已经变了。EditingControlWantsInputKey控制键盘事件是发给编辑控件还是交给DataGridView处理比如下拉框需要吃掉上下方向键。ApplyCellStyleToEditingControl把单元格样式同步给编辑控件保证视觉上无缝衔接。PrepareEditingControlForEdit进入编辑状态前做初始化比如全选文本。GetEditingControlFormattedValue把编辑控件里当前内容格式化为单元格需要的值。这里容易踩的坑是EditingControlValueChanged这个属性的职责。它不等于“值是否合法”只是告诉DataGridView“编辑控件内部有过变化记得重新取值”。实现时通常在编辑控件的内部控件事件里把它置为trueDataGridView在合适时机调GetEditingControlFormattedValue收走值然后重置为false。3. 手写一个带状态圆点的“状态选择列”下面进入正题。我以一个设备状态列为例正常显示时单元格里有彩色圆点和状态文本双击进入编辑模式时弹出一个自定义下拉面板里面有“待机、运行、告警、离线”四个选项选完回车确认单元格立即重绘并同步到数据源。完整实现需要四个类型一个状态枚举、一个编辑控件、一个单元格、一个列。3.1 第一步定义状态枚举和编辑控件状态枚举是数据的根public enum DeviceState { Idle 0, Running 1, Alarm 2, Offline 3 }接着写编辑控件。我这里直接用一个UserControl包着ComboBox方便以后扩展更多控件进去public class DeviceStateEditingControl : UserControl, IDataGridViewEditingControl { private ComboBox comboBox; private DataGridView dataGridView; private int rowIndex; private bool valueChanged; public DeviceStateEditingControl() { comboBox new ComboBox(); comboBox.DropDownStyle ComboBoxStyle.DropDownList; comboBox.Dock DockStyle.Fill; comboBox.Items.AddRange(new object[] { new KeyValuePairDeviceState, string(DeviceState.Idle, 待机), new KeyValuePairDeviceState, string(DeviceState.Running, 运行), new KeyValuePairDeviceState, string(DeviceState.Alarm, 告警), new KeyValuePairDeviceState, string(DeviceState.Offline, 离线) }); comboBox.SelectedIndexChanged (s, e) { valueChanged true; EditingControlDataGridView.NotifyCurrentCellDirty(true); }; Controls.Add(comboBox); } public DeviceState SelectedState { get { if (comboBox.SelectedItem is KeyValuePairDeviceState, string pair) { return pair.Key; } return DeviceState.Idle; } set { foreach (KeyValuePairDeviceState, string pair in comboBox.Items) { if (pair.Key value) { comboBox.SelectedItem pair; break; } } } } public DataGridView EditingControlDataGridView { get dataGridView; set dataGridView value; } public int EditingControlRowIndex { get rowIndex; set rowIndex value; } public bool EditingControlValueChanged { get valueChanged; set valueChanged value; } public object EditingControlFormattedValue { get SelectedState; set { if (value is DeviceState state) { SelectedState state; } else if (value is string str Enum.TryParse(str, out DeviceState parsed)) { SelectedState parsed; } } } public bool RepositionEditingControlOnValueChange false; public object GetEditingControlFormattedValue(DataGridViewDataErrorContexts context) { return SelectedState; } public void ApplyCellStyleToEditingControl(DataGridViewCellStyle dataGridViewCellStyle) { comboBox.Font dataGridViewCellStyle.Font; comboBox.ForeColor dataGridViewCellStyle.ForeColor; comboBox.BackColor dataGridViewCellStyle.BackColor; } public void PrepareEditingControlForEdit(bool selectAll) { comboBox.Focus(); } public bool EditingControlWantsInputKey(Keys keyData, bool dataGridViewWantsInputKey) { Keys keyCode keyData Keys.KeyCode; return keyCode Keys.Down || keyCode Keys.Up || keyCode Keys.Enter; } }这笔代码里有两个细节值得注意。一个是SelectedIndexChanged里调了NotifyCurrentCellDirty(true)这是为了让DataGridView把当前单元格标记为“脏”这样CellValueChanged事件才能正常触发否则你改了值DataGridView根本感知不到。另一个是EditingControlWantsInputKey处理了上下方向键和回车键否则用户按方向键时光标会在单元格之间跳而不是在下拉列表里切换选项。3.2 第二步自定义单元格负责绘制和启动编辑单元格是这条链路里最忙的类。它要告诉DataGridView自己用什么编辑控件、值的类型是什么、格式化显示成什么样还要自己画非编辑状态下的圆点和文字。public class DeviceStateCell : DataGridViewTextBoxCell { public override Type EditType typeof(DeviceStateEditingControl); public override Type ValueType typeof(DeviceState); public override Type FormattedValueType typeof(string); public override void InitializeEditingControl(int rowIndex, object initialFormattedValue, DataGridViewCellStyle dataGridViewCellStyle) { base.InitializeEditingControl(rowIndex, initialFormattedValue, dataGridViewCellStyle); if (DataGridView.EditingControl is DeviceStateEditingControl control) { if (Value ! null Value ! DBNull.Value Value is DeviceState state) { control.SelectedState state; } } } 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, paintParts ~DataGridViewPaintParts.ContentForeground); if (value is DeviceState state) { Color dotColor GetStateColor(state); int dotSize 8; int paddingLeft 6; int yCenter cellBounds.Y cellBounds.Height / 2; using (SolidBrush brush new SolidBrush(dotColor)) { graphics.FillEllipse(brush, cellBounds.X paddingLeft, yCenter - dotSize / 2, dotSize, dotSize); } string text GetStateText(state); TextRenderer.DrawText(graphics, text, cellStyle.Font, new Rectangle(cellBounds.X paddingLeft dotSize 6, cellBounds.Y, cellBounds.Width - paddingLeft - dotSize - 6, cellBounds.Height), cellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); } else { base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, DataGridViewPaintParts.ContentForeground); } } private static Color GetStateColor(DeviceState state) { switch (state) { case DeviceState.Idle: return Color.FromArgb(150, 150, 150); case DeviceState.Running: return Color.FromArgb(34, 177, 76); case DeviceState.Alarm: return Color.FromArgb(255, 60, 60); case DeviceState.Offline: return Color.FromArgb(20, 90, 200); default: return Color.Gray; } } private static string GetStateText(DeviceState state) { switch (state) { case DeviceState.Idle: return 待机; case DeviceState.Running: return 运行; case DeviceState.Alarm: return 告警; case DeviceState.Offline: return 离线; default: return state.ToString(); } } }Paint方法这里我用了点技巧先调用base.Paint把背景、边框、选中高亮全部画好但通过位运算把ContentForeground部分去掉留着我自己画内容。这样能最大程度复用基类的绘制逻辑自己只需要补圆点和文字。注意TextRenderer.DrawText画文字比graphics.DrawString更接近系统原生渲染效果字体清晰度也更好。ValueType和FormattedValueType分开设置也是常规操作内部存的是枚举显示给用户看的是字符串这个错位关系一定要理清楚。3.3 第三步自定义列把三者绑定列对象本身其实没什么重量级逻辑它唯一的职责是固定单元格模板的类型public class DeviceStateColumn : DataGridViewColumn { public DeviceStateColumn() : base(new DeviceStateCell()) { } public override DataGridViewCell CellTemplate { get base.CellTemplate; set { if (value ! null !(value is DeviceStateCell)) { throw new InvalidCastException(DeviceStateColumn 只能使用 DeviceStateCell 作为单元格模板); } base.CellTemplate value; } } public override object Clone() { return base.Clone(); } }思考一个问题为什么CellTemplate的setter要加类型检查因为DataGridView的列集合、设计器、序列化机制在某些场景下会直接给CellTemplate赋值。如果赋了一个不兼容的单元格进去这个列创建出来的所有单元格都会变成错误类型运行到一半才发现就是事故现场。所以这里用类型检查把错误拦在源头这是我吃过亏之后加的保险。3.4 第四步让设计器认识新列自定义列写完之后直接编译你会发现工具箱和列编辑器里根本找不到它。需要在类上加Designer特性还要提供一个继承自DataGridViewColumnDesigner的设计器类。需要引用System.Design程序集旧版.NET Framework项目里要手动加上。using System.ComponentModel; using System.Drawing; using System.Windows.Forms; using System.Windows.Forms.Design; [Designer(typeof(DeviceStateColumnDesigner))] public class DeviceStateColumn : DataGridViewColumn { // 上面的代码 } public class DeviceStateColumnDesigner : DataGridViewColumnDesigner { }加了Designer特性后在DataGridView的“编辑列”对话框里选择“添加”就能在下拉列表里看到DeviceStateColumn拖进列集合后直接能配DataPropertyName、宽度、排序模式这些属性。这一步对团队协作很重要别人拿到代码不需要读源码就知道怎么用这个列。4. 把这些代码用进项目数据绑定和提交时机4.1 让状态值随行数据一起提交自定义列做完了接入项目时要建立一整套数据流转机制。我通常把状态列绑定到数据源里的一个枚举属性上public class DeviceInfo { public string DeviceName { get; set; } public DeviceState State { get; set; } }然后在Form里绑定BindingSource bindingSource new BindingSource(); bindingSource.DataSource typeof(ListDeviceInfo); ListDeviceInfo devices new ListDeviceInfo { new DeviceInfo { DeviceName 1号设备, State DeviceState.Running }, new DeviceInfo { DeviceName 2号设备, State DeviceState.Idle } }; bindingSource.DataSource devices; dataGridView1.DataSource bindingSource;这时再设置DeviceStateColumn的DataPropertyName State单元格显示时会通过反射从DeviceInfo.State读取枚举值编辑提交后写回去。DataGridView这套绑定机制本质上是给每行数据建了个DataBoundItemCell.Value和DataBoundItem之间由DataPropertyName做桥梁。用户编辑完毕值从EditingControl回写到Cell再通过BindingSource回写到DeviceInfo.State整个链条就通了。4.2 避免编辑后界面不刷新实际使用中经常遇到一个现象改完状态界面显示还是老样子非要鼠标晃一下或者滚动一下才刷新。这是因为编辑控件把值回写后单元格没有主动触发重绘。解决办法是在CellValueChanged事件里强制刷新一下private void dataGridView1_CellValueChanged(object sender, DataGridViewCellEventArgs e) { if (dataGridView1.Columns[e.ColumnIndex] is DeviceStateColumn) { dataGridView1.InvalidateRow(e.RowIndex); } }这里用InvalidateRow而不是Refresh整个表格是因为刷新整表在数据量大的时候会有明显卡顿。只让当前行重绘性能开销小得多。另外别忘了包含这句dataGridView1.CurrentCellDirtyStateChanged (s, e) { if (dataGridView1.IsCurrentCellDirty) { dataGridView1.CommitEdit(DataGridViewDataErrorContexts.Commit); } };这个事件是为了处理ComboBox和CheckBox列在编辑时“值已经变了但焦点没离开就不提交”的问题。加了CommitEdit之后改完下拉框内容马上就把编辑提交不会等光标移走或敲回车才生效。5. 常见问题与排查技巧实录5.1 编辑过一次之后下拉控件不消失或位置偏移这个现象绝大多数时候是因为没有正确实现EditingControl的宿主协议。检查两个点第一InitializeEditingControl里要调用base.InitializeEditingControl方法这是DataGridView内部管理编辑控件生命周期的关键第二EditingControlWantsInputKey返回true的按键要克制不要把所有按键都吃掉否则DataGridView可能误判编辑未结束。我曾经把Ctrl、Shift都返回true结果编辑结束后护栏不消失定位了半天。5.2 下拉框被相邻单元格遮挡自定义编辑控件本质上是临时盖在DataGridView上的普通控件当它比较宽、包含子面板时容易被后面一格的边框和文字遮住。解决办法编辑控件本身设置足够大的Bounds或者给DataGridView设置EditingPanel的Padding。DataGridView有一个EditingPanel它是编辑控件真正坐落的容器可以给它留点缓冲dataGridView1.EditingPanel.Padding new Padding(1);另外不要在编辑控件的Paint事件里画花活容易和DataGridView自己的绘制冲突。5.3 设计器报错“未找到类型”自定义列类如果放在别的程序集里设计器默认找不到。除了加Designer特性外还要确保项目引用了对应的程序集并且类不是internal或private。可以试试把自定义列类文件拖进同一项目或者直接在使用该列的项目里新建这些类省得处理设计器程序集解析的一堆破事。5.4 单元格显示为类型名而不是状态文本如果FormattedValueType只设置了typeof(DeviceState)而单元格又走默认的ToString转换界面上就会显示出“DeviceState”。要记得把FormattedValueType覆盖为typeof(string)并且在FormattedValue的获取逻辑里做转换。上面代码里我是直接在Paint里根据枚举值画文字不依赖FormattedValueType所以避开了这个坑但如果你用DataGridView的默认格式化流程就一定要处理这段。5.5 每列都想要这个状态样式怎么办用DataGridView的CellPainting事件统一处理也是一种思路但事件耦合度高、代码分散不如自定义列来得干净。自定义列最大的优势是可以封装、复用、被设计器识别。一套代码写好哪怕十个窗体要用也只是拖动添加一列的功夫。6. 最后再分享一点个人体会这几个月在几个上位机项目里反复用这套自定义列方案最大的感受是DataGridView这个东西看着憨厚其实个性很强。内置列类型够用的时候别去折腾扩展一旦业务确实需要定制交互就老老实实按Column、Cell、EditingControl三层架构来写不要图省事去叠控件或者改事件堆逻辑。我自己踩过的最深的坑是早期图省事用DataGridView的CellPainting和CellClick事件模拟按钮列。界面上看起来像个按钮但键盘导航、鼠标悬停状态、高亮样式全部缺失用户体验很差代码也越堆越乱。后来改成自定义Button列把绘制和点击封装进去代码反而简洁了。这套状态列只是自定义列最简单的一类。掌握了这条链路之后往上加DateTimePicker编辑控件、自定义进度条列、图片按钮列、TreeView下拉列都是同样的设计模式。建议先拿状态列跑通再把明白了的三个类的分工彻底吃透然后自由发挥就好。感谢同事帮忙测试了编辑状态下的各种键盘操作和视觉细节。源码放在这里大家直接拿去看有问题评论区一起研究。本文还有配套的精品资源点击获取