Winform 项目里,DataGridView 的单元格(DataGridViewCell)默认只认得一段文本或者一张图片。你若想在一个格子里同时摆两三个按钮,或者左边显示状态文字、右边跟一个操作入口,直接 Controls.Add 是走不通的——控件会浮在表格上方,一滚动就错位,一排序就串行。这个需求我碰到过不止一次,从最早的“操作列里塞三个按钮”,到后来“状态标签 + 快捷操作”混排,坑踩得够多,也总结出几条能直接抄作业的路线。
这篇东西讲的是:怎么在 DataGridView 的单个单元格里呈现出多个控件(按钮组、标签加按钮、复合输入框),三条主流技术路线各自的实现方式、参数怎么算、坑在哪里。适合已经会用 DataGridView 绑数据、但被“单元格里放控件”卡住的中级开发者,也适合刚接手老 Winform 项目、需要快速改一个操作列的朋友。自绘方案会给出完整可编译的代码,编辑控件方案会讲清楚那九个接口成员到底在干什么,真控件覆盖法也会说明它在什么规模下还能用。
1. 先搞清楚 DataGridViewCell 到底是个什么东西
1.1 单元格是“画”出来的,不是“装”出来的
很多人第一次尝试在单元格里放控件,思路是“单元格是个容器,我往里 Add 就行”。这个直觉来自 Panel,但 DataGridView 的渲染模型完全不是这套。DataGridView 本质是一个自绘控件,它的 OnPaint 会遍历可见的行和列,对每个单元格调用 DataGridViewCell.Paint,把背景、边框、内容一次性画到同一个 Graphics 上。单元格对象手里没有窗口句柄,它只是一个“绘制单元 + 状态容器”。
这个认知非常关键,它决定了后面所有方案的走向。你看到的那些“单元格里有个下拉框”的效果,在非编辑状态下全部是画出来的像素,只有进入编辑态时,DataGridView 才会临时创建一个真实的编辑控件浮在上面,编辑结束立刻销毁。所以“单元格里放多个控件”这件事,精确的表述应该是:在单元格的绘制区域里,切分出多个子区域,分别绘制出控件的样子,并且自己处理鼠标命中和状态反馈。想通了这一层,后面的代码就是水到渠成。
反过来说,如果你坚持要真实的控件对象,那就只能走“浮层”路线,也就是把控件的位置和大小实时对齐到单元格的显示矩形上,然后祈祷滚动、排序、列宽拖拽这些操作不会把它甩出去。这条路能走,但维护成本高,后面第 5 章会细说。
1.2 三条技术路线,一张表看清取舍
在动手之前,先把可选方案摆出来对比,比写到一半推倒重来要省事得多。我实际项目里用过的就这三种,没有第四种。
| 方案 | 实现成本 | 交互能力 | 万行级性能 | 典型场景 |
|---|---|---|---|---|
| 自绘 Cell + 命中测试 | 中等,一次封装长期复用 | 点击、悬停、按压反馈,不能输入 | 好,不增加窗口句柄 | 操作按钮组、状态徽标、进度条、评分星 |
| 自定义编辑控件 | 高,要实现九个接口成员 | 完整的输入控件树 | 中,只在编辑时创建 | 单元格内复合输入,比如下拉选择加备注 |
| Controls.Add 浮层覆盖 | 低,几十行能跑起来 | 完整控件树,随便放 | 差,每行若干句柄 | 行数少、交互复杂的配置表 |
先说结论:九成场景走第一条。操作列放三个按钮,是最典型的需求,自绘方案能做得又轻又稳,一万行数据滚动照样跟手。只有当你需要在单元格里敲字、拉下拉框、勾多选框并且这些状态要参与数据绑定时,才值得上第二条。第三条我通常只在“这一屏就 30 行、但是每行单元格里要嵌一个复杂的用户选择器”这种特殊场景才用,一旦超过两三百行,卡顿和句柄泄漏会让你怀疑人生。
提示:不要试图混用。同一个 DataGridView 里既有自绘列又有浮层控件列,滚动时的重绘顺序会让你调试到深夜。
1.3 为什么不建议一上来就用 Controls.Add
我见过不少代码是这么写的:在 CellPainting 事件里,把单元格的显示矩形算出来,然后 new 一个 Button,设置 Bounds,Add 到 DataGridView.Controls 里。跑起来看着挺美,滚动一下问题全来了。
第一个问题是遮盖。DataGridView 的重绘和子控件的重绘是两套机制,快速滚动时你会看到按钮“粘”在原地不动,然后突然跳一下。第二个问题是坐标。单元格的显示矩形会随滚动变成负数,控件跟着跑到可视区外面,虽然看不见,但句柄还在。第三个问题是排序。DataGridView 排序后行索引全变,你缓存的“第 3 行对应哪个控件”这套映射全部失效,除非每次排序都重建一遍。第四个问题是内存,一万行就是几万个控件,每个控件都是真实的窗口句柄,任务管理器里的 GDI 对象数会以肉眼可见的速度往上爬。
这些问题不是不能解,但每解一个都要写一堆同步代码,而自绘方案天生没有这些烦恼——单元格滚到哪里,Paint 就画到哪里,没有任何需要同步的状态。所以我的建议是:先花两个小时把自绘 Cell 封装好,后面所有项目都能复用。
2. 动手前的设计决策
2.1 按钮数量固定还是随数据变化
这一步决定了你的类怎么设计。如果整列都是“查看 / 编辑 / 删除”三个固定按钮,那按钮文案直接放在 Column 上,所有行共用一套,实现最简单。如果每一行显示的按钮不一样——比如状态是“待审核”时显示“通过 / 驳回”,状态是“已完成”时只显示“详情”——那文案就得从数据里来。
我的做法是在 Cell 里留一个可重写的钩子:
protected virtual string[] GetButtonTexts(int rowIndex) { if (OwningColumn is MultiActionColumn col) return col.ButtonTexts; return new[] { "操作" }; }需要按行变化时,继承 MultiActionCell 重写这个方法,从 DataGridView.Rows[rowIndex].DataBoundItem 里读状态字段,返回对应的文案数组。代价是每一行的按钮数量不同,区域切分逻辑要跟着变,命中测试也要按当前行的文案数组来算,不能缓存。这一点务必注意,缓存区域矩形的写法在固定文案下没问题,在动态文案下必然出错。
2.2 单元格宽度够不够,得先算一笔账
按钮挤成一团是这个需求最常见的视觉灾难。我给个经验公式:每个按钮的宽度 = 文本测量宽度 + 左右内边距(各 6 到 8 像素)+ 边框(2 像素),按钮之间的间隙 4 到 6 像素,整个单元格两侧再留 3 到 4 像素的安全边。
拿“查看 / 编辑 / 删除”举例,9pt 微软雅黑下,两个汉字的测量宽度大概 24 像素,加上 14 像素的内边距,单个按钮约 38 像素。三个按钮加两个间隙,总需求约 126 像素。列宽设 160 就有富余,设 120 就会挤。实际操作中我会把列宽设成“需求宽度 + 20%”,然后给列加一个 MinimumWidth 兜底,防止用户拖拽列宽把按钮压扁。
如果列宽是动态的(比如 Fill 模式),那切分逻辑就必须先测量再分配,不能简单地等分。等分的问题在于:中文两字和四字的按钮看起来宽度一样,视觉上非常别扭。第 3 章给的代码里就是先测量再按比例分配富余空间,列宽变化时自动重算,不需要额外处理。
2.3 只读列、虚拟模式与数据绑定的影响
自定义列加到 DataGridView 上有几个固定动作要检查。第一,列的 ReadOnly 建议设成 true,因为自绘按钮不参与单元格编辑,如果列可编辑,用户双击会触发 DataGridView 的编辑流程,而你的 Cell 的 EditType 是 null,最终表现是“双击没反应”,体验很怪。第二,SortMode 设成 NotSortable,按钮列排序没有意义,设成 Automatic 反而会让表头出现一个奇怪的排序箭头。
如果项目用的是虚拟模式(VirtualMode = true),那 CellValueNeeded 事件必须给这一列返回一个非 null 的值,否则 DataGridView 会认为这个单元格没有数据,Paint 里拿到的 value 是 null,虽然不影响按钮绘制,但选中行、复制单元格这些内置行为会变得奇怪。我的习惯是让虚拟模式下的这一列返回行的唯一标识,顺便在点击事件里用得上。
绑定 DataSource 的场景还有个顺序问题:自定义列必须在设置 DataSource 之后再 Add,否则 DataGridView 自动生成列的逻辑会把它当成一个数据列,试图去绑定同名的属性,抛出一堆“找不到字段”的异常。这个坑我在老项目里遇到过,报错信息很难看出是列顺序导致的。
3. 自绘方案落地:MultiActionColumn 完整实现
3.1 列与单元格的骨架代码
先把两个类搭起来。DataGridViewColumn 的派生类负责持有配置和对外抛事件,DataGridViewCell 的派生类负责绘制和命中测试。两者通过 OwningColumn 关联起来。
using System; using System.Drawing; using System.Windows.Forms; using System.Windows.Forms.VisualStyles; public class MultiActionColumn : DataGridViewColumn { public event EventHandler<MultiActionEventArgs> ButtonClick; private string[] _buttonTexts = { "查看", "编辑", "删除" }; public MultiActionColumn() : base(new MultiActionCell()) { Width = 200; ReadOnly = true; SortMode = DataGridViewColumnSortMode.NotSortable; DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleLeft; } public string[] ButtonTexts { get { return _buttonTexts; } set { _buttonTexts = (value == null || value.Length == 0) ? new[] { "操作" } : value; } } public override DataGridViewCell CellTemplate { get { return base.CellTemplate; } set { if (value != null && !(value is MultiActionCell)) throw new ArgumentException("CellTemplate 必须是 MultiActionCell 类型。"); base.CellTemplate = value; } } internal void RaiseButtonClick(MultiActionCell cell, int buttonIndex) { string text = (buttonIndex >= 0 && buttonIndex < _buttonTexts.Length) ? _buttonTexts[buttonIndex] : string.Empty; var handler = ButtonClick; if (handler != null) handler(this, new MultiActionEventArgs(cell.RowIndex, Index, buttonIndex, text)); } }这里有个细节值得说:CellTemplate 的类型检查为什么放在 setter 里而不是构造函数里。因为 DataGridView 内部会克隆 CellTemplate 来给每一行生成单元格实例,克隆出来的对象类型必须和你预期的一致,否则继承的样式、事件全部对不上。在 setter 里拦一道,能在设计器里就把错误暴露出来,而不是运行到某一行的绘制阶段才崩。
事件参数类很简单,把行号、列号、按钮序号、按钮文案都带上:
public class MultiActionEventArgs : EventArgs { public int RowIndex { get; private set; } public int ColumnIndex { get; private set; } public int ButtonIndex { get; private set; } public string ButtonText { get; private set; } public MultiActionEventArgs(int rowIndex, int columnIndex, int buttonIndex, string buttonText) { RowIndex = rowIndex; ColumnIndex = columnIndex; ButtonIndex = buttonIndex; ButtonText = buttonText; } }行号给的是显示行索引,如果表格允许排序或者用户手动拖动行,调用方拿到的 RowIndex 和数据源里的顺序并不一致。这一点必须在文档里写清楚,否则接手的同事很容易在这里写出“删错行”的 bug。稳妥做法是调用方用grid.Rows[e.RowIndex].DataBoundItem反查真实对象,而不是直接拿索引去数组里取。
3.2 区域切分与高 DPI 适配
区域切分是自绘方案的核心算法,写不好就会出现“列宽一拖,按钮飞出格子”或者“高 DPI 屏幕上按钮成了一条线”。我的切分逻辑分三步走:先算内容区,再测量每个按钮的最小宽度,最后按可用空间分配。
private const int Edge = 3; private const int Gap = 5; private Rectangle[] GetButtonRects(Rectangle cellBounds, DataGridViewCellStyle style) { string[] texts = GetButtonTexts(RowIndex); if (texts == null || texts.Length == 0) return new Rectangle[0]; Padding pad = style.Padding; int left = cellBounds.X + pad.Left + Edge; int top = cellBounds.Y + pad.Top + Edge; int width = cellBounds.Width - pad.Horizontal - Edge * 2; int height = cellBounds.Height - pad.Vertical - Edge * 2; if (width <= 0 || height <= 0) return new Rectangle[0]; // 按钮高度跟字体走,行再高也不把按钮拉成柱子 int btnHeight = Math.Min(height, style.Font.Height + 10); int y = top + (height - btnHeight) / 2; int[] needWidth = new int[texts.Length]; int totalNeed = Gap * (texts.Length - 1); for (int i = 0; i < texts.Length; i++) { Size sz = TextRenderer.MeasureText(texts[i], style.Font); needWidth[i] = sz.Width + 16; totalNeed += needWidth[i]; } Rectangle[] rects = new Rectangle[texts.Length]; int x = left; if (totalNeed <= width) { int extra = (width - totalNeed) / texts.Length; for (int i = 0; i < texts.Length; i++) { int w = needWidth[i] + extra; rects[i] = new Rectangle(x, y, w, btnHeight); x += w + Gap; } } else { int w = Math.Max(18, (width - Gap * (texts.Length - 1)) / texts.Length); for (int i = 0; i < texts.Length; i++) { rects[i] = new Rectangle(x, y, w, btnHeight); x += w + Gap; } } return rects; }为什么按钮高度要用style.Font.Height + 10而不是写死 24 像素?因为 Winform 的高 DPI 支持是按控件自动缩放的,但你在代码里写死的像素值不会被缩放。9pt 字体在 96 DPI 下大约 15 像素高,加 10 像素内边距得到 25 像素,视觉合适。切到 144 DPI,字体自动变成 22 像素高,按钮跟着变成 32 像素,比例不变。写死 24 的话,高 DPI 下按钮会比文字矮,字会被压出边框。
宽度这边用了 TextRenderer.MeasureText,注意它测量的是“绘制文本”的宽度,比 Graphics.MeasureString 更贴近实际渲染结果,但需要显式加上内边距,因为它不包含按钮边框。我给的 16 像素是两侧各 6 像素加边框 2 像素的保守值,中文短文案够用,如果按钮文案是“导出全部数据”这种长文本,这个值要往上调,否则按钮里的字会被 Ellipsis 截成“导出...”。
注意:当总需求超过可用宽度时,代码退化成了等分。这时候一定要给列设置 MinimumWidth,比如
col.MinimumWidth = 140,让用户拖不到压扁的宽度,比在 Paint 里做各种省略号处理靠谱得多。
3.3 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, paintParts & ~DataGridViewPaintParts.ContentForeground); if (rowIndex < 0) return; Rectangle drawRect = Rectangle.Intersect(cellBounds, clipBounds); if (drawRect.Width <= 0 || drawRect.Height <= 0) return; Rectangle[] rects = GetButtonRects(cellBounds, cellStyle); if (rects.Length == 0) return; bool readOnly = ReadOnly || (DataGridView != null && DataGridView.ReadOnly); GraphicsState gs = graphics.Save(); graphics.SetClip(drawRect, System.Drawing.Drawing2D.CombineMode.Intersect); for (int i = 0; i < rects.Length; i++) { PushButtonState state; if (readOnly) state = PushButtonState.Disabled; else if (_pressedIndex == i) state = PushButtonState.Pressed; else if (_hoverIndex == i) state = PushButtonState.Hot; else state = PushButtonState.Normal; ButtonRenderer.DrawButton( graphics, rects[i], rects[i].Height > 0 ? GetButtonTexts(rowIndex)[i] : string.Empty, cellStyle.Font, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis, false, state); } graphics.Restore(gs); }第一点,paintParts & ~DataGridViewPaintParts.ContentForeground这行不能省。基类默认会把 value 转成文本画在单元格里,如果你既画按钮又画文本,两者会叠在一起。有些场景确实需要“左边一段文字、右边几个按钮”,那就在 base.Paint 之后自己补画文字,然后按文字实际宽度调整按钮起始位置,不能让两者打架。
第二点,Rectangle.Intersect(cellBounds, clipBounds)和graphics.SetClip是配套的。单元格滚动出可视区时,cellBounds 会有负数坐标,ButtonRenderer 并不知道这个边界,它会照着坐标硬画,结果就是表格边缘出现半截按钮。用了裁剪之后,越界部分自动被削掉,滚动过程看起来就很干净。
第三点,ButtonRenderer 走的是系统视觉样式,在 Windows 10 和 Windows 11 上会自动呈现当前主题的按钮外观,比用 ControlPaint.DrawBorder 手拼一个假按钮好看得多。但它对PushButtonState.Disabled的处理比较明显(整个按钮发灰),如果你的列是只读的又不想让按钮看起来“不可用”,那就别传 Disabled,改成 Normal,只是在点击逻辑里直接 return。
第四点,GetButtonTexts(rowIndex)在循环里被调用了两次(一次在 GetButtonRects 里,一次在绘制文本时)。如果文案是静态的,这点开销可以忽略;如果是按状态动态计算的,建议在 Paint 开头算一次存进局部变量,避免两处不一致——这个 bug 很隐蔽,表现为“按钮区域按三个按钮切分,但只画出了两个文字”。
3.4 鼠标状态机与命中测试
按钮要有点击感,就得维护三个状态:正常、悬停、按下。DataGridViewCell 提供了 OnMouseMove、OnMouseDown、OnMouseUp、OnMouseLeave 这几个可重写方法,参数里的 X、Y 是相对于单元格左上角的坐标,这一点要记牢。
private int HitTest(DataGridViewCellMouseEventArgs e) { if (DataGridView == null) return -1; Rectangle cellBounds = DataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false); if (cellBounds.Width <= 0) return -1; Rectangle[] rects = GetButtonRects(cellBounds, InheritedStyle); Point p = new Point(cellBounds.X + e.X, cellBounds.Y + e.Y); for (int i = 0; i < rects.Length; i++) if (rects[i].Contains(p)) return i; return -1; } protected override void OnMouseMove(DataGridViewCellMouseEventArgs e) { int hit = HitTest(e); if (hit != _hoverIndex) { _hoverIndex = hit; if (DataGridView != null) DataGridView.InvalidateCell(this); } if (DataGridView != null) DataGridView.Cursor = hit >= 0 ? Cursors.Hand : Cursors.Default; base.OnMouseMove(e); } protected override void OnMouseLeave(int rowIndex) { _hoverIndex = -1; _pressedIndex = -1; if (DataGridView != null) { DataGridView.InvalidateCell(this); DataGridView.Cursor = Cursors.Default; } base.OnMouseLeave(rowIndex); }坐标转换是这段代码里最容易出错的地方。GetCellDisplayRectangle 返回的是相对于 DataGridView 客户区的矩形,而鼠标事件的 e.X / e.Y 是相对于单元格的,两者必须统一到同一个坐标系里才能比较。我这里选择把鼠标坐标“抬”到控件坐标系,好处是 GetButtonRects 里算出来的矩形本来就是控件坐标系的,不需要再转换一次。
InvalidateCell(this)只重绘当前单元格,不会触发整行重绘,这是性能友好的关键。有些人图省事写DataGridView.Invalidate(),一万行数据下鼠标一动就全表重绘,卡得没法用。
光标切换那里,严格来说应该只在鼠标真的落在按钮上时才改成手型,离开就恢复。但 DataGridView 的光标是控件级的,多个单元格共用一个光标属性,如果同时有别的 Cell 也在改光标就会打架。实际项目里我通常只在整列都是按钮的情况下才做光标切换,混合列就干脆不切,省得出现“鼠标移到别处还是手型”的尴尬。
3.5 事件外抛与调用方代码
Cell 自己不应该知道业务逻辑,它只需要把“第几行、第几个按钮被点了”这件事告诉外面。前面在 Column 上定义的 ButtonClick 事件就是干这个的。
protected override void OnMouseDown(DataGridViewCellMouseEventArgs e) { if (e.Button == MouseButtons.Left) { int hit = HitTest(e); if (hit >= 0) { _pressedIndex = hit; if (DataGridView != null) DataGridView.InvalidateCell(this); } } base.OnMouseDown(e); } protected override void OnMouseUp(DataGridViewCellMouseEventArgs e) { if (e.Button == MouseButtons.Left && _pressedIndex >= 0) { int hit = HitTest(e); int pressed = _pressedIndex; _pressedIndex = -1; if (DataGridView != null) DataGridView.InvalidateCell(this); // 按下和抬起都落在同一个按钮上才算点击 if (hit == pressed && pressed >= 0) { var col = OwningColumn as MultiActionColumn; if (col != null) col.RaiseButtonClick(this, pressed); } } base.OnMouseUp(e); }这里特意做了“按下和抬起必须是同一个按钮”的判断。如果没有这个判断,用户在“查看”上按下、滑到“删除”上松开,就会误触删除,这种体验事故在实际使用中是会发生的,尤其在小尺寸按钮上。
调用方代码很短:
var actionCol = new MultiActionColumn(); actionCol.ButtonTexts = new[] { "查看", "编辑", "删除" }; actionCol.Width = 180; actionCol.MinimumWidth = 140; actionCol.ButtonClick += (s, e) => { if (e.RowIndex < 0 || e.RowIndex >= grid.Rows.Count) return; var item = grid.Rows[e.RowIndex].DataBoundItem as OrderItem; if (item == null) return; switch (e.ButtonIndex) { case 0: ShowDetail(item); break; case 1: OpenEditor(item); break; case 2: ConfirmAndDelete(item); break; } }; grid.Columns.Add(actionCol);注意这里用 DataBoundItem 反查对象,而不是用行索引去 List 里取。原因前面提过:排序、筛选之后行索引和数据索引就对不上了。这个习惯一旦养成,能省掉非常多“删错行”的线上事故。
4. 单元格里要能输入:自定义编辑控件
4.1 IDataGridViewEditingControl 的九个成员逐个说
如果需求从“点按钮”升级到“填内容”,自绘就不够了。DataGridView 提供的正规通道是编辑控件:你给 Cell 的 EditType 指定一个控件类型,进入编辑态时 DataGridView 会 new 一个实例,挂到自己的控件树里,位置对齐到单元格上,编辑结束后销毁并把值写回。
要参与这套机制,控件必须实现 IDataGridViewEditingControl 接口。这个接口有九个成员,初次接触会觉得莫名其妙,我把它们的作用逐个说清楚。
| 成员 | 作用 | 常见坑 |
|---|---|---|
| EditingControlDataGridView | 回指宿主表格 | 在构造函数里赋值会拿到 null,要等 DataGridView 注入 |
| EditingControlFormattedValue | 读写的值 | get 返回的值会被转成字符串写回单元格 |
| EditingControlRowIndex | 当前编辑的行号 | 行被删除时要及时更新 |
| EditingControlValueChanged | 标记值已变 | 变了必须置 true,否则 DataGridView 不提交 |
| EditingPanelCursor | 编辑面板上的光标 | 一般返回 Cursors.Default |
| RepositionEditingControlOnValueChange | 值变时是否重定位 | 内部控件会伸缩的场景要返回 true |
| ApplyCellStyleToEditingControl | 把单元格样式套到控件上 | 字体、前景色、背景色都要同步,否则编辑态会“变脸” |
| EditingControlWantsInputKey | 哪些键控件自己处理 | 方向键处理不好会导致光标跳不出单元格 |
| PrepareEditingControlForEdit | 进入编辑时的准备 | 需要全选的在这里 SelectAll |
最容易出问题的是 EditingControlWantsInputKey。这个方法返回 true 表示“这个键我控件自己处理,表格别管”,返回 false 表示“交给表格”。典型写法是:上下左右方向键在内部控件需要移动光标时返回 true,Tab 和 Enter 返回 false 让表格切换单元格。
public bool EditingControlWantsInputKey(Keys keyData, bool dataGridViewWantsInputKey) { switch (keyData & Keys.KeyCode) { case Keys.Left: case Keys.Right: case Keys.Up: case Keys.Down: case Keys.Home: case Keys.End: return true; default: return !dataGridViewWantsInputKey; } }这段不写好的后果很直接:用户在下拉框里按方向键想选一项,结果整个单元格的选择框跳到下一行去了,体验非常糟糕。
4.2 InitializeEditingControl 和 PositionEditingControl
Cell 这一侧有两件事要做。第一是告诉 DataGridView 用哪个控件:
public override Type EditType { get { return typeof(ComboTextEditingControl); } }第二是在进入编辑时把数据塞进去:
public override void InitializeEditingControl( int rowIndex, object initialFormattedValue, DataGridViewCellStyle dataGridViewCellStyle) { base.InitializeEditingControl(rowIndex, initialFormattedValue, dataGridViewCellStyle); var ctl = DataGridView.EditingControl as ComboTextEditingControl; if (ctl == null) return; var model = Value as ComboTextValue; if (model != null) { ctl.SelectedCode = model.Code; ctl.Remark = model.Remark; } else { ctl.SelectedCode = null; ctl.Remark = initialFormattedValue == null ? string.Empty : initialFormattedValue.ToString(); } }这里有个前提:单元格的 Value 必须是一个能承载两组数据的对象,不能是单纯字符串。我一般定义一个轻量的值对象,包含下拉选项的编码和备注文本两个字段,然后在 ToString 里拼成“编码 - 备注”的展示形式。DataGridView 在非编辑态会调用 ToString 来得到 formattedValue,自绘的时候直接用这个字符串就行。
PositionEditingControl 一般不用重写,DataGridView 会按单元格的显示矩形摆好编辑控件。但如果你想让编辑控件比单元格矮一点、往里缩一点,就得重写:
protected override void PositionEditingControl( bool setLocation, bool setSize, Rectangle cellBounds, Rectangle cellClip, DataGridViewCellStyle cellStyle, bool singleVerticalBorderAdded, bool singleHorizontalBorderAdded, bool isFirstDisplayedColumn, bool isFirstDisplayedRow) { var shrunk = new Rectangle(cellBounds.X + 2, cellBounds.Y + 2, cellBounds.Width - 4, cellBounds.Height - 4); base.PositionEditingControl(setLocation, setSize, shrunk, cellClip, cellStyle, singleVerticalBorderAdded, singleHorizontalBorderAdded, isFirstDisplayedColumn, isFirstDisplayedRow); }这个方法的参数列表很长,但真正要动的只有 cellBounds 和 cellClip,其余原样透传即可。我见过有人把 cellClip 忘了,结果编辑控件在表格右边缘溢出去,盖住了滚动条。
4.3 值回写与校验失败的处理
编辑结束,DataGridView 会调用 GetEditingControlFormattedValue 拿值,然后通过 ParseFormattedValue 转成单元格的 Value。这个转换链路是可以拦截的,拦截点就是 Cell 上重写的 ParseFormattedValue——而它恰好是 protected 的,只能在 Cell 派生类里写。
public override object ParseFormattedValue( object formattedValue, DataGridViewCellStyle cellStyle, TypeConverter formattedValueTypeConverter, TypeConverter valueTypeConverter) { string text = formattedValue == null ? string.Empty : formattedValue.ToString(); var parts = text.Split(new[] { " - " }, 2, StringSplitOptions.None); return new ComboTextValue { Code = parts.Length > 0 ? parts[0] : string.Empty, Remark = parts.Length > 1 ? parts[1] : string.Empty }; }校验失败的情况不要在这里抛异常。DataGridView 有内置的 DataError 事件,抛出异常会弹出一个默认的错误对话框,非常难看。正确做法是在 CellValidating 事件里判断,不通过就设置e.Cancel = true,同时用单元格的 ErrorText 显示一个红色小图标,用户一看就知道哪里有问题。
还有一点,编辑控件里的内部控件(比如那个 ComboBox)的值变化时,必须把EditingControlValueChanged置为 true,并且主动调用EditingControlDataGridView.NotifyCurrentCellDirty(true)。少写这一步,用户改完值直接点别的单元格,改动会丢失——而且不报错,查起来特别费劲。
5. 真控件覆盖法:能用但要用对
5.1 定位公式与滚动同步
回到最朴素的思路:既然单元格是画出来的,那我就按单元格的位置把真实控件盖上去。核心就一个方法:
private void SyncControls() { foreach (DataGridViewRow row in grid.Rows) { var host = row.Tag as CellControlHost; if (host == null || host.Panel == null) continue; Rectangle rect = grid.GetCellDisplayRectangle(host.ColumnIndex, row.Index, false); // 单元格完全滚出可视区,直接藏起来 if (rect.Width <= 0 || rect.Height <= 0) { host.Panel.Visible = false; continue; } host.Panel.Visible = true; host.Panel.Bounds = new Rectangle( rect.X + 2, rect.Y + 2, rect.Width - 4, rect.Height - 4); } }注意 GetCellDisplayRectangle 的第三个参数 cutOverflow 传 false,传 true 会返回被裁剪后的矩形,滚动到一半时宽度会变,控件跟着抽搐。传 false 拿完整矩形,再靠 Visible 判断是否显示。
必须挂的事件有一长串,少一个就会出现“某种操作下控件错位”的怪现象:Scroll、ColumnWidthChanged、RowHeightChanged、Resize、CellValueChanged、RowsAdded、RowsRemoved、Sorted、ColumnDisplayIndexChanged、CellBeginEdit、CellEndEdit。我通常写一个 AttachEvents 方法统一挂,避免遗漏。
grid.Scroll += (s, e) => SyncControls(); grid.ColumnWidthChanged += (s, e) => SyncControls(); grid.RowHeightChanged += (s, e) => SyncControls(); grid.Resize += (s, e) => SyncControls(); grid.Sorted += (s, e) => SyncControls(); grid.RowsAdded += (s, e) => SyncControls(); grid.RowsRemoved += (s, e) => SyncControls(); grid.CellEndEdit += (s, e) => SyncControls();5.2 生命周期与内存泄漏
控件什么时候创建、什么时候销毁,是这条路线的第二大坑。我的做法是:单元格进入可见区域时创建,滚出时保留但不销毁(避免频繁 new),行被删除时销毁。保留策略的副作用是控件数量会随着滚动逐渐增长到全部行数,所以行数超过几百之后,这个策略就不可取了。
更稳的是“池化 + 复用”:准备一个控件池,每次只给可视区内的行分配控件,滚出时回收进池子。这样控件的总数量恒定在“可视行数 + 少量缓冲”这个量级,无论表格有多少行,句柄数都不会涨。代价是要写一套分配回收逻辑,代码量翻倍。
不管用哪种策略,行被删除时一定要记得 Dispose。我见过一个项目,操作列里放了一个自定义的日期选择控件,每次刷新数据源都会重新生成行,但没有 Dispose 旧的,跑一天下来进程的 GDI 句柄数到了三万多,最后整个界面变成一堆白块