
简介针对.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 Liststring CheckBoxColumns { get; set; } new Liststring(); 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 合并区域里点击和选中错位现象合并区域看起来是一个整体但单击后只有一个格子的背景色变蓝整个合并块没有联动选中有时候点合并区域的中间行高亮的却是旁边未合并列的那个格子。原因合并是绘制层面的视觉欺骗单元格本身没有合并点击事件仍然命中单个 CellSelectionMode 默认是按单元格选择所以视觉上“半个格子被选中”。解决把 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 时做预处理。缺点是不能直接编辑——中间行的值被清空了用户要修改得先去补数据。我一般会在需要逻辑合并时写一个扩展方法处理 DataTablepublic 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 行数据看滚动是否卡顿因为每次绘制都要向上向下扫描连续相同值行数大了之后合并计算有开销。资源里对这块做了缓存处理但我每次还是会强制自己跑一遍大数据量验证这个习惯帮我提前挡掉过好几个线上问题。从那以后我每次做完合并第一件事就是重新绑定数据加排序从头到尾过一遍验证流程动作固化下来之后踩坑的次数明显少了。希望帮到你。本文还有配套的精品资源点击获取