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

资讯详情

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

DataGridView合并单元格从原理到实战:绘制、交互与导出方案解析

DataGridView合并单元格从原理到实战:绘制、交互与导出方案解析 简介面向 C# WinForms 开发者的 DataGridView 合并单元格实现方案解决表格标题行、汇总行等复杂布局需求。资源从逻辑合并与视觉合并两种思路切入重点演示通过重写 Paint 事件、自定义绘制单元格达到视觉合并效果并给出对齐方式、自动列宽禁用、行头合并等关键处理细节适合需要在表格中实现跨行跨列展示的 .NET 开发人员参考。资源包共 22 个文件以 6 个 C# 源码文件为核心搭配 3 个可直接运行的 exe 及配套 resx、resources、pdb、settings、sln 等项目文件压缩包仅 44KB便于快速下载与本地调试。已有 344 人学习下载。通过源码研读与运行示例可快速掌握逻辑合并与视觉合并的适用场景理解 DataGridView 事件绘制机制并直接复用或改造代码减少自行摸索控件底层绘制细节的时间成本。 做进销存系统的时候客户提了一个很典型的需求出货单明细里同一个产品类别的产品名称列要合并显示跟Excel里的合并单元格一模一样。我当时的第一个念头就是——DataGridView这控件原生压根儿没这能力。网上翻了一圈能跑的Demo确实不少但大多一遇到滚动、点击、排序就露馅要么文字重叠要么边框乱飞要么选区整个崩溃。这篇文章把我最终落地的一套方案完整整理出来包括数据填充后自动识别合并范围、基于CellPainting的绘制逻辑以及点击选区、滚动排序这些连锁问题的处理思路给同样被合并单元格折磨过的朋友做个参考。1. 为什么DataGridView原生不支持合并单元格1.1 网格模型的硬约束DataGridView的底层模型是一个严格的二维网格每一行每一列都对应一个独立的单元格对象行高、列宽、单元格样式都绑定在这个网格结构上。它不像HTML的Table那样通过rowspan和colspan从布局层面直接声明这块区域属于同一个逻辑单元格。DataGridView没有这种结构所有单元格都是物理独立的各自拥有自己的Border、Style、Value。这意味着什么意味着我们看到的合并本质上只能是一种视觉欺骗——把被合并区域的内部边框隐藏掉把文字画在整个区域的中心让肉眼以为它们是一个整体。但控件本身并不知道这事儿它的事件系统、选中机制、编辑流程全部按物理单元格来触发。1.2 事件与状态机制无法感知逻辑单元格当你实现了一个看起来正常的合并效果后麻烦才刚开始。比如用户单击合并区域其实只选中了那一个物理单元格背景高亮只出现在那一小块再用键盘方向键上下移动CurrentCell会在合并区域内逐个物理单元格跳动文字绘制位置和选区状态互相冲突界面瞬间就花了。更麻烦的是编辑。如果用户在合并区域里按下F2或者直接键入内容DataGridView只会把当前那个物理单元格切成编辑状态TextBox盖在那一格上合并区域瞬间撕裂。所以合并单元格从来不是画一个框那么简单它牵扯到一连串和用户交互相关的状态同步问题。这也是为什么网上很多Demo只在静态截图上看着完美一跑起来就各种露馅。1.3 网上Demo为什么普遍走不通大部分网帖给的方案是在自定义控件的OnPaint里画一个大矩形再画几行文字看起来确实实现了。但这类方案基本没有处理滚动重绘、单元格选中状态、交替行背景色差异、边框颜色不一致这些问题。等用户一操作重绘区域的坐标一错乱之前画的内容和单元格原始背景叠在一起全部变成花屏。所以要做出一个能真正上线使用的合并单元格功能必须同时搞定三件事合并范围的数据结构设计、正确的绘制逻辑、以及围绕交互状态展开的补丁性处理。下面我按这个思路来拆解。2. 三种合并方案横向对比与选型理由2.1 CellPainting绘制法改动小、可控性高严格来说CellPainting方案也算绘制法但它挂在DataGridView公开的CellPainting事件上不需要继承控件改动面很小。核心思路是判断当前正在绘制的物理单元格是否属于某个合并区域如果是非起始单元格就只填充背景、盖掉边框如果是起始单元格就绘制整个合并区域的外边框并把文字画在合并区域的中心。这个方案的优点是侵入性低随时可以贴到现有项目里缺点是一切都要自己处理包括选区、滚动、排序后的重算工作都在细枝末节里。如果只是临时报表需求它是最快的路径。2.2 自定义控件扩展封装完整但工作量大继承DataGridView重写OnCellPainting、OnPaint、OnCellMouseDown等方法把合并管理器内置到控件内部对外暴露AddMerge、ClearMerge这些接口。这个方案的封装性最好后续维护也方便但前置工作量明显更大还要考虑DataGridView里那些内部方法和状态很容易踩到protected成员和基类绘制顺序的坑。2.3 第三方商业控件省事但要付出成本ComponentOne、DevExpress这类成熟控件都原生支持单元格合并可以按值自动合并也可以手动指定合并区域。如果项目工期很紧、预算充足直接换控件是最省心的选择。但代价也很明显整个项目的数据网格控件要统一存在授权费用而且部分老系统的.Net Framework版本不一定支持最新版控件。方案改造成本功能完整度维护成本适用场景CellPainting绘制法低中需自行补交互中已有系统局部需求继承DataGridView重写中中高中新项目、需要多处复用第三方商业控件低换控件高依赖厂商项目初期选型、预算充足个人最终选的是CellPainting绘制法理由很实际老项目已经用DataGridView铺了几个页面换控件风险太大继承方式耦合度高。挂事件的方式不影响原有逻辑出了问题也好回退。3. 绘制法合并的完整落地实现3.1 MergeCellInfo与MergeManager合并信息的数据结构合并的前提是有一份合并规则。无论哪种方式必须先描述清楚哪个起始单元格开始、横向纵向各跨多少格。我设计了两个类public class MergeCellInfo { public int StartRow { get; set; } public int StartColumn { get; set; } public int RowSpan { get; set; } public int ColumnSpan { get; set; } public int EndRow StartRow RowSpan - 1; public int EndColumn StartColumn ColumnSpan - 1; }MergeCellInfo只描述一块合并区域本身。真正参与检索的是MergeManager它负责把物理单元格坐标映射到所属的合并区域。用一个Dictionary以行,列为键做索引查起来是O(1)绘制调用频繁也不会有性能压力public class MergeManager { private readonly Dictionarystring, MergeCellInfo _mergeMap new Dictionarystring, MergeCellInfo(); public void AddMerge(int startRow, int startCol, int rowSpan, int colSpan) { var info new MergeCellInfo { StartRow startRow, StartColumn startCol, RowSpan rowSpan, ColumnSpan colSpan }; for (int r startRow; r info.EndRow; r) { for (int c startCol; c info.EndColumn; c) { _mergeMap[${r},{c}] info; } } } public MergeCellInfo GetMerge(int row, int col) { _mergeMap.TryGetValue(${row},{col}, out var info); return info; } public void Clear() _mergeMap.Clear(); }这个结构的关键点是合并区域内的每一个物理单元格都能找到所属的那一整块区域。绘制时非起始单元格要盖掉自己的边框起始单元格要把整块区域当成一个整体来画。没有这个映射关系后面所有逻辑都无从谈起。3.2 数据填充后自动识别相邻相同单元格填充数据合并单元格是热词里最常见的场景也就是数据绑定之后某些列存在大量相邻相同值希望自动合并。这里的核心逻辑是遍历当前列找出值与上一个不同的位置在值发生变化的两个位置之间就是一块可合并区。private void RefreshMergeInfo() { mergeManager.Clear(); // 以第0列为基准合并相邻相同内容 int startRow 0; for (int i 1; i dgv.Rows.Count; i) { string current dgv.Rows[i].Cells[0].Value?.ToString() ?? ; string previous dgv.Rows[startRow].Cells[0].Value?.ToString() ?? ; if (current ! previous) { if (i - startRow 1) { mergeManager.AddMerge(startRow, 0, i - startRow, 1); } startRow i; } } if (dgv.Rows.Count - startRow 1) { mergeManager.AddMerge(startRow, 0, dgv.Rows.Count - startRow, 1); } }实际项目里往往有多个列需要合并比如第0列按类别合并第1列按子类别合并。处理办法是逐列重复这个过程但要注意合并时不能破坏已有合并区域。一个稳妥的做法是先按固定列生成规则业务上有特殊需求再手动AddMerge补充。3.3 CellPainting核心绘制边框、文字与背景的处理绘制逻辑是整个方案的核心也最容易翻车。挂在DataGridView.CellPainting事件里每个物理单元格绘制时都会触发一次private void dgv_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; MergeCellInfo mergeInfo mergeManager.GetMerge(e.RowIndex, e.ColumnIndex); if (mergeInfo null) return; if (e.ColumnIndex ! mergeInfo.StartColumn || e.RowIndex ! mergeInfo.StartRow) { // 非起始单元格用背景色整块填充盖掉内部边框 using (SolidBrush brush new SolidBrush(e.CellStyle.BackColor)) { e.Graphics.FillRectangle(brush, e.CellBounds); } e.Handled true; return; } // 起始单元格绘制整块区域的外边框和居中文字 e.PaintBackground(e.ClipBounds, true); Rectangle mergeRect GetMergeBounds(mergeInfo); string text dgv.Rows[e.RowIndex].Cells[e.ColumnIndex].FormattedValue?.ToString() ?? ; TextRenderer.DrawText( e.Graphics, text, e.CellStyle.Font, mergeRect, e.CellStyle.ForeColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); using (Pen pen new Pen(dgv.GridColor)) { Rectangle borderRect mergeRect; borderRect.Width - 1; borderRect.Height - 1; e.Graphics.DrawRectangle(pen, borderRect); } e.Handled true; }GetMergeBounds用来计算合并区域的实际像素范围。这里要用GetCellDisplayRectangle拿到起始和结束单元格的屏幕坐标注意参数第三个必须是false表示不需要转换为相对DataGridView的坐标private Rectangle GetMergeBounds(MergeCellInfo info) { Rectangle r1 dgv.GetCellDisplayRectangle(info.StartColumn, info.StartRow, false); Rectangle r2 dgv.GetCellDisplayRectangle(info.EndColumn, info.EndRow, false); return new Rectangle( r1.Left, r1.Top, r2.Right - r1.Left, r2.Bottom - r1.Top); }这里有一个非常关键但容易被忽略的细节非起始单元格填充背景色时一定要使用e.CellStyle.BackColor而不是直接用DataGridView.BackgroundColor。因为DataGridView有交替行底色被合并区域内部的单元格背景色可能不一致如果用控件背景色填充页面上会有一块明显的色差。另一个细节是外边框颜色。取dgv.GridColor比取CellStyle.BorderColor更接近原生边框效果。此外DrawRectangle画出来的线条会有一部分画在边界之外所以要先对宽高减1否则最右边和最下边的边框会被裁掉一截看起来比其他单元格的边框粗。4. 合并之后绕不开的连锁坑4.1 点击、选中的整块识别处理绘制问题解决后下一步是让用户交互符合预期。比如用户点击合并区域视觉上应该整块一起高亮而不能只亮一格。绑定CellMouseDown事件private void dgv_CellMouseDown(object sender, DataGridViewCellMouseEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; MergeCellInfo info mergeManager.GetMerge(e.RowIndex, e.ColumnIndex); if (info null) return; dgv.ClearSelection(); for (int r info.StartRow; r info.EndRow; r) { for (int c info.StartColumn; c info.EndColumn; c) { dgv.Rows[r].Cells[c].Selected true; } } dgv.CurrentCell dgv.Rows[info.StartRow].Cells[info.StartColumn]; }把CurrentCell放到起始单元格是为了避免键盘上下移动时焦点落在合并区域的非起始单元格上。否则用户按一下方向键光标在合并区域内卡住或者乱跳体验很差。这里还要注意一件事合并区域的背景高亮因为我们在CellPainting里用e.CellStyle.BackColor盖住了非起始单元格理论上选中背景色也会被盖掉导致选中反馈不明显。一个改进办法是在绘制前判断起始单元格的Selected状态如果选中整块区域用DataGridView的SelectedCellBackColor填充而不是用BackColor。4.2 编辑、排序、滚动对合并的影响合并区域进入编辑状态是所有方案最头疼的问题。最省事的处理方式就是禁止编辑合并区域private void dgv_CellBeginEdit(object sender, DataGridViewCellCancelEventArgs e) { if (mergeManager.GetMerge(e.RowIndex, e.ColumnIndex) ! null) { e.Cancel true; } }同时把DataGridView设为只读或者设置EditMode为EditProgrammatically从根源上减少误触发。排序是另一个大坑。DataGridView的排序是物理行的重新排列排序之后原来在RowIndex5的合并区域可能换到了RowIndex2合并范围就错位了。处理思路有两种一是直接禁掉用户排序只允许在数据源层面按既定顺序展示二是在SortCompare或数据源变化事件里重新调用RefreshMergeInfo但这样排序过程中画面会闪动而且合并单元格里的值在语义上还是跟着整行走的业务上通常很难接受。滚动的处理倒是相对简单——CellPainting本来就是每次重绘都触发滚动时DataGridView会重新绘制可见区域绘制法天然支持滚动。唯一要注意的是不要在Painting里做耗时操作比如ToString格式化、数据库查询、复杂计算这些都会直接拖低滚动帧率。4.3 性能细节Painting事件里千万别做这些事CellPainting事件极其高频控件的每一次部分重绘都会触发多次事件。在这个事件里任何有资源分配嫌疑的代码都会被无线放大。比如new Pen、new SolidBrush如果写在事件方法里最好用using包起来让它在绘图结束后立刻释放。更推荐的做法是在事件外面预创建需要复用的Pen和Brush事件内部直接用。字符串拼接也是这样。查合并映射时我用的是${row},{col}这种字符串Key每次绘制都要拼接一次字符串。演示代码可以这么写但追求极致性能时应该改成int再编码成单个int的Key比如row * 100000 col避免字符串驻留和比较开销。数据量小无所谓几千行加多列合并时这个差异体感很明显。5. 不规则合并与汇总统计的工程化思路5.1 手动建模不规则合并区域热词里有非规则单元格怎么合并汇总这句话点出了很多人的真实痛点。业务里经常出现这种表产品类别列跨3行但其中某一行的子类别又跨2列下一类别只跨1行却要横跨3列。这种不规则的合并区域无论如何不能靠相邻相同值自动识别搞定必须用手动建模。MergeManager的设计对手动建模是天然友好的直接指定起始坐标和跨度区域mergeManager.AddMerge(0, 0, 3, 1); // 第0列的第0行到第2行合并 mergeManager.AddMerge(1, 1, 2, 2); // 第1行第1列开始跨2行2列 mergeManager.AddMerge(3, 0, 1, 3); // 第3行第0列开始横向跨3列所有坐标都在RefreshMergeInfo之外手动补充自动生成的和手动添加的可以并存。实际经验是不规则合并尽量只在数据源可控、行数不动态变化的场景用。如果用户在界面上新增了一行后面的手动合并区域全部要跟着平移处理起来非常麻烦。5.2 合并前的数据汇总计算合并汇总和合并单元格经常被一起提起而且顺序很重要——应该先汇总数据再依据汇总结果决定合并区域。也就是说数据源层面先把分组小计算好填充到DataTable里再对展示用的DataTable做相邻相同值扫描生成合并信息。举个例子需求是每个产品类别下面加一行小计显示数量和金额。做法是先在DataTable里追加小计行把类别名留空或者只写到第一列然后用之前写的自动扫描逻辑处理。小计行和类别行之间如果内容不连续自然就不会错误合并。这个过程建议用DataTable.Compute或者Linq的GroupBy来做不要手动循环累加代码又长又容易漏算。5.3 导出Excel保持一致性界面上的合并效果一旦导出到Excel合并关系就会丢失。业务方拿到Excel往往第一件事就是要求导出文件里的表格也能看到合并。用NPOI导出时关键接口是ISheet.AddMergedRegionsheet.AddMergedRegion(new CellRangeAddress(startRow, endRow, startCol, endCol));导出的难点在于要遍历合并管理器里所有区域把它们转成CellRangeAddress。但导出的行序和界面上的RowIndex不一定一致尤其是有过滤、有排序的情况下导出前必须重新基于导出的数据源构建一次合并映射不能直接复用界面那套。我在这上面吃过亏导出后Excel打开报格式不一致的警告排查半天发现是RowIndex错位。如果只是打印还有一个更简单的替代方案直接把DataGridView的当前显示画面截图或者用DrawToBitmap生成整图输出。这种方式保真度最高样式和界面完全一致但缺点是打印出来文字清晰度一般数据量大的时候一页放不下。个人项目的最后我把MergeManager从窗体代码里抽成了一个独立的工具类挂在控件外面用不侵入任何窗体逻辑。好处是后续如果有新页面要用一行代码就能挂上。这个需求的本质其实是UI呈现的视觉结构和数据网格的物理结构之间的不一致理解了这一点就不难明白那些连锁坑都出在哪里。做这类功能先把数据结构和边界条件想清楚再动手写绘制代码会比自己埋头debug一圈然后怀疑GDI有问题要靠谱得多。本文还有配套的精品资源点击获取
返回列表