简介:一份面向C# WinForms开发者的Vs2008编程实例,专门演示在DataGridView控件中实现单元格合并,适合做报表统计、数据看板等需要复杂表格展示的中级开发者参考。压缩包共12个文件,以6个.cs源代码文件为核心,覆盖合并单元格的核心逻辑与窗体代码,另含2个.resx资源文件、项目工程文件、解决方案文件及配置文件,整体仅14KB,结构精简,可直接打开工程研究实现路径。由于原数据库文件丢失,运行后无法直接看到演示效果,但源码完整保留了合并单元格的关键处理代码,开发者可据此重建数据库或替换为自己的数据源,重点学习自定义单元格绘制、合并区域计算等思路。目前已有3226人学习下载,适合想突破DataGridView默认呈现方式、实现跨行跨列表格的读者,从中提取合并算法并迁移到实际项目中。 做上位机界面的朋友估计都遇到过这个矛盾:DataGridView出数据方便、绑定简单,但产品型号、班次、检测结果这些列经常连续多行是同一个值,一行一行重复着看特别累。每次评审界面,现场的人都会问一句“能不能像Excel一样把相同单元格合并起来?”在C#的WinForms项目里,DataGridView默认没有MergeCells这种属性,网上搜到的方案也各有坑。这篇文章就把我在实际项目里验证过的一套自绘合并方案完整拆开讲,代码直接能抄,顺便把滚动、排序、选中这几个最容易翻车的地方一并说清楚。
1. 为什么DataGridView天生不支持合并,以及我为何选择自绘
1.1 三条替代路线实测后的取舍
遇到合并需求,很多人第一反应是换控件。ComponentOne、DevExpress这类商业表格组件确实自带合并单元格功能,显示效果也好,但问题在于:老项目里如果已经大面积用了DataGridView,换控件意味着牵扯到几十个窗体的主题样式、事件签名、授权费用,改动面太大,领导也很难接受为一个小功能引入一整套商业依赖。
第二个思路是在DataTable层面做手脚:把相同列的重复值清空,只保留每组第一行的文字。这个做法实现最简单,但它不是真正的合并,行与行之间的网格线还在,视觉上就是留白,排序后数据位置一变,空值行也会跟着乱跑,选中和编辑时更是漏洞百出。实测下来只能算“看起来好像合并了”,经不起交互验证。
所以最后我选了第三条路:继承DataGridView,接管CellPainting事件自己画。这样做的好处是数据源、排序、取值逻辑完全不用动,合并只是显示层的“障眼法”,代码量可控,而且可以精确控制什么样的情况下才合并。说白了,在C#的WinForms技术栈里,这是投入产出比最高的一条路。
1.2 自绘方案的边界:视觉合并并非结构合并
这里先把边界说透:自绘合并改变的是“看起来长什么样”,并没有改变DataGridView本身的单元格结构。底层数据依然是每一行一个独立单元格,合并区域里的每一行都能触发自身的CellPainting,只是我们通过绘制逻辑把相同值的连续区域画成了一个整体。
所以这个方案最适合的场景是只读报表、工艺参数表、检测结果展示,尤其是C#上位机开发里常见的设备状态一览、生产记录查询这类页面。如果业务上需要在“合并单元格”里做编辑、绑定复杂的单元格控件,这个方案就不合适了,那种情况还是老老实实考虑换控件或改用树形表格,别硬扛。
2. 把“合并单元格”翻译成绘制逻辑:三个关键问题
2.1 谁来画:用“向上看”决定责任单元格
DataGridView的每个单元格都会触发CellPainting,我们要利用这个机制做一个“责任分配”:如果当前单元格的值和它上方紧挨着的那个单元格相同,那当前单元格就不负责画内容,由上方那个单元格连它一起画。这就像排队领工资,一队人同名,只有队首举牌子,后面的人跟着站就行。
用代码表达就是:
if (e.RowIndex > 0 && ValuesEqual(e.RowIndex, col, e.RowIndex - 1)) { e.Handled = true; return; }当然,后面还要补一个滚动时的特殊情况,这里先记住这个责任模型。关键在于:合并块里的“顶端”单元格才是绘制的责任方,其余单元格都把自己“隐藏”掉。
2.2 画多大:向上找头、向下数尾
顶端单元格知道自己有合并责任以后,还需要算出合并块的范围。这里分两步:
向上找头:如果上一行同列值相等,就继续往上,直到找到第一个不同的行,这一行就是合并块的真正顶端。向下数尾:从顶端开始往下逐行比较,直到遇到不同的值,累计行数就是合并跨度span。
代码写出来非常直白:
private int GetMergeStart(int row, int columnIndex) { while (row > 0 && ValuesEqual(row, columnIndex, row - 1)) row--; return row; } private int GetMergeSpan(int start, int columnIndex) { int span = 1; while (start + span < Rows.Count && ValuesEqual(start + span, columnIndex, start)) span++; return span; }这里有个容易忽略的细节:GetMergeSpan里是比较“当前行”和“start行”是否相等,不是比较当前行和上一行。因为start是块顶端,只要后续行和start的值相同,就都在同一个合并块里。
2.3 画什么:背景、边框、文字的分工
顶端单元格要画的内容分三块:背景、边框、文字。背景用Graphics.FillRectangle填充一个矩形,把合并区域内的所有默认背景和内部网格线盖掉;边框用ControlPaint.DrawBorder画在矩形外沿;文字用TextRenderer.DrawText居中对齐绘制。三样都画完,把e.Handled设为true,告诉DataGridView“这个单元格我自己处理了,你不用再画默认内容”。
之所以用TextRenderer.DrawText而不是Graphics.DrawString,是因为前者在高DPI环境下渲染更锐利,和DataGridView默认的文字绘制效果更一致,在工业触摸屏和缩放屏上差别尤其明显。
3. 可以直接抄走的完整实现:自定义MergeableDataGridView
3.1 核心代码
我封装了一个自定义控件MergeableDataGridView,调用方只需要指定哪些列需要合并,其余逻辑都在内部处理:
public class MergeableDataGridView : DataGridView { private readonly HashSet<int> _mergeColumns = new HashSet<int>(); public void SetMergeColumn(int columnIndex, bool enabled) { if (enabled) _mergeColumns.Add(columnIndex); else _mergeColumns.Remove(columnIndex); Invalidate(); } private bool ValuesEqual(int rowA, int columnIndex, int rowB) { object a = Rows[rowA].Cells[columnIndex].Value; object b = Rows[rowB].Cells[columnIndex].Value; return Convert.ToString(a) == Convert.ToString(b); } private int GetMergeStart(int row, int columnIndex) { while (row > 0 && ValuesEqual(row, columnIndex, row - 1)) row--; return row; } private int GetMergeSpan(int start, int columnIndex) { int span = 1; while (start + span < Rows.Count && ValuesEqual(start + span, columnIndex, start)) span++; return span; } protected override void OnCellPainting(DataGridViewCellPaintingEventArgs e) { if (e.RowIndex >= 0 && e.ColumnIndex >= 0 && _mergeColumns.Contains(e.ColumnIndex)) { if (e.RowIndex > 0 && ValuesEqual(e.RowIndex, e.ColumnIndex, e.RowIndex - 1) && Rows[e.RowIndex - 1].Displayed) { e.Handled = true; return; } int start = GetMergeStart(e.RowIndex, e.ColumnIndex); int span = GetMergeSpan(start, e.ColumnIndex); if (span > 1) { int y = e.CellBounds.Y; for (int r = e.RowIndex - 1; r >= start; r--) y -= Rows[r].Height; Rectangle rect = new Rectangle(e.CellBounds.X, y, e.CellBounds.Width, e.CellBounds.Height); for (int r = start + 1; r < start + span; r++) rect.Height += Rows[r].Height; DataGridViewCell topCell = Rows[start].Cells[e.ColumnIndex]; Color bg = e.CellStyle.BackColor; if (bg == Color.Empty) bg = DefaultCellStyle.BackColor; if (topCell.Selected || e.State.HasFlag(DataGridViewElementStates.Selected)) { Color selBg = topCell.Style.SelectionBackColor; if (selBg == Color.Empty) selBg = DefaultCellStyle.SelectionBackColor; bg = selBg; } using (SolidBrush brush = new SolidBrush(bg)) { e.Graphics.FillRectangle(brush, rect); } ControlPaint.DrawBorder(e.Graphics, rect, GridColor, ButtonBorderStyle.Solid); string text = Convert.ToString(e.Value); TextRenderer.DrawText( e.Graphics, text, e.CellStyle.Font, Rectangle.Inflate(rect, -4, -4), e.CellStyle.ForeColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); e.Handled = true; return; } } base.OnCellPainting(e); } protected override void OnDataBindingComplete(DataGridViewBindingCompleteEventArgs e) { base.OnDataBindingComplete(e); Invalidate(); } protected override void OnSorted(EventArgs e) { base.OnSorted(e); Invalidate(); } }这段代码里有一个关键判断:Rows[e.RowIndex - 1].Displayed。它的作用是应对滚动场景,具体原因在第4节专门讲。如果不加这个判断,滚动到合并块中间时会出现整块空白,这是很多网上的教程没处理到位的地方。
3.2 如何使用
使用方式很简单:
MergeableDataGridView grid = new MergeableDataGridView(); grid.DataSource = table; grid.SetMergeColumn(0, true); // 第一列按相同值合并 grid.SetMergeColumn(2, true); // 第三列按相同值合并有两个属性建议一起设置:grid.CellBorderStyle = DataGridViewCellBorderStyle.Single;如果把默认的浮雕边框和自绘的平面边框混在一起,视觉上会很突兀,统一用Single风格最协调。另外,需要合并的列建议设置成ReadOnly,因为编辑单元格会改变值,合并关系会重新计算,虽然重绘会自动跟上,但用户一边输入一边看到单元格“跳变”,体验很差。
3.3 值比较与行高累加两个细节
值比较我统一用Convert.ToString,这样处理null和DBNull都比较安全,Convert.ToString(null)返回空字符串,不会抛异常。如果你不希望把空字符串也合并成一个块,可以在ValuesEqual里加一行判断:if (string.IsNullOrWhiteSpace(Convert.ToString(a)) && string.IsNullOrWhiteSpace(Convert.ToString(b))) return false;根据你自己的业务语义决定。
行高累加也是个容易踩的坑。DataGridView允许用户拖拽行高,也支持AutoSizeRows自动调整行高,这时候每行的Rows[i].Height可能都不一样。所以我计算合并矩形时用的是逐行累加:
for (int r = start + 1; r < start + span; r++) rect.Height += Rows[r].Height;而不是图省事写成e.CellBounds.Height * span。后者在行高不一致的时候,合并区域底部会多出来或缺一块,看起来像边框错位。
4. 实测必踩的坑:滚动、排序、选中的连锁问题
4.1 滚动到合并区域中间,文字消失的根因和解决
这是我最开始写这个控件时卡得最久的问题。如果只按第2节的简单逻辑判断“上一行同值就不画”,会有一个很隐蔽的Bug:当用户往下滚动,合并块的顶端行滚出可视区域,但中间行还在可视区时,中间行发现自己上一行同值,就e.Handled=true不画了。而真正负责绘制的顶端行已经不在可视区,根本不会被触发绘制,结果就是用户看到的合并块中间区域变成一片空白,行列头倒是还在。
解决办法就是代码里的Rows[e.RowIndex - 1].Displayed判断:只有当上一行同值并且上一行还在可视区时,当前行才选择“隐身”;如果上一行同值但已经滚出可视区,说明当前行是可视窗口里这个合并块的第一行,它必须自己担任“临时画手”,从真正顶端回溯计算合并矩形,把内容和边框重新画出来。
这里还涉及一个文字跳动的问题。临时画手绘制矩形时,我特意把rect.Y回溯到了真正顶端:
for (int r = e.RowIndex - 1; r >= start; r--) y -= Rows[r].Height;这样文字始终相对于整个合并块的几何中心对齐,用户滚动时文字是平滑进入视野的,不会出现“每滚一行文字就跳一次”的劣质感。
4.2 排序和数据源刷新后的合并错乱
DataGridView点击列头排序后,数据行的物理顺序会变,原来相邻的相同值可能被打散,也可能产生新的相邻相同值。我们的合并判断完全基于当前Rows集合的实时顺序,所以逻辑上会自动适配,但画面上如果不强制重绘,旧内容会残留。我在自定义控件里重写了OnSorted,排序完成后调用Invalidate,这个问题就解决了。
同样,如果外部代码重新给DataSource赋值,DataBindingComplete事件里也要Invalidate一次。实际项目中我就遇到过:把绑定的DataTable重新查询了一遍再赋给DataGridView,由于行是新建的,绘制信息没跟上,界面上出现了几个该合并却没合并的格子,后来加上重绘才正常。
4.3 选中高亮与鼠标点击的错位处理
用户点击合并块中间的行时,DataGridView默认会选中它点击的那个单元格,但视觉上这个单元格已经被上方的大块覆盖了,用户会觉得自己点了没反应。解决办法是把选择引导到合并块的顶端:
protected override void OnCellMouseDown(DataGridViewCellMouseEventArgs e) { if (e.RowIndex >= 0 && e.ColumnIndex >= 0 && _mergeColumns.Contains(e.ColumnIndex)) { int start = GetMergeStart(e.RowIndex, e.ColumnIndex); if (start != e.RowIndex) { CurrentCell = Rows[start].Cells[e.ColumnIndex]; Rows[start].Cells[e.ColumnIndex].Selected = true; } } base.OnCellMouseDown(e); }这里有个实测经验:在.Net Framework 4.8和.Net 6的WinForms环境中,先修正CurrentCell再调用base的顺序是稳定的;如果哪天你的项目里发现选中状态被base覆盖了,可以把这段逻辑挪到OnSelectionChanged里面去统一修正。如果这个报表本身是纯只读展示,建议直接把SelectionMode设为CellSelect,同时用颜色区分状态,不依赖选中高亮,省心很多。
5. 再进一步:多列联合合并和表头跨列合并的变体
5.1 多列联合合并:先分组再合并
实际业务里光按单列合并往往不够。比如检测记录表里,经常要求“同一个产品型号的大组内,再合并班次列”,不能跨型号合并班次。这种场景只要把合并判断改成“分组列相同且当前列值相同”即可。
做法是在类里加一个_groupColumns列表,改造合并判断函数:
private bool ValuesEqualInGroup(int rowA, int rowB, int columnIndex) { foreach (int groupCol in _groupColumns) { if (Convert.ToString(Rows[rowA].Cells[groupCol].Value) != Convert.ToString(Rows[rowB].Cells[groupCol].Value)) return false; } return Convert.ToString(Rows[rowA].Cells[columnIndex].Value) == Convert.ToString(Rows[rowB].Cells[columnIndex].Value); }把GetMergeStart和GetMergeSpan里的ValuesEqual替换成ValuesEqualInGroup,就能实现“在同一型号内合并班次”。这个扩展很实用,而且改动量小。
5.2 顶部叠加Label模拟跨列表头
跨列合并还有一种常见需求是表头合并,比如“设备编号”和“设备名称”上方要加一个“设备信息”的大标题。DataGridView列头不支持跨列,但可以用一个取巧办法:在DataGridView所在Panel上叠一个Label,背景色和列头保持一致,文字居中,然后在Scroll事件里移动它的位置。
private void dataGridView1_Scroll(object sender, ScrollEventArgs e) { Rectangle r1 = dataGridView1.GetColumnDisplayRectangle(0, true); Rectangle r3 = dataGridView1.GetColumnDisplayRectangle(2, true); if (r1.Width <= 0 || r3.Width <= 0) { labelHeader.Visible = false; return; } labelHeader.Visible = true; labelHeader.Left = r1.Left; labelHeader.Width = r3.Right - r1.Left; }这段代码的思想是:用GetColumnDisplayRectangle拿到目标列在当前可视区域的位置,如果列被滚出视野,宽度会变成0,直接隐藏Label;如果可见,就动态更新Label的Left和Width,让它始终盖在对应列上方。实际项目里我用这个土办法做了好几处复合表头,效果稳定,比去改列头自绘省心得多。
最后再分享一个小技巧:如果你用这个方法做上位机检测界面,可以把合并列的文字加粗,同时把背景色设成浅灰,这样整个报表的层次感会好很多。合并单元格在DataGridView里做起来不复杂,但要把滚动、排序、选中这些连锁反应都处理干净,才算真正落地。
本文还有配套的精品资源,点击获取