简介:这份资源面向使用 C# 开发 Windows Forms 应用的开发者,聚焦 DataGridView 控件的单元格合并问题。当需要在表格中创建标题行、汇总行或复杂表头时,普通单元格布局往往难以满足需求,资源围绕逻辑合并与视觉合并两种思路展开,重点讲解通过重写 OnPaint 事件自定义绘制来实现视觉合并,并涉及对齐方式设置、AutoSizeColumnsMode 调整、行头主副表头处理以及自定义 DataGridViewCell 等关键细节。压缩包为 rar 格式,共 22 个文件,约 44KB,包含 6 个 cs 源码文件、3 个 exe 可执行程序、2 个 resx 与 2 个 resources 资源文件、2 个 pdb 调试符号,以及 csproj、sln、suo、settings、manifest 等工程配置与缓存文件,构成一套可直接运行的完整示例工程。目前已有 344 人学习下载,适合希望快速掌握合并单元格实现方式、对照源码理解绘制流程与排错思路的读者参考。
1. 从一次报表翻车说起:DataGridView 合并单元格到底能解决什么
去年帮一家做仓储管理的客户改 WinForms 老系统,出库单打印预览里同一个订单号在明细表里重复出现了十几行,业务方拍着桌子说“这表格看着像没做完”。当时我第一反应是去改 SQL 做分组,结果发现数据本身没问题,是展示层需要把相同订单号的单元格视觉上合并成一块。这就是 DataGridView 合并单元格最典型的落地场景:数据行必须保留,但视觉上要按某个或某几个字段归并。它解决的不是数据聚合问题,而是展示层的可读性问题。适合谁?维护 .NET Framework WinForms 老项目的工程师、做内部管理系统的独立开发者、以及被业务方要求“表格看起来专业一点”的同行。这篇笔记不讲虚的,直接拆我实际用过的合并方案、参数怎么设、哪些坑会让你在演示现场翻车。
2. 合并的底层逻辑:为什么 DataGridView 没有现成的 Merge 属性
2.1 单元格绘制与数据分离的机制
DataGridView 从设计上就是一个“平铺”的网格控件,每个单元格(DataGridViewCell)对应一行一列的交叉点,它没有像 Excel 那样原生的 Merge 属性。你看到的“合并”效果,本质上是通过CellPainting 事件或单元格重绘把相邻相同值的单元格区域画成一个整体,同时把被“吃掉”的单元格内容清空、边框抹掉。换句话说,合并是画出来的,不是数据结构层面的合并。理解这一点非常关键,因为后面所有坑都源于此:排序、选中、编辑、复制粘贴这些行为仍然按原始单元格粒度走,不会因为你视觉上合并了就自动变成“一个单元格”。
常见做法有两种:一是用DataGridViewCellPaintingEventArgs在绘制阶段动态合并,适合数据量大、合并规则会变的场景;二是预先计算合并区域,用DataGridViewAdvancedBorderStyle和背景色覆盖来“伪装”合并,适合规则固定、追求性能的场景。我一般会优先选第一种,因为它的侵入性小,数据源不用动,排序后重新触发绘制即可。
2.2 合并区域的计算:从行遍历到区间标记
不管用哪种绘制方式,核心都是先算出哪些单元格要合并。以最常见的“同一列中连续相同值合并”为例,算法逻辑是:遍历该列每一行,维护一个起始行 startRow,当发现当前行值与上一行不同时,就把 [startRow, 当前行-1] 这个区间标记为一个合并块。这里有个容易被忽略的边界:只合并连续相同的行,如果中间隔了一行不同的值,即使后面又出现相同值,也不能跨过去合并,否则视觉上会误导用户以为它们是一组。这个规则在订单号、批次号场景里尤其重要。
下面是我常用的一个合并区间计算函数,返回一个字典,key 是列索引,value 是合并区间的列表:
// 计算指定列中连续相同值的合并区间 // 参数:dgv-目标表格, columnIndex-要合并的列索引 // 返回:List<(int startRow, int endRow)> 每个元素是一个合并块 private Dictionary<int, List<(int start, int end)>> CalcMergeRanges( DataGridView dgv, int[] columnIndexes) { var result = new Dictionary<int, List<(int, int)>>(); foreach (var col in columnIndexes) { var ranges = new List<(int, int)>(); if (dgv.Rows.Count == 0) { result[col] = ranges; continue; } int start = 0; for (int i = 1; i <= dgv.Rows.Count; i++) { // 到达末尾或值发生变化时,结算一个区间 bool isEnd = i == dgv.Rows.Count; bool isDiff = !isEnd && !Equals( dgv.Rows[i].Cells[col].Value, dgv.Rows[start].Cells[col].Value); if (isEnd || isDiff) { if (i - 1 > start) // 至少两行才需要合并 ranges.Add((start, i - 1)); start = i; } } result[col] = ranges; } return result; }这段代码里columnIndexes是你要合并的列数组,比如订单号在第 0 列、客户名称在第 1 列,就传new[]{0,1}。注意Equals比较的是单元格的 Value,如果数据源里是 DBNull 或者空字符串,要提前统一,否则null和""会被判定为不同值,导致该合并的没合并。我一般会在绑定数据后先做一次空值归一化。
2.3 CellPainting 事件里做合并绘制的完整流程
算出区间后,就在CellPainting事件里动手。思路是:对于每个合并区间的起始单元格,正常绘制它的内容,但把背景和边框画成覆盖整个区间的大小;对于区间内的其他单元格,直接跳过内容绘制,只画背景(或者什么都不画,让起始单元格的绘制盖住)。这里必须设置e.Handled = true,否则默认绘制会把内容又画一遍,出现重影。
// 在窗体加载时绑定事件 dgv.CellPainting += Dgv_CellPainting; private Dictionary<int, List<(int start, int end)>> _mergeRanges; private void Dgv_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex < 0) return; if (_mergeRanges == null || !_mergeRanges.ContainsKey(e.ColumnIndex)) return; var ranges = _mergeRanges[e.ColumnIndex]; var range = ranges.FirstOrDefault(r => e.RowIndex >= r.start && e.RowIndex <= r.end); if (range == default) return; // 不在任何合并区间内,走默认绘制 // 只由区间起始行负责绘制整个合并块 if (e.RowIndex == range.start) { // 计算合并块的总高度 int totalHeight = 0; for (int i = range.start; i <= range.end; i++) totalHeight += dgv.Rows[i].Height; Rectangle rect = new Rectangle( e.CellBounds.Left, e.CellBounds.Top, e.CellBounds.Width, totalHeight); using (Brush backBrush = new SolidBrush(e.CellStyle.BackColor)) e.Graphics.FillRectangle(backBrush, rect); // 画边框 using (Pen pen = new Pen(dgv.GridColor)) e.Graphics.DrawRectangle(pen, rect); // 画内容,垂直居中 TextRenderer.DrawText(e.Graphics, e.Value?.ToString() ?? "", e.CellStyle.Font, rect, e.CellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter); e.Handled = true; } else { // 区间内非起始行,只填充背景,不画内容 using (Brush backBrush = new SolidBrush(e.CellStyle.BackColor)) e.Graphics.FillRectangle(backBrush, e.CellBounds); e.Handled = true; } }参数说明:e.CellBounds是当前单元格的矩形区域,totalHeight是把区间内所有行高累加,这样合并块才能跨越多行。TextFormatFlags.VerticalCenter让文字在合并块里垂直居中,否则会贴在顶部很难看。这段代码在数据绑定后、排序后、行高变化后都需要重新计算_mergeRanges并调用dgv.Invalidate()触发重绘。
3. 填充数据合并单元格:从 DataTable 到界面的完整链路
3.1 数据准备阶段的三个归一化动作
热词里提到的“填充数据合并单元格”,我理解成两个层面:一是数据填充到 DataGridView 之后再做合并,二是合并后的单元格如何正确显示填充的数据。不管哪种,数据准备阶段有三件事必须做,否则后面必出玄学问题。第一,空值归一化:把 DBNull、null、空字符串统一成""或某个占位符,避免Equals比较时把本该合并的行拆开。第二,排序稳定性:如果业务要求按订单号合并,那数据源必须按订单号排序,且排序要稳定,否则相同订单号被其他行隔开,合并区间就断了。第三,列顺序确认:合并列的索引在绑定后不能变,如果用户能拖动列顺序,你的columnIndexes就失效了,得改用列名来定位。
// 数据绑定后立即做归一化和排序 DataTable dt = GetOrderDetails(); // 按订单号排序,保证相同订单号连续 DataView dv = dt.DefaultView; dv.Sort = "OrderNo ASC, LineNo ASC"; dt = dv.ToTable(); // 空值归一化 foreach (DataRow row in dt.Rows) { foreach (DataColumn col in dt.Columns) { if (row.IsNull(col)) row[col] = ""; } } dgv.DataSource = dt; // 绑定后重新计算合并区间 _mergeRanges = CalcMergeRanges(dgv, new[] { 0, 1 }); dgv.Invalidate();这里dv.Sort的排序字段要根据实际业务定,OrderNo ASC保证相同订单号聚在一起,LineNo ASC保证明细行顺序不乱。归一化那层循环看起来笨,但比在每个比较处写?? ""要可靠得多,尤其是数据源来自多个不同数据库的时候。
3.2 合并后选中与复制行为的修正
合并绘制完成后,你会发现一个很别扭的现象:鼠标点在合并块的中间行,选中的是那一行的单元格,而不是整个合并块。复制的时候也只复制了那一行的值,用户会以为复制了整个合并内容。这是 CellPainting 方案的天然缺陷,因为选中逻辑还是按原始单元格走的。我的处理办法是在SelectionChanged或CellClick事件里做补偿:当用户点击的单元格属于某个合并区间时,手动把整个区间的行都选中。
private void Dgv_CellClick(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex < 0) return; if (_mergeRanges == null || !_mergeRanges.ContainsKey(e.ColumnIndex)) return; var range = _mergeRanges[e.ColumnIndex] .FirstOrDefault(r => e.RowIndex >= r.start && e.RowIndex <= r.end); if (range == default) return; dgv.ClearSelection(); for (int i = range.start; i <= range.end; i++) dgv.Rows[i].Cells[e.ColumnIndex].Selected = true; }注意ClearSelection要先调,否则之前选中的行会残留。这个补偿逻辑只对合并列生效,其他列还是正常单选。如果业务方要求复制合并块的内容,还得在KeyDown里拦截 Ctrl+C,自己拼剪贴板文本,这个后面进阶部分再展开。
3.3 行高变化与滚动时的重绘时机
合并块的高度依赖行高,如果用户手动拖拽调整了某一行的高度,或者程序里根据内容动态设置了AutoSizeRowsMode,合并块的绘制就会错位。我踩过的坑是:设置了AutoSizeRowsMode = AllCells之后,行高在绘制过程中还在变,导致totalHeight算出来和实际不一致,合并块要么盖住下一行,要么留一条白缝。解决办法是在RowHeightChanged或AutoSizeRowsModeChanged事件里重新计算合并区间并Invalidate,而且要把AutoSizeRowsMode设成None之后手动调AutoResizeRows,避免绘制和自动调整同时发生。
// 关闭自动行高,改为手动触发 dgv.AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None; dgv.AutoResizeRows(DataGridViewAutoSizeRowsMode.AllCells); // 行高变化后重算合并 dgv.RowHeightChanged += (s, e) => { _mergeRanges = CalcMergeRanges(dgv, new[] { 0, 1 }); dgv.Invalidate(); };滚动条滚动时,DataGridView 会重绘可见区域,CellPainting会被反复触发,所以_mergeRanges必须缓存好,不能在事件里现算,否则大数据量下滚动会卡成幻灯片。我一般把计算放在数据绑定后和排序后,滚动时只读缓存。
4. 避坑与排查:合并单元格最常见的五类翻车现场
4.1 现象:合并后单元格内容重影或错位
原因通常是e.Handled没有正确设置,或者区间内非起始行也走了默认绘制。默认绘制会把单元格的值再画一遍,和你的合并绘制叠在一起。解决:确保起始行绘制后e.Handled = true,非起始行只填充背景并同样设置e.Handled = true。另外检查e.Value是否在绘制前被清空过,如果数据源里起始行的值本身是空的,画出来就是空白。
4.2 现象:排序后合并块错乱,相同值没有合并
原因是排序后没有重新计算_mergeRanges,缓存还是旧的区间。解决:在ColumnHeaderMouseClick或数据源ListChanged事件里重新调用CalcMergeRanges并Invalidate。如果用的是 BindingSource,监听BindingSource.ListChanged更稳。
4.3 现象:合并列无法编辑,双击后内容消失
CellPainting 方案下,合并块的非起始行单元格其实还在,双击进入编辑时,编辑的是那一行的值,而绘制时又被跳过,看起来就像内容消失了。解决:如果合并列需要编辑,建议只允许编辑起始行,在CellBeginEdit里判断当前行是否为区间起始行,不是就e.Cancel = true。或者干脆把合并列设成ReadOnly,编辑走弹窗。
4.4 现象:大数据量下滚动卡顿,CPU 飙升
原因是CellPainting里做了 LINQ 查询或重复计算。ranges.FirstOrDefault在每次绘制时都遍历列表,行数一多就慢。解决:把区间查找改成字典或二分查找,或者提前为每一行缓存它所属的区间。我一般会建一个int[] rowToRangeStart数组,绘制时 O(1) 拿到起始行。
4.5 现象:合并块边框和网格线对不齐,出现双线
原因是默认网格线还在画,你的合并绘制又画了一遍边框。解决:把dgv.CellBorderStyle设成None,完全由你的绘制逻辑控制边框;或者只画合并块的外框,内部不画。注意设成 None 后,非合并列也没有网格线了,需要自己在CellPainting里补画。
5. 进阶技巧:把合并单元格做成可复用的扩展方法
5.1 封装成 DataGridView 扩展类的思路
每次新建窗体都复制一遍CellPainting代码太蠢,我后来把它封装成一个扩展类DataGridViewMergeExtension,核心接口就两个:EnableMerge(int[] columnIndexes)和RefreshMerge()。内部持有_mergeRanges缓存和_mergedColumns数组,事件绑定在EnableMerge时完成。这样业务窗体里只需要一行dgv.EnableMerge(new[]{0,1}),数据变化后调dgv.RefreshMerge()即可。封装时注意事件解绑,避免窗体关闭后事件还挂着导致内存泄漏。
public static class DataGridViewMergeExtension { private static readonly Dictionary<DataGridView, MergeContext> _contexts = new Dictionary<DataGridView, MergeContext>(); public static void EnableMerge(this DataGridView dgv, int[] columns) { var ctx = new MergeContext { Columns = columns }; _contexts[dgv] = ctx; dgv.CellPainting += (s, e) => OnCellPainting(dgv, e); dgv.CellClick += (s, e) => OnCellClick(dgv, e); dgv.RowHeightChanged += (s, e) => RefreshMerge(dgv); RefreshMerge(dgv); } public static void RefreshMerge(this DataGridView dgv) { if (!_contexts.TryGetValue(dgv, out var ctx)) return; ctx.Ranges = CalcMergeRanges(dgv, ctx.Columns); dgv.Invalidate(); } // OnCellPainting / OnCellClick / CalcMergeRanges 实现略 }这个封装里MergeContext存列数组和区间缓存,_contexts用 DataGridView 实例做 key。注意静态字典会导致控件无法被 GC 回收,窗体关闭时要调DisableMerge移除。如果嫌麻烦,可以把扩展状态挂在dgv.Tag上,但类型安全差一些。
5.2 验证合并是否正确:三个自检动作
写完合并逻辑后,我习惯做三个验证。第一,边界验证:构造只有一行、两行相同、两行不同、首尾相同中间不同这几种数据,看合并块是否只在连续相同处出现。第二,交互验证:点击合并块中间行,看选中范围是否覆盖整个块;滚动到列表底部再滚回来,看合并块是否错位。第三,性能验证:灌 5000 行数据,快速拖动滚动条,看是否有明显卡顿。这三个动作走完,基本能覆盖 90% 的翻车场景。
| 验证项 | 操作 | 预期结果 |
|---|---|---|
| 单行数据 | 只绑定一行 | 不产生合并块,正常显示 |
| 首尾相同 | 第一行和最后一行值相同,中间不同 | 不合并,因为不连续 |
| 全列相同 | 所有行值一致 | 合并成一个大块 |
| 快速滚动 | 拖动滚动条到底再回顶 | 合并块位置和内容不变 |
| 点击中间行 | 点击合并块非起始行 | 整个块被选中 |
5.3 一个我常备的调试习惯
从那以后我每次做合并单元格,都强制先在CalcMergeRanges里加一行日志,把每个区间的 start 和 end 打到Debug.WriteLine,绑定数据后先看日志确认区间对不对,再看界面。因为合并的 bug 十有八九出在区间计算上,界面绘制反而是最不容易错的。这个习惯帮我省了至少三次在演示前夜抓瞎的时间。希望帮到你。
本文还有配套的精品资源,点击获取