资讯动态

C# WinForms DataGridView单元格合并实战:CellPainting绘制与性能优化

发布时间:2026/10/16 9:39:47 来源:尧图企业网站定制
简介这份资源面向使用 C# 开发 Windows Forms 应用的开发者聚焦 DataGridView 控件中单元格合并与二维表头的实现难点。内容围绕如何合并具有相同值的连续单元格、构建多层分类表头展开涉及 MergeCells 方法、自定义绘制事件以及表头样式调整等关键技巧适合已掌握 WinForms 基础、希望提升数据展示界面表现力的中初级开发者参考。资源包共 48 个文件以 cs 源码、resx 资源文件、resources 资源、exe 可执行程序、sln 解决方案与 csproj 工程文件为主另含少量 txt 说明、gif 图示与 dll 依赖压缩包约 97KB结构紧凑便于直接运行调试。目前已有 870 人学习下载。通过阅读源码与工程结构读者可掌握单元格合并范围设置、二维表头绘制与分割线处理等实现思路并据此改造出更直观的数据表格界面。1. DataGridView单元格合并从“看着乱”到“一眼清”的WinForms表格改造做过 WinForms 上位机或者内部管理系统的朋友大概率都碰过这个场景DataGridView 绑定了订单明细同一个订单号连着出现七八行左边几列全是重复值用户盯着屏幕找差异眼睛都快看花了。这时候最直接的需求就是把相同内容的单元格合并成一个让表格从“密密麻麻”变成“分组清晰”。这个项目就是围绕 C# 里 DataGridView 的单元格合并展开的核心解决的是多行数据在视觉上按列归并的问题适合正在用 WinForms 做数据展示、报表预览或者工控界面的开发者。它不依赖第三方控件库纯 C# 手写合并逻辑拿到源码就能嵌进现有项目里跑起来。2. 合并逻辑拆解从数据源到绘制事件的完整链路2.1 为什么不能直接改单元格的值很多人第一反应是遍历 DataGridView 的 Rows把下面重复的单元格 Value 清空然后让上面那个单元格自动撑高。这个思路在 WinForms 里会直接翻车。DataGridView 的单元格高度默认是固定的除非你把 AutoSizeRowsMode 打开但那样整个表格的行高会变得不可控滚动条也会跳来跳去。更关键的是清空 Value 只是让内容看不见单元格的边框还在视觉上依然是“一格一格”的根本没有合并的效果。真正靠谱的做法是走 CellPainting 事件自己控制绘制区域。也就是说数据层不动只在绘制层做文章。这样排序、筛选、导出这些功能都不受影响属于典型的“显示与数据分离”思路。2.2 核心合并算法的实现下面这段代码是合并逻辑的主干放在 CellPainting 事件里执行。它的判断依据是当前单元格的值是否和上一行同列的值相同如果相同就跳过绘制让上一个单元格的绘制区域向下延伸。private void dataGridView1_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { // 只处理数据行表头和最后一行不参与合并 if (e.RowIndex 0 || e.ColumnIndex 0) return; if (e.RowIndex dataGridView1.Rows.Count - 1) return; // 指定需要合并的列这里假设第0列是订单号第1列是客户名称 if (e.ColumnIndex ! 0 e.ColumnIndex ! 1) return; string currentValue e.Value?.ToString() ?? ; string nextValue dataGridView1.Rows[e.RowIndex 1].Cells[e.ColumnIndex].Value?.ToString() ?? ; // 如果当前行和下一行值相同说明当前单元格需要向下合并 if (currentValue nextValue !string.IsNullOrEmpty(currentValue)) { // 计算合并后需要跨越的行数 int mergeCount 1; for (int i e.RowIndex 1; i dataGridView1.Rows.Count; i) { string compareValue dataGridView1.Rows[i].Cells[e.ColumnIndex].Value?.ToString() ?? ; if (compareValue currentValue) mergeCount; else break; } // 计算合并后的矩形区域 Rectangle mergeRect e.CellBounds; for (int i 1; i mergeCount; i) { mergeRect.Height dataGridView1.Rows[e.RowIndex i].Height; } // 绘制背景和边框 using (Brush backBrush new SolidBrush(e.CellStyle.BackColor)) { e.Graphics.FillRectangle(backBrush, mergeRect); } using (Pen gridPen new Pen(dataGridView1.GridColor)) { e.Graphics.DrawRectangle(gridPen, mergeRect); } // 绘制文字内容居中显示 TextRenderer.DrawText(e.Graphics, currentValue, e.CellStyle.Font, mergeRect, e.CellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter); // 告诉系统这个单元格已经自己画完了不要再走默认绘制 e.Handled true; } }这段代码有几个关键点需要说清楚。第一e.CellBounds拿到的是当前单元格的原始矩形高度只包含一行。第二mergeCount的计算是从当前行往下数直到遇到不同的值为止这个循环保证了合并区域是连续的。第三e.Handled true必须加否则系统会在你画完之后再画一遍默认样式出现重影。第四绘制文字用TextRenderer.DrawText而不是Graphics.DrawString前者在 WinForms 里渲染更清晰尤其是小字号的时候。2.3 参数调整与列配置上面代码里写死了第 0 列和第 1 列参与合并实际项目里列的位置经常变。比较稳妥的做法是把需要合并的列索引放到一个 List 里在窗体加载时根据列名动态填充。private Listint mergeColumns new Listint(); private void Form1_Load(object sender, EventArgs e) { // 根据列名找到需要合并的列索引 foreach (DataGridViewColumn col in dataGridView1.Columns) { if (col.Name OrderNo || col.Name CustomerName) { mergeColumns.Add(col.Index); } } }然后在 CellPainting 里把if (e.ColumnIndex ! 0 e.ColumnIndex ! 1)换成if (!mergeColumns.Contains(e.ColumnIndex))。这样以后调整列顺序或者增删列只需要改列名判断不用去数索引。还有一个容易忽略的参数是行高。如果合并后的区域特别高文字默认是垂直居中的但如果你希望文字靠上对齐可以把TextFormatFlags.VerticalCenter换成TextFormatFlags.Top再加一点上边距。这个细节在合并行数超过 5 行的时候特别明显居中反而不好看。3. 避坑与排查合并单元格时最容易翻车的几个点3.1 排序后合并错乱现象点击列头排序之后合并的区域全乱了有的单元格跨到了不相干的行上。原因CellPainting 是在绘制时实时计算合并范围的排序改变了行的物理顺序但合并逻辑依赖的是“相邻行值相同”这个条件。如果排序后相同值没有连续排列合并就会断掉或者错位。解决在排序事件里先清空合并状态或者干脆在排序后重新触发一次 Invalidate。更稳妥的做法是合并的前提就是数据已经按合并列排好序了如果业务上需要动态排序建议把合并列作为第一排序条件。3.2 滚动时闪烁严重现象表格行数多了之后滚动滚动条时合并区域一闪一闪的体验很差。原因CellPainting 每次重绘都会重新计算合并范围如果数据量大这个计算开销不小。加上默认的双缓冲没开就会闪。解决在窗体构造函数里开启双缓冲。public Form1() { InitializeComponent(); typeof(DataGridView).InvokeMember(DoubleBuffered, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.SetProperty, null, dataGridView1, new object[] { true }); }这段反射代码是 WinForms 里的老偏方了DataGridView 的 DoubleBuffered 属性是 protected 的只能通过反射打开。开了之后闪烁基本消失。3.3 合并后选中行高亮异常现象合并的单元格被选中时只有第一行高亮下面合并的区域还是原来的背景色。原因默认的选中绘制只作用于单个单元格合并区域是我们自己画的系统不知道这个区域是一个整体。解决在 CellPainting 里判断当前行是否被选中如果是就用选中背景色填充整个合并区域。Color backColor e.CellStyle.BackColor; if ((e.State DataGridViewElementStates.Selected) ! 0) { backColor e.CellStyle.SelectionBackColor; } using (Brush backBrush new SolidBrush(backColor)) { e.Graphics.FillRectangle(backBrush, mergeRect); }3.4 导出 Excel 时合并丢失现象界面上看着合并得好好的一点导出 Excel合并效果全没了还是每行重复。原因合并只发生在绘制层数据层每一行都是完整的。导出走的是数据层自然没有合并信息。解决如果导出也需要合并得在导出逻辑里单独处理。常见做法是导出前先按合并列分组用 EPPlus 或者 NPOI 的 Merge 方法手动合并单元格区域。这部分代码跟界面合并是两套逻辑不要指望一套代码通吃。3.5 空值单元格被误合并现象某一列有很多空值结果这些空值全被合并成一个大格子。原因代码里判断currentValue nextValue时如果两个都是空字符串条件成立。解决加一个非空判断!string.IsNullOrEmpty(currentValue)必须同时满足。这个坑很隐蔽因为空值合并看起来“好像也没错”但用户会觉得很奇怪为什么这里空了一大块。4. 二次开发改造从能用到好用的几个方向4.1 支持多列联合合并现在的逻辑是每一列独立判断订单号相同就合并订单号客户名称相同就合并客户名称。但实际业务里经常需要“订单号客户名称”联合判断只有两个都相同才合并。改造思路是把单列的值拼成一个复合键。private string GetMergeKey(int rowIndex, Listint columns) { StringBuilder sb new StringBuilder(); foreach (int col in columns) { sb.Append(dataGridView1.Rows[rowIndex].Cells[col].Value?.ToString() ?? ); sb.Append(|); } return sb.ToString(); }然后在 CellPainting 里用GetMergeKey替代单列的值比较。这样订单号相同但客户名称不同的行就不会被合并逻辑更严谨。4.2 合并列的动态配置界面如果这个项目要给非技术用户用最好加一个配置面板让用户自己勾选哪些列需要合并。实现上就是一个 CheckedListBox加载时把 DataGridView 的列名填进去用户勾选后更新 mergeColumns 列表然后调dataGridView1.Invalidate()刷新。private void btnApplyMerge_Click(object sender, EventArgs e) { mergeColumns.Clear(); foreach (var item in checkedListBoxColumns.CheckedItems) { string colName item.ToString(); if (dataGridView1.Columns.Contains(colName)) { mergeColumns.Add(dataGridView1.Columns[colName].Index); } } dataGridView1.Invalidate(); }这个改造不复杂但实用性提升很大不同角色的用户对合并列的需求不一样硬编码肯定不够用。4.3 性能优化缓存合并范围数据量超过 5000 行之后每次 CellPainting 都重新计算 mergeCount 会明显卡顿。优化的思路是在数据绑定完成后预先计算好每一列的合并区间存到一个字典里绘制时直接查。private Dictionaryint, ListMergeRange mergeCache new Dictionaryint, ListMergeRange(); private void BuildMergeCache() { mergeCache.Clear(); foreach (int col in mergeColumns) { ListMergeRange ranges new ListMergeRange(); int start 0; for (int i 1; i dataGridView1.Rows.Count; i) { bool isEnd i dataGridView1.Rows.Count; string prev dataGridView1.Rows[i - 1].Cells[col].Value?.ToString() ?? ; string curr isEnd ? null : dataGridView1.Rows[i].Cells[col].Value?.ToString() ?? ; if (isEnd || prev ! curr) { if (i - start 1) { ranges.Add(new MergeRange { StartRow start, EndRow i - 1 }); } start i; } } mergeCache[col] ranges; } }MergeRange 就是一个简单的类存 StartRow 和 EndRow。绘制时先查当前行是否落在某个区间里如果是就按区间高度画。这样把 O(n) 的查找变成了 O(1) 的字典查询大数据量下流畅很多。4.4 一个容易忽略的细节合并后的复制粘贴用户选中合并区域复制的时候系统只会复制第一个单元格的值下面合并的行是空的。如果业务上需要复制完整数据得在 KeyDown 事件里拦截 CtrlC自己组装剪贴板内容。这个需求不算常见但一旦遇到用户会觉得“你这合并是假的”。我一般会在合并功能上线前先问一句复制粘贴要不要保持完整数据要的话就得多写一层。从那以后我每次做 DataGridView 合并都会先把排序、导出、复制这三个场景过一遍确认合并逻辑不会在这三个地方掉链子。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑