DataGridView单元格合并与样式美化:自绘方案实战 简介面向 C# WinForm 开发者的 DataGridView 单元格合并及样式示例工程适用于需要在表格中呈现分组信息、区分不同行或列、提升界面可读性的中初级开发者。压缩包内含 30 个文件以 C# 源码为主包括 Form1.cs、RowMergeView.cs 等窗体及自定义控件另有 resx 资源文件、exe 可执行程序、pdb 调试符号和 txt 说明文档整体仅 65KB便于快速下载与本地调试。资源围绕单元格合并与样式两条主线展开不仅演示了通过 MergeType 属性合并同值单元格还覆盖了自定义单元格字体、背景色、前景色、边框样式以及交替行着色等常用技巧同时提供了 CellPainting 事件绘制的扩展思路帮助读者处理更复杂的界面需求。压缩包内项目结构完整可直接用 Visual Studio 打开运行方便对照学习与二次修改其中还包含大数据量使用时的性能优化提示有助于避免样式绘制引起的卡顿。已有 585 人学习或下载适合希望快速掌握 DataGridView 高级用法的 WinForm 开发者。 做WinForms开发的十有八九都会遇到一个需求DataGridView里要把同一列的几行单元格合并起来或者把表头两行合并到一起。这个控件功能确实强大但偏偏没给你提供“合并单元格”这个开箱即用的功能。之前我在一个生产报表项目里就卡在这上面报表的物料分组列必须跨行合并否则一堆重复的物料编码堆在每一行里看着就头疼客户那边天天催。折腾了几天试过换第三方控件、试过DevExpress、也试过直接用Excel导出替代最后发现最干净、最可控的方案还是自己用自绘解决。这篇文章就围绕“DataGridView单元格合并及样式”这个经典需求把我实际趟过的坑、最终采用的实现方案、以及样式美化里那些容易被忽略的细节全部整理出来。无论你是刚接触C# WinForms的新手还是已经被DataGridView折磨过的老手这套方案都值得直接抄作业。1. 项目定位与整体思路1.1 这个需求到底卡在哪DataGridView从功能上讲已经非常全面绑定数据、排序、筛选、编辑、虚拟模式这些都有但就是没有“合并单元格”这个功能。官方也不打算加因为在表格控件里合并单元格的交互逻辑远比看起来复杂——合并后选中状态怎么处理边框线怎么避免重叠排序之后合并关系怎么维护这些都是自己动手时要面对的连环问题。网上搜这个需求能找到的答案通常分几类一是推荐第三方控件比如ComponentOne、DevExpress的表格控件功能是齐全但一个授权费就能劝退不少个人开发者和小公司二是有人写了一套继承DataGridView的自定义控件拿过来能用但往往只解决了“画出来”交互细节一塌糊涂比如合并后选中不高亮、鼠标悬浮无反应三就是这次要讲的方案——基于CellPainting事件自绘合并区域再用样式美化兜底。我自己最终选择的是第三种方案。原因也很直接不引入额外依赖、实现逻辑完全可控、后续要调整样式或交互都非常方便。这篇文章里所有代码都是基于.NET Framework 4.7.2的WinForms项目实测过的迁移到.NET 6/8的Windows Forms项目也兼容。1.2 为什么选择自绘方案在动手之前我先把当时考虑的几条路线做了个对比这样大家也能明白我为什么最后选定自绘方案优点缺点第三方收费控件开箱即用功能全授权费高、体积大、定制受限继承DataGridView重写面向对象封装性好侵入性强改动现有代码成本高CellPainting事件自绘零依赖、灵活、可控需要自己处理边界和交互细节这里多说一句如果你项目的表格交互极其复杂比如行列同时合并、多级表头、单元格内嵌图表那花钱买第三方控件确实是省心之路。但如果只是报表展示场景——按分组归并同类项、表头跨行跨列、视觉上要好看——自绘方案完全够用而且性能可控遇到问题也能自己修不用等厂商的发版周期。2. 单元格合并的核心实现2.1 合并信息的数据结构设计合并的本质是让多个单元格在视觉上“合为一体”但数据层面的每个Cell还是原样存在。所以第一步要先定义一个结构把“哪些单元格属于同一块合并区域”描述出来。我实际用的是这样的类public class MergeInfo { public int RowIndex { get; set; } public int ColumnIndex { get; set; } public int RowSpan { get; set; } public int ColumnSpan { get; set; } // 用于快速判断某个单元格是否属于当前合并区域 public bool Contains(int rowIndex, int colIndex) { return rowIndex RowIndex rowIndex RowIndex RowSpan colIndex ColumnIndex colIndex ColumnIndex ColumnSpan; } public override string ToString() { return $R{RowIndex}C{ColumnIndex} Span{RowSpan},{ColumnSpan}; } }然后在整个DataGridView级别维护一个合并区域集合private ListMergeInfo _mergeInfos new ListMergeInfo(); // 根据行列号查找所属合并区域 private MergeInfo GetMergedRange(int rowIndex, int colIndex) { foreach (var info in _mergeInfos) { if (info.Contains(rowIndex, colIndex)) return info; } return null; }这段逻辑表面上简单但有一个关键点合并区域的RowIndex和ColumnIndex必须是“左上角起始单元格”的坐标。我在第一次实现时图省事把每一行都存了一个MergeInfo结果查找时同一个合并块会被匹配到多个对象绘制和交互全部错乱。后来改成只存左上角单元格查找时靠Contains判断归属问题立刻消失。如果你是做动态合并比如根据数据内容自动判断哪些行该合并在数据源绑定完成后扫描一遍数据连续相同值的行就归为一个MergeInfo这套数据结构完全够用。2.2 CellPainting事件里的绘制逻辑结构定义好之后真正的核心在于绘制。DataGridView里每个单元格绘制时都会触发CellPainting事件我们可以在这里拦截绘制过程把属于同一个合并区域的单元格统一“画成一块”。先说整体流程再给代码判断当前正在绘制的单元格是否属于某个合并区域。如果不属于合并区域直接用默认绘制逻辑base.OnCellPainting。如果属于合并区域则计算整个合并区域的矩形范围。用该区域左上角单元格的文本来绘制内容。手动绘制边框避免合并区域内部出现重复的网格线。关键代码大致是这样的private void dataGridView1_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; var merge GetMergedRange(e.RowIndex, e.ColumnIndex); if (merge null) return; // 当前单元格不是合并区域左上角时清空背景并结束绘制 // 因为整块内容的绘制由左上角单元格负责 if (e.RowIndex ! merge.RowIndex || e.ColumnIndex ! merge.ColumnIndex) { e.Graphics.FillRectangle(new SolidBrush(e.CellStyle.BackColor), e.CellBounds); e.Handled true; return; } // 计算合并区域的完整矩形 Rectangle mergeRect GetMergeRect(merge); // 清除默认背景 using (var bgBrush new SolidBrush(e.CellStyle.BackColor)) { e.Graphics.FillRectangle(bgBrush, mergeRect); } // 绘制选中高亮背景这里就是合并区域整体变色的核心 if (IsSelected(merge)) { using (var selBrush new SolidBrush(dataGridView1.DefaultCellStyle.SelectionBackColor)) { e.Graphics.FillRectangle(selBrush, mergeRect); } } // 绘制文本TextRenderer比e.Graphics.DrawString更清晰、更接近系统原生渲染 string cellValue GetMergeValue(merge); Rectangle textRect mergeRect; textRect.X e.CellStyle.Padding.Left 5; textRect.Y e.CellStyle.Padding.Top 2; textRect.Width - e.CellStyle.Padding.Right 5; textRect.Height - e.CellStyle.Padding.Bottom 2; TextRenderer.DrawText(e.Graphics, cellValue, e.CellStyle.Font, textRect, GetForeColor(merge), TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); // 绘制边框 DrawMergeBorder(e.Graphics, merge); e.Handled true; }这里有一个非常容易踩的坑当你在合并区域的非左上角单元格里先把e.Handled true后续单元格就不会再绘制文本但默认的网格线也没了。所以边框必须自己补齐否则合并区域看起来就像少了好几根线。我的处理方式是把“合并区域的外边界”看作一个整体只绘制最左边、最上边、最右边、最下边四条边框内部不再绘制任何分隔线。private void DrawMergeBorder(Graphics g, MergeInfo merge) { Rectangle rect GetMergeRect(merge); using (var pen new Pen(dataGridView1.GridColor)) { // 上边 g.DrawLine(pen, rect.Left, rect.Top, rect.Right - 1, rect.Top); // 左边 g.DrawLine(pen, rect.Left, rect.Top, rect.Left, rect.Bottom - 1); // 下边仅当下方没有继续合并的单元格 if (!IsMergedWithBelow(merge)) { g.DrawLine(pen, rect.Left, rect.Bottom - 1, rect.Right - 1, rect.Bottom - 1); } // 右边仅当右侧没有继续合并的单元格 if (!IsMergedWithRight(merge)) { g.DrawLine(pen, rect.Right - 1, rect.Top, rect.Right - 1, rect.Bottom - 1); } } }这个“IsMergedWithBelow/Right”的判断解决的是相邻两个合并块之间边框重复绘制的问题。如果下方紧邻的单元格也属于另一个合并区域那下边框应该由下方的合并区域自己画当前区域就不画否则会出现一条很粗的线。这段逻辑是当初调样式时花了最多时间的地方但补上之后整个表格的网格线就非常干净了。2.3 合并单元格的点击联动与高亮把合并单元格“画出来”只是第一步真正让人感觉“好用”还得处理交互。最典型的问题是点击合并区域里的任意一行默认情况下只有被点击的那个单元格会高亮整个合并块其它位置还是白底看起来就像“没选中”非常奇怪。我处理这个问题的方式是在CellMouseMove和CellClick事件里判断鼠标当前落在哪个合并区域然后手动让整个合并区域进入高亮状态。核心思路是记录当前鼠标所在的合并区域触发Invalidate让整个区域重绘绘制时根据“当前区域是否被选中”来决定要不要填充高亮背景色。private MergeInfo _hotMerge; // 当前鼠标所在的合并区域 private void dataGridView1_CellMouseMove(object sender, DataGridViewCellMouseEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; var merge GetMergedRange(e.RowIndex, e.ColumnIndex); if (!Equals(merge, _hotMerge)) { // 先让旧区域重绘一次 if (_hotMerge ! null) dataGridView1.Invalidate(GetMergeRect(_hotMerge)); _hotMerge merge; // 再让新区域重绘一次 if (_hotMerge ! null) dataGridView1.Invalidate(GetMergeRect(_hotMerge)); } }然后在上面的CellPainting代码里判断“鼠标是否在当前合并区域内”或者“合并区域里是否有单元格被选中”决定是否填充高亮色。这里有个细节是不要直接在事件里调用Refresh()全表刷新而是用Invalidate(rectangle)只刷新合并区域那一块矩形否则表格行数多的时候鼠标一动就满屏闪烁体验很差。合并区域选中状态的判断也可以根据业务场景定制。例如我的报表场景里只要合并区域内的任意一个单元格处于Selected状态就认为整个合并块被选中这样用户点任何一行都能看到整块高亮视觉反馈非常明确。3. 样式美化与细节处理3.1 列宽从“够用”到“舒适”DataGridView列宽大概是这个控件里吐槽率最高的部分之一。很多人把DataGridView.AutoSizeColumnsMode DataGridViewAutoSizeColumnsMode.Fill一设以为万事大吉结果数据一变长某些列还是会被挤得很难看。热词里提到的“列标题列宽没有超出却又一些会被拥挤”就是这个问题的典型表现。其实Fill模式的工作原理是把所有列的宽度总和拉伸到DataGridView的客户区宽度按比例分配。但如果某列设置了MinimumWidth或者某一列的内容特别长导致其最小宽度超过分配值那一列就会强行占到更多的空间其它列就会被过度压缩。我常用的处理方式是// 整体用Fill模式但不给所有列一律的平均分配 dataGridView1.AutoSizeColumnsMode DataGridViewAutoSizeColumnsMode.Fill; // 对关键的数据列手动设置一个相对权重 dataGridView1.Columns[ProductName].FillWeight 3; // 产品名称列占比高 dataGridView1.Columns[Quantity].FillWeight 1; // 数量列占比低 dataGridView1.Columns[UnitPrice].FillWeight 1;FillWeight的分配是DataGridView按照权重值占总权重的比例来分配剩余空间而不是单纯的平均分配。这个参数非常适合做“主次分明”的表格布局。另外如果想要某几列严格按内容自适应、其它列再填充剩余空间可以把AutoSizeColumnsMode设为AllCellsExceptHeader然后只对标题列单独设置AutoSizeMode为Fill。组合使用比单靠一个全局模式要灵活得多。3.2 行列样式与视觉层次DataGridView样式体系的优先级从低到高大致是DataGridView.DefaultCellStyle - Column.DefaultCellStyle - RowsDefaultCellStyle和AlternatingRowsDefaultCellStyle - Cell.Style。很多人改样式发现在界面上没反应多半是优先级搞混了。比如你在Column级别设置了BackColor但又在DataGridView级别设置了AlternatingRowsDefaultCellStyle那么偶数行的背景色会覆盖列级设置。我做样式美化时的基本配置如下// 全局默认样式 dataGridView1.BackgroundColor Color.White; dataGridView1.BorderStyle BorderStyle.None; dataGridView1.CellBorderStyle DataGridViewCellBorderStyle.SingleHorizontal; dataGridView1.GridColor Color.FromArgb(230, 230, 230); // 标题样式 dataGridView1.EnableHeadersVisualStyles false; dataGridView1.ColumnHeadersDefaultCellStyle.BackColor Color.FromArgb(52, 73, 94); dataGridView1.ColumnHeadersDefaultCellStyle.ForeColor Color.White; dataGridView1.ColumnHeadersDefaultCellStyle.Font new Font(Microsoft YaHei UI, 10F, FontStyle.Bold); dataGridView1.ColumnHeadersHeight 40; dataGridView1.ColumnHeadersHeightSizeMode DataGridViewColumnHeadersHeightSizeMode.DisableResizing; // 斑马纹 dataGridView1.RowsDefaultCellStyle.BackColor Color.White; dataGridView1.RowsDefaultCellStyle.ForeColor Color.FromArgb(51, 51, 51); dataGridView1.RowsDefaultCellStyle.SelectionBackColor Color.FromArgb(91, 164, 229); dataGridView1.RowsDefaultCellStyle.SelectionForeColor Color.White; dataGridView1.AlternatingRowsDefaultCellStyle.BackColor Color.FromArgb(245, 247, 249); // 行高 dataGridView1.RowTemplate.Height 32;这里有一个非常关键的开关EnableHeadersVisualStyles。它默认是true意味着列标题会使用系统当前主题的视觉样式你设置的ColumnHeadersDefaultCellStyle根本不会生效。网上好多人改样式半天没反应80%都是因为没把这个属性设成false。这一点值得特别记一下。3.3 行高自适应与长文本换行合并单元格和普通单元格还有一个很大的不同普通单元格有默认的行高内容如果超出会被裁掉合并单元格因为跨了多行通常内容更长如果不做换行要么文字被截断要么大片空白。之前我的报表里有一列“备注”合并后文本经常有几十个字直接显示成一行EndEllipsis看着很别扭。后来我做了两个小优化第一个是合并单元格里的文本自动换行。把TextRenderer.DrawText的flag从TextFormatFlags.EndEllipsis改成带TextFormatFlags.WordBreak这样长文本会按单词换行而不是直接截断。TextFormatFlags flags TextFormatFlags.VerticalCenter | TextFormatFlags.WordBreak; TextRenderer.DrawText(e.Graphics, cellValue, e.CellStyle.Font, textRect, GetForeColor(merge), flags);第二个是动态计算行高。既然合并单元格要跨多行一个合并块里如果文本太长应该适当增加每行行高让所有内容都放得下。我写了一个简单的方法在数据绑定之后统一计算一次private void AdjustRowHeightForMerge(int colIndex) { foreach (var merge in _mergeInfos) { if (merge.ColumnIndex ! colIndex) continue; string text GetMergeValue(merge); Rectangle rect GetMergeRect(merge); int maxWidth rect.Width; Size contentSize TextRenderer.MeasureText(text, dataGridView1.Font, new Size(maxWidth, int.MaxValue), TextFormatFlags.WordBreak); // 合并区域总高度 / 跨的行数 每行需要的最小行高 int requiredRowHeight (int)Math.Ceiling((double)contentSize.Height / merge.RowSpan); for (int i merge.RowIndex; i merge.RowIndex merge.RowSpan; i) { if (dataGridView1.Rows[i].Height requiredRowHeight) dataGridView1.Rows[i].Height requiredRowHeight 6; // 留一点内边距 } } }这个函数不需要每次都执行只在数据源刷新后跑一遍即可。如果数据条数上千行也不需要担心性能——因为只计算合并区域涉及的少量行不会全表扫描。4. 常见问题与排查技巧4.1 列宽挤压问题排查前面提到的“列宽没有超出却显得拥挤”我做了个快速排查表基本能覆盖90%的情况现象可能原因解决办法Fill模式下某列特别窄其它列有较大的MinimumWidth挤压了剩余空间检查所有列的MinimumWidth调小或移除列标题被截断ColumnHeadersHeightSizeMode固定高度标题文本过长改为AutoSize或手动调大Header高度合并单元格文本被截断绘制时没加WordBreak把TextFormatFlags加上WordBreak某列无论如何都占不到期望宽度FillWeight分配不均或Fill模式失效手动设置AutoSizeMode为None再设Width横向滚动条出现但列宽没超存在不可见的列或行头宽度异常检查Columns集合是否包含隐藏列、RowHeadersWidth大小还有一个我自己遇到过的情况DataGridView的RowHeadersVisible为true时行头本身会占约40多像素导致表格可视区域变小Fill模式下每列分到的宽度都会缩水。如果不需要行头建议直接把RowHeadersVisible设为false列宽分配会宽松很多。4.2 合并后选中不高亮这个问题的根源在于自绘时没有感知到“当前单元格所在合并区域是否被选中”。如果在CellPainting里只判断当前单元格的Selected属性那么合并区域的其它单元格因为没被真正选中就不会绘制高亮背景。解决思路是在判断时把“当前绘制单元格是否属于某个合并区域”和“这个合并区域里是否有任意一个单元格处于选中状态”组合起来。实现上可以在绘制之前先遍历合并区域的每个Cell判断是否有Selectedprivate bool IsSelected(MergeInfo merge) { for (int r merge.RowIndex; r merge.RowIndex merge.RowSpan; r) { for (int c merge.ColumnIndex; c merge.ColumnIndex merge.ColumnSpan; c) { if (dataGridView1.Rows[r].Cells[c].Selected) return true; } } return false; }然后在高亮背景的填充上统一用SelectionBackColor填充整个合并区域矩形而不是只填当前单元格。前面CellPainting里的代码已经包含了这一步加上之后点击合并区域内任意位置整个块都会高亮体验就很顺了。4.3 性能与闪烁优化如果表格数据量大合并区域又多自绘方案很容易出现滚动闪烁和卡顿。我实际踩过几次坑之后总结出几条有效的优化手段开启双缓冲DataGridView默认有双缓冲但自绘时最好再显式设置一次减少OnPaint重绘的闪烁。typeof(DataGridView).GetProperty(DoubleBuffered, BindingFlags.Instance | BindingFlags.NonPublic).SetValue(dataGridView1, true);绘制时避免频繁创建GDI对象SolidBrush、Pen这些对象尽量用using包裹或者直接声明成字段复用不要在Paint事件里new了又释放、释放了又new。高频触发时会大量产生GDI句柄最终导致绘制越来越卡甚至报错。按需重绘而不是全表刷新Invalidate(Rectangle)只刷新需要的区域比如鼠标悬停高亮、选中状态变化都不要用Refresh()全表刷新。全表刷新在几百行时看不出差别一旦数据上几千行差距会非常明显。缓存合并矩形GetMergeRect如果在Paint里频繁计算每次都要把RowIndex转换成屏幕Y坐标行列一变就要重新算。建议在合并区域集合构建或数据结构变化时预先算好每个合并区域的Rectangle缓存起来绘制时直接取值。第4点尤其重要。我之前在数据量大的项目里CellPainting事件里每次都动态计算GetMergeRect结果每条数据滚动时卡到掉帧。改成缓存之后同样的数据瞬间流畅说明Paint事件里的计算量对性能影响极大。配置好这些之后一个大致的代码结构可以先跑通基础功能// 示例绑定数据后自动合并相同值的列 private void BindDataAndMerge() { dataGridView1.DataSource GetReportData(); _mergeInfos.Clear(); int startRow 0; for (int r 1; r dataGridView1.Rows.Count; r) { bool same r dataGridView1.Rows.Count dataGridView1.Rows[r].Cells[ProductName].Value?.ToString() dataGridView1.Rows[startRow].Cells[ProductName].Value?.ToString(); if (!same) { if (r - startRow 1) { _mergeInfos.Add(new MergeInfo { RowIndex startRow, ColumnIndex dataGridView1.Columns[ProductName].Index, RowSpan r - startRow, ColumnSpan 1 }); } startRow r; } } AdjustRowHeightForMerge(dataGridView1.Columns[ProductName].Index); dataGridView1.ClearSelection(); dataGridView1.Invalidate(); }5. 小结与扩展思考DataGridView合并单元格这个需求网上搜一圈答案很多但能真正用起来、处理好细节的不多这也是我写这篇文章的初衷。核心的点其实就两条一是用CellPainting自绘控制合并区域的绘制逻辑二是把交互细节高亮、边框、点击处理到位。只要这两点打通剩下的样式美化、性能优化都是锦上添花的事。以我自己实际项目的体会来说如果你只是做一个几百行数据的报表展示这套代码完全够用。如果你要做的是几千行以上的大数据量表格建议在合并信息结构上再优化一层用字典缓存merge矩形把Paint里的计算量再降一个量级。我最后再说一个小技巧在合并区域集合构建的时候尽量按“从上到下、从左到右”的顺序排序这样GetMergedRange查找时可以在前面的匹配中快速返回避免遍历整个集合拖慢速度。这套方案我在几个生产项目里跑了蛮长时间目前还没有翻过车。本文还有配套的精品资源点击获取