简介:这份源码资源面向.NET Windows Forms开发者,解决在DataGridView单元格中嵌入NumericUpDown控件、实现数值精确增减输入的问题。资源通过自定义DataGridViewNumericUpDownCell、EditingControl与Column等类,将数值输入控件与表格列类型结合,并处理ValueChanged、CellEnter、CellLeave等事件,使控件在单元格切换时正确显示与隐藏,同时兼顾布局与样式一致性。压缩包共96个文件,约487KB,以cs源码、dll动态库、pdb调试符号、exe示例程序、csproj工程文件及resx资源文件为主,另含少量缓存与配置类文件,引入DLL即可在自有工程中直接复用。目前已有761人学习下载,适合需要提升表格数值录入效率与准确性的中高级开发者参考,可据此理解自定义列类型的实现思路、事件交互逻辑与调试测试方法。
1. DataGridView 里塞进 NumericUpDown:一个被低估的编辑体验改造
做过 WinForms 表格录入的人多半遇到过这个场景:订单明细表里有一列「数量」,用户双击单元格,弹出来的还是一个纯文本框,能输入abc,能输入-5,能输入1.2.3。你在CellValidating里写一堆正则去拦,用户体验依然糟糕——没有上下箭头、没有范围约束、没有即时反馈。DataGridView 自带的DataGridViewTextBoxColumn本质上就是个裸文本框,它不负责数值语义。
把NumericUpDown嵌进单元格,就是让「数量」「单价」「折扣率」这类列在编辑态直接变成一个带步进按钮、带最小值最大值、带小数位控制的数值控件。这不是炫技,是实打实减少脏数据的手段。适合谁?适合还在维护 WinForms 业务系统、表格里有大量数值录入、又不想整体迁移到 WPF 或 Web 的开发者。下面这套源码思路,我前后在几个内部工具里用过,核心逻辑稳定,坑也踩得比较全。
2. 为什么不用 TextBox 列硬扛:选型与控件宿主原理
2.1 原生 TextBox 列在数值场景的三个硬伤
先说清楚为什么值得折腾。DataGridViewTextBoxColumn的编辑控件是DataGridViewTextBoxEditingControl,它继承自TextBox,没有任何数值语义。第一个硬伤是输入过滤只能靠事件拦截,KeyPress里判断字符、Validating里判断范围,代码散落且容易漏。第二个硬伤是交互缺失,用户想从 1 调到 10 只能手动敲,没有步进按钮。第三个硬伤是格式不统一,1000和1,000.00在提交前是两副面孔,绑定到int属性时还可能抛格式异常。
有人会说,用DataGridViewComboBoxColumn做下拉枚举不就行了?那是离散值场景。连续数值、需要范围约束、需要小数精度的场景,NumericUpDown才是对的工具。它的Minimum、Maximum、DecimalPlaces、Increment四个属性,恰好对应业务里最常见的四类约束。
2.2 自定义列的三件套:Column、Cell、EditingControl
WinForms 里给 DataGridView 扩展一个新编辑控件,标准做法是实现三个类,这是官方推荐的宿主模式,不是野路子。
第一个是编辑控件类,继承NumericUpDown,同时实现IDataGridViewEditingControl接口。这个接口是控件和单元格之间的契约,负责「把值读进来」「把值写回去」「告诉单元格我要重绘」「处理方向键」等。
第二个是单元格类,继承DataGridViewTextBoxCell(注意不是直接继承DataGridViewCell,那样要自己实现绘制,工作量大且容易出视觉 bug)。重写InitializeEditingControl创建编辑控件,重写EditType返回控件类型,重写ParseFormattedValue和GetValue处理值转换。
第三个是列类,继承DataGridViewTextBoxColumn,把CellTemplate设成自定义单元格。三者关系是:列决定用哪个单元格模板,单元格决定编辑时创建哪个控件,控件负责实际输入。
提示:不要试图直接给
DataGridView.Controls里 Add 一个 NumericUpDown 然后手动定位,那种做法在滚动、排序、列宽变化时会集体翻车,是血泪经验。
2.3 值传递链路:从单元格到控件的完整闭环
理解这条链路,后面调 bug 才有方向。用户双击单元格,DataGridView 调用单元格的InitializeEditingControl,把当前值、样式、行索引传进去,单元格在这里创建编辑控件并设置Value。用户操作控件,控件通过EditingControlValueChanged通知单元格「值变了」,单元格再触发DataGridView.NotifyCurrentCellDirty。用户离开单元格,DataGridView 调用控件的EditingControlFormattedValue取值,再经过单元格的ParseFormattedValue转成最终值写回数据源。
这条链路里任何一环断了,表现都是「值没保存」或「值显示不对」。最常见的断点是EditingControlFormattedValue返回了decimal而数据源属性是int,或者ParseFormattedValue没处理DBNull。
3. 从零写出 NumericUpDown 列:三个类的完整源码
3.1 编辑控件类 NumericUpDownEditingControl
先写编辑控件。它要继承NumericUpDown并实现接口,核心是几个属性和方法的实现。
using System; using System.Windows.Forms; public class NumericUpDownEditingControl : NumericUpDown, IDataGridViewEditingControl { private DataGridView dataGridView; private bool valueChanged; private int rowIndex; public NumericUpDownEditingControl() { // 编辑态下不需要千分位,避免解析歧义 ThousandsSeparator = false; DecimalPlaces = 0; Minimum = 0; Maximum = 1000000; Increment = 1; } // 告诉 DataGridView 这个控件属于哪个表格 public DataGridView EditingControlDataGridView { get { return dataGridView; } set { dataGridView = value; } } // 编辑态显示的值,DataGridView 会拿它去写回单元格 public object EditingControlFormattedValue { get { return Value.ToString(); } set { if (value is int || value is decimal || value is double) Value = Convert.ToDecimal(value); } } public int EditingControlRowIndex { get { return rowIndex; } set { rowIndex = value; } } public bool EditingControlValueChanged { get { return valueChanged; } set { valueChanged = value; } } public Cursor EditingPanelCursor { get { return Cursors.Default; } } public bool RepositionEditingControlOnValueChange { get { return false; } } // 把外部值灌进控件 public object GetEditingControlFormattedValue(DataGridViewDataErrorContexts context) { return EditingControlFormattedValue; } // 应用单元格样式,字体、颜色、对齐都要同步 public void ApplyCellStyleToEditingControl(DataGridViewCellStyle style) { Font = style.Font; ForeColor = style.ForeColor; BackColor = style.BackColor; TextAlign = ConvertContentAlignment(style.Alignment); } private HorizontalAlignment ConvertContentAlignment(DataGridViewContentAlignment alignment) { switch (alignment) { case DataGridViewContentAlignment.MiddleRight: case DataGridViewContentAlignment.TopRight: case DataGridViewContentAlignment.BottomRight: return HorizontalAlignment.Right; case DataGridViewContentAlignment.MiddleCenter: case DataGridViewContentAlignment.TopCenter: case DataGridViewContentAlignment.BottomCenter: return HorizontalAlignment.Center; default: return HorizontalAlignment.Left; } } // 哪些键交给 DataGridView 处理,哪些自己吃掉 public bool EditingControlWantsInputKey(Keys keyData, bool dataGridViewWantsInputKey) { switch (keyData & Keys.KeyCode) { case Keys.Left: case Keys.Right: case Keys.Up: case Keys.Down: return true; // 方向键自己处理,用于步进和光标 case Keys.Home: case Keys.End: return true; default: return !dataGridViewWantsInputKey; } } public void PrepareEditingControlForEdit(bool selectAll) { if (selectAll) Select(0, Text.Length); } // 值变化时通知表格 protected override void OnValueChanged(EventArgs e) { base.OnValueChanged(e); valueChanged = true; if (dataGridView != null) dataGridView.NotifyCurrentCellDirty(true); } }逻辑说明:EditingControlFormattedValue的 getter 用Value.ToString()而不是Text,因为Text可能带千分位或前导零,Value是干净的数值。setter 里做了类型判断,避免字符串直接赋值抛异常。EditingControlWantsInputKey是关键,上下键必须返回true,否则按上键会跳到上一行而不是增加数值,这是新手最容易忽略的一处。
参数说明:构造函数里设的Minimum、Maximum只是默认值,真正生效的是列级别传入的配置,后面会讲怎么传。DecimalPlaces默认 0,整数场景够用,小数场景要在列里改。
3.2 单元格类 NumericUpDownCell
单元格类负责创建控件、处理值转换。继承DataGridViewTextBoxCell可以复用大部分绘制逻辑。
using System; using System.Windows.Forms; public class NumericUpDownCell : DataGridViewTextBoxCell { public NumericUpDownCell() { } // 告诉表格编辑时用哪个控件 public override Type EditType { get { return typeof(NumericUpDownEditingControl); } } // 单元格值的类型 public override Type ValueType { get { return typeof(decimal); } set { base.ValueType = value; } } public override Type FormattedValueType { get { return typeof(string); } } // 创建并初始化编辑控件 public override void InitializeEditingControl(int rowIndex, object initialFormattedValue, DataGridViewCellStyle dataGridViewCellStyle) { base.InitializeEditingControl(rowIndex, initialFormattedValue, dataGridViewCellStyle); var ctl = DataGridView.EditingControl as NumericUpDownEditingControl; if (ctl == null) return; // 从列配置里读取范围参数 var column = OwningColumn as NumericUpDownColumn; if (column != null) { ctl.Minimum = column.Minimum; ctl.Maximum = column.Maximum; ctl.Increment = column.Increment; ctl.DecimalPlaces = column.DecimalPlaces; } // 初始值处理,空值给最小值兜底 if (initialFormattedValue == null || initialFormattedValue == DBNull.Value) ctl.Value = ctl.Minimum; else { decimal v; if (decimal.TryParse(initialFormattedValue.ToString(), out v)) { if (v < ctl.Minimum) v = ctl.Minimum; if (v > ctl.Maximum) v = ctl.Maximum; ctl.Value = v; } else ctl.Value = ctl.Minimum; } } // 把编辑控件的值解析成单元格值 public override object ParseFormattedValue(object formattedValue, DataGridViewCellStyle cellStyle, System.ComponentModel.TypeConverter formattedValueTypeConverter, System.ComponentModel.TypeConverter valueTypeConverter) { if (formattedValue == null || formattedValue == DBNull.Value) return 0m; decimal result; if (decimal.TryParse(formattedValue.ToString(), out result)) return result; return 0m; } // 克隆,DataGridView 内部会用到 public override object Clone() { var cell = (NumericUpDownCell)base.Clone(); return cell; } }逻辑说明:InitializeEditingControl里先调base,因为基类会做一些必要的挂接。然后从OwningColumn拿到列配置,把范围参数灌进控件。初始值做了三重保护:空值、解析失败、越界,全部兜到合法范围。ParseFormattedValue里解析失败返回 0 而不是抛异常,因为编辑过程中用户可能临时清空内容,抛异常会导致单元格进入错误态。
参数说明:ValueType返回decimal,这是为了兼容整数和小数两种场景。如果你的业务确定只用整数,可以改成typeof(int),但要注意ParseFormattedValue里也要相应改。FormattedValueType返回string,因为显示层最终是文本。
3.3 列类 NumericUpDownColumn
列类把配置参数暴露出来,让使用者在设计器或代码里设置。
using System; using System.ComponentModel; using System.Windows.Forms; public class NumericUpDownColumn : DataGridViewTextBoxColumn { public NumericUpDownColumn() { CellTemplate = new NumericUpDownCell(); Minimum = 0; Maximum = 1000000; Increment = 1; DecimalPlaces = 0; } [Category("Numeric"), DefaultValue(0)] public decimal Minimum { get; set; } [Category("Numeric"), DefaultValue(1000000)] public decimal Maximum { get; set; } [Category("Numeric"), DefaultValue(1)] public decimal Increment { get; set; } [Category("Numeric"), DefaultValue(0)] public int DecimalPlaces { get; set; } public override object Clone() { var col = (NumericUpDownColumn)base.Clone(); col.Minimum = Minimum; col.Maximum = Maximum; col.Increment = Increment; col.DecimalPlaces = DecimalPlaces; return col; } }逻辑说明:构造函数里设置CellTemplate,这是列和单元格的绑定点。四个属性加了Category和DefaultValue,这样在设计器属性面板里会归到 Numeric 分组,且默认值不会污染代码生成。Clone必须重写并复制自定义属性,否则在设计器里复制列或序列化时会丢配置,这个坑很隐蔽。
参数说明:Minimum和Maximum用decimal类型,是为了和NumericUpDown的属性类型一致,避免隐式转换。Increment控制点击上下箭头的步长,业务里常见的是 1 或 0.01。DecimalPlaces控制显示的小数位,设成 2 时输入 1 会显示 1.00。
3.4 在窗体里挂上这一列
三个类写完,使用就很简单了。可以在设计器里加,也可以纯代码。
private void SetupGrid() { dataGridView1.AutoGenerateColumns = false; dataGridView1.Columns.Clear(); // 普通文本列 dataGridView1.Columns.Add(new DataGridViewTextBoxColumn { Name = "ProductName", HeaderText = "商品名称", DataPropertyName = "ProductName", Width = 160 }); // 数值列,范围 1 到 9999,步长 1,整数 var qtyCol = new NumericUpDownColumn { Name = "Quantity", HeaderText = "数量", DataPropertyName = "Quantity", Minimum = 1, Maximum = 9999, Increment = 1, DecimalPlaces = 0, Width = 100 }; dataGridView1.Columns.Add(qtyCol); // 金额列,范围 0 到 999999.99,步长 0.01,两位小数 var priceCol = new NumericUpDownColumn { Name = "UnitPrice", HeaderText = "单价", DataPropertyName = "UnitPrice", Minimum = 0, Maximum = 999999.99m, Increment = 0.01m, DecimalPlaces = 2, Width = 120 }; dataGridView1.Columns.Add(priceCol); dataGridView1.DataSource = GetOrderList(); }逻辑说明:AutoGenerateColumns = false是必须的,否则自动生成的列会覆盖自定义列。DataPropertyName绑定到数据源的属性名,值传递链路最终靠它落地。两个数值列展示了整数和小数两种典型配置。
参数说明:Minimum = 1表示数量不允许为 0,这是业务约束。金额列Maximum用了m后缀,确保是decimal字面量。Width只是显示宽度,不影响功能。
4. 避坑与排查:五个真实翻车现场
4.1 按上下键跳行而不是改数值
现象:编辑状态下按上箭头,光标跳到上一行,数值没变。
原因:EditingControlWantsInputKey没有正确处理方向键,或者返回了false,DataGridView 就把按键当成导航键吃掉了。
解决:确认EditingControlWantsInputKey里Keys.Up和Keys.Down返回true。如果还不行,检查是否在OnKeyDown里调了base导致事件被提前处理。
4.2 编辑完值没保存,切走就还原
现象:输入新数值,点别的单元格,值变回原来的。
原因:OnValueChanged里没有调NotifyCurrentCellDirty,DataGridView 不知道单元格脏了,提交时用了旧值。
解决:确保OnValueChanged重写里调了dataGridView.NotifyCurrentCellDirty(true)。另外检查EditingControlFormattedValue的 getter 是否返回了正确的值,返回Text而不是Value在某些格式下会解析失败。
4.3 数据源是 int 属性,提交时报格式异常
现象:DataError事件触发,提示「输入字符串的格式不正确」。
原因:单元格ValueType是decimal,ParseFormattedValue返回decimal,但数据源属性是int,绑定层转换失败。
解决:两种方案。一是把数据源属性改成decimal,最省事。二是在ParseFormattedValue里根据OwningColumn的配置决定返回类型,但这样代码会复杂。我一般选第一种,业务层用decimal存数值没有坏处。
4.4 设计器里复制列后配置丢失
现象:在设计器里复制一个 NumericUpDownColumn,粘贴出来的新列 Minimum、Maximum 都变回默认值。
原因:Clone方法没有复制自定义属性,基类的Clone只处理标准属性。
解决:重写Clone,把四个自定义属性都复制一遍。这个坑在纯代码场景下不会遇到,只有设计器操作才暴露。
4.5 空单元格进入编辑态显示最小值而不是空白
现象:数据源里某行该字段是DBNull,双击编辑,控件显示的是Minimum而不是空。
原因:NumericUpDown本身不支持空值,Value属性必须有值。InitializeEditingControl里对空值做了兜底,赋了Minimum。
解决:如果业务允许空值,需要在单元格层面处理。一种做法是加一个「允许空」的布尔属性,编辑时如果原值为空,用一个特殊标记,提交时再转回DBNull。另一种做法是业务上不允许空,那就接受显示最小值,但要在CellFormatting里把未编辑状态显示成空白。我一般选后者,简单且不容易出 bug。
5. 进阶:让数值列支持千分位显示与批量校验
基础功能跑通后,有两个进阶点值得做。第一个是显示态的千分位格式化。编辑态用NumericUpDown不带千分位是为了解析干净,但只读显示时用户希望看到1,234,567.89。做法是在单元格的GetFormattedValue里做格式化。
protected override object GetFormattedValue(object value, int rowIndex, ref DataGridViewCellStyle cellStyle, System.ComponentModel.TypeConverter valueTypeConverter, System.ComponentModel.TypeConverter formattedValueTypeConverter, DataGridViewDataErrorContexts context) { if (value == null || value == DBNull.Value) return string.Empty; decimal d; if (decimal.TryParse(value.ToString(), out d)) { var col = OwningColumn as NumericUpDownColumn; int places = col != null ? col.DecimalPlaces : 0; // 显示态加千分位,编辑态不加 return d.ToString("N" + places); } return value.ToString(); }逻辑说明:GetFormattedValue只在显示态被调用,编辑态走的是InitializeEditingControl,所以这里加千分位不会影响编辑解析。"N" + places是标准数值格式,N0是整数带千分位,N2是两位小数带千分位。
参数说明:places从列配置读取,保证显示精度和编辑精度一致。如果列配置拿不到,兜底 0 位。
第二个进阶点是批量校验。用户可能粘贴一列数据,或者程序化赋值,这时逐单元格校验不够。做法是在数据绑定的BindingList的ListChanged事件里做统一校验,或者重写OnCellValidating做范围检查。
private void dataGridView1_CellValidating(object sender, DataGridViewCellValidatingEventArgs e) { var col = dataGridView1.Columns[e.ColumnIndex] as NumericUpDownColumn; if (col == null) return; decimal v; if (!decimal.TryParse(e.FormattedValue?.ToString(), out v)) { dataGridView1.Rows[e.RowIndex].ErrorText = "请输入有效数值"; e.Cancel = true; return; } if (v < col.Minimum || v > col.Maximum) { dataGridView1.Rows[e.RowIndex].ErrorText = string.Format("数值需在 {0} 到 {1} 之间", col.Minimum, col.Maximum); e.Cancel = true; return; } dataGridView1.Rows[e.RowIndex].ErrorText = string.Empty; }逻辑说明:CellValidating在提交前触发,e.Cancel = true会阻止离开单元格。ErrorText会在行头显示错误图标和提示,比弹 MessageBox 体验好。注意e.FormattedValue可能是null,要判空。
参数说明:校验用的是列配置的Minimum和Maximum,和编辑控件的范围一致,避免两套标准。如果业务有跨列校验(比如总价 = 数量 × 单价),要放在CellEndEdit里做,因为CellValidating时其他单元格的值可能还没提交。
最后说个习惯。这类自定义控件的代码,我一般会单独放一个文件,类名统一加前缀,避免和业务代码混在一起。每次改完,先在设计器里拖一个列出来,手动点几下上下箭头,再粘贴一批数据,最后用 Tab 键走一遍全表。这套动作能覆盖九成以上的回归问题。数值列的坑大多不在逻辑本身,而在和 DataGridView 宿主环境的交互细节上,多手动点几次比写单元测试还管用。希望帮到你。
本文还有配套的精品资源,点击获取