C# WinForms DataGridView单元格合并实战:CellPainting绘制与性能优化
简介这份资源面向使用 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 合并都会先把排序、导出、复制这三个场景过一遍确认合并逻辑不会在这三个地方掉链子。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

ImageNet devkit 拆解:验证集真值、类别映射与 top-1/top-5 官方评测全流程

ImageNet devkit 拆解:验证集真值、类别映射与 top-1/top-5 官方评测全流程

简介:ILSVRC2012开发工具包是ImageNet大规模视觉识别挑战赛二〇一二年的官方配套材料,面向计算机视觉研究人员、深度学习初学者以及参赛团队,用于解决图像分类任务中的类别映射、数据划分、结果评估与排名提交等关键问题。压缩包内共十五个文…

2026/10/16 9:39:46 阅读更多 →
RTP裸流合成MP4:H264与AAC封装实战

RTP裸流合成MP4:H264与AAC封装实战

简介:这份资源面向音视频开发初学者与流媒体协议实践者,聚焦于将RTP通道中承载的H264视频与AAC音频数据重新封装为可播放的MP4文件,解决接收端裸流难以直接播放、存档与二次分析的问题。压缩包共31个文件,约197KB,以C语…

2026/10/16 9:39:46 阅读更多 →
毕业论文的“施工进度条”:云智变AI毕业论文撰写生成功能科普|云智变AI官网www.yunzhibian.cn 微信公众号搜一搜云智变ai学术

毕业论文的“施工进度条”:云智变AI毕业论文撰写生成功能科普|云智变AI官网www.yunzhibian.cn 微信公众号搜一搜云智变ai学术

毕业论文写作最大的痛苦,不是“不会写”,而是“失控”。 本科论文八千到一万五,硕士论文三万起步,周期横跨几个月。你明明每天都在想它,却不知道今天该写哪一段;你明明读了很多文献,却不知道它…

2026/10/17 21:40:42 阅读更多 →

最新新闻

2026本溪软件检测第三方公司排行榜TOP5:软件检测+功能性能测试+信息安全测评+软件验收报告 机构地址及联系方式推荐

2026本溪软件检测第三方公司排行榜TOP5:软件检测+功能性能测试+信息安全测评+软件验收报告 机构地址及联系方式推荐

本溪软件检测市场近年日渐繁荣,机构鳞次栉比、良莠并存。街巷楼宇间,既有挂牌多年的实体老店,亦有冠名“国家级”却难觅办公实景的空壳平台。为帮本地开发者、政务单位及中小企客户拨云见日,小编历时三周实地踏访,核验…

2026/10/17 23:55:51 阅读更多 →
CentOS 7启动失败:I/O error与元数据损坏修复

CentOS 7启动失败:I/O error与元数据损坏修复

服务器通电后一直停在开机阶段,反复重启依然进不去系统,屏幕上滚动的不是熟悉的登录提示符,而是大段的 Buffer I/O error、EXT4-fs error 和 “metadata corruption detected” 这类报错。我敢说大部分运维遇到这个画面心里都会沉一下&#x…

2026/10/17 23:55:51 阅读更多 →
NCP1337准谐振电源设计:无线圈谷底检测与实战避坑指南

NCP1337准谐振电源设计:无线圈谷底检测与实战避坑指南

简介:本资源是一篇面向电源工程师与电子设计从业者的专业技术文章,聚焦NCP1337增强型准谐振PWM控制器的原理剖析与工程实践,解决高效率、低EMI隔离电源在工业控制场景下的设计难点。文中系统阐述无线圈去磁检测与软跳周期两大核心技术&#x…

2026/10/17 23:55:51 阅读更多 →
Flutter扩展库鸿蒙适配实战:daily_extensions百道扩展切面落地

Flutter扩展库鸿蒙适配实战:daily_extensions百道扩展切面落地

有人在群里发了一条消息:某项目在鸿蒙上跑 Flutter,启动明显卡顿,日志里铺满了平台通道的重复调用;紧接着有人说他们团队为了适配鸿蒙,把项目里所有跟设备相关的逻辑重写了一遍;然后又有人问 daily_extensi…

2026/10/17 23:55:51 阅读更多 →
RectTransform 全攻略:锚点、sizeDelta 与 UI 自适应

RectTransform 全攻略:锚点、sizeDelta 与 UI 自适应

1. 先把那些让你UI乱跑的根源说清楚:RectTransform到底多出来了什么做Unity界面开发的同学,十有八九都在RectTransform上调过半天参数。我也一样,早期做UI时最迷惑的就是:明明在Inspector里把Width和Height都填好了,运…

2026/10/17 23:55:51 阅读更多 →
JMEE论文精解:实体与关系联合抽取的图注意力建模范式

JMEE论文精解:实体与关系联合抽取的图注意力建模范式

1. 这篇EMNLP 2018论文到底在解决什么真问题?“Jointly Multiple EE via Attention-based Graph Information Aggregation”——光看标题,很多人第一反应是:又一篇堆砌术语的NLP论文?Attention、Graph、Jointly、Multiple EE……这…

2026/10/17 23:54:50 阅读更多 →

日新闻

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

简介:《DeepSeek使用方法.pdf》是一份面向DeepSeek入门及进阶用户的完整使用指南,重点围绕V3与R1两款模型的能力差异与适用场景展开,帮助读者根据任务类型与成本选择合适的模型。资源共1个PDF文件,压缩包大小约311KB,篇…

2026/10/17 0:00:25 阅读更多 →
200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

简介:这是一份聚焦DeepSeek提示词优化的《200Deepseek润色指令》PDF文档,共收录两百余条可直接套用的润色指令,涵盖语法修正、风格统一、逻辑梳理、表达精炼等维度,并针对商务写作、学术论文、广告文案等场景给出差异化建议。文档…

2026/10/17 0:00:25 阅读更多 →
OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

简介:本资源是一份基于OpenCV的本科课程设计完整报告,面向计算机视觉初学者、图像处理学习者及高校通信/信息类专业学生,解决物体智能计数与检测结果结构化记录的实际问题。项目覆盖图像预处理、轮廓检测、边界框绘制、轨迹栏交互式参数调节、…

2026/10/17 0:00:25 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/16 21:35:51 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/17 1:44:59 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/16 15:04:24 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 21:54:38 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/17 17:22:31 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 14:23:36 阅读更多 →