资讯动态

C# WinForms DataGridView 单元格合并实战:基于 CellPainting 的自绘实现

发布时间:2026/10/16 3:24:21 来源:尧图企业网站定制
简介这份资源面向使用 C# 开发 Windows Forms 应用的开发者聚焦 DataGridView 控件中单元格合并与二维表头创建这两个常见但实现门槛较高的需求。包内共 48 个文件以 cs 源码、resx 资源文件、resources 资源、sln 解决方案与 csproj 工程文件为主另含 exe 可执行程序、pdb 调试文件、gif 演示图及少量配置与样式文件压缩包约 97KB结构完整可直接打开运行对照调试。已有 870 人学习下载说明该主题在实际项目中需求稳定。资源通过示例代码演示了单元格合并的范围设置与取值方式并借助自定义绘制事件实现多层表头布局读者可据此掌握合并逻辑、表头样式定制与分割线绘制等技巧并在此基础上扩展动态表头、异常处理等场景提升数据展示界面的可读性与用户体验。1. 从一张“看起来像Excel”的表格说起做过 WinForms 桌面项目的人大概率都遇到过这种需求客户甩过来一张 Excel 截图说“我就要这种效果同一个订单号只显示一次后面的格子合并起来”。你打开 Visual Studio把 DataGridView 拖到窗体上数据一绑发现它默认是每个单元格都规规矩矩地画出来想合并对不起没有现成的Merge属性。这就是 DataGridView 单元格合并这个需求反复被搜的原因——它不是控件自带能力而是要靠自己动手在绘制阶段做文章。这份资源围绕的就是 C# WinForms 里 DataGridView 的单元格合并实现适合两类人一类是正在做 ERP、进销存、报表导出这类桌面系统被“相同值合并显示”卡住的开发者另一类是想搞懂 DataGridView 自绘机制、把CellPainting、CellFormatting这些事件真正用起来的人。它解决的不是“怎么把数据塞进表格”而是“怎么让表格按业务语义长得像人话”。下面我按能跑起来、能看懂、能改动的顺序把这份代码拆一遍。2. 项目定位与技术选型为什么是 WinForms 自绘而不是第三方控件2.1 这个项目到底在解决什么问题先把场景说清楚。假设有一张订单明细表字段是订单号、客户、商品、数量、金额。业务上希望同一个订单号只出现一次纵向合并同一个订单下的客户名也只出现一次。如果直接绑 DataTableDataGridView 会把每一行的订单号都画出来视觉上非常啰嗦打印出来也不好看。单元格合并的本质是在绘制阶段“骗过”用户的视觉让某些单元格不画自己的边框和文字让相邻的相同值看起来是一个大格子。DataGridView 没有提供声明式的合并 API所以只能靠事件拦截绘制过程。这份资源走的就是这条路——不引入第三方控件纯 C# 自绘。注意合并是“视觉合并”底层数据仍然是多行选中、排序、编辑这些行为要额外处理不能想当然。2.2 技术栈与运行环境项目是标准的 .NET Framework WinForms 工程语言 C#。没有用到 WPF、没有用到 DevExpress、Telerik 这类商业表格控件依赖极低拷贝到任何一台装了对应 .NET Framework 的 Windows 机器上都能编译。常见做法是建一个 Windows 窗体应用把 DataGridView 拖进主窗体然后在 Form_Load 里构造测试数据。项目要素选型说明UI 框架WinForms轻量适合内部管理系统语言C#事件驱动自绘能力强核心机制CellPainting / CellFormatting拦截绘制控制显示数据源DataTable / List任意可实现行索引的数据结构目标框架.NET Framework 4.x兼容老项目选 WinForms 而不是 WPF是因为这类需求大量存在于存量桌面系统里客户机器上跑的就是 .NET Framework重写不现实。选自绘而不是买控件是因为合并规则往往跟业务强相关第三方控件的合并配置反而更绕。我一般会先确认合并是按哪几列、是否要求连续相同才合并、是否允许跨页——这三点决定了后面代码怎么写。2.3 目录与代码组织这份资源的代码量不大核心就是一个窗体加一个合并辅助类。合理的组织方式是这样CellMergeDemo/ ├── CellMergeDemo.sln ├── CellMergeDemo/ │ ├── Program.cs // 程序入口 │ ├── MainForm.cs // 主窗体挂 DataGridView │ ├── MainForm.Designer.cs // 控件声明 │ └── MergeHelper.cs // 合并逻辑封装把合并逻辑单独抽到MergeHelper里而不是全塞进窗体的CellPainting是为了后面能复用到别的表格。血泪经验是一旦把合并判断写死在事件里第二个表格要合并时就得复制粘贴改一处漏一处。抽出来之后窗体只负责调用规则集中在辅助类。3. 核心实现拆解CellPainting 里到底做了什么3.1 合并判断先算出每个单元格该不该画合并的第一步不是画是判断。对每一列从上到下扫描遇到相同值就记下连续区间。常见做法是维护一个“合并区间表”键是行号列号值是这个单元格是否是合并块的起始格、以及它跨多少行。// MergeHelper.cs public class MergeHelper { // 记录每个单元格的合并信息起始行、跨行数 private readonly Dictionary(int row, int col), (int startRow, int span) _mergeMap new Dictionary(int, int), (int, int)(); // 根据指定列计算纵向合并区间 public void BuildMergeMap(DataGridView grid, params int[] mergeColumns) { _mergeMap.Clear(); foreach (int col in mergeColumns) { int start 0; for (int i 1; i grid.Rows.Count; i) { bool same i grid.Rows.Count Convert.ToString(grid.Rows[i].Cells[col].Value) Convert.ToString(grid.Rows[start].Cells[col].Value); if (!same) { // [start, i-1] 是连续相同区间 _mergeMap[(start, col)] (start, i - start); for (int k start 1; k i; k) _mergeMap[(k, col)] (start, i - start); // 非起始格也记录便于判断 start i; } } } } public bool TryGetMerge(int row, int col, out int startRow, out int span) { if (_mergeMap.TryGetValue((row, col), out var info)) { startRow info.startRow; span info.span; return true; } startRow row; span 1; return false; } }这段代码的关键点有三个。第一BuildMergeMap接收的是列索引数组意味着你可以只合并订单号列和客户列商品列不合并灵活。第二same判断用的是字符串比较实际项目里如果值是数字或日期要先统一格式否则1和1.0会被判成不同。第三非起始格也写进字典是为了在绘制时快速判断“这个格子属于哪个合并块”避免每次都重新扫描。参数说明grid是目标表格mergeColumns是可变参数传0, 1表示合并第 0 列和第 1 列。调用时机一般在数据绑定之后、绘制之前数据变了要重新调用。3.2 绘制拦截CellPainting 里隐藏重复内容判断做完接下来是画。DataGridView 的CellPainting事件在每次画单元格时触发我们可以在这里决定“这个格子要不要画”。核心思路是如果是合并块的起始格正常画但把背景和边框画成跨行的样子如果是非起始格直接e.Handled true跳过绘制。// MainForm.cs private void Grid_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; if (_mergeHelper.TryGetMerge(e.RowIndex, e.ColumnIndex, out int startRow, out int span)) { if (e.RowIndex ! startRow) { // 非起始格不画交给起始格统一画 e.Handled true; return; } // 起始格画背景、边框文字垂直居中 e.PaintBackground(e.CellBounds, true); e.PaintContent(e.CellBounds); // 重画右侧和底部边框让合并块看起来是一个整体 using (var pen new Pen(Color.Gray)) { Rectangle rect e.CellBounds; rect.Height * span; // 高度扩展到跨行 e.Graphics.DrawRectangle(pen, rect); } e.Handled true; } }逻辑说明e.PaintBackground负责画底色和选中态e.PaintContent负责画文字。如果直接自己DrawString选中高亮、对齐方式这些都要手动处理容易翻车。用PaintBackgroundPaintContent是更稳的做法。边框部分rect.Height * span把矩形高度撑到跨行再画一个矩形框视觉上就是一个大格子。参数说明e.CellBounds是当前单元格的矩形区域span来自前面的合并映射。注意e.Handled true一定要设否则控件还会按默认方式再画一遍出现重影。3.3 选中与滚动合并后不能忽略的交互细节合并只解决了“看起来对”但用户会点、会滚、会用键盘上下移动。如果不管会出现选中一个格子却高亮了整个合并块、或者滚动时合并块错位的问题。常见做法是在SelectionChanged里做修正让选中行落在合并块的起始行。private void Grid_SelectionChanged(object sender, EventArgs e) { if (Grid.CurrentCell null) return; int row Grid.CurrentCell.RowIndex; int col Grid.CurrentCell.ColumnIndex; if (_mergeHelper.TryGetMerge(row, col, out int startRow, out int span) row ! startRow) { // 把选中焦点移到合并块起始行 Grid.CurrentCell Grid.Rows[startRow].Cells[col]; } }这段代码解决的是“点到了合并块中间某个隐藏格”的问题。因为非起始格虽然没画但它仍然存在、仍然可点。把焦点拉回起始行用户感知上就是“整个合并块被选中”。滚动错位一般出现在行高不固定或虚拟模式下如果项目用了VirtualMode合并映射要在CellValueNeeded里同步维护否则滚动后数据对不上。提示合并列如果允许排序排序后相同值不再连续合并会失效。要么禁用这几列的排序要么在排序后重新调用BuildMergeMap。4. 跑起来与常见问题排查从编译到显示的全流程4.1 最小可运行步骤拿到代码后跑起来的路径很短。新建或打开解决方案确认目标框架是 .NET Framework 4.x然后按下面几步走。在窗体上放一个 DataGridView命名为Grid设置AllowUserToAddRows false。在Form_Load里构造 DataTable塞几行测试数据绑定到Grid.DataSource。实例化MergeHelper调用BuildMergeMap(Grid, 0, 1)指定要合并的列。订阅Grid.CellPainting和Grid.SelectionChanged挂上前面的事件处理。按 F5 运行观察相同订单号是否只显示一次。private void MainForm_Load(object sender, EventArgs e) { var dt new DataTable(); dt.Columns.Add(订单号); dt.Columns.Add(客户); dt.Columns.Add(商品); dt.Rows.Add(SO001, 甲公司, 螺丝); dt.Rows.Add(SO001, 甲公司, 螺母); dt.Rows.Add(SO002, 乙公司, 垫片); Grid.DataSource dt; Grid.AllowUserToAddRows false; _mergeHelper new MergeHelper(); _mergeHelper.BuildMergeMap(Grid, 0, 1); // 合并订单号和客户两列 }这段代码是入口BuildMergeMap的参数0, 1就是列索引。如果表格列顺序变了这里要跟着改。数据绑定后立刻调用保证绘制前映射已就绪。4.2 常见问题与排查清单合并这类自绘需求翻车点比较集中下面几条是我实际踩过的。现象一合并后文字被截断或错位。原因通常是起始格只画了自己的区域没有按跨行高度重新排版。解决是把e.CellBounds的高度乘以span后再PaintContent或者手动设置e.Graphics的裁剪区域。现象二滚动后合并块闪烁或消失。原因是CellPainting里依赖了行索引但滚动时行索引会变映射没更新。解决是在Scroll事件里重新调用BuildMergeMap或者改用CellFormatting配合缓存。现象三选中整行时合并块高亮不完整。原因是SelectionChanged只处理了单元格没处理整行选中模式。解决是判断Grid.SelectionMode如果是FullRowSelect在RowPrePaint里统一处理背景。现象四导出 Excel 后合并效果丢失。这是最容易忽略的——屏幕上的合并是画出来的数据本身没合并。导出时要单独用 NPOI 或 EPPlus 的MergeCells重新做一遍不能指望 DataGridView 的绘制结果被导出。现象五编辑单元格后合并没刷新。原因是CellValueChanged后没有重建映射。解决是在该事件里重新BuildMergeMap并Grid.Invalidate()。注意CellPainting里不要做耗时计算它触发非常频繁。合并映射一定要提前算好缓存否则大数据量下界面会卡成幻灯片。5. 二次开发改造把合并规则做成可配置5.1 从硬编码列索引到规则配置原始代码里BuildMergeMap(Grid, 0, 1)是写死的列索引实际项目里列顺序可能变甚至不同表格合并规则不同。改造方向是把规则抽成一个配置对象比如用列名而不是索引并支持“是否要求连续相同”这个开关。public class MergeRule { public string ColumnName { get; set; } // 按列名指定避免索引错位 public bool RequireContinuous { get; set; } true; // 是否要求连续相同才合并 } // 调用侧 var rules new ListMergeRule { new MergeRule { ColumnName 订单号 }, new MergeRule { ColumnName 客户 } }; _mergeHelper.BuildMergeMap(Grid, rules);这样改的好处是列顺序调整时不用改代码只要列名不变。RequireContinuous设为 false 时可以把不相邻但相同值的行也合并——不过这种需求很少见而且实现复杂度高我一般不建议开。5.2 验证改造是否生效的三种方法改完之后怎么确认没改坏我习惯用三个层次的验证。第一层单元测试MergeHelper给定一组行数据断言合并映射的起始行和跨度是否正确这层不依赖 UI跑得快。第二层手动构造边界数据全相同、全不同、只有两行相同、首尾相同看绘制是否正常。第三层实际打印预览因为打印走的是另一套绘制路径屏幕对不代表打印对。验证层次验证内容工具单元测试合并映射计算测试框架手动边界全同/全异/首尾运行界面打印预览打印绘制路径PrintPreviewDialog5.3 一个容易忽略的性能技巧如果表格行数上千每次CellPainting都查字典虽然不慢但Invalidate整表重绘仍然有开销。我一般会只重绘受影响的区域用Grid.InvalidateCell(row, col)代替Grid.Invalidate()。另外合并映射的字典键用(int, int)元组在 .NET Framework 4.x 上需要 ValueTuple 包如果项目不能引包就换成row * 1000 col这样的整数键简单可靠。从那以后我每次做 DataGridView 合并都强制先写单元测试验证映射再上界面调绘制最后走一遍打印预览。这套流程帮我省掉了至少两次“屏幕好好的、打印出来全乱”的返工。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑