
简介面向C# Windows Forms开发者的DataGridView进阶技巧资料包聚焦单元格合并与二维表头创建两大常见需求。单元格合并适用于相同值连续单元格合并、复杂表头布局等场景二维表头则能在常规表头之上增加分类层级让数据分组展示更清晰。资源共48个文件以16个cs源码文件为主配套resx/resources资源文件、sln/csproj工程配置和exe可执行程序等整体仅97KB便于快速下载查阅。示例代码演示了通过MergeCells方法合并单元格以及利用CustomPaint自定义绘制为DataGridView增加两层表头的完整方案涵盖字体样式、背景色、分割线绘制及异常情况处理可直接参考集成。附带可运行的WindowsApplication工程便于对照调试。已有864人学习使用适合开发数据管理桌面应用、希望优化DataGridView展示效果的初中级C#工程师。 我最早接触DataGridView的单元格合并是被一个报表需求逼出来的。WinForms里做台账展示客户要求“同一产品、同一批次的订单记录第一列和第二列要跨行合并显示”就像Excel里那句“合并单元格”一样。我当时第一反应是DataGridView原生支持单元格合并吗很遗憾原生的DataGridView没有这个能力它只支持列头合并通过ColumnHeadersHeightSizeMode和TopLeftHeaderCell的套路勉强实现并没有提供真正意义上的数据单元格合并接口。所以这个需求本质上是一个“自绘控件”的活。你需要基于DataGridView的绘制事件自己画出一个“合并后”的效果。这篇文章我不绕弯子直接把我在实际项目里用的方案、踩过的坑、处理的边界情况都写出来给那些在报表、台账、统计展示类WinForms项目里遇到同样问题的人做参考。1. 整体设计思路为什么选“绘制模拟”而不是“真正合并”1.1 原生控件的能力边界首先要把需求说清楚。DataGridView单元格合并分三种常见形态行方向合并多行同一列的相同值合并成一个视觉单元格这是最常见的分组报表需求。列方向合并多列在某一行的相同值合并类似跨列表头或特殊摘要行。多级合并先按某列分组组内再按另一列合并也就是热词里“非规则单元格怎么合并汇总”对应的场景。原生DataGridView不支持这三种中的任何一种。有人可能想通过“设置单元格为无边框 保留值”的方式来模拟合并。具体做法是对需要合并的后几行把对应单元格的Value设为空同时把边框去掉这样看起来像合并了。这个方案我试过代价是单元格内容丢失、复制粘贴数据变了、排序和过滤后状态错乱而且一旦用户点击空白区域被隐藏的值就露馅了。所以视觉上的“伪合并”只适合纯展示、不允许排序的静态表格完全不适合做交互性报表。1.2 绘制模拟方案的核心思路我采用的方案是“自定义单元格绘制”核心逻辑包括两块计算合并区域在DataGridView绑定数据或绘制前扫描数据源确定哪些行、哪些列的取值相同把连续的相同值划为一个合并区域记录区域的行范围。覆盖绘制在CellPainting事件里对该区域的非首行或非首列单元格绘制成“隐藏状态”不画背景、不画文字、不画边框对区域的首个单元格负责绘制完整的边框和内容并且把内容按区域高度垂直居中。这个思路的好处是不改变数据模型DataGridView的Rows、Cells总数不变排序、选择、编辑仍然可用坏处是必须处理绘制的很多细节比如滚动重绘、边框衔接、不同行高、列宽变化时文字位置等。一句话总结合并是画的不是真的。理解了这一点后续所有细节都有了解释。2. 核心细节解析合并区域的计算逻辑与绘制规则2.1 合并区域的数据结构做合并逻辑之前先定一个数据结构来记录“哪些行合并在一起”。我用的是一个字典键是列索引值是合并Region列表public class CellMergeRegion { public int ColumnIndex { get; set; } // 列索引 public int StartRowIndex { get; set; } // 起始行索引 public int EndRowIndex { get; set; } // 结束行索引 }有了这个Region列表绘制时判断当前单元格是不是所在Region的第一行StartRowIndex即可。这里有个细节Region的划分必须绑定“当前显示的数据行”而不是表格的行索引。因为DataGridView可以排序、过滤BindingSource的Filter一旦Rows的顺序变了你预先算好的Region就全废了。所以合并区域的扫描不能只写一次而要在每次数据源变化后重新计算或在Paint时动态判断。2.2 扫描规则如何判断“连续相同值”扫描逻辑不复杂但有几个坑。先说基本规则从第1行开始逐列扫描比较当前行和上一行同一列的值。如果值相等则归入同一个Region继续向下找直到遇到不同值为止。示例代码如下private Dictionaryint, ListCellMergeRegion BuildMergeRegions() { var result new Dictionaryint, ListCellMergeRegion(); if (dataGridView1.Rows.Count 0) return result; // 默认需要合并的列索引集合 int[] mergeColumns { 0, 1 }; foreach (int col in mergeColumns) { var list new ListCellMergeRegion(); int start 0; while (start dataGridView1.Rows.Count) { int end start; string currentValue dataGridView1.Rows[start].Cells[col].Value?.ToString() ?? ; while (end 1 dataGridView1.Rows.Count) { string nextValue dataGridView1.Rows[end 1].Cells[col].Value?.ToString() ?? ; if (nextValue currentValue) { end; } else { break; } } list.Add(new CellMergeRegion { ColumnIndex col, StartRowIndex start, EndRowIndex end }); start end 1; } result[col] list; } return result; }判断相等时建议统一对象类型比较前都转为字符串避免int的1和string的“1”被认为是不同值。另外null和空字符串建议统一处理否则一个有值、一个为空的新行会打断合并。这里还有一个更微妙的场景某列A和列B都要合并但A的分组边界和B的分组边界不同。比如列A连续5行都是“产品1”列B在这5行里前3行是“批次1”后2行是“批次2”。这种情况如果两列分别独立扫描最后绘制效果是列A的5行合并在一起列B则分成两个Region。这在GroupBy报表里很常见属于多级合并。但没有“层级关联”时两列独立合并往往会导致视觉上的“串行”问题列A合并高度和列B完全对不齐。处理办法是先确定最细粒度的分组列通常是批次列再在其基础上合并上层列。具体我放在第3节实操里讲。2.3 绘制规则如何“藏起”非首行单元格有了Region后重写CellPainting事件。绘制规则按“当前单元格是否是Region首行”分两支是首行正常绘制背景、边框、文字。但有一个额外条件——如果这个Region同时跨多行文字要垂直居中到整个区域高度而不是仅仅在当前行内居中。所以获取当前单元格的Bounds后要扩展为整个Region的矩形从StartRow的Top到EndRow的Bottom然后在这个大矩形里绘制文字。不是首行只绘制背景颜色隐藏文字和所有边框并且把单元格右下边框除顶边外全部取消。这样视觉上和首行连成一个整体。关键代码骨架private void dataGridView1_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; var region FindRegion(e.ColumnIndex, e.RowIndex); if (region null) return; bool isFirstRow (e.RowIndex region.StartRowIndex); // 先让默认绘制擦除背景但我们要自己控制边框所以Skip掉默认的边框绘制 if (!isFirstRow) { if (!typeof(DataGridViewCheckBoxCell).IsAssignableFrom(e.CellType)) // 特殊类型自己处理 { e.Paint(e.CellBounds, DataGridViewPaintParts.Background | DataGridViewPaintParts.SelectionBackground); e.Handled true; return; } } }注意这里的DataGridViewCheckBoxCell合并列一般不勾选但如果你的列里混有CheckBox列直接隐藏会导致勾选框也消失。这种情况要么把CheckBox放在非合并列要么绘制时单独绘制一个ChekBox状态图标而不是整行隐藏。绘制边框时最难处理的是“边框衔接”。DataGridView默认每行都有自己的Bottom边框如果隐藏了中间行的边框还要保证整个合并区域四周有完整边框。一个取巧的做法在首行绘制时不绘制bottom边框在尾行EndRowIndex绘制时再绘制bottom边框。但DataGridView的CellPainting事件是逐格触发的当前格只能确定自己是不是首行/尾行并不知道上下格子的状态所以更稳妥的方式是在CellPainting里对每个格子都判断“我是不是某个Region的首行、中间行、尾行”然后按规则画边框。很多网上的代码图省事直接“非首行不画边框”结果合并区域的底部会出现缺失线条。因为尾行的底部边框默认被绘制逻辑跳过了。我处理的方式是对非首行格子如果它是尾行仍然绘制BottomBorder但不绘制TopBorder对中间行什么都不绘制。对首行绘制TopBorder和LeftBorder、RightBorder不绘制BottomBorder。这段边框逻辑说起来容易实际调试时需要反复放大缩小Grid看边界建议弄一个小工具函数private static DataGridViewPaintParts BuildBorderParts(bool isFirstRow, bool isLastRow, DataGridViewPaintParts original)把需要的Parts设置好然后通过e.Paint(e.CellBounds, parts)绘制。3. 实操过程一个可直接复现的合并单元格Demo3.1 完整实现步骤我把这个方案封装成了一个可在你的Form里直接引用的完整Demo。核心流程分三步初始化数据、计算Region、挂载CellPainting事件。步骤一界面与数据准备先建一个空的WinForms项目拖一个DataGridView。在Form_Load里绑定数据源var dt new DataTable(); dt.Columns.Add(产品, typeof(string)); dt.Columns.Add(批次, typeof(string)); dt.Columns.Add(数量, typeof(int)); dt.Rows.Add(产品A, 批次1, 10); dt.Rows.Add(产品A, 批次1, 20); dt.Rows.Add(产品A, 批次2, 30); dt.Rows.Add(产品B, 批次1, 40); dt.Rows.Add(产品B, 批次1, 50); dataGridView1.DataSource dt;为了让效果直观我特意构造了“产品A / 批次1 / 批次2”这种交叉数据这样跑起来后你既能验证单列合并区域产品A合并3行也能验证多级合并时列A区域和列B区域边界不一致的情况。步骤二为DataGridView挂合并方法在窗体类里加上一个公共方法把合并Region计算好并订阅事件。建议不要在Form_Load里直接绑事件而是封装成private void SetupMergeDataGridView() { dataGridView1.CellPainting dataGridView1_CellPainting; dataGridView1.CellFormatting (s, e) { /* 可留作后续处理 */ }; dataGridView1.DataSourceChanged (s, e) BuildMergeRegions(); }DataSourceChanged和CellPainting这两个事件是整个方案的基石。DataSourceChanged负责在数据源变化后重新计算RegionCellPainting负责用这些Region做绘制。如果数据源是BindingSource过滤还需要监听BindingSource.ListChanged事件在过滤后重算Region。步骤三核心CellPainting实现这是最关键的代码也是完整版。它包含了找Region、判断首尾行、按需绘制边框、绘制垂直居中文字private Dictionaryint, ListCellMergeRegion _mergeRegions; private void dataGridView1_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; if (!_mergeRegions.TryGetValue(e.ColumnIndex, out var regions)) return; var region regions.FirstOrDefault(r e.RowIndex r.StartRowIndex e.RowIndex r.EndRowIndex); if (region null) return; bool isFirst e.RowIndex region.StartRowIndex; bool isLast e.RowIndex region.EndRowIndex; if (isFirst) { // 先让默认绘制处理背景和选中状态我们自己接管边框和文字 var rect GetRegionBounds(region); e.Paint(e.CellBounds, DataGridViewPaintParts.Background | DataGridViewPaintParts.SelectionBackground | DataGridViewPaintParts.ContentForeground); using (var brush new SolidBrush(e.CellStyle.ForeColor)) { var text e.FormattedValue?.ToString() ?? ; var textRect new Rectangle(rect.X e.CellStyle.Padding.Left, rect.Y e.CellStyle.Padding.Top, rect.Width - e.CellStyle.Padding.Left - e.CellStyle.Padding.Right, rect.Height - e.CellStyle.Padding.Top - e.CellStyle.Padding.Bottom); TextRenderer.DrawText(e.Graphics, text, e.CellStyle.Font, textRect, e.CellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter | TextFormatFlags.NoPrefix); } // 绘制边框首行不画底部 var parts DataGridViewPaintParts.Border; if (!isLast) { parts ~DataGridViewPaintParts.Border; // 按需绘制上下左右边框 ControlPaint.DrawBorder(e.Graphics, e.CellBounds, e.CellStyle.BackColor, 0, ButtonBorderStyle.None, Color.Black, isFirst ? 1 : 0, ButtonBorderStyle.Solid, Color.Black, 1, ButtonBorderStyle.Solid, Color.Black, 1, ButtonBorderStyle.Solid, Color.Black, 0, ButtonBorderStyle.None); } e.Handled true; } else { // 非首行绘制背景同时处理选中状态 e.Paint(e.CellBounds, DataGridViewPaintParts.Background | DataGridViewPaintParts.SelectionBackground); if (isLast) { // 尾行需要绘制底部边框 ControlPaint.DrawBorder(e.Graphics, e.CellBounds, e.CellStyle.BackColor, 0, ButtonBorderStyle.None, Color.Black, 0, ButtonBorderStyle.None, Color.Black, 1, ButtonBorderStyle.Solid, Color.Black, 1, ButtonBorderStyle.Solid, Color.Black, 1, ButtonBorderStyle.Solid); } e.Handled true; } } private Rectangle GetRegionBounds(CellMergeRegion region) { var firstCell dataGridView1.Rows[region.StartRowIndex].Cells[region.ColumnIndex]; var lastCell dataGridView1.Rows[region.EndRowIndex].Cells[region.ColumnIndex]; return new Rectangle(firstCell.DataGridView.GetCellDisplayRectangle(region.ColumnIndex, region.StartRowIndex, false).X, firstCell.DataGridView.GetCellDisplayRectangle(region.ColumnIndex, region.StartRowIndex, false).Y, firstCell.Size.Width, lastCell.DataGridView.GetCellDisplayRectangle(region.ColumnIndex, region.EndRowIndex, false).Bottom - firstCell.DataGridView.GetCellDisplayRectangle(region.ColumnIndex, region.StartRowIndex, false).Top); }这段代码的注释我写得很细但有一个地方要重点说明垂直居中文字的矩形宽度。很多实现直接用e.CellBounds这样文字只会首行那一格内显示合并效果是文字贴在上面下面空一大片。要真正让文字居中到整个合并区域必须用region的完整高度去算也就是GetRegionBounds返回的高度。这个细节直接决定效果是否像Excel合并。3.2 完整的多级合并处理策略刚才提到了多级合并和“非规则单元格怎么合并汇总”的问题这里单独展开。当列A和列B都要合并但粒度不同时最稳妥的方式是“先按最细分组的列计算每个区间的边界再让上层列跟随下层边界”。用前面Demo的数据举例列B批次的Region是 0,1 和 2,2 和 3,4列A产品的Region如果独立扫描是 0,2 和 3,4。此时两列都独立合并视觉上列A在第0~2行合并列B在第3~4行合并确实没问题。但如果数据是产品A, 批次1 产品A, 批次1 产品A, 批次2 产品A, 批次2独立扫描列A和列B都是 0,1 和 2,3也没问题。真正的坑在于产品A, 批次1 产品A, 批次2 产品A, 批次2 产品A, 批次2独立扫描列A是 0,3列B是 0,0 和 1,3。合并出来的效果是列A用一个大区域盖住4行列B里批次1只占第0行下面1~3行是批次2。视觉上批次1文字在行0但产品区域的文字垂直居中于整个0~3行看起来没问题。可如果你点列A的单元格会发现选择区域是4行而列B只有1行很不一致。更麻烦的是交替数据产品A, 批次1 产品A, 批次2 产品A, 批次1 产品A, 批次2此时列A无法合并值相同但中间被批次隔断但如果按“列A的合并边界必须和批次边界对齐”来做你就会发现列A其实不应该合并成一大块因为批次是交错的合并后无法呈现清晰的分组层级。解决这类问题通常有两种策略策略一视觉优先只做单列独立合并不保证跨列边界对齐。适合纯展示。策略二分组优先先按指定的多列做复合分组计算每组的行范围再对组内列进行合并组与组之间绝不对跨。相当于把多列合并绑定到一个分组层级上。适合报表统计。如果客户想要的是“产品A大分组组内按批次再分小组”就必须用策略二。实现上需要用分组Key来算Region而不是逐列独立扫描private Dictionaryint, ListCellMergeRegion BuildGroupedMergeRegions() { var result new Dictionaryint, ListCellMergeRegion(); int[] groupColumns { 0, 1 }; // 按这些列做分组 int start 0; while (start dataGridView1.Rows.Count) { int end start; var key0 dataGridView1.Rows[start].Cells[0].Value?.ToString() ?? ; var key1 dataGridView1.Rows[start].Cells[1].Value?.ToString() ?? ; while (end 1 dataGridView1.Rows.Count) { var k0 dataGridView1.Rows[end 1].Cells[0].Value?.ToString() ?? ; var k1 dataGridView1.Rows[end 1].Cells[1].Value?.ToString() ?? ; if (k0 key0 k1 key1) end; else break; } // 列0和列1的合并区域都使用这个 interval AddRegion(result, 0, start, end); AddRegion(result, 1, start, end); start end 1; } return result; }这个方案能保证只要行数据在“产品批次”上相同就合并一旦批次变化产品列即使还是同一个值也被强制切开不会出现跨批次的视觉大块。我个人更推荐策略二因为它和实际业务分组语义一致。4. 常见问题与排查技巧实录这个方案写起来不算难但真要在项目里稳定运行需要处理很多边角问题。我把实战中踩过的坑整理成一张排查表每条都是真实遇到过并且验证过解决方法的。现象根本原因解决方案滚动后合并区域错乱、有残留线DataGridView虚拟模式或滚动时部分单元格未重绘在Scroll事件里调用Invalidate()强制刷新如果使用虚拟模式需要重写CellValueNeeded并确保Region随数据更新合并单元格点击文字不显示点击后进入编辑状态编辑框盖住了绘制内容对合并列设置ReadOnly true或在CellBeginEdit里取消编辑排序后合并区域完全错乱Region是按行索引预计算的排序后行顺序变了禁用排序默认SortedColumn设空或在SortCompare里重算Region更稳妥的是设置AllUsersMerge当排序后重新绑定DataSource列宽变化后文字位置不对绘制时用了旧的cellBounds监听ColumnWidthChanged和RowsAdded、RowsRemoved重新计算Region并Invalidate合并区域的选中样式不一致非首行没有绘制选择状态在Paint时对所有行都绘制SelectionBackground具体是否高亮还需结合CurrentCell判断这个在Excel里也是类似的处理复制粘贴时合并区域内容缺失我们只是不绘制数据仍然存在但复制时WPS/Excel会取Cell的Value粘贴后出现多行重复内容如果业务要求复制时按合并区域输出需要重写ClipboardCopyMode或在CtrlC事件里自定义返回去重后的文本行高不一样时合并文字未居中且边框断裂默认行高相同的假设不成立绘制时不要用cellBounds.Height用region整体高度计算行高自适应时要重新getBounds我特别想聊一下“滚动残留”这个问题。DataGridView的单元格绘制是异步的滚动过程中会频繁触发CellPainting如果你在绘制里做了比较重的计算比如每次都调用FindRegion而Region数目又很多就能明显感觉到滚动卡顿。解决办法是把Region放到一个成员变量里启动时算一次绘制时使用二分查找Region按StartRowIndex有序可以用List的BinarySearch而不是线性查找。一个只有几十行的表格可能感觉不出来但几千行时差距非常明显。再补充一个经验合并操作不要和编辑功能混用。很多人问我“合并单元格能不能让用户直接在里面编辑”我的建议是非首行直接禁用编辑首行编辑时同步更新整个合并区域的值。如果数据源是DataTable更新时要手动遍历Region里所有行的DataRow把值统一改掉否则排序、过滤后就会出现“一部分行是新值、一部分是旧值”的脏数据。实现上可以在CellEndEdit事件里遍历当前Region把同区域所有Cell的Value都同步为编辑后的值。最后分享一个我常用的调试技巧在合并区域交替设置不同的背景色分别标记首行、中间行、尾行这样一眼就能看出边框和区域划分哪里出了问题。调试完再替换成正式样式比反复对着黑灰色的网格线猜测高效得多。毕竟这个方案本质上是“画”出来的所有视觉问题最终都得靠绘制调试去解决。本文还有配套的精品资源点击获取