尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

WinForms DataGridView 合并单元格原理与实战:视觉合并与踩坑记录

WinForms DataGridView 合并单元格原理与实战:视觉合并与踩坑记录 简介面向 C# Windows Forms 开发者这份压缩包聚焦 DataGridView 控件单元格合并的完整实现。资源围绕逻辑合并与视觉合并两条技术路线重点演示通过重写 Paint 事件、自定义绘制来达成的视觉合并效果并给出对齐方式、列宽模式、行头处理等关键代码片段适合需要在表格中制作标题行、汇总行或复杂布局的初中级开发者参考。包内含 22 个文件以 cs 源码、resx 资源文件、sln 工程文件为主另附可执行 exe 和说明 txt方便直接运行查看效果或对照源码学习。整个资源包仅 44KB轻量易下载目前已吸引 344 人学习下载。通过示例工程读者可快速掌握 DataGridView 合并单元格的画法思路与事件处理技巧并将其迁移到自身项目中避免重复踩坑。1. 为什么需要合并单元格——不只是“好看”这么简单做了这么多年 WinForms 开发DataGridView 应该是最常用的控件之一了。但如果你接过报表类需求迟早会遇到合并单元格的问题。我第一次接到这个需求是做一个销售统计表要求把相同日期的行合并起来让数据区域看起来像 Excel 那样干净整齐。没有合并的时候表格里同一列连续几十行内容完全相同视觉上一片重复领导看了直接说“看不清重点”。合并单元格解决的本质问题是将重复的、具有从属关系的数据在视觉上折叠成一块让阅读者一眼就能看到数据的分组边界。比如按部门汇总时一个部门下挂了 50 条记录部门名称不用重复显示 50 次合并成一块不仅省空间更重要的是信息的层级关系立刻清晰了。但注意这里说的“合并”其实有两种完全不同的诉求视觉合并数据还在每一行里只是绘制的时候把相同值的相邻单元格边框去掉看起来像合并了。这种方案实现简单性价比最高。真实合并像 Excel 那样多个单元格真正变成一个单元格DataGridView 原生不支持需要继承封装或借助第三方控件库。我们日常做的报表90% 的需求属于第一种。本文以第一种为主把原理和踩过的坑都讲透。2. 核心原理窗口事件机制与绘制层控制在动手写代码之前必须先搞懂 DataGridView 的渲染机制。很多人网上抄了一段 CellPainting 的代码能跑起来但不知道为什么要这么写一旦需求变动就抓瞎。2.1 每次绘制都执行事件明白 cellPainting 的运行规则DataGridView 的单元格不是静态画布而是每次刷新、滚动、鼠标悬停都会重新绘制。CellPainting 事件在每次需要重绘某个单元格时被触发这是它能够自定义外观的基础。这个事件有几个关键的参数几乎每个合并方案都要用到e.CellBounds当前单元格在控件中的坐标和尺寸。e.Value当前单元格的值。e.FormattedValue格式化后的显示值。e.PaintContent是否需要绘制内容。e.Handled置为 true 后系统不再执行默认绘制完全由你自己的代码画。合并的核心思路是当单元格的值和它上方的单元格值相同时就不画这个单元格的上边框和左边框同时把之前合并区域的值重新绘制到当前合并区域的正中间。2.2 视觉合并边框 文本重绘的组合拳真正的合并效果在绘制层面由两个动作完成第一合并边框。比如纵向合并当前单元格和上方单元格值一样就把当前单元格的上边框画成白色或背景色这样视觉上上下两个格子连成一片。同理横向合并就隐藏左边框。第二重绘居中文本。合并之后文本不能每个格子都显示否则会出现多个重复值。通常的做法是只在合并区域的第一行显示文本后续行只画背景不画文本整个区域的文本位置通过计算区域总高度将文本垂直居中。这个逻辑听起来不复杂但实际操作时涉及文本的测量和坐标计算细节特别容易出错。2.3 为什么不用现成的第三方控件网上确实有一些第三方控件比如 ComponentFactory.Krypton、DevExpress它们原生支持合并单元格。但对于很多中小项目来说引入一套商业控件库的成本太高授权费用、学习成本、打包体积而且项目里已经在用微软自带的 DataGridView为了一个合并功能换掉整个控件体系显然不划算。自己写一个合并辅助类代码量控制在两三百行维护成本可控也不需要额外依赖是绝大多数场景下性价比最高的选择。3. 实操写一个通用的纵向合并方案3.1 用 CellPainting 实现第一版合并效果我直接给出一个经过多个项目验证过的实现。这里先讲纵向合并相同值在上下的合并这是最常见的需求形态。/// summary /// DataGridView 单元格合并辅助类纵向合并 /// /summary public class DataGridViewMergedCell { // 需要合并的列索引集合 private static HashSetint _mergedColumns new HashSetint(); /// summary /// 注册需要合并的列 /// /summary public static void EnableMerge(DataGridView grid, params int[] columnIndexes) { if (grid null) return; _mergedColumns.Clear(); foreach (int index in columnIndexes) { _mergedColumns.Add(index); } grid.CellPainting Grid_CellPainting; } private static void Grid_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; if (!_mergedColumns.Contains(e.ColumnIndex)) return; DataGridView grid sender as DataGridView; // 当前单元格的值 object currValue grid.Rows[e.RowIndex].Cells[e.ColumnIndex].Value; // 当前单元格所在合并区域的起始行 int mergeStartRow FindMergeStartRow(grid, e.RowIndex, e.ColumnIndex); // 当前单元格所在合并区域的结束行 int mergeEndRow FindMergeEndRow(grid, e.RowIndex, e.ColumnIndex); if (mergeStartRow mergeEndRow) { // 无需合并正常绘制 e.Paint(e.CellBounds, DataGridViewPaintParts.All); e.Handled true; return; } // 计算合并区域的总高度用于文本垂直居中 int mergeHeight 0; for (int row mergeStartRow; row mergeEndRow; row) { mergeHeight grid.Rows[row].Height; } // 绘制背景色 using (SolidBrush bgBrush new SolidBrush(grid.DefaultCellStyle.BackColor)) { e.Graphics.FillRectangle(bgBrush, e.CellBounds); } // 绘制文本仅在与本行是合并区域的第一行时才绘制 if (e.RowIndex mergeStartRow) { string text e.FormattedValue?.ToString() ?? ; TextRenderer.DrawText( e.Graphics, text, e.CellStyle.Font, new Rectangle( e.CellBounds.Left 5, e.CellBounds.Top (mergeHeight - TextRenderer.MeasureText(text, e.CellStyle.Font).Height) / 2, e.CellBounds.Width - 10, mergeHeight), e.CellStyle.ForeColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter); } // 绘制边框 using (Pen pen new Pen(grid.GridColor)) { // 左边框 e.Graphics.DrawLine(pen, e.CellBounds.Left, e.CellBounds.Top, e.CellBounds.Left, e.CellBounds.Bottom); // 右边框 e.Graphics.DrawLine(pen, e.CellBounds.Right - 1, e.CellBounds.Top, e.CellBounds.Right - 1, e.CellBounds.Bottom); // 下边框 e.Graphics.DrawLine(pen, e.CellBounds.Left, e.CellBounds.Bottom - 1, e.CellBounds.Right, e.CellBounds.Bottom - 1); // 上边框——如果不是合并区域第一行则不画实现视觉合并 if (e.RowIndex mergeStartRow) { e.Graphics.DrawLine(pen, e.CellBounds.Left, e.CellBounds.Top, e.CellBounds.Right, e.CellBounds.Top); } } e.Handled true; } /// summary /// 从当前行向上找合并区域的起始行 /// /summary private static int FindMergeStartRow(DataGridView grid, int rowIndex, int columnIndex) { object value grid.Rows[rowIndex].Cells[columnIndex].Value; int start rowIndex; while (start 0) { object prevValue grid.Rows[start - 1].Cells[columnIndex].Value; if (Equals(value, prevValue)) start--; else break; } return start; } /// summary /// 从当前行向下找合并区域的结束行 /// /summary private static int FindMergeEndRow(DataGridView grid, int rowIndex, int columnIndex) { object value grid.Rows[rowIndex].Cells[columnIndex].Value; int end rowIndex; while (end grid.Rows.Count - 1) { object nextValue grid.Rows[end 1].Cells[columnIndex].Value; if (Equals(value, nextValue)) end; else break; } return end; } }3.2 调用方式一分钟接入现有项目用起来非常简单在窗体加载的地方注册需要合并的列即可// 在 Form_Load 中调用 DataGridViewMergedCell.EnableMerge(dataGridView1, 0, 1, 3);这样第 0、1、3 列就会自动进行纵向合并值相同的连续行会合并成一块。后续添加的代码完全不需要改动对现有业务侵入极小。3.3 横向合并跨列合并的变通方案横向合并流水账里面把“备注”跨两列显示比纵向合并复杂一些因为涉及不同列的宽度求和、文本绘制范围调整。思路与纵向合并类似只是把“找上一行”换成“找左侧列”把高度计算换成宽度计算。核心代码改造点如下// 横向合并时用列宽累加代替行高累加 int mergeWidth 0; for (int col mergeStartCol; col mergeEndCol; col) { mergeWidth grid.Columns[col].Width; } // 文本水平居中而不是垂直居中 TextRenderer.DrawText( e.Graphics, text, e.CellStyle.Font, new Rectangle( e.CellBounds.Left (mergeWidth - TextRenderer.MeasureText(text, e.CellStyle.Font).Width) / 2, e.CellBounds.Top, mergeWidth, e.CellBounds.Height), e.CellStyle.ForeColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);这里有个细节要特别注意横向合并时合并区域的右边框应该只在最后一列绘制中间列的右边框要隐藏。如果你把纵向合并的代码直接改改就用大概率会出现中间多一条竖线的问题。3.4 非规则合并的取舍策略有的需求更复杂比如“表头跨两行、某些列跨两列”这种非规则合并建议用表格布局控件TableLayoutPanel嵌套或者改用HTML 报表导出的方式处理不要在 DataGridView 里硬刚。DataGridView 本质上是一个数据网格控件不是排版工具强行实现非规则合并会让代码变得极其脆弱一次排序、一次滚动就可能崩。我的经验是DataGridView 负责数据展示非规则表头用 Panel 叠加或导出 HTML 时处理各司其职。4. 进阶问题排序、滚动、性能三大坑第一版代码跑起来后会陆续遇到几个高频问题我把实际调试中的解决方案整理如下省得你一个一个踩。4.1 排序后合并错乱DataGridView 默认启用列头排序用户点击列头就排序。排序之后原来是合并区域的行被打散了但合并是在绘制时根据当前行的数据动态计算的所以理论上排序后仍然能正确合并。但有一个隐患如果你在 CellPainting 里用了“当前行和上一行比较”的缓存逻辑排序后上一行已经变了就会出错。所以合并判定一定要基于当前单元格的值动态向上、向下查找绝不能缓存合并区间。如果你希望排序后合并区间的值在不同行之间“断裂”比如排序后相同的值不再连续这在 Excel 里是正常的但有的业务不希望这样。可以在 SortCompare 事件里加上“优先按合并列排序”的逻辑private void dataGridView1_SortCompare(object sender, DataGridViewSortCompareEventArgs e) { // 保证合并列的值在排序后仍然连续 if (e.Column.Index 0 || e.Column.Index 1) { e.SortResult String.Compare(e.CellValue1?.ToString(), e.CellValue2?.ToString()); e.Handled true; } }4.2 滚动时闪烁 / 绘制异常合并绘制方案在数据量比较大时滚动会产生明显的闪烁。原因是每次滚动所有可见行都会触发 CellPainting而我们的绘制代码里做了MeasureText和多次DrawLine成本偏高。优化手段有两个第一开启双缓冲。// 在窗体构造函数或 Load 事件中设置 typeof(DataGridView).GetProperty(DoubleBuffered, BindingFlags.Instance | BindingFlags.NonPublic) .SetValue(dataGridView1, true);第二减少不必要的绘制。在 CellPainting 开头快速判断当前单元格的值是否和前后行相同如果相同且不是合并区域的第一行则只绘制背景和边框不绘制文本这样可以省掉大量TextRenderer.DrawText调用。if (e.RowIndex ! mergeStartRow) { // 不是合并区域第一行无需绘制文本 e.PaintBackground(e.CellBounds, true); // 画边框不画上边框 // ... e.Handled true; return; }4.3 单元格值变化后合并未刷新如果业务上允许用户在 DataGridView 里直接编辑单元格编辑后值变了但合并区域还是按旧值绘制就会出现显示错乱。解决方法是监听CellValueChanged和RowsAdded/RowsRemoved事件强制刷新整个网格private void dataGridView1_CellValueChanged(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex 0 _mergedColumns.Contains(e.ColumnIndex)) { dataGridView1.Invalidate(); } }Invalidate()会让 DataGridView 重新绘制所有可见区域即调用一次所有可见单元格的 CellPainting花费不大但能保证合并始终基于最新数据。4.4 合并列的列头也想合并怎么办纵向合并只处理了数据行RowIndex 0列头RowIndex -1需要单独处理。列头合并的常见场景是“季度”作为大标题跨多列。实现思路是用ColumnHeadersHeight划分上下两行区域但 DataGridView 原生不支持多层列头最多只能绘制一个虚假的第二行。我的建议是列头合并如果超过两行别在 DataGridView 里做。因为 DataGridView 的列头高度是全局统一的没法单独控制某列高某列低。可以用两个 DataGridView 上下拼接上面放合并列头的“假表格”下面放真正的数据表格同步滚动。这个方案我实测下来最稳。5. 常见问题速查表与避坑技巧最后把我在多个项目中踩过的坑整理成表格方便你遇到问题时快速定位。问题现象可能原因解决方案合并单元格文字跑到左上角或重叠未正确处理e.PaintContent内容被系统重复绘制在e.Handled true前调用e.Paint(e.CellBounds, DataGridViewPaintParts.Background)或完全自定义绘制并设置e.Handled true滚动后文本出现残影未完全覆盖单元格背景旧的文本残留在画布上先FillRectangle填充背景再绘制文本不要只绘制内容不画背景合并区域的选中状态异常合并是视觉层面的数据层仍是独立单元格选中时单个格子高亮在CellFormatting或CellPainting中判断当前行是否在合并区域内统一设置选中背景色最后一行的下边框丢失合并区域遍历到最后一行时未绘制下边框在FindMergeEndRow返回后判断e.RowIndex mergeEndRow强制画下边框不同值但空格不同的单元格被误合并Equals对DBNull和的处理不同合并判定前统一转换value?.ToString()?.Trim()再比较单元格内有多行文本WrapText时高度不够默认RowTemplate.Height太小在CellPainting中先MeasureText计算实际需要的高度若超过当前行高则调整grid.Rows[row].Height注意性能5.1 数据源是 DataTable 时的一个隐藏陷阱如果你的 DataGridView 绑定的是 DataTable并且用了_mergedColumns按列索引判断那么列的索引顺序可能会被用户拖动列头打乱。DataGridView 的DisplayIndex和Index不是一回事。当用户把第 3 列拖到第 0 列位置时按ColumnIndex绘制的合并逻辑会跟着列走吗答案是不会完全跟对。e.ColumnIndex是列的Index属性它表示列在 DataGridView.Columns 集合中的位置而不是显示位置。如果你希望用户拖动列后合并依然正确在ColumnDisplayIndexChanged事件中更新注册的列索引private void dataGridView1_ColumnDisplayIndexChanged(object sender, DataGridViewColumnEventArgs e) { // 重新注册合并列按 DisplayIndex 排序后的 Index 重新计算 // 这块逻辑取决于业务最简单的方案是不允许用户拖动列 dataGridView1.AllowUserToOrderColumns false; }实测下来大多数业务场景直接禁用用户拖列是最省心的。5.2 大数据的性能优化实测数据我拿一个 5 万行的数据表做测试不开合并时滚动流畅60fps 左右开启合并绘制后下降到 20fps有明显撕裂感。优化三步后开启双缓冲。非合并区域第一行不画文本。MeasureText的结果缓存到一个 Dictionarykey 是行索引列索引。优化后滚动帧率恢复到 45fps肉眼基本无感。如果你的数据量更大10 万行以上建议直接分页或懒加载DataGridView 本身就不是为大数据量设计的控件。5.3 真正的“合并且导出 Excel”方案最后提一个很多人忽略的需求界面上合并了导出 Excel 时也要合并。Excel 的合并单元格用 NPOI 实现非常简单// NPOI 导出时合并单元格 sheet.AddMergedRegion(new CellRangeAddress(startRow, endRow, startCol, endCol)); // 合并后设置边框 CellStyle style workbook.CreateCellStyle(); style.BorderTop BorderStyle.Thin; style.BorderBottom BorderStyle.Thin; // ... // 对合并区域内的每个单元格设置样式 for (int row startRow; row endRow; row) { for (int col startCol; col endCol; col) { sheet.GetRow(row).GetCell(col).CellStyle style; } }表格界面合并的判定逻辑和导出 Excel 时的合并逻辑可以复用同一个函数找连续相同值的起始行和结束行这样一次编写两处使用避免两个地方逻辑不一致。结合我自身的体会DataGridView 的单元格合并本质上是绘制的艺术只要理解了 CellPainting 的执行机制和绘制顺序就能灵活扩展出各种效果。等到这些基础扎实了你会发现哪怕是复杂的报表界面也都能用这套底层思路逐步拆解实现。本文还有配套的精品资源点击获取
返回列表