简介:面向有一定C#基础的WinForms开发者,DataGridView合并单元格实现方案专注于解决跨列标题、行头合并、汇总行等复杂表格布局任务,完整解析逻辑合并与视觉合并两条技术路线的原理、实现步骤与适用场景。资源压缩包共22个文件,大小仅44KB,核心包括6个.cs源代码、3个.exe可运行示例、Visual Studio解决方案与工程文件,另含资源文件、调试符号和配置文件,结构完整且便于直接编译运行查看效果。目前已有345人学习下载。代码演示了两种实现思路:逻辑合并通过隐藏列来拼接视觉效果,但会残留不可见数据;视觉合并则重写OnPaint事件逐格遍历并绘制覆盖矩形,同时利用DefaultCellStyle控制内容对齐、调整AutoSizeColumnsMode维持布局稳定,还涉及行头样式定制,以及在合并单元格内通过自定义DataGridViewCell放置按钮、文本框等扩展做法,兼顾显示效果与数据独立性。适合需要快速集成或二次开发此类表格功能的开发人员。
1. 先说清楚:DataGridView 合并单元格到底是什么,为什么默认不支持
很长一段时间里,我都不太愿意在 WinForms 项目里接“合并单元格”这个需求,因为 DataGridView 本身没有这个能力。后来才发现,判断一个开发者有没有真正碰过表格控件,只要抛出一个问题就够了:为什么 DataGridView 这么成熟的控件,官方文档里连一个 Merge 方法都不给?原因其实不在表格显示,而在单元格的索引模型——每个单元格被行号和列号唯一锁定,拖动滚动条、排序、编辑全部依赖这套索引。合并单元格意味着一个逻辑单元格要占多个物理格子的区域,这会直接破坏索引模型。所以现实项目里 90% 的合并单元格,都是用“视觉合并”骗过眼睛,而不是让控件真的把两个格子揉成一个。
这个需求通常出现在报表类界面里:产品线、地区、月份这些分组维度在表格里天然有连续重复值,客户希望它们只在首行显示一次,其他行留空或者跨行居中。数据源不需要动,界面却要变成“按组显示”。本文会把最常见的同值列合并讲到能直接落地,包括绘制、选中、排序、滚动避坑,最后给出一个映射表驱动导出的进阶思路。
2. 三条实现路径:绘制模拟、真合并重写、第三方组件,怎么选
2.1 DataGridView 没有原生合并的原因:索引模型绑死了每个格子
DataGridView 的底层模型是典型的网格模型:行号对应数据源行号,列号对应字段。控件在绘制时按可见矩形逐个遍历单元格,每个单元格只负责自己的区域。这个模型最大的优点是性能和命中测试都简单,代价是它假设了“单元格之间不存在重叠或跨越”。
一旦要做合并,就要打破这个假设。比如第 2 列的第 3、4、5 行显示成一个大格子,控件的索引系统仍然认为它们分别是三个物理单元格。那问题就来了:鼠标点在第 4 行时,是命中哪个单元格?编辑框应该出现在哪里?排序的时候,三个物理单元格要不要一起移动?这些问题不是靠一个Merge()方法能解决的,它需要整个绘制、命中、编辑管线都认识“合并区间”这个东西,工程量不亚于重写一个表格控件。
所以大多数项目选择的路线是:不动控件的索引模型,只动绘制结果。这就是“视觉合并”能成为主流方案的根本原因。
2.2 绘制模拟方案:视觉合并怎么骗过眼睛和鼠标
所谓绘制模拟,就是在CellPainting事件里截获单元格的默认绘制,按照“同一列连续行值相同”的规则,把区域内其他行的边框和文字隐藏掉,把文字只画在区域首行,并且用区域总高度做垂直居中。合并区间内的每个物理单元格仍然存在,仍然可以被鼠标点击和键盘导航,只是画出来像一个整体。
这套方案工作量最小,通常两百行左右的代码就能覆盖绑定、重绘、选中高亮。适用场景也最广:产品统计、订单明细、排班表这类“按照某列分组展示”的报表。它的局限也很明显——只能合并同列相邻行,不能跨列合并,因为跨列会涉及列宽计算和命中区域的改变,这已经超出绘制层能解决的范围。
我一般会建议:先确认需求是不是“同值列合并”,如果是,直接走绘制模拟;如果需求里有“把两列内容拼成一个格子”,那第一步不是写代码,而是重新确认产品逻辑,因为这种展示形态应该由“数据列合并”解决,而不是表格控件解决。
2.3 重写底层与第三方组件:什么场景才值得引入
真正意义上的合并,是指控件内部把多个单元格坐标重排为一个区域,命中测试、编辑框、焦点框都只针对合并区域整体工作。这在 DataGridView 上不是不能做,但需要重写按列布局的坐标计算逻辑,还要处理滚动视差、列宽变化、选中扩展等一连串联动,工作量是以周为单位的。
有一类场景值得这么做:单元格数量固定、跨行跨列规则复杂、且交互要求高。比如值班表、甘特图式的计划表。这类界面数据量通常不大,但对“一个格子占多个行列”有硬性要求,绘制模拟应付不了。常见做法是直接把控件放在自定义 UserControl 里重绘,甚至自绘整个表格。另一个可选方向是引入自带合并能力的第三方表格控件,省掉底层工作量,代价是引入外部依赖和授权成本。
选型判断我给一条经验:项目里表格用于“查询展示”,优先绘制模拟或者第三方;表格用于“数据录入且合并区域需要整体编辑”,请直接评估第三方控件,不要试图改造 DataGridView 的编辑模型。
3. 用 CellPainting 实现同值列合并:最小可运行代码
3.1 配置合并列:数据准备与事件入口
先用最简单的方式准备一份带重复值的数据源,绑定到 DataGridView。这里我选用 DataTable,因为它在 WinForms 里最常见,而且后续如果要加 BindingSource 做筛选,改动也最小。
// Form1.cs public partial class Form1 : Form { // 参与合并的列索引:0=产品线,1=销售区域 private readonly List<int> _mergeColumns = new List<int> { 0, 1 }; // 合并区间映射表:列索引 -> 该列上的所有连续合并区间 private readonly Dictionary<int, List<MergeRange>> _mergeMap = new Dictionary<int, List<MergeRange>>(); public Form1() { InitializeComponent(); // 1. 准备数据,故意放入连续重复值 var dt = new DataTable(); dt.Columns.Add("产品线", typeof(string)); dt.Columns.Add("销售区域", typeof(string)); dt.Columns.Add("销量", typeof(int)); dt.Rows.Add("AI 套件", "华东", 120); dt.Rows.Add("AI 套件", "华南", 80); dt.Rows.Add("传统软件", "华东", 45); dt.Rows.Add("传统软件", "华南", 60); dt.Rows.Add("传统软件", "华北", 55); dgv.DataSource = dt; // 2. 开启双缓冲,减少后期滚动重绘的闪烁 typeof(DataGridView).GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) ?.SetValue(dgv, true); // 3. 绑定绘制事件 dgv.CellPainting += Dgv_CellPainting; // 4. 数据已就绪,先构建合并区间映射表 RebuildMergeMap(); } } // MergeRange.cs —— 合并区间结构,后面绘制和导出都靠它 public struct MergeRange { public int Start; // 合并区间起始行(含) public int End; // 合并区间结束行(含) public MergeRange(int start, int end) { Start = start; End = end; } }代码里有三个关键点。一是_mergeColumns用的是列索引而是不列名,因为CellPainting事件参数只提供ColumnIndex,用索引查找最快。二是RebuildMergeMap()放在DataSource赋值之后调用,如果放在绑定之前调用,遍历行数会是 0,构建结果为空。三是双缓冲通过反射开启,虽然稍微有点“黑科技”,但对滚动闪烁的改善非常直观。
3.2 预计算合并区间:一次遍历生成合并映射表
绘制事件是高频触发的,滚动一次可能调用几十次。绝对不能在CellPainting里递归查找上下行做合并判断,那样性能会非常难看。正确做法是预先遍历一次数据行,把连续相同值的行区间全部构建出来,绘制时只查字典。
// 重建合并映射表:数据变化后调用一次 private void RebuildMergeMap() { _mergeMap.Clear(); foreach (int col in _mergeColumns) { if (col < 0 || col >= dgv.Columns.Count) continue; var ranges = new List<MergeRange>(); int startRow = 0; for (int r = 1; r <= dgv.Rows.Count; r++) { if (r == dgv.Rows.Count || !SameValue(col, r - 1, r)) { // 连续相同行数 >= 2 才生成合并区间 if (r - startRow >= 2) { ranges.Add(new MergeRange(startRow, r - 1)); } startRow = r; } } _mergeMap[col] = ranges; } // 映射表变了,让表格整体重绘一次 dgv.Invalidate(); } // 判断同一列相邻两行的显示值是否相同 private bool SameValue(int col, int row1, int row2) { string v1 = dgv[col, row1].FormattedValue?.ToString() ?? string.Empty; string v2 = dgv[col, row2].FormattedValue?.ToString() ?? string.Empty; return string.Equals(v1, v2, StringComparison.Ordinal); }这里值得说明的是SameValue方法用的是FormattedValue,而不是Value。FormattedValue是单元格格式化之后显示出来的内容,比如日期列显示成2024-05-01,枚举列显示成“已完成”。如果用Value比较,底层可能是DateTime对象或者枚举数字,界面看起来相同的两行会被判断成不同,合并结果和用户所见不一致。这一条我在后面的避坑章还会展开。
循环逻辑也很常规:从第 1 行开始向右比较,跳到值变化的位置,只要发现连续行数大于等于 2 就记录为一个区间。空值情况也算相同值,连续空行同样会被合并。如果业务上不允许合并空值,在SameValue里加一行判断,把两个空值情况返回false即可。
3.3 重绘文字与边框:核心绘制代码与参数说明
这一步是整个方案的心脏。思路是:查映射表,如果当前单元格位于某个合并区间内,且不是区域首行,那就不画文字、不画边框,只保留背景色;如果是区域首行,就按“从首行到末行”的总高度来绘制文字和边框。
// 核心绘制:CellPainting 事件 private void Dgv_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { // 只处理数据区,列头和行头直接放行 if (e.RowIndex < 0 || e.ColumnIndex < 0) return; // 不在合并列范围内的列走默认绘制 if (!_mergeColumns.Contains(e.ColumnIndex)) return; // 从映射表查找当前单元格属于哪个合并区间 if (!TryGetRange(e.ColumnIndex, e.RowIndex, out var range)) return; // 情况一:不是合并区间的首行 if (e.RowIndex != range.Start) { // 只画背景,不画边框不画文字,视觉上融入上方单元格 e.Paint(e.ClipBounds, DataGridViewPaintParts.Background | DataGridViewPaintParts.SelectionBackground); e.Handled = true; return; } // 情况二:是合并区间首行,需要跨行绘制 // 通过 GetCellDisplayRectangle 拿到合并区间最后一行的显示矩形 Rectangle lastRect = dgv.GetCellDisplayRectangle(e.ColumnIndex, range.End, false); // 合并区域的实际绘制范围:从当前单元格顶部一直延伸到末行底部 Rectangle area = new Rectangle( e.CellBounds.Left, e.CellBounds.Top, e.CellBounds.Width, lastRect.Bottom - e.CellBounds.Top); // 先画默认背景,保留选中高亮 e.Paint(e.ClipBounds, DataGridViewPaintParts.Background | DataGridViewPaintParts.SelectionBackground); // 再画文字:垂直居中在合并区域,超长时省略 TextRenderer.DrawText( e.Graphics, dgv[e.ColumnIndex, e.RowIndex].FormattedValue?.ToString() ?? "", dgv.Font, new Rectangle(area.Left + 4, area.Top, area.Width - 8, area.Height), dgv.ForeColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); // 最后画外边框,放在文字之后避免网格线压字 using (Pen pen = new Pen(dgv.GridColor)) { e.Graphics.DrawLine(pen, area.Left, area.Top, area.Right - 1, area.Top); e.Graphics.DrawLine(pen, area.Left, area.Top, area.Left, area.Bottom); e.Graphics.DrawLine(pen, area.Right - 1, area.Top, area.Right - 1, area.Bottom); e.Graphics.DrawLine(pen, area.Left, area.Bottom, area.Right - 1, area.Bottom); } e.Handled = true; } // 根据列和行查找所属合并区间 private bool TryGetRange(int col, int row, out MergeRange range) { range = default; if (!_mergeMap.TryGetValue(col, out var ranges)) return false; foreach (var r in ranges) { if (row >= r.Start && row <= r.End) { range = r; return true; } } return false; }两个参数要特别讲一讲。第一个是GetCellDisplayRectangle的第三个参数cutOverflow,我传了false,意思是返回完整显示矩形,不做裁剪。如果传true,当单元格部分滚出视口时返回的宽度会被裁剪,合并区域就会少一截。第二个是TextFormatFlags里的VerticalCenter,它让文字相对合并区域总高度居中,而不是相对首行单元格居中,这正是用户期望看到的跨行居中效果。
e.Handled = true是这整段代码的生命线。它告诉控件“我已经接管了这个单元格的绘制”,防止默认绘制再把文字画一遍。凡是所有分支都要保证最后设置e.Handled = true,否则会出现双重文字或者边框残缺。
4. 合并单元格的标配改造:选中、滚动、编辑与排序
4.1 选中状态:把高亮从单行扩展成整段
绘制层把单元格“画”成了合并区域,但选中逻辑还是按物理行走的。默认情况下,用户单击合并区域中间的那一行,只有那一行变蓝,视觉上区域断裂。通常的解决方式是监听SelectionChanged,在用户点中合并列时,把选区扩展到整个合并区间。
private bool _syncingSelection; private void Dgv_SelectionChanged(object sender, EventArgs e) { if (_syncingSelection) return; DataGridViewCell cell = dgv.CurrentCell; if (cell == null) return; // 只处理合并列里的点击 if (!_mergeColumns.Contains(cell.ColumnIndex)) return; if (!TryGetRange(cell.ColumnIndex, cell.RowIndex, out var range)) return; _syncingSelection = true; try { dgv.ClearSelection(); // 把合并区间内所有行都选中 for (int r = range.Start; r <= range.End; r++) { dgv.Rows[r].Selected = true; } // 恢复当前单元格,防止 ClearSelection 把焦点也清掉 dgv.CurrentCell = dgv[cell.ColumnIndex, cell.RowIndex]; } finally { _syncingSelection = false; } }_syncingSelection是一个防重入标志,因为ClearSelection()和CurrentCell赋值都会再次触发SelectionChanged,如果没有这个标志会无限递归。这段代码只在SelectionMode = FullRowSelect时有意义;如果项目里需要单独选中单元格而不是整行,则要把Range内的每一行对应列都设为Selected,逻辑相似,只是从行集合换成了单元格集合。
4.2 滚动与双缓冲:合并区域跟随视口的关键
滚动偏移是视觉合并最容易翻车的地方。合并区域的底边坐标如果保存的是“构建时的绝对坐标”,滚动后就会错位。在上面代码里,我们没有缓存任何绝对坐标,每次绘制都用GetCellDisplayRectangle实时计算末行位置,这个方法内部已经考虑了滚动偏移量,所以滚动时合并区域是跟着视口走的。
真正需要操心的反而是性能。数据量在几百行以内时,CellPainting里多画几根线感受不到区别;一旦上千行,滚动时连续触发绘制事件,就可能会出现文字重影或者背景闪烁。优先把DoubleBuffered打开,这一步通过反射设置,代码很短但效果明显。如果重影仍然存在,检查有没有把Pen对象放在类字段里复用,跨绘制事件复用的Pen在滚动时会产生诡异残影,我见过不少这种玄学问题,最后都是字段改局部变量解决的。
4.3 编辑与排序:合并列两个最常见的动作限制
合并列默认是不能直接编辑的,因为用户双击合并区域中间的单元格,编辑框会出在那一行,而不是整个区域,看起来非常奇怪。最简单的做法是把合并列设为只读。如果需要允许编辑,常见做法是双击时自动跳到合并区间首行,然后只允许首行编辑。
// 在 CellBeginEdit 中做行跳转 private void Dgv_CellBeginEdit(object sender, DataGridViewCellCancelEventArgs e) { if (!_mergeColumns.Contains(e.ColumnIndex)) return; if (!TryGetRange(e.ColumnIndex, e.RowIndex, out var range)) return; // 非首行不允许编辑,直接取消并把光标移动到首行 if (e.RowIndex != range.Start) { dgv.CurrentCell = dgv[e.ColumnIndex, range.Start]; e.Cancel = true; } }排序则是另一个大坑。DataGridView 点击列头后会直接重排行数据,合并映射表里存的行号全是旧行顺序,排序后不重建,合并区间就会对不上。我的习惯是给合并列干脆设置SortMode = DataGridViewColumnSortMode.NotSortable,禁止用户点合并列排序。如果业务上一定要按合并列排序,只能在ColumnHeaderMouseClick事件里做自定义排序,排序结束后调用RebuildMergeMap()重新构建映射,并接受“合并组可能因为排序而拆分”的结果。
5. DataGridView 合并单元格避坑指南:典型问题与排查方案
5.1 现象:滚动后文字和边框错位,像残影一样
现象:数据超过一屏后,用鼠标滚轮向下翻,合并区域首行的文字没有跟着滚动,或者底边框拖出一段残留,位置和当前数据行对不上。
原因:绘制时使用了缓存的绝对坐标。常见写法是在RebuildMergeMap时把合并区域的 Y 坐标和高度直接算好存下来,滚动后这些绝对坐标已经失效。
解决:合并区域的每个绘制值都必须来自e.CellBounds或GetCellDisplayRectangle,这两个方法返回的是实时视口坐标。不要在任何地方缓存合并区域的Rectangle,用的时候临时算,慢不了多少。如果滚动残影严重,先开DoubleBuffered,再检查是否复用了字段级的Pen或Brush对象。
5.2 现象:单击合并区域中间的行,高亮只有一行
现象:用户点击合并区域里的第二行或者第三行,视觉上选中条只有一行宽,合并区域看起来像是被截断成两半。
原因:绘制层和选中层没有联动。绘制把多行画成了一个整体,但选中是控件默认逻辑,仍然只高亮当前点击的那一行。
解决:用第一节 4.1 的SelectionChanged扩展逻辑,把选区覆盖到整个合并区间。注意SelectionChanged触发频率高,防重入标志必须加。还有一个容易忽略的行为:用户在非合并列点击时,DataGridView 会按默认逻辑清掉之前的合并选区,这是正常表现,不要为此去强行保留状态。
5.3 现象:用 Value 比较合并,格式化后的显示不一致
现象:某一列存放日期,界面上显示为“2024-05-01”,但其中两行的底层DateTime值一个是DateTime对象,一个是字符串,合并结果要么没合上,要么把不应该合的行合在了一起,跟用户看到的完全不一致。
原因:合并判断用Value比较,底层对象类型不同或者带时间精度,界面格式化后看起来相同,实际对象不同。
解决:统一用FormattedValue?.ToString()做比较。代价是FormattedValue获取比Value慢一些,但合并映射是预计算的,每次数据变化只构建一次,性能损失可以忽略。注意比较时用StringComparison.Ordinal,避免ToLower这种多余的开销。
5.4 现象:一排序整个合并区域全乱
现象:某列允许排序,用户点击列头后,原本已经合并好的区间错乱,凑进来一些值相同但业务上不该合并的行,或者合并区域直接消失。
原因:DataGridView 自带排序会重排行顺序,合并映射表仍然绑定旧行号,新行序和旧区间无法对应。
解决:如果合并列本身不参与排序,直接给该列设置SortMode = NotSortable。如果有其他列参与排序,在ColumnHeaderMouseClick中捕获排序事件,排序完成后调用RebuildMergeMap()。如果必须按合并列排序,请先确认合并规则是“值相同就合并”还是“按业务主键合并”,后者建议放弃 DataGridView 方案,换用自带合并能力的控件。
5.5 现象:跨列合并怎么画都是歪的
现象:想把第 1 列和第 2 列合并成一个宽格子,用CellPainting画边框和文字,走到第二列时列宽不同,画出来的背景和边框对不齐。
原因:DataGridView 的列位置和宽度由列布局管理器统一控制,单个单元格的绘制区域被限制在自己的列范围里。绘制层可以画出跨列的文字,但无法让两列的背景、边框、命中测试区域保持一致。这不是代码写得不够好,而是控件模型不支持。
解决:优先考虑给数据源增加一个计算列,把两列内容提前拼成一个字符串。把原来的两列隐藏掉,用一个新列展示。视觉上等同于跨列合并,但数据模型仍然是单列,所有合并绘制逻辑都能复用。如果必须按两列独立排序、独立筛选,那就只能接受视觉合并的限制,或者引入第三方表格控件。
5.6 现象:数据源刷新后映射表不更新
现象:调用BindingSource.ResetBindings()、修改Filter、删除行之后,合并区域还是老样子,甚至出现索引越界报错。
原因:RebuildMergeMap()只在Form_Load时调用过一次,后续数据源变化没有触发重建。
解决:监听BindingSource.ListChanged事件,在Reset、ItemAdded、ItemDeleted三种类型下调用RebuildMergeMap()。注意ItemChanged类型也可能会触发单元格内容变化,如果项目允许用户直接编辑合并列,ItemChanged也需要重建,否则一个单元格值被改掉,合并区间还按旧值留着。
6. 进阶:用合并映射表驱动导出、提示与验证
6.1 把合并区间翻译成 Excel 的真实单元格合并
我在项目里踩过最多次的坑,是界面合并好了,导出到 Excel 却变成一堆重复单元格。如果到导出时再重新算一遍合并区间,代码要写两份,而且很容易出现“界面判定规则”和“导出判定规则”不一致。正确做法是复用同一个映射表,把MergeRange翻译成 Excel 的行号区域。
// 把某一列的合并区间转成 Excel 所用的行范围列表 public List<ExcelMergeRegion> GetExcelMergeRegions(int col) { var result = new List<ExcelMergeRegion>(); if (!_mergeMap.TryGetValue(col, out var ranges)) return result; foreach (var range in ranges) { // Excel 第一行是表头,数据从第 2 行开始,所以要 +2 // 如果要合并的列前面还有序号列等,这里还要考虑列偏移 result.Add(new ExcelMergeRegion { RowFrom = range.Start + 2, RowTo = range.End + 2, ColIndex = col }); } return result; } public class ExcelMergeRegion { public int RowFrom { get; set; } public int RowTo { get; set; } public int ColIndex { get; set; } }导出代码拿到这个列表后,调用 Excel COM 或第三方库的Range.Merge填入即可。这样界面和导出的合并判定永远一致,改一处逻辑两边同时生效。映射表做到这一步就不再是一个绘制辅助变量,而是业务数据的一部分。
6.2 悬浮提示与省略号:让合并单元显示完整信息
合并区域里的文字如果过长,上面代码里的EndEllipsis会把它截断成省略号。用户想看到完整内容时,需要悬浮提示。不要用MouseMove自己去算哪个格子触发,DataGridView 提供了CellToolTipTextNeeded事件,配合合并区间可以直接给首行赋值完整文本。
private void Dgv_CellToolTipTextNeeded(object sender, DataGridViewCellToolTipTextNeededEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex < 0) return; if (!_mergeColumns.Contains(e.ColumnIndex)) return; if (!TryGetRange(e.ColumnIndex, e.RowIndex, out var range)) return; // 只在合并区间首行显示提示,避免移动鼠标时频繁触发 if (e.RowIndex == range.Start) { e.ToolTipText = dgv[e.ColumnIndex, e.RowIndex].FormattedValue?.ToString() ?? ""; } }一个建议是给映射表加一个“是否被省略”的判断,只有文字宽度超出合并区域宽度时才设置ToolTipText。这个判断可以放在RebuildMergeMap阶段用TextRenderer.MeasureText完成,避免每次鼠标移动都去量文字。
6.3 一个养成习惯的细节
做了多年表格控件,我最想强调的习惯是:把合并区间看作数据,而不是绘制副作用。所有和合并相关的逻辑——绘制、选中、导出、提示——只从一个映射表读取数据,不要让每个功能模块各自判断“哪些行该合并”。这也是我后来把MergeRange和相关方法挪到独立类里的原因。做这个改造之前,界面和导出各写一套合并规则的代码,每次规则调整都是双倍排查量;改造之后,整个合并单元格方案维持了几年没有大改。这个细节值得你从现在就开始做,希望帮到你。
本文还有配套的精品资源,点击获取