简介:针对.NET Windows Forms中DataGridView控件的表格显示与复杂布局需求,这一压缩包为C#开发者提供了一套完整的单元格合并实现方案。资源围绕逻辑合并与视觉合并两种思路展开,重点演示重写Paint事件、自定义绘制单元格、设置对齐方式与调整列宽模式等关键技术,适合需要制作嵌套表头、分组汇总或复杂报表界面的中高级开发者参考学习。
压缩包共包含22个文件,以6个C#源码文件为骨干,并配有项目工程文件(sln/csproj)、界面资源配置文件(resx/resources)、配置文件(settings/manifest)以及可执行程序(exe)与运行调试所需的PDB文件,整体仅44KB,结构紧凑,便于直接打开工程查看核心代码与界面设定。配套的txt说明还梳理了行头合并、单元格内嵌控件等扩展注意点,可帮助减少调试弯路,快速迁移到实际项目中。
资源现有344人浏览学习,对于希望在保持单元格独立存储的前提下实现视觉合并效果的开发者来说,是一份简洁实用、可直接借鉴的代码参考。
1. 先说清楚:DataGridView 没有合并单元格,你拿到的这份资源到底解决什么
做 WinForms 表格的同行应该都有同感:DataGridView 是微软留的一个半成品,功能不少,但唯独不给合并单元格。官方没有这个属性,网上问“DataGridView 合并单元格”的帖子能从十年前翻到今天,答案全是“自己画”。这份资源的核心,就是一套基于自绘实现的合并单元格方案,帮你把同值列合并、表头跨列合并做出来,而且不破坏排序、不污染数据源。
它解决的是报表和录入界面里“同一列相同内容重复显示”的问题,比如项目台账里“项目名称”跨多行、订单明细里“订单号”跨多行,视觉上要合并成一个大单元格。适合已经会用 DataGridView 绑定数据、但对自绘还不熟、想直接拿现成封装用的读者。先提醒一句:它做的是“看起来合并”,不是 Excel 那种物理合并,分清这个边界,后面才不会踩坑。
2. 选型之前:合并单元格的三种实现路径与适用边界
2.1 绘制法:不改数据、不破坏排序,但别拿它当 Excel
绘制法的思路简单粗暴:数据源该怎么存还怎么存,只是画的时候把相邻相同值的格子连成一块。具体做法是在 CellPainting 事件里拦截绘制,判断当前格子是不是合并区域的第一行,是就画内容,不是就只画背景色、不画内容,再把内部的网格线盖掉。这样表面上看是一个大格子,实际上每个 Cell 都还独立存在,数据、排序、选中都还是按普通单元格走。
这个方案的优点很实在:数据源零改动,绑定 List 还是 DataTable 都不影响;排序的时候行索引变了,但合并判断是基于值重新计算的,排序完视觉上依然正确;也不会因为合并导致复制粘贴、导出 Excel 时出现莫名其妙的空行。缺点也明显:它不产生真正的跨行单元格,所以你要用 Excel 那种“合并后只有一个地址”的语义去操作它,就会翻车,比如找不到合并后单元格的地址。
2.2 数据源预合并:把多行塞进一个单元格的代价
另一类做法是把数据源直接改掉,合并前把相同值的行处理成一行,或者把多行文本用换行符拼进同一个单元格。比如订单明细里订单号相同的三行,只保留一条记录,明细列用 Environment.NewLine 拼接。听上去简单,实现快,但副作用一大堆。
排序时行跟着值整体漂移,你没法单独排某一行;用户想编辑其中一行,得先拆开字符串,编辑完再拼回去,体验非常差;如果中间插了一行新数据,合并逻辑要重算,做不好就会出现一个单元格里堆着好几条不相关记录的情况。我见过不少新人一开始走这条路,最后都回来改绘制法。这份资源里的实现也是绘制法,坚持用值判断而不是行号判断,就是为了避免这些连锁问题。
2.3 为什么最终选“子类 + 绘制”的封装路线
直接挂 CellPainting 事件也能实现合并,但每次新建窗体都要重写一遍事件代码,合并区域的判断逻辑、边框处理、刷新机制全是重复劳动,而且事件代码散落在各窗体里,后续维护要翻好几个文件。资源里把它封装成 DataGridView 的子类,把合并判断、绘制、选中联动都收进控件内部,窗体只需要设置几个属性,比如哪些列参与合并、合并方向是纵向还是横向。
封装之后的好处是用法贴近日常习惯:你在设计器里拖一个 MergeDataGridView,设置 MergeColumnIndexes 属性,指定要合并的列,运行时自动生效。底层用 CellPainting 重绘,但对外暴露的是属性配置和合并规则,不要求使用者也懂 GDI+。这也是我把它定位成“工具包”而不是“教程代码”的原因——拿来就能用,出问题也好定位。
3. 从零实现:合并单元格核心代码与绘制流程
3.1 重写 OnPaint 还是挂 CellPainting?
第一次做的人常纠结这个问题。我建议挂 CellPainting,而不是重写 OnPaint。原因是 DataGridView 有滚动机制,OnPaint 只负责当前可视区域,你手动重绘整个控件反而容易在滚动时出现残影;CellPainting 是每个单元格独立触发的,滚动到哪就画到哪,天然规避了刷新问题。
核心绘制的顺序是这样的:先让默认绘制把单元格背景和值画出来,然后判断当前单元格是否属于某个合并区域。如果是合并区域内部的行,就用背景色把默认画出来的内容盖掉,再画上和第一行一致的背景;如果是第一行,就把边框调整一下,把和内部行相邻的那条边也盖掉,让视觉上连成一体。
protected override void OnCellPainting(DataGridViewCellPaintingEventArgs e) { // 先调用默认绘制,保证背景、选中色等基础效果正常 base.OnCellPainting(e); // 只处理指定列,且只处理内容区域,忽略行头、列头 if (e.RowIndex < 0 || e.ColumnIndex < 0) return; if (!_mergeColumns.Contains(e.ColumnIndex)) return; // 找到当前单元格所在合并区域的起始行和结束行 int firstRow = GetMergeFirstRow(e.ColumnIndex, e.RowIndex); int lastRow = GetMergeLastRow(e.ColumnIndex, e.RowIndex); // 如果不是合并区域的第一行,则盖掉默认内容,只保留背景 if (e.RowIndex != firstRow) { using (Brush bgBrush = new SolidBrush(e.CellStyle.BackColor)) { e.Graphics.FillRectangle(bgBrush, e.CellBounds); } // 盖掉上半边内部边框,让上下单元格看起来没有分隔线 using (Pen pen = new Pen(e.CellStyle.BackColor)) { e.Graphics.DrawLine(pen, e.CellBounds.Left, e.CellBounds.Top, e.CellBounds.Right, e.CellBounds.Top); } e.Handled = true; return; } // 第一行:计算整个合并区域的范围,画出完整内容 Rectangle mergeBounds = GetMergeBounds(e.ColumnIndex, firstRow, lastRow); e.Graphics.FillRectangle(new SolidBrush(e.CellStyle.BackColor), mergeBounds); TextRenderer.DrawText(e.Graphics, GetCellText(e.ColumnIndex, firstRow), e.CellStyle.Font, mergeBounds, e.CellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); e.Handled = true; }这里几个关键点。_mergeColumns是参与合并的列索引集合,GetMergeFirstRow和GetMergeLastRow负责往上下找“值相同”的连续行范围,这是合并判断的核心逻辑。GetMergeBounds把从第一行到最后一行、当前列对应的矩形区域算出来,文字画在这个合并范围里。注意我用的是TextRenderer.DrawText,而不是Graphics.DrawString,前者在 WinForms 里和系统字体渲染一致,清晰度更好。
e.Handled = true必须要写,告诉 DataGridView 你已经接管了当前单元格的绘制,否则它会在你画完之后再画一遍默认内容,等于白干。
3.2 同值合并的判断逻辑:值相等才是硬标准
合并区域的判断是整个实现里最容易出错的地方。最容易犯的错是用行号判断,比如“第 2 到第 5 行合并”。这样做一旦排序、插入、删除行,合并区域就全乱了。正确的做法是:只判断相邻行的值是否相等,相等就归入同一个合并区域,不等就断开。
private int GetMergeFirstRow(int columnIndex, int rowIndex) { int first = rowIndex; while (first > 0) { // 忽略 DataGridView 的 NewRow 行 if (Rows[first - 1].IsNewRow) break; if (!IsSameValue(columnIndex, first - 1, first)) break; first--; } return first; }IsSameValue里不能直接用==比较,因为 Cell.Value 是 object 类型,整数和字符串比较会出现类型不匹配。我一般先转成字符串再比较,同时处理 null 和空字符串:null 和空串不合并,避免出现一整列空白连成一大块的情况。
private bool IsSameValue(int columnIndex, int rowA, int rowB) { object valA = Rows[rowA].Cells[columnIndex].Value; object valB = Rows[rowB].Cells[columnIndex].Value; if (valA == null || valB == null) return false; if (valA == DBNull.Value || valB == DBNull.Value) return false; string strA = valA.ToString().Trim(); string strB = valB.ToString().Trim(); if (strA.Length == 0 || strB.Length == 0) return false; return strA == strB; }空值的处理逻辑值得单独说。很多业务里空单元格意味着“还没填”,如果也参与合并,会出现一整列空白区域,用户根本看不出哪些行是空的。所以资源里把空值、DBNull、空字符串统一视为不合并。如果你的业务确实需要把空白也合并,可以加一个MergeEmptyCells属性控制,默认 false。
3.3 表头跨列合并:横向合并的参数设置
同值列合并是纵向的,表头合并是横向的,两者原理不同。表头通常没有数据值可比较,而是指定“从第几列到第几列合成一个标题”。比如第一列表头叫“项目信息”,横跨三列。这类合并不需要值判断,而是按列索引范围直接合并。
资源里对表头合并单独处理了,做的不是覆盖内容,而是把要合并的列头区域整体画一块背景,再居中画标题文字,同时把内部的列头分隔线盖掉。这样视觉效果和 Excel 的跨列居中很像,但表头单元格本身仍然独立,不会影响列宽调整和列移动。
设置上有一个隐性规则:在 DataGridView 里,第一列表头的宽度默认是 50px 左右,如果你把第一列和第二列合并,第一列的标题文字位置要重新计算。常见的做法是把第一列表头单元格的宽度设成合并总宽度,其他列表头宽度设成 0,但这样会导致列宽调整变得奇怪。更稳的做法是在 ColumnHeaderCell 的绘制里,直接把标题画在整个合并区域的矩形范围内,不依赖单个列头的宽度。
private Rectangle GetHeaderMergeBounds(int firstCol, int lastCol) { Rectangle firstBounds = GetColumnDisplayRectangle(firstCol, false); Rectangle lastBounds = GetColumnDisplayRectangle(lastCol, false); return Rectangle.Union(firstBounds, lastBounds); }注意GetColumnDisplayRectangle第二个参数是cutOverflow,这里必须传 false,否则超出可视区域的列会返回空矩形,横向滚动时表头文字会出现断层。这也是一个典型的坑,下面避坑章节会展开。
4. 直接把控件拖进工程:合并配置与绑定实战
4.1 引入控件类与基本属性配置
拿到资源包之后,先引用 MergeDataGridView.cs 到你的工程里,重新编译一次,工具栏里就会多出一个 MergeDataGridView 控件。之后像拖普通 DataGridView 一样拖到窗体上,然后配置参与合并的列。
最基础的用法是纵向同值合并,通常用于报表展示。设置 ColumnMerge 属性,传入要合并的列索引,或者用 ColumnMergeNames 按列名设置,后者在列顺序变动时更安全。
this.mergeDataGridView1.ColumnMergeNames = "项目名称,订单号"; this.mergeDataGridView1.MergeMode = MergeMode.OnlySameValue;这里的MergeMode有三个取值:None表示关闭合并,OnlySameValue表示按相同值合并,OnlySameCell表示手动指定具体哪些行合并。大部分场景用第一个。设置完之后不需要写任何绘制代码,绑定数据后合并自动生效。
我一般会在Form_Load里先绑定数据再配置合并属性,顺序不能反。如果先设置合并属性再绑定数据,绑定过程会触发 CellPainting,此时数据还没加载完,可能出现一次闪烁,虽然不影响最终效果,但观感不好。
4.2 合并规则配置:多列分别合并、按条件合并
资源里最实用的是多列独立合并。比如一个采购单里,“供应商”列要合并,“采购员”列也要合并,但两列的分组边界不同:供应商相同但采购员不同的行,两列各自独立合并,互不影响。这要求合并判断必须是“按列独立计算”,而不是把整行分组后再合并。
做法很简单,ColumnMergeNames传多列就行,内部会为每一列单独维护一份合并区域映射。这一点是我当时选这个资源的关键,早期我见过某些实现是“先按某列分组,再把组内的其他列全部合并”,那种做法在处理“多列各自合并但边界不一致”时完全没有扩展性,行高都不好处理。
如果需要按条件合并,资源里留了一个扩展点:重写IsSameValue方法。比如你要求“只有状态为‘已完成’的行才允许合并”,就可以在子类里覆盖这个方法,加一个状态判断。常见做法是加一个属性MergeWhenStatus,然后在IsSameValue里先判断状态列的值。
public class MergeDataGridView : DataGridView { public string MergeStatusColumn { get; set; } public string MergeStatusValue { get; set; } protected override bool IsSameValue(int columnIndex, int rowA, int rowB) { if (!string.IsNullOrEmpty(MergeStatusColumn)) { object statusA = Rows[rowA].Cells[MergeStatusColumn].Value; object statusB = Rows[rowB].Cells[MergeStatusColumn].Value; if (statusA == null || statusB == null) return false; if (statusA.ToString() != MergeStatusValue) return false; if (statusB.ToString() != MergeStatusValue) return false; } return base.IsSameValue(columnIndex, rowA, rowB); } }这个方法的好处是逻辑集中在控件内部,业务代码不用到处判断。如果你的合并条件是“从第 N 行开始合并”,也可以在这个方法里加上对 rowIndex 的判断,灵活度足够。
4.3 绑定 List 和 0/1 转 CheckBox 的典型写法
很多新手在绑定 List 时会遇到一个困惑:实体类的 bool 属性绑定到 DataGridView 会自动生成 CheckBox 列,但数据库或接口里给的是 int 类型的 0 和 1,直接绑定显示的是数字而不是勾选状态。数据通道为 0/1 时的处理,网上问“datagridview 填充数据合并单元格”的帖子也经常顺带问这个。处理方式是在 CellFormatting 事件里做转换,合并控件把这个事件也封装好了。
public class MergeDataGridView : DataGridView { public List<string> CheckBoxColumns { get; set; } = new List<string>(); protected override void OnCellFormatting(DataGridViewCellFormattingEventArgs e) { base.OnCellFormatting(e); if (e.RowIndex < 0 || e.ColumnIndex < 0) return; string colName = Columns[e.ColumnIndex].Name; if (!CheckBoxColumns.Contains(colName)) return; if (e.Value is int intVal) { e.Value = intVal == 1 ? true : false; e.FormattingApplied = true; } } }设置时把实体类属性名加入CheckBoxColumns列表即可。这个方案避免了在实体类里加额外属性,也不破坏数据源。配合合并使用时要注意一点:如果 CheckBox 列也参与了合并,同一个合并区域内只有第一行会显示勾选状态,后续行都是空白,这是视觉合并的正常表现。实际项目里,CheckBox 列一般不建议参与合并,因为用户没法直观看到哪些行是勾选的。
绑定 List 时,建议把 DataSource 类型设为 BindingList 而不是 List 。原因很简单,普通 List 不支持增删改的自动通知,用户编辑了某个单元格的值,界面不会自动刷新;BindingList 会发通知事件,合并区域显示的值能跟着变。这个差异在调试时不容易发现,上线后用户改了内容却发现表格里还是旧值,体验很尴尬。
5. 避坑手册:合并后常见的五个翻车现场
5.1 合并区域里点击和选中错位
现象:合并区域看起来是一个整体,但单击后只有一个格子的背景色变蓝,整个合并块没有联动选中;有时候点合并区域的中间行,高亮的却是旁边未合并列的那个格子。
原因:合并是绘制层面的视觉欺骗,单元格本身没有合并,点击事件仍然命中单个 Cell,SelectionMode 默认是按单元格选择,所以视觉上“半个格子被选中”。
解决:把 SelectionMode 改成 FullRowSelect,或者监听 SelectionChanged 事件,判断当前选中单元格是否属于某个合并区域,属于则把整个区域的行全部标记选中。注意使用 FullRowSelect 时,行头点击和多选的行为也会变化,建议在 OnCellClick 里做判断,只对合并区域内的点击做整块选中。
protected override void OnCellClick(DataGridViewCellEventArgs e) { if (e.RowIndex >= 0 && e.ColumnIndex >= 0 && IsMergedCell(e.ColumnIndex, e.RowIndex)) { int first = GetMergeFirstRow(e.ColumnIndex, e.RowIndex); int last = GetMergeLastRow(e.ColumnIndex, e.RowIndex); for (int i = first; i <= last; i++) { Rows[i].Selected = true; } // 阻止默认的单个单元格选中效果 return; } base.OnCellClick(e); }5.2 绑定数据刷新后出现绘制残影
现象:调用 DataGridView.DataSource 重新赋值,或者调用 Refresh 之后,合并区域边缘出现明显的白色或灰色残留线条,像是没擦干净。
原因:合并区域内部行的内容被背景色盖住了,但盖的时候用的背景色是e.CellStyle.BackColor,而 DataGridView 在交替行背景色开启时,偶数行和奇数行的背景色不同。用单色填充合并区域,会把原本交替的背景色覆盖成统一颜色,于是边缘和相邻行之间形成色差,看起来就是残影。
解决:默认关闭AlternatingRowsDefaultCellStyle的交替背景,或者合并区域的背景色统一取第一行的 BackColor。资源里对这块做了处理:绘制合并区域时,背景色强制取合并区域第一行的单元格背景色,不取当前行背景色。这样整块区域的底色保持一致,边缘消失。
5.3 打印导出时合并单元格跨页出现空白区
现象:用 PrintDocument 打印合并后的 DataGridView,当合并区域跨到下一页时,第二页顶部出现一大块空白,内容却从中间被截断了。
原因:绘制合并时,内容只画在第一行,后续行是空白背景。打印分页时,第一页底部刚好截断了合并区域,第二页从合并区域的中间行开始打印,此时整个合并区域内没有任何内容可画,只输出背景色,看起来就是大面积留白。
解决:有两个思路。如果只是导出 PDF 或打印预览,可以先把数据转入 DataTable,按合并规则把相同值只保留第一行,导出为普通表格;不能用视觉合并去打印,打印逻辑不知道你的合并规则。如果必须在 DataGridView 上直接打印,就要自定义分页逻辑:检测到某行属于合并区域的中间部分时,让它跟随上一行一起被推到下一页顶部,保证合并区域总是从第一行开始打印。这个方案实现复杂些,但效果最接近所见即所得。常见做法是我自己用的这一套:打印前遍历合并区域,把跨页行的起始行强制设为下一页第一行。
5.4 横向滚动时表头文字错位或消失
现象:表格列很多,横向滚动条拖动后,合并的表头单元格文字只显示一半或者位置偏移,未合并的表头正常。
原因:表头合并绘制时用了GetColumnDisplayRectangle,第二参数正确传 false 时拿到的是完整矩形,但某些实现里传了 true,就会截断到当前可视区域,导致滚动时矩形范围算错。
解决:确保所有表头矩形计算使用cutOverflow: false。另外,还要重写OnScroll方法,在横向滚动触发时调用InvalidateColumnHeader强制重绘表头区域,否则 DataGridView 会认为表头没有变化而跳过绘制。
protected override void OnScroll(ScrollEventArgs e) { base.OnScroll(e); if (e.ScrollOrientation == ScrollOrientation.HorizontalScroll) { Invalidate(new Rectangle(0, 0, Width, ColumnHeadersHeight)); } }5.5 编辑单元格内容后合并区域没有重新计算
现象:用户在合并列里修改了值,原来相同的内容改成了不同的,但视觉上还是合并状态,过一会儿才恢复。
原因:合并区域的计算发生在 CellPainting 里,Painting 的触发时机是滚动、刷新、重绘。用户编辑完单元格,DataGridView 不一定触发重绘,尤其是值变化但视觉上没变化时。
解决:监听 CellValueChanged 事件,调用Invalidate强制重绘。资源里封装了CellValueChanged触发合并重算的逻辑。如果你的业务里有批量更新数据源的操作,更新完记得调用mergeDataGridView1.ReCalculateMerge(),这个方法会清掉合并缓存并强制全部重绘。
6. 一个更省事的做法:用“逻辑合并”代替视觉合并
6.1 逻辑合并的思路
如果只是想让报表看起来不重复,可以完全绕开自绘。逻辑合并的思路是:数据源里把相同值只保留第一行显示,其余行置空。这个方案代码量最小,也不会遇到绘制类的坑,适合导出场景,比如把 DataTable 导成 Excel 时做预处理。缺点是不能直接编辑——中间行的值被清空了,用户要修改得先去补数据。
我一般会在需要逻辑合并时写一个扩展方法处理 DataTable:
public static void MergeSameValues(this DataTable table, string[] columns) { foreach (string col in columns) { string lastValue = null; for (int i = 0; i < table.Rows.Count; i++) { string curValue = table.Rows[i][col]?.ToString().Trim(); if (curValue == lastValue) { table.Rows[i][col] = string.Empty; } else { lastValue = curValue; } } } }注意这个实现里,首次遇到空串时会让 lastValue 变成空串,后续行判断可能出现连续空行,这是边界问题,真实工程里建议配合行号遍历。逻辑合并适合纯展示和导出,不适合可编辑表格,这个边界要记牢。
6.2 视觉辅助:边框、底色与字号
合并之后最大的问题是用户看不清边界到底在哪。视觉合并后的区域没有内部横线,如果背景色和边框色接近,整块区域和上下行之间的界限很模糊。常见的做法是给合并区域加一个左边框粗线,或者给合并区域设置一个浅底色,让这个块状结构更明显。资源里可以单独设置合并区域的背景色,不影响未合并列的颜色。字号方面,合并区域的内容字号不宜比普通单元格小,建议不少于默认字号的 95%,否则打印出来会显得突兀。
6.3 验证合并是否生效的三种方法
做完合并之后,先别急着交付,我习惯按三个步骤验证。第一步:滚动到表格中部,确认滚动前后合并区域没有残影、没有位移。第二步:排序一次,确认合并区域是跟着值重新计算的,而不是固定在某几行。第三步:打印预览,重点看合并区域跨页的情况。三步走完基本就稳了。如果有条件,再做一次数据量测试,插入 5000 行数据看滚动是否卡顿,因为每次绘制都要向上向下扫描连续相同值,行数大了之后合并计算有开销。资源里对这块做了缓存处理,但我每次还是会强制自己跑一遍大数据量验证,这个习惯帮我提前挡掉过好几个线上问题。
从那以后,我每次做完合并第一件事就是重新绑定数据加排序,从头到尾过一遍验证流程,动作固化下来之后踩坑的次数明显少了。希望帮到你。
本文还有配套的精品资源,点击获取