WinForms DataGridView 单元格多控件:自绘与编辑控件

发布时间:2026/10/26 5:09:37
WinForms DataGridView 单元格多控件:自绘与编辑控件

Winform 的 DataGridView 里,一个单元格放多个控件,是很多人做业务表格时绕不开的一道坎:库存盘点表要在同一格里同时给出数量、加减按钮和单位下拉,工单列表要把状态标签和小图标并排摆,权限配置表想让勾选框和说明文字共存。内置的 DataGridViewCheckBoxColumn、DataGridViewButtonColumn、DataGridViewComboBoxColumn 各自只认一种控件,把它们塞进同一格立刻失效。下面这些内容全部围绕 DataGridView、DataGridViewCell、单元格内多个控件这条主线,把我从自绘、编辑控件、宿主控件三条路上踩过的坑和最终定型的写法摊开讲,代码可以直接拿去跑,也能顺着思路改成任意控件组合。

1. 从一个盘点表格说起:单格多控件到底难在哪

1.1 内置列类型的模型:一格一控件是刻在骨子里的

DataGridView 的单元格体系是一条很清晰的链条:DataGridViewColumn 负责列级配置,DataGridViewCell 负责单元格级的绘制、尺寸和值,EditingControl 负责进入编辑态后真正接收键盘输入的控件。这三者之间的关系是一对一绑定的,一个单元格同一时刻只承认一个编辑控件。

你可以打开 DataGridViewButtonColumn 的源码看一眼,它做的事情非常朴素:把 CellTemplate 设成 DataGridViewButtonCell,然后 DataGridViewButtonCell 在 Paint 里画一个带边框的矩形和居中文字,在 OnMouseClick 里判断点击是否落在那个矩形里就完事。ComboBoxColumn 则把 EditType 指向 DataGridViewComboBoxEditingControl,进入编辑态就弹下拉。

这套模型的问题在于它假设"单元格的视觉表达"和"单元格的编辑行为"是同一个东西。而真实的业务需求经常不是这样:一个数量单元格,视觉上要显示三个区域(减号、数字、加号),编辑时可能只需要一个文本框,而你希望它对用户来说始终是"一格"。此时内置模型没法直接表达,必须自己接管。

我见过一些新手尝试的路子是把两个内置列叠在一起,或者干脆把 DataGridViewCheckBoxColumn 塞进 DataGridViewButtonColumn,结果要么编译报错,要么运行起来点按钮触发的是勾选逻辑。这条路走不通,别浪费时间。

注意:DataGridViewCell 的实例分两种存在形式——一种是 CellTemplate,全表共享同一个实例用来做尺寸和类型的模板;另一种是每一行实际创建出来的独立实例。任何跟"悬停状态""按下状态"相关的字段,都必须写在实例上,绝对不能写在 CellTemplate 上,否则全表会一起闪烁。

1.2 需求拆解:一个格子里到底要有几个"可点区域"

先把自己的需求写清楚,比上来就动代码重要得多。拿盘点表的"数量"格举例,拆开看有四个可交互区域:

区域视觉表现点击行为是否需要键盘输入
减号按钮左侧小方块,画一个减号数值减一,触底停止否
数字文本中间区域,右对齐或居中双击进入编辑态是(进入后)
加号按钮右侧小方块,画一个加号数值加一,封顶停止否
整格背景与边框单击选中整格否

把这四个区域列出来之后,结论就很明确了:格子里有多个命中区域,但只有一个编辑入口。这决定了后面架构的基础——绘制可以完全自绘,点击可以自己分发,编辑态只在需要的时候叫出来一个真正的控件顶上去。

这一步最关键的是提前想清楚"哪些区域需要 hover 高亮""哪些区域在只读状态下要隐藏""数值的上下限是多少"。我曾经漏掉只读判断,结果在查看模式下用户照样能点加号把数据改掉,返工重新梳理了一遍状态判定。这类需求边界,写代码前用一张表列清楚,后面能省掉一大半调试时间。

2. 自绘、编辑控件、宿主控件:三条路线的能力边界与代价

2.1 宿主控件嵌入:看起来最直觉,实际最难收场

宿主控件方案的核心思路是:把真正的 Button、NumericUpDown、ComboBox 通过DataGridView.Controls.Add()加到表格控件上,然后用GetCellDisplayRectangle拿到单元格的屏幕坐标,手动设置子控件的 Location。

代码写出来不到二十行,第一次跑起来还挺有成就感。但只要表格出现滚动、行高变化、列宽拖拽、窗口缩放,问题就成片出现:

  • 滚动时子控件不会跟着单元格走,必须在 Scroll 事件里重新定位,而 Scroll 事件触发时单元格的位置更新还没完成,定位会差一格;
  • 子控件是浮在 DataGridView 上层的独立窗口,不会被单元格裁剪,滚出可视区之后还会飘在表头上面;
  • 行数超过几百行时,如果你给每行都创建一套控件,Controls集合会膨胀到几千个,界面直接卡死;
  • 编辑态和非编辑态的边界彻底模糊,用户点按钮时当前单元格未必切换到那一格,Focus 逻辑一片混乱。

这条路的唯一合理用法是"只给当前行创建控件、随 CurrentCell 迁移复用",但那样改完之后代码量已经不比分自绘少,控制力却差很多。我在一个订单明细模块里用这套方案撑了两个月,最后还是全部推翻重写。除非是极简场景(比如只有一行需要特殊控件),否则不建议作为首选。

2.2 编辑控件方案:交互最完整,但只在编辑态生效

编辑控件方案是重写EditType属性,让它返回一个实现了IDataGridViewEditingControl接口的 UserControl,这个 UserControl 内部随便你摆多少个控件。进入编辑态时 DataGridView 会实例化它、定位它、把焦点交给它。

优点很突出:键盘输入、Tab 切换、边框样式、值提交全是 DataGridView 原生帮你处理的,你几乎不用管鼠标事件。

短板也同样明显:它只存在于编辑态。用户没双击单元格之前,看到的是DataGridViewCell.Paint画出来的东西,那还是需要你自己画。所以这个方案本质上是"自绘方案 + 编辑态增强"的组合,不是替代关系。如果你的需求是"平时显示一个静态文本,双击后弹出带加减按钮的复合控件",这套组合是最合适的;但如果需求是"加号减号随手就能点,不用先双击",那就纯属给用户添堵。

2.3 自绘加命中测试:控制力最强,代价是要自己管一切

第三条路是把单元格当成一块画布。Paint里画出所有视觉元素,OnMouseMove里根据坐标判断悬停在哪块区域,OnMouseClick里根据坐标决定触发哪个逻辑。完全不依赖任何真实控件,也就没有任何位置同步的烦恼。

它的优势在于:性能极好(几百行表格滚动毫不卡顿)、视觉完全可控(圆角、渐变、图标随你)、命中逻辑想怎么定就怎么定(一个格子里放五个可点区域都行)。代价是你得自己处理画笔资源、DPI 缩放、只读态、选中态、边框绘制这些细节。

对比维度宿主控件嵌入编辑控件方案自绘 + 命中测试
非编辑态可见是否是
滚动同步需手动处理,易错原生支持天然一致
大数据量性能差好好
交互丰富度高高(仅编辑态)中等(无输入法支持)
实现复杂度中高中
适用场景单行特殊控件双击后复合编辑格内常驻多个按钮/标签

实际项目里我的选择通常是:格内常驻的可点区域(加减按钮、删除叉、状态灯)走自绘,双击后需要的复杂输入走编辑控件,两者拼在同一个自定义 Cell 里。这也是下面所有代码的组织方式。

3. 手写一个多控件单元格:Column 与 Cell 的最小闭环

3.1 继承 DataGridViewColumn 与 DataGridViewCell,四个重写方法缺一不可

自定义列的标准做法是先写 Cell,再用 Cell 包装成 Column。Column 那边其实只有一个必须重写的方法:Clone()。DataGridView 在设计器里复制列、或者在代码里调用Columns.Add传入列模板时,都会走 Clone。如果你忘了重写,复制出来的列会退化成普通 DataGridViewTextBoxColumn,单元格类型全丢,然后你会看到一片空白。

Cell 这边必须处理的有四件事:

  1. Clone():同 Column,必须把单元格实例复制出来,否则每行共用一份状态。
  2. ValueType与FormattedValueType:前者告诉绑定源这个格子存的是什么类型(我们的例子是 int),后者决定复制粘贴出去、以及导出到 CSV 时的文本形态。
  3. GetPreferredSize:返回单元格期望尺寸,列宽自适应、行高自适应全靠它。返回Size.Empty表示跟随默认。
  4. Paint:绘制主体,所有视觉都在这里。

这四条构成最小闭环,缺任何一个都会在某个场景下暴露问题——尤其是 Clone,它的问题最隐蔽,平时开发完全不报错,一进设计器或者一换皮肤就炸。

public class SpinColumn : DataGridViewColumn { public SpinColumn() : base(new SpinCell()) { this.Width = 110; } public override object Clone() { var col = (SpinColumn)base.Clone(); col.CellTemplate = (SpinCell)this.CellTemplate.Clone(); return col; } }

这里有个细节值得说:base.Clone()内部会复制 CellTemplate,但复制出来的是父类DataGridViewColumn的克隆,返回类型仍然是 SpinColumn 没错,只是 CellTemplate 的引用可能还是原来那个。显式再 Clone 一次保险,代价几乎为零。

3.2 Paint 里的绘制顺序与 DataGridViewPaintParts 的取舍

Paint方法有十一个参数,看起来吓人,实际用到的就三组:cellBounds(单元格在控件坐标系里的矩形)、cellState(选中、焦点、只读等标志位)、paintParts(本次要画哪些部分)。

paintParts是位标志,DataGridView 会根据当前上下文只让你画一部分。比如打印的时候不画选中背景,鼠标悬停高亮的时候只画前景。一定要逐位判断,不要无脑全画,否则打印预览里会出现一堆不该有的底色。

标准绘制顺序是:背景 → 边框 → 内容 → 错误图标。边框用父类提供的PaintBorder,它内部会处理DataGridViewAdvancedBorderStyle里的各种单双线配置,比自己画靠谱得多。

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) { bool selected = (cellState & DataGridViewElementStates.Selected) != 0; if ((paintParts & DataGridViewPaintParts.Background) != 0) { Color back = selected ? cellStyle.SelectionBackColor : cellStyle.BackColor; if (back.IsEmpty) back = SystemColors.Window; using (var brush = new SolidBrush(back)) graphics.FillRectangle(brush, cellBounds); } if ((paintParts & DataGridViewPaintParts.Border) != 0) PaintBorder(graphics, clipBounds, cellBounds, cellStyle, advancedBorderStyle); if ((paintParts & DataGridViewPaintParts.ContentForeground) != 0) { Color fore = selected ? cellStyle.SelectionForeColor : cellStyle.ForeColor; if (fore.IsEmpty) fore = SystemColors.WindowText; DrawContent(graphics, cellBounds, value, cellStyle, fore, selected); } }

cellStyle.BackColor如果是空色(Color.Empty),说明这一格没有单独设颜色,得回退到系统默认。这个回退不写的话,未选中且未显式设色的单元格会画成透明,底下露出 DataGridView 自己的背景,视觉上像一块缺角。

4. 布局函数只写一份:绘制和点击必须共用同一套矩形

4.1 把矩形计算抽成静态方法,是命中测试不出错的唯一办法

自绘方案里最经典的 bug 是"看着点左边,实际触发了右边"。根因几乎都是一样的:Paint里算矩形用了一套坐标,OnMouseClick里又写了一遍,两边差了一两个像素,或者内缩量的正负号写反了。

唯一的解法是把布局计算抽成一个纯函数,输入是单元格的本地矩形,输出是各个子区域的矩形,绘制和点击都调它。这样只要函数对,两边永远一致。

internal static class SpinLayout { public const int ButtonMinWidth = 24; public static void Split(Rectangle local, int buttonWidth, out Rectangle minus, out Rectangle plus, out Rectangle text) { int w = Math.Min(buttonWidth, Math.Max(ButtonMinWidth, local.Width / 4)); minus = new Rectangle(local.Left, local.Top, w, local.Height); plus = new Rectangle(local.Right - w, local.Top, w, local.Height); text = Rectangle.FromLTRB(minus.Right, local.Top, plus.Left, local.Bottom); } }

注意这里的local一定要是相对于单元格左上角的坐标,而不是相对于整个 DataGridView 的坐标。Paint拿到的cellBounds是绝对坐标,所以要先用new Rectangle(Point.Empty, cellBounds.Size)转成本地矩形再喂进去;OnMouseClick拿到的e.Location天生就是相对单元格的,直接就能用。

坐标系的对齐是这块最容易翻车的地方,我在第一次实现时忘了转换,导致第一行的按钮点击全落到第二行,排查了半天。

4.2 三个鼠标入口的分工:Down、Move、Click 各管什么

DataGridViewCell提供了几个可重写的鼠标入口,各有用途,混用会出问题:

  • OnMouseMove:负责悬停高亮。进入某个按钮区域时设置_hotPart字段并调用DataGridView.InvalidateCell重绘。注意必须判断"状态是否真的变了"再重绘,否则鼠标每移动一像素就重绘一次,滚动时会明显掉帧。
  • OnMouseDown:负责按下态。把_pressedPart记下来,画的时候给按钮一个下沉的视觉效果。
  • OnMouseClick:负责真正的业务触发。这里是修改数值、弹出下拉、删除行的入口。
protected override void OnMouseClick(DataGridViewCellMouseEventArgs e) { base.OnMouseClick(e); if (e.Button != MouseButtons.Left || DataGridView == null) return; var bounds = DataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false); var local = new Rectangle(Point.Empty, bounds.Size); SpinLayout.Split(local, GetButtonWidth(), out var minus, out var plus, out _); if (!minus.Contains(e.Location) && !plus.Contains(e.Location)) return; if (ReadOnly || DataGridView.ReadOnly) return; int current = ToInt(Value); int next = minus.Contains(e.Location) ? Math.Max(Minimum, current - Step) : Math.Min(Maximum, current + Step); if (next == current) return; DataGridView.EndEdit(); Value = next; DataGridView.InvalidateCell(e.ColumnIndex, e.RowIndex); }

这里用了DataGridView.GetCellDisplayRectangle来还原单元格尺寸,而不是用OwningColumn.Width乘OwningRow.Height。原因是单元格实际显示尺寸还会受边框样式、行头宽度、冻结列偏移影响,GetCellDisplayRectangle返回的才是和Paint里那个cellBounds完全一致的值。

4.3 命中区域要比视觉区域大一圈

用户的手指不是像素级的。按钮画出来 20 像素宽,如果把命中判定严格限制在这 20 像素内,实际使用时的点击成功率会让人抓狂,尤其是笔记本触控板上。

我的习惯是把命中矩形在垂直方向撑满整个单元格高度,水平方向左右各外扩 2 像素。反正是自己画的区域,边界稍微宽松一点,用户在视觉上感知不到,操作手感会明显变好。但要注意别让相邻的两个区域重叠——一旦重叠,Contains的判断顺序就决定了行为,写起来会变成玄学。

5. 进入编辑态的时机与值搬运的完整链路

5.1 EditType 与 IDataGridViewEditingControl 的对接细节

前面说过,纯自绘方案最大的局限是不支持键盘输入和输入法。要解决这个问题,得让单元格在双击时进入编辑态,弹出一个实现了IDataGridViewEditingControl的控件。

IDataGridViewEditingControl有九个成员必须实现,看起来繁琐,实际每个都很简单:

成员作用常见实现
EditingControlDataGridView指向宿主表格属性直接自动存取
EditingControlRowIndex当前编辑行属性直接自动存取
EditingControlValueChanged值是否已改变属性直接自动存取
EditingControlFormattedValue值的格式化形态转字符串
EditingPanelCursor光标形状返回Cursors.Default
RepositionEditingControlOnValueChange值变是否要重定位返回false
ApplyCellStyleToEditingControl应用单元格样式同步字体和颜色
EditingControlWantsInputKey哪些键自己处理数字键、退格返回 true
PrepareEditingControlForEdit进入编辑前的准备选中全部文本
GetEditingControlFormattedValue提交时取值返回内部控件的值

关键在于EditingControlWantsInputKey。如果这里无脑返回true,Tab 键会被编辑控件吃掉,用户没法切换到下一格;如果无脑返回false,退格键会传给 DataGridView 触发删除行。正确的写法是列出编辑控件真正需要的键(数字、退格、Delete、左右方向键),其余交给 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.Back: case Keys.Delete: return true; default: return !dataGridViewWantsInputKey; } }

5.2 值在 Cell 和编辑控件之间的来回搬运

数据流是双向的,方向搞混就会出现"输入的值提交后变回原样"这种诡异现象。

进入编辑态时,DataGridView 调用InitializeEditingControl(rowIndex, initialFormattedValue, cellStyle),你需要在这个方法里把初始值灌进编辑控件。默认实现会去找一个同名属性,找不到就什么也不做,所以一般都要重写。

提交时,DataGridView 会调用GetEditingControlFormattedValue(DataGridViewDataErrorContexts)拿到字符串,再交给你重写的ParseFormattedValue转成真正的值类型。这个转换如果抛异常,会触发DataError事件,用户看到的是红叉图标。

protected override object GetValue(int rowIndex) => base.GetValue(rowIndex); public override object ParseFormattedValue(object formattedValue, DataGridViewCellStyle cellStyle, TypeConverter formattedValueTypeConverter, TypeConverter valueTypeConverter) { if (formattedValue == null) return Minimum; if (int.TryParse(Convert.ToString(formattedValue), out int v)) return Math.Min(Maximum, Math.Max(Minimum, v)); return Minimum; }

ParseFormattedValue里做上下限夹取是个好习惯。用户手输一个 99999,你直接拒绝反而体验差,静默夹到最大值更合适。当然如果业务上必须提示,就抛ArgumentException然后处理DataError事件给出友好提示。

5.3 重写 PositionEditingControl 让编辑框只覆盖文本区

默认情况下编辑控件会铺满整个单元格,把你自己画的加减按钮盖住,视觉上很突兀。重写PositionEditingControl可以把编辑控件限制在文本区域:

public override void PositionEditingControl(bool setLocation, bool setSize, Rectangle cellBounds, Rectangle cellClip, DataGridViewCellStyle cellStyle, bool singleVerticalBorderAdded, bool singleHorizontalBorderAdded, bool isFirstDisplayedColumn, bool isFirstDisplayedRow) { var local = new Rectangle(Point.Empty, cellBounds.Size); SpinLayout.Split(local, GetButtonWidth(), out var minus, out var plus, out var text); var target = new Rectangle( cellBounds.Left + text.Left, cellBounds.Top, text.Width, cellBounds.Height); base.PositionEditingControl(setLocation, setSize, target, cellClip, cellStyle, singleVerticalBorderAdded, singleHorizontalBorderAdded, isFirstDisplayedColumn, isFirstDisplayedRow); }

有个坑需要注意:PositionEditingControl在编辑过程中会被反复调用(比如用户输入导致内容变宽),如果你在方法里计算了跟字体相关的尺寸,字体变化时布局会跳。把按钮宽度缓存成字段,在FontChanged时刷新,能避免这种抖动。

6. 实测翻车清单:滚动残影、高 DPI 偏移、排序丢值

6.1 画笔资源的生命周期与滚动残影

自绘单元格里最忌讳的两件事:一是在Paint里new Font,二是不using画笔。

Paint在一次滚动里会被调用几百上千次,每次 new 一个 Font 出来就是几百个 GDI 对象,几分钟之后程序就会因为句柄耗尽而崩溃或者界面卡成幻灯片。正确做法是把画笔、字体缓存成静态字段或者实例字段,在Dispose里清理。

private static readonly Pen BorderPen = new Pen(SystemColors.ControlDark); private static readonly SolidBrush HotBrush = new SolidBrush(SystemColors.ControlLight);

静态字段的生命周期跟着进程走,数量固定,不会有泄漏问题。但要注意如果颜色需要跟随系统主题变化,静态字段就得多加一层"主题变更时重建"的逻辑,SystemEvents.UserPreferenceChanged是个可用的入口。

滚动残影的成因一般是Paint里没有正确处理DataGridViewPaintParts,某个部分没画导致上一帧的内容残留。解决办法是确保背景和边框两个部分一定被填充,并且Background部分的填充矩形用完整的cellBounds而不是内缩后的区域。

另外,DataGridView 的双缓冲属性是 protected 的,直接设不了,可以用反射打开:

typeof(DataGridView) .GetProperty("DoubleBuffered", BindingFlags.Instance | BindingFlags.NonPublic) ?.SetValue(dataGridView1, true, null);

这一行对自绘单元格的观感提升非常明显,滚动时的撕裂感会基本消失。

6.2 高 DPI 下的坐标漂移与按钮宽度计算

在 125% 或 150% 缩放的屏幕上,固定像素的按钮宽度会显得特别窄,文字被挤变形。更麻烦的是,Paint里的坐标是物理像素,OnMouseClick里的坐标也是物理像素,两者一致没问题,但如果你的布局函数里混入了逻辑像素(比如从设计器读到的Width),就会出现绘制和命中不一致。

我现在的做法是:按钮宽度不写死像素,而是根据当前字体测量出来。

private int GetButtonWidth() { Font font = DataGridView?.DefaultCellStyle.Font ?? SystemFonts.DefaultFont; int textWidth = TextRenderer.MeasureText("+", font).Width; float scale = (DataGridView?.DeviceDpi ?? 96) / 96f; return (int)Math.Round((textWidth + 14) * scale); }

DeviceDpi是 .NET Framework 4.7 之后才有的属性,老项目上可以用Graphics.DpiX代替。TextRenderer.MeasureText走的是 GDI 测量,比Graphics.MeasureString更贴近实际绘制宽度,不会出现文字被截断的情况。

还有一个细节:如果程序清单里声明了 PerMonitorV2,窗体在不同 DPI 的显示器之间拖动时会触发重绘,此时DeviceDpi会变化,布局函数每次现算就自然跟上了。别把按钮宽度存在字段里初始化一次就不管了。

6.3 排序、复制、只读状态引发的连带问题

排序:自定义 Cell 的ValueType是 int,DataGridView 会尝试用Comparer.Default比较,int 本身实现了IComparable,所以数值排序没问题。但如果值是 null,比较时会抛异常。稳妥的做法是重写GetValue,把 null 统一转成默认值(比如最小值),或者在SortCompare事件里自己处理。

复制:用户选中一片单元格按 Ctrl+C,DataGridView 取的是FormattedValue。如果你的FormattedValueType是 string 却返回了 int,复制出来会是空白。这个问题我在一个导出功能的模块里遇到过,排查了很久才发现是类型没对上。

只读:ReadOnly在三个层级上都存在——DataGridView.ReadOnly、DataGridViewColumn.ReadOnly、DataGridViewCell.ReadOnly。三个都要判,缺一个就会出现"整表只读但某一列还能点"的怪现象。我的做法是封装一个小方法统一判断:

private bool IsEditable(int rowIndex) { if (DataGridView == null) return false; if (DataGridView.ReadOnly) return false; if (OwningColumn?.ReadOnly == true) return false; if (ReadOnly) return false; if (DataGridView.Rows[rowIndex].ReadOnly) return false; return true; }

数据绑定回写:给Value赋值时,如果表格是绑定到List<T>的,只有 T 实现了INotifyPropertyChanged或者绑的是BindingList<T>,改动才会真正落到对象上。绑普通List<T>的话,界面看着变了,底层数据纹丝不动,保存到数据库时全是旧值。这个坑跟 DataGridView 本身无关,但配合自定义单元格使用时特别容易撞上,因为用户会反复点加减号,改的是一批数据,出问题时很难定位到哪一格没写回去。

7. 完整可跑代码与往更复杂组合上扩展

7.1 一个能直接编译的 SpinColumn 完整实现

把前面所有片段拼起来,下面是完整可用的版本。新建一个 WinForms 项目,把三个类丢进去,然后在窗体上dataGridView1.Columns.Add(new SpinColumn())就能看到效果。

using System; using System.Drawing; using System.Windows.Forms; public class SpinColumn : DataGridViewColumn { public SpinColumn() : base(new SpinCell()) { Width = 120; } public override object Clone() { var col = (SpinColumn)base.Clone(); col.CellTemplate = (SpinCell)this.CellTemplate.Clone(); return col; } } internal static class SpinLayout { public static void Split(Rectangle local, int buttonWidth, out Rectangle minus, out Rectangle plus, out Rectangle text) { int w = Math.Max(20, Math.Min(buttonWidth, local.Width / 3)); minus = new Rectangle(local.Left, local.Top, w, local.Height); plus = new Rectangle(local.Right - w, local.Top, w, local.Height); text = Rectangle.FromLTRB(minus.Right, local.Top, plus.Left, local.Bottom); } } public class SpinCell : DataGridViewCell { public int Minimum = 0; public int Maximum = 9999; public int Step = 1; private int _hotPart; // 0 无, 1 减, 2 加 private int _pressPart; public override Type ValueType => typeof(int); public override Type FormattedValueType => typeof(string); public override object Clone() { var cell = (SpinCell)base.Clone(); cell._hotPart = 0; cell._pressPart = 0; return cell; } protected override Size GetPreferredSize(Graphics graphics, DataGridViewCellStyle cellStyle, int rowIndex, Size constraintSize) { return new Size(120, 28); } private int GetButtonWidth() { Font font = DataGridView?.DefaultCellStyle.Font ?? SystemFonts.DefaultFont; int tw = TextRenderer.MeasureText("+", font).Width; float scale = (DataGridView?.DeviceDpi ?? 96) / 96f; return (int)Math.Round((tw + 14) * scale); } 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) { bool selected = (cellState & DataGridViewElementStates.Selected) != 0; if ((paintParts & DataGridViewPaintParts.Background) != 0) { Color back = selected ? cellStyle.SelectionBackColor : cellStyle.BackColor; if (back.IsEmpty) back = SystemColors.Window; using (var b = new SolidBrush(back)) graphics.FillRectangle(b, cellBounds); } if ((paintParts & DataGridViewPaintParts.Border) != 0) PaintBorder(graphics, clipBounds, cellBounds, cellStyle, advancedBorderStyle); if ((paintParts & DataGridViewPaintParts.ContentForeground) == 0) return; Color fore = selected ? cellStyle.SelectionForeColor : cellStyle.ForeColor; if (fore.IsEmpty) fore = SystemColors.WindowText; var inner = Rectangle.Inflate(cellBounds, -1, -1); SpinLayout.Split(new Rectangle(Point.Empty, inner.Size), GetButtonWidth(), out var minus, out var plus, out var text); minus.Offset(inner.Left, inner.Top); plus.Offset(inner.Left, inner.Top); text.Offset(inner.Left, inner.Top); DrawButton(graphics, minus, "\u2212", cellStyle.Font, _hotPart == 1, _pressPart == 1); DrawButton(graphics, plus, "+", cellStyle.Font, _hotPart == 2, _pressPart == 2); string s = ToInt(value).ToString(); TextRenderer.DrawText(graphics, s, cellStyle.Font, text, fore, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding | TextFormatFlags.EndEllipsis); } private static void DrawButton(Graphics g, Rectangle r, string caption, Font font, bool hot, bool pressed) { Color fill = pressed ? SystemColors.ControlDark : hot ? SystemColors.ControlLight : SystemColors.Control; using (var b = new SolidBrush(fill)) g.FillRectangle(b, r); using (var p = new Pen(SystemColors.ControlDark)) g.DrawRectangle(p, r.Left, r.Top, r.Width - 1, r.Height - 1); Color fore = pressed ? SystemColors.HighlightText : SystemColors.ControlText; TextRenderer.DrawText(g, caption, font, r, fore, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding); } private static int ToInt(object v) => v == null || v == DBNull.Value ? 0 : Convert.ToInt32(v); private Rectangle GetLocalBounds(int rowIndex) { Rectangle bounds = DataGridView.GetCellDisplayRectangle(ColumnIndex, rowIndex, false); return new Rectangle(Point.Empty, Rectangle.Inflate(bounds, -1, -1).Size); } private int HitTest(Rectangle ptLocal, int rowIndex) { SpinLayout.Split(GetLocalBounds(rowIndex), GetButtonWidth(), out var minus, out var plus, out _); if (minus.Contains(ptLocal)) return 1; if (plus.Contains(ptLocal)) return 2; return 0; } protected override void OnMouseMove(DataGridViewCellMouseEventArgs e) { base.OnMouseMove(e); int part = HitTest(e.Location, e.RowIndex); if (part == _hotPart) return; _hotPart = part; DataGridView?.InvalidateCell(ColumnIndex, e.RowIndex); if (DataGridView != null) DataGridView.Cursor = part == 0 ? Cursors.Default : Cursors.Hand; } protected override void OnMouseLeave(int rowIndex) { base.OnMouseLeave(rowIndex); if (_hotPart == 0) return; _hotPart = 0; DataGridView?.InvalidateCell(ColumnIndex, rowIndex); if (DataGridView != null) DataGridView.Cursor = Cursors.Default; } protected override void OnMouseDown(DataGridViewCellMouseEventArgs e) { base.OnMouseDown(e); _pressPart = HitTest(e.Location, e.RowIndex); if (_pressPart != 0) DataGridView?.InvalidateCell(ColumnIndex, e.RowIndex); } protected override void OnMouseUp(DataGridViewCellMouseEventArgs e) { base.OnMouseUp(e); _pressPart = 0; DataGridView?.InvalidateCell(ColumnIndex, e.RowIndex); } protected override void OnMouseClick(DataGridViewCellMouseEventArgs e) { base.OnMouseClick(e); if (e.Button != MouseButtons.Left || DataGridView == null) return; int part = HitTest(e.Location, e.RowIndex); if (part == 0) return; if (!IsEditable(e.RowIndex)) return; int cur = ToInt(Value); int next = part == 1 ? Math.Max(Minimum, cur - Step) : Math.Min(Maximum, cur + Step); if (next == cur) return; if (DataGridView.IsCurrentCellInEditMode) DataGridView.EndEdit(); Value = next; DataGridView.InvalidateCell(ColumnIndex, e.RowIndex); } private bool IsEditable(int rowIndex) { if (DataGridView == null || DataGridView.ReadOnly) return false; if (OwningColumn?.ReadOnly == true || ReadOnly) return false; return !DataGridView.Rows[rowIndex].ReadOnly; } }

跑起来之后你会看到一个 120 宽的行,左边减号、中间数字、右边加号,鼠标移上去按钮变色、光标变手型,点击即时增减,数值到达上下限后按钮继续点击也不再变化。整个过程没有任何真实控件参与,几百行数据滚动毫无压力。

7.2 往更多控件组合上扩展的三种思路

这个骨架的价值在于它是可扩展的。想把"加减按钮 + 单位下拉"拼在一格里,有三个方向可以走。

第一种是继续在Paint里画,把下拉做成"点击弹出 ContextMenuStrip"。命中下拉区域时,OnMouseClick里根据单元格位置计算一个屏幕坐标,然后contextMenuStrip.Show(DataGridView, pt)。菜单项选中后回写值并重绘。这种做法不引入任何子控件,性能最好,视觉上的一致性也最好掌控,适合选项不多的场景(比如单位、状态、优先级这类枚举值)。

第二种是把编辑控件那套接进来,让EditType返回一个内部含 NumericUpDown 和 ComboBox 的 UserControl。双击单元格时它顶上来,键盘输入、下拉展开都是原生行为。缺点是必须双击才能用,不能像加减号那样随手点。

第三种是混合:加减按钮保持自绘,文本区域单击进入编辑态。这也是我实际项目里用得最多的组合。需要注意的是,进入编辑态的触发时机要自己控制——DataGridView默认是双击进入,如果希望单击文本区就进入,可以重写OnMouseClick判断命中区域后调用DataGridView.BeginEdit(true)。

// 在 OnMouseClick 的 part == 0 分支里补上 if (IsEditable(e.RowIndex) && !DataGridView.IsCurrentCellInEditMode) DataGridView.BeginEdit(true);

要强调的是BeginEdit的参数:传true会选中全部文本,传false光标落在末尾。数量类字段通常希望全选,方便直接覆盖输入。

一路写下来我最大的体会是:自定义单元格这件事,难点从来不在"怎么画",而在"怎么让绘制、命中、编辑、数据绑定这四条线在同一个坐标系里对齐"。把布局函数抽出来共用、把状态字段放在实例上、把只读判断收拢成一个方法,这三点做到了,剩下的就是纯粹的美术活儿。至于什么时候该用自绘、什么时候该上编辑控件,判断标准也很简单——用户需要的是"随手可点"还是"精确输入",前者自绘,后者编辑控件,两者都要就混合。