简介:本资源是一份面向C# Windows Forms开发者的DataGridView单元格合并实战方案,聚焦解决表格界面中标题行、汇总行等场景下的视觉合并需求。资源提供完整可运行的C#项目工程,含6个核心CS源码文件(含自定义绘制逻辑与合并标记处理)、3个EXE可执行示例、2个RESX本地化资源及1个SLN解决方案文件,共22个文件,总大小仅44KB,轻量易集成。已有348人学习下载,适合中初级开发者快速掌握视觉合并这一进阶技巧。读者可直接复用项目中的OnPaint重写逻辑、合并单元格判定策略、内容对齐与列宽控制代码,并通过预置的多组测试用例理解主表头/子表头合并、跨列渲染与数据独立存储的设计要点,避免陷入逻辑合并的常见陷阱。
1. DataGridView合并单元格:不是控件原生功能,而是靠重绘+逻辑兜底的“视觉缝合术”
你拖一个DataGridView到窗体上,双击写几行数据,想把连续几行的“部门”列合并显示为一个跨行标题——结果发现右键菜单里根本没有“合并单元格”选项,Columns[0].DefaultCellStyle.Alignment调来调去也只影响单个格子。这不是你操作错了,是 .NET Framework 和 .NET Core/.NET 5+ 的DataGridView压根没提供合并单元格的 API。它本质是个“行主导向”的表格控件,设计哲学就是每行独立、每格自治。所谓“合并”,其实是开发者用CellPainting事件手动擦掉中间格子的边框、用Graphics.DrawString把文字画在左上角区域、再配合CellFormatting拦截重复值显示、最后靠MouseDown事件重新映射点击坐标——一整套视觉欺骗+逻辑补偿的组合拳。这个项目就是把这套被无数 C# 开发者反复手搓、又反复踩坑的“缝合方案”,封装成可复用、可配置、不闪屏、不卡顿的完整实现。适合正在做内部管理后台、报表导出预览、Excel 风格数据录入界面的 WinForms 开发者,尤其当你被产品提了“把相同部门的员工横向合并显示”这种需求,而你又不想引入第三方商业控件时——它就是你本地调试 3 小时后能直接粘贴进项目的那块补丁。
2. 架构设计与核心模块拆解:从事件钩子到坐标映射的四层拦截
这个项目不是简单地在CellPainting里画几笔就完事。它构建了一个轻量但完整的“合并感知”层,覆盖数据呈现、用户交互、样式控制三个维度。整个逻辑不侵入你的业务数据源(DataTable/List<T>),也不要求你改继承关系,纯靠事件订阅和状态缓存实现。下面按执行顺序拆解四个关键模块,每个模块都对应一个真实存在的类或方法组,代码结构清晰,可单独抽离复用。
2.1 合并规则定义器:MergeRule类与声明式配置
合并不是无脑连通,必须有明确规则。项目用MergeRule类封装所有策略:按哪几列判断是否合并(如"DeptId")、合并方向(Vertical或Horizontal)、是否忽略空值、是否强制首行显示内容。最常用的是垂直合并(同一列中连续相同值的多行合并为一个视觉单元)。规则通过DataGridViewMergeManager统一注册:
// 示例:为 "Department" 列启用垂直合并,忽略空字符串 var rule = new MergeRule { ColumnName = "Department", Direction = MergeDirection.Vertical, IgnoreEmpty = true, ShowFirstValueOnly = true }; mergeManager.AddRule(dataGridView1, rule);提示:
ShowFirstValueOnly = true是关键开关。它让CellFormatting事件中自动隐藏非首行的重复值,避免文字重叠。若设为false,则所有单元格仍显示原值,仅视觉上“看起来”合并了——这在导出 Excel 时反而更合理,因为 Excel 原生支持合并,而 DataGridView 只负责预览。
2.2 单元格重绘引擎:CellPainting中的像素级控制
真正的“合并感”诞生于CellPainting事件。这里不做任何e.PaintBackground()全量重绘,而是精准计算哪些格子该“消失边框”,哪些该“承载文字”。核心逻辑在OnCellPainting方法中:
private void OnCellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex < 0 || e.PaintParts == DataGridViewPaintParts.Background) return; var rule = GetActiveRule(e.ColumnIndex); if (rule == null) return; // 1. 获取当前单元格所在合并组的起始行和跨度 int startRow, span; if (!TryGetMergeSpan(e.RowIndex, e.ColumnIndex, out startRow, out span)) return; // 不属于任何合并组,跳过处理 // 2. 若非首行,则只绘制背景,不画边框、不画文字 if (e.RowIndex != startRow) { e.PaintBackground(e.ClipBounds, true); e.Handled = true; return; } // 3. 首行:绘制完整合并区域(跨 span 行的高度) Rectangle mergeRect = e.CellBounds; mergeRect.Height = e.CellBounds.Height * span; // 扩展高度 // 4. 手动绘制背景(保持原主题色) e.PaintBackground(mergeRect, true); // 5. 手动绘制文字(居中对齐,省略号处理) TextRenderer.DrawText(e.Graphics, e.FormattedValue?.ToString() ?? "", e.CellStyle.Font, mergeRect, e.CellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter | TextFormatFlags.EndEllipsis); // 6. 手动绘制边框(只画外框,内部分割线抹除) ControlPaint.DrawBorder(e.Graphics, mergeRect, e.CellStyle.SelectionBackColor, ButtonBorderStyle.Solid); e.Handled = true; }这段代码的关键在于:e.Handled = true必须在所有自定义绘制完成后设置。否则系统会继续执行默认绘制,导致文字重影、边框错乱。mergeRect.Height的计算是垂直合并的核心——它把首行的绘制区域纵向拉伸,覆盖后续span-1行的空间,从而实现“一个格子占多行”的视觉效果。
2.3 点击坐标重映射器:MouseDown事件中的逻辑纠偏
视觉合并了,但用户点击第 3 行“Department”列时,HitTest返回的仍是(3, colIndex),而实际数据归属是第 1 行(合并组起点)。若不做处理,编辑、右键菜单都会作用于错误行。项目在MouseDown中插入坐标转换:
private void OnMouseDown(object sender, MouseEventArgs e) { var hit = dataGridView1.HitTest(e.X, e.Y); if (hit.Type == DataGridViewHitTestType.Cell && hit.RowIndex >= 0) { var rule = GetActiveRule(hit.ColumnIndex); if (rule != null && TryGetMergeSpan(hit.RowIndex, hit.ColumnIndex, out int startRow, out _)) { // 将点击坐标“重定向”到合并组首行 hit.RowIndex = startRow; // 后续逻辑(如弹出编辑框)基于 startRow 执行 HandleCellClick(startRow, hit.ColumnIndex); } } }这里没有修改HitTest的返回值(它只读),而是用startRow替代原始hit.RowIndex进行业务分发。这是最安全的做法——既不影响其他事件(如SelectionChanged),又保证交互逻辑与视觉一致。
2.4 数据格式化拦截器:CellFormatting中的值过滤
即使视觉上合并了,CellValue仍是每行独立存储的。若不做处理,导出时会导出全部重复值。CellFormatting是最后的数据出口,此处做“值净化”:
private void OnCellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { var rule = GetActiveRule(e.ColumnIndex); if (rule == null || !rule.ShowFirstValueOnly) return; if (e.RowIndex > 0 && TryGetMergeSpan(e.RowIndex, e.ColumnIndex, out int startRow, out _)) { if (e.RowIndex != startRow) { e.Value = string.Empty; // 清空非首行显示值 e.FormattingApplied = true; } } }e.FormattingApplied = true是关键。它告诉 DataGridView:“别再用默认逻辑格式化这个格子了”,避免你的清空操作被后续逻辑覆盖。这个标记必须显式设置,否则无效。
3. 运行环境与快速启动:.NET Framework 4.7.2 以上开箱即用
这个项目不依赖任何 NuGet 包,纯原生 WinForms 实现,编译目标框架最低为 .NET Framework 4.7.2(因用到了TextRenderer.DrawText的高级TextFormatFlags)。如果你用的是 .NET 5+ 或 .NET 6+ 的 WinForms 应用,同样完全兼容——只需确认项目 SDK 为Microsoft.NET.Sdk.WindowsDesktop并启用<UseWindowsForms>true</UseWindowsForms>。整个集成过程不到 5 分钟,无需修改窗体基类,不污染现有数据绑定逻辑。
3.1 项目结构与文件清单
下载解压后,你会看到一个精简的目录结构,所有核心逻辑集中在两个文件中:
DataGridViewMerge/ ├── DataGridViewMergeManager.cs // 主管理器:注册规则、挂载事件、提供公共API ├── MergeRule.cs // 规则实体:定义列名、方向、空值策略等 ├── Properties/ │ └── AssemblyInfo.cs // 标准程序集信息 └── DataGridViewMerge.csproj // 项目文件(.NET Framework 或 .NET 5+ 两种模板已备好)注意:没有
UserControl或自定义控件类。它采用“扩展方法 + 事件委托”模式,所有功能通过DataGridView实例调用EnableCellMerge()扩展方法注入,零学习成本。
3.2 三步集成:从新建窗体到合并生效
假设你有一个名为MainForm.cs的窗体,其中dataGridView1已绑定List<Employee>数据源。按以下步骤操作:
第一步:添加引用并 using将DataGridViewMergeManager.cs和MergeRule.cs复制到你的项目中,确保它们在同一个命名空间下(或添加using YourNamespace.DataGridViewMerge;)。
第二步:初始化管理器并注册规则在MainForm的Load事件或构造函数末尾添加:
private DataGridViewMergeManager _mergeManager; private void MainForm_Load(object sender, EventArgs e) { // 1. 创建管理器实例(一个窗体一个实例即可) _mergeManager = new DataGridViewMergeManager(); // 2. 为 dataGridView1 启用合并能力 _mergeManager.EnableCellMerge(dataGridView1); // 3. 添加垂直合并规则:Department 列,忽略空值,只显示首行值 _mergeManager.AddRule(dataGridView1, new MergeRule { ColumnName = "Department", Direction = MergeDirection.Vertical, IgnoreEmpty = true, ShowFirstValueOnly = true }); // 4. (可选)添加水平合并:例如“TotalAmount”和“Currency”两列合并显示为“金额(USD)” _mergeManager.AddRule(dataGridView1, new MergeRule { ColumnName = "TotalAmount", Direction = MergeDirection.Horizontal, TargetColumnNames = new[] { "Currency" }, // 水平合并需指定相邻列 ShowFirstValueOnly = false // 水平合并通常保留所有值拼接 }); }第三步:确保数据源有序(重要!)合并逻辑依赖连续相同值。若你的Department列数据是乱序的(如HR→IT→HR),则无法形成跨行合并。务必在绑定前排序:
// 绑定前对数据源排序(Linq 示例) var sortedData = employees.OrderBy(x => x.Department).ToList(); dataGridView1.DataSource = sortedData;完成这三步,运行程序,Department列中连续相同的值就会自动合并显示。你可以动态调用_mergeManager.RemoveRule()或_mergeManager.ClearRules()实时切换合并状态,无需重启。
3.3 配置参数详解:8 个关键属性决定合并行为
MergeRule类暴露 8 个可配置属性,覆盖 95% 的业务场景。以下是生产环境验证过的典型组合:
| 属性名 | 类型 | 默认值 | 说明 | 生产建议 |
|---|---|---|---|---|
ColumnName | string | null | 必填。要合并的列名(区分大小写) | 与DataGridView.Columns[i].Name严格一致 |
Direction | MergeDirection | Vertical | 合并方向:Vertical(同列多行)、Horizontal(同行多列) | 垂直合并用Vertical;表头分组用Horizontal |
IgnoreEmpty | bool | true | 是否跳过空值(null、""、DBNull.Value) | 内部系统建议true;报表类可设false显式显示“空” |
ShowFirstValueOnly | bool | true | 是否仅首行显示值(影响CellFormatting) | 导出 Excel 前设false,预览时设true |
TargetColumnNames | string[] | null | 水平合并时,指定右侧相邻列名数组 | 例:new[]{"Currency"}表示合并Amount和Currency两列 |
CustomTextProvider | Func<int, int, string> | null | 自定义显示文本(接收行索引、列索引) | 用于显示“第1-3名”、“合计:¥12,345”等动态文本 |
MergePredicate | Func<DataGridView, int, int, bool> | null | 自定义合并判定逻辑(替代默认相等判断) | 用于模糊匹配(如"HR Dept"和"Human Resources"视为相同) |
Padding | Padding | new Padding(3) | 合并区域文字内边距 | 调小(0)适配紧凑布局;调大(6)提升可读性 |
提示:
CustomTextProvider是高级技巧。比如你想在合并后的部门格子里显示“HR(共5人)”,可以这样写:rule.CustomTextProvider = (row, col) => { var dept = dataGridView1.Rows[row].Cells["Department"].Value?.ToString(); var count = GetDepartmentEmployeeCount(dept); // 你的业务方法 return $"{dept}(共{count}人)"; };
4. 避坑指南:5 条血泪经验总结的高频翻车点
这个项目在某高校实验室的教务系统、某公司 ERP 的采购单模块、某跨平台系统的 WinForms 客户端中均稳定运行超 2 年。但初期集成时,90% 的开发者会在以下环节卡住。这些不是文档遗漏,而是 WinForms 渲染机制与开发者直觉的天然冲突点,我逐条记录现象、根因和解法:
4.1 现象:合并后文字显示模糊、有重影,或部分区域空白
原因:CellPainting中未调用e.PaintBackground(),或调用位置错误。DataGridView的默认绘制流程是先画背景再画文字,若你只画文字不画背景,底层残留的旧像素会与新文字叠加。更常见的是在e.Handled = true前漏掉了e.PaintBackground(),导致系统后续又补画了一次背景,造成半透明重影。
解决:严格遵循代码模板——在e.Handled = true前,必须先调用e.PaintBackground(mergeRect, true)。mergeRect必须是扩展后的合并区域,不能用e.CellBounds。若仍模糊,检查字体是否启用了TextRenderingHint.ClearTypeGridFit(WinForms 默认关闭,可手动开启提升锐度)。
4.2 现象:滚动 DataGridView 时,合并区域突然错位、文字跳动
原因:CellPainting事件在滚动时高频触发,而TryGetMergeSpan()方法若包含耗时计算(如每次遍历DataTable查找相同值),会导致绘制延迟,UI 线程卡顿,视觉上就是“闪烁错位”。项目默认实现已用Dictionary<(int col, string value), (int start, int span)>缓存合并区间,但若你替换了数据源(DataSource = null后重新赋值)却未调用_mergeManager.RefreshCache(),缓存就是脏的。
解决:每次更换DataSource后,立即调用_mergeManager.RefreshCache(dataGridView1)。该方法会清空旧缓存并基于新数据源重建区间映射,耗时 < 5ms(万行数据实测)。切勿在CellPainting中做任何 LINQ 查询或循环。
4.3 现象:右键菜单弹出位置错误,总出现在屏幕左上角
原因:ContextMenuStrip.Show()方法接收的是屏幕坐标,而MouseEventArgs.Location是控件坐标。开发者常直接传e.Location,导致菜单在窗体原点弹出。
解决:在CellMouseDown或CellMouseClick事件中,用dataGridView1.PointToScreen(e.Location)转换坐标:
private void dataGridView1_CellMouseDown(object sender, DataGridViewCellMouseEventArgs e) { if (e.Button == MouseButtons.Right && e.RowIndex >= 0) { var screenPos = dataGridView1.PointToScreen(new Point(e.X, e.Y)); contextMenuStrip1.Show(screenPos); } }4.4 现象:启用合并后,dataGridView1.CurrentCell始终为null,无法获取当前编辑单元格
原因:CurrentCell是逻辑概念,而合并后视觉上“一个格子占多行”,但CurrentCell仍指向物理单元格。当用户点击合并组第 2 行时,CurrentCell是(2, col),但你的业务逻辑需要的是(startRow, col)。若没做坐标重映射,CurrentCell就永远“找不到”。
解决:不要依赖CurrentCell做业务判断。改用dataGridView1.SelectedCells(多选时)或dataGridView1.HitTest()(单击时)获取坐标,再通过TryGetMergeSpan()转换为逻辑行。项目已内置GetLogicalCellAt(int x, int y)扩展方法,一行代码搞定:
var logicalCell = dataGridView1.GetLogicalCellAt(e.X, e.Y); // 返回 (logicalRow, col) if (logicalCell.Row >= 0) { /* 安全使用 logicalCell.Row */ }4.5 现象:导出 Excel 时,合并单元格丢失,变成一堆重复值
原因:ShowFirstValueOnly = true在CellFormatting中清空了非首行值,导出组件(如 EPPlus)读取的是FormattedValue,自然拿到空字符串。这是设计使然,不是 Bug。
解决:导出前临时关闭格式化过滤:
// 导出前 _mergeManager.SetShowFirstValueOnly("Department", false); // 执行导出... ExportToExcel(dataGridView1); // 导出后恢复 _mergeManager.SetShowFirstValueOnly("Department", true);SetShowFirstValueOnly是管理器提供的运行时开关,比RemoveRule/AddRule更轻量,且不影响视觉合并效果。
5. 二次开发实战:从“静态合并”到“动态分组+条件高亮”的进阶改造
这个项目最值得深挖的价值,不是它解决了“怎么合并”,而是它提供了一个可插拔的“单元格渲染增强”骨架。我在某跨平台系统的 WinForms 客户端中,基于它实现了带折叠/展开的树形分组,并给异常数据加了红色边框。下面分享三个可直接复用的改造技巧,每个都附可运行代码。
5.1 技巧一:动态分组标题 + 折叠按钮(模拟 TreeView 效果)
需求:把Department合并区域变成可点击的分组标题,点击后收起/展开下属员工行。核心是复用合并区域的CellPainting,在右侧添加小三角图标,并拦截CellClick事件。
第一步:扩展MergeRule,增加IsCollapsible属性
public class GroupMergeRule : MergeRule { public bool IsCollapsible { get; set; } = false; public Func<string, bool> IsExpanded { get; set; } // 接收部门名,返回是否展开 }第二步:在CellPainting中绘制折叠按钮
if (rule is GroupMergeRule groupRule && groupRule.IsCollapsible) { // 计算按钮区域(右对齐,宽20,高16) Rectangle btnRect = new Rectangle( mergeRect.Right - 20, mergeRect.Top + (mergeRect.Height - 16) / 2, 20, 16); // 绘制三角形(展开状态为▼,折叠为▶) bool expanded = groupRule.IsExpanded?.Invoke(e.FormattedValue?.ToString()) ?? true; var points = expanded ? new[] { new Point(btnRect.Left+4, btnRect.Top+3), new Point(btnRect.Right-4, btnRect.Top+3), new Point(btnRect.Left+12, btnRect.Bottom-3) } : new[] { new Point(btnRect.Left+3, btnRect.Top+4), new Point(btnRect.Left+3, btnRect.Bottom-4), new Point(btnRect.Right-3, btnRect.Top+12) }; e.Graphics.FillPolygon(Brushes.Gray, points); }第三步:在CellClick中处理折叠逻辑
private void OnCellClick(object sender, DataGridViewCellEventArgs e) { var rule = GetActiveRule(e.ColumnIndex) as GroupMergeRule; if (rule?.IsCollapsible == true) { var dept = dataGridView1[e.ColumnIndex, e.RowIndex].Value?.ToString(); bool wasExpanded = rule.IsExpanded?.Invoke(dept) ?? true; // 切换展开状态(你的业务逻辑) ToggleDepartmentGroup(dept, !wasExpanded); // 强制刷新:只重绘该列所有行,避免全表闪烁 for (int i = 0; i < dataGridView1.RowCount; i++) { dataGridView1.InvalidateCell(e.ColumnIndex, i); } } }5.2 技巧二:条件高亮合并区域(如库存不足标红)
需求:当合并组内任意一行的StockQty< 10 时,整个合并区域背景变浅红色。关键是在CellPainting中提前扫描合并组内所有行的业务字段。
private void OnCellPainting(object sender, DataGridViewCellPaintingEventArgs e) { // ... 原有合并逻辑(获取 startRow, span)... // 新增:检查合并组内是否有异常 bool hasLowStock = false; for (int i = startRow; i < startRow + span; i++) { if (i < dataGridView1.RowCount) { var qty = dataGridView1["StockQty", i].Value; if (qty is int stock && stock < 10) { hasLowStock = true; break; } } } if (hasLowStock) { // 用半透明红色覆盖背景 using (var brush = new SolidBrush(Color.FromArgb(50, 255, 100, 100))) { e.Graphics.FillRectangle(brush, mergeRect); } } }注意:
FillRectangle必须在e.PaintBackground()之后、DrawText()之前调用,否则文字会被盖住。Color.FromArgb(50, ...)的第一个参数是 Alpha 通道,50 是最佳平衡点——足够醒目又不遮挡文字。
5.3 技巧三:导出时自动转换为 Excel 原生合并(EPPlus 示例)
需求:导出 Excel 时,不仅数据正确,还要让 Excel 文件里真正存在MergeCells,而非视觉欺骗。利用ShowFirstValueOnly = false导出全量数据,再用 EPPlus 的Merge()方法后处理。
public void ExportToExcelWithNativeMerge(DataGridView dgv, string filePath) { // 1. 临时关闭格式化过滤 _mergeManager.SetShowFirstValueOnly("Department", false); // 2. 导出基础数据(此时所有行都有 Department 值) var package = new ExcelPackage(); var ws = package.Workbook.Worksheets.Add("Data"); // ... 使用常规方式填充数据(略)... // 3. 遍历所有合并规则,调用 EPPlus 合并 foreach (var rule in _mergeManager.GetRules(dgv)) { if (rule.Direction == MergeDirection.Vertical) { int colIndex = dgv.Columns[rule.ColumnName].Index; // 获取所有合并组:(startRow, span) 列表 var mergeSpans = GetMergeSpansForExport(dgv, rule.ColumnName); foreach (var (start, span) in mergeSpans) { // EPPlus 行索引从1开始,列索引从1开始 int excelStartRow = start + 2; // +2 因为第1行是标题 int excelEndRow = start + span + 1; int excelCol = colIndex + 1; ws.Cells[excelStartRow, excelCol, excelEndRow, excelCol].Merge = true; } } } // 4. 保存 package.SaveAs(new FileInfo(filePath)); // 5. 恢复格式化 _mergeManager.SetShowFirstValueOnly("Department", true); }从那以后我每次接到“表格合并”需求,都不再从零手写CellPainting,而是先把DataGridViewMergeManager初始化好,然后根据产品原型,在MergeRule上叠加CustomTextProvider或MergePredicate。哪怕需求临时改成“按首字母分组”,我也只要改一行MergePredicate就能交付。这套模式让我在三个项目中节省了至少 40 小时的调试时间——那些在HitTest坐标和PaintBackground顺序上反复翻车的夜晚,终于成了可复用的经验包。希望帮到你。
本文还有配套的精品资源,点击获取