ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

DataGridView合并单元格:CellPainting自绘实现

DataGridView合并单元格:CellPainting自绘实现 简介面向C#开发者的DataGridView合并单元格实现示例依托Visual Studio 2008环境编写适用于需要在WinForms表格中呈现跨行跨列效果的中级开发者。项目围绕UnionCellDuty展开核心源码展示了通过自定义绘制或事件处理方式让DataGridView支持单元格合并的关键思路并保留数据库访问相关代码即使原始数据库文件缺失也可依据已有C#源码逆向还原数据结构与示例运行环境。压缩包内共12个文件以6个.cs源文件为主辅以2个.resx资源配置、1个.csproj项目文件、1个.sln解决方案、1个.suo用户选项及1个.settings设置文件整体大小仅14KB结构紧凑、便于快速对照学习。已有3226人浏览学习适合对DataGridView高级功能、自绘表格或WinForms界面优化感兴趣的开发者参考可帮助理解合并单元格实现的底层逻辑并迁移到自身项目中。 做上位机或者数据报表界面的时候多多少少都会遇到一个需求DataGridView 里同一列的相同值希望像 Excel 那样合并成一个单元格。比如设备状态列连续几行都是“运行中”报表里希望只显示一次整体看过去清爽很多。但 DataGridView 这个 WinForms 里最常用的网格控件官方没有提供 Merge 属性直接设置根本做不到。这篇文章就基于我自己在项目里用纯 C# 实现的方案讲清楚 DataGridView 合并单元格到底怎么处理先分清跨列合并和跨行合并的区别再用 CellPainting 事件自定义绘制实现视觉合并最后附上可以直接复用的辅助类代码和实际踩坑记录。对正在做上位机、工控软件或者 WinForms 报表的开发者来说这套思路可以直接抄。1. 先搞清楚DataGridView合并的两类需求很多同学一上来就找“合并单元格的库”结果越找越乱因为需求根本没说清楚。合并这件事在 DataGridView 里至少分成两种完全不同的场景处理方式也完全不一样。1.1 官方不提供Merge这是设计取舍不是遗漏DataGridView 的单元格模型是“一行一列交叉对应一个 Cell 对象”每个 Cell 都有自己的 Value、Style、Bounds。这种线性模型在遍历数据、排序、编辑时非常高效但也意味着要实现合并必须打破“每个格子独立占一块矩形区域”的基本假设。合并后有几个问题非常棘手键盘方向键导航时焦点应该怎么跳点击合并区域里非顶部的行值应该编辑到哪个 Cell矩形选择复制的时候跨行跨列的范围怎么处理这些交互逻辑如果没想清楚就贸然开放 Merge 属性会带来一堆 bug。所以微软把这个扩展点留给了开发者让需要的人通过自定义绘制自己实现。明白了这一点你就会理解为什么网上搜出来的方案基本都是围绕 CellPainting 事件做的而不是设置某个现成属性。1.2 跨列合并和跨行合并绘图思路完全相反跨列合并是把同一行的连续几列合成一个大单元格最常见的就是表头分组。比如列头第一行显示“设备信息”下面第二行才拆成“设备名称”、“设备编号”视觉上“设备信息”这个格子横跨两列。这种情况的本质是横向扩大绘制区域让第一个单元格的文本画到右侧相邻列的范围上同时隐藏掉相邻列自己的内容和边框。跨行合并是把同一列的连续几行合成一个纵向区域最常见的则是数据区里相同值合并。比如下面几行设备的“所属产线”都是“一号线”那么这一列只需要在第一行显示一次“一号线”下面几行留着空白。这种情况的本质是纵向扩大绘制区域让起始行的内容画到下面相邻行的范围上同时把中间行的内容全部清掉。两种合并在绘制方向上完全不同代码不能互相套用。如果看到某个方案只讲了表头合并你要意识到它大概率不适用于数据区跨行合并反过来也一样。1.3 合并前先把数据处理好排序和分组不能省必须强调DataGridView 合并单元格合并的是“相邻且相同”的值不是全局去重。如果原始数据里相同值没有排在一起而是分散在不同行那么它们不会被合并显示出来依然是一个一个独立的单元格。所以合并之前建议先在数据层面做一次排序和分组。最简单的方式是在填充 DataGridView 之前对 DataTable 执行 SortDataTable dt GetDeviceTable(); DataView dv dt.DefaultView; dv.Sort ProductLine ASC, DeviceName ASC; dgv.DataSource dv.ToTable();排序之后相同产线会连续排列再交给合并逻辑去扫描效果才符合预期。另外要注意DataGridView 如果是可编辑的末尾会有“新行”这个新行的数据通常是空值计算合并区域时一定要把 IsNewRow 的行排除掉否则空值区域会被莫名其妙地合并成一大块。2. 用CellPainting事件把“合并”画出来DataGridView 绘制每个单元格时都会触发 CellPainting 事件这是实现自绘合并的唯一入口。核心逻辑其实不复杂接管绘制过程自己决定背景、文本、边框怎么画。2.1 接管单元格绘制先看懂默认绘制流程默认情况下DataGridView 绘制一个单元格时会画三样东西背景BackColor 或 SelectionBackColor、边框CellBorderStyle 决定、内容文本或编辑控件的值。CellPainting 事件参数里的 e.Paint() 方法控制是否执行默认绘制而 e.Handled true 则表示“剩余部分我自己处理你不用再往下画了”。我们的合并实现思路是对需要合并的区域先计算出一个完整的矩形范围例如跨行合并时要拿到从起始行到结束行整个列的 Bounds。用背景色填充这个完整矩形掩盖掉内部原本存在的网格线。把文本绘制在整个矩形正中而不是只画在第一个单元格范围内。手动绘制合并区域的外边框内部不再出现横线视觉上就形成了合并效果。这个流程中最关键的坑在于绘制顺序。DataGridView 的 CellPainting 是按单元格逐个触发的你没有控制哪个先画、哪个后画如果不做特殊处理后触发的单元格填充背景时会把先画好的文本覆盖掉。这也是很多半成品方案在滚动或选中后出现“文字消失”问题的根本原因。2.2 新手热身跨列合并表头的完整代码先看最直观的跨列合并表头。假设表格第一列和第二列分别是“设备名称”和“设备编号”希望表头显示成一个“设备信息”。绘制时我们让第 0 列绘制合并后的完整表头第 1 列只填充背景不显示任何内容dgv.EnableHeadersVisualStyles false; dgv.ColumnHeadersHeight 36; private void dgv_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex -1 (e.ColumnIndex 0 || e.ColumnIndex 1)) { if (e.ColumnIndex 0) { Rectangle mergedRect new Rectangle( e.CellBounds.Left, e.CellBounds.Top, e.CellBounds.Width dgv.Columns[1].Width, e.CellBounds.Height); using (SolidBrush brush new SolidBrush(e.CellStyle.BackColor)) { e.Graphics.FillRectangle(brush, mergedRect); } TextRenderer.DrawText(e.Graphics, 设备信息, e.CellStyle.Font, mergedRect, e.CellStyle.ForeColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); using (Pen pen new Pen(dgv.GridColor)) { e.Graphics.DrawLine(pen, mergedRect.Left, mergedRect.Top, mergedRect.Right - 1, mergedRect.Top); e.Graphics.DrawLine(pen, mergedRect.Left, mergedRect.Bottom - 1, mergedRect.Right - 1, mergedRect.Bottom - 1); e.Graphics.DrawLine(pen, mergedRect.Left, mergedRect.Top, mergedRect.Left, mergedRect.Bottom - 1); } e.Handled true; } else { using (SolidBrush brush new SolidBrush(e.CellStyle.BackColor)) { e.Graphics.FillRectangle(brush, e.CellBounds); } using (Pen pen new Pen(dgv.GridColor)) { e.Graphics.DrawLine(pen, e.CellBounds.Right - 1, e.CellBounds.Top, e.CellBounds.Right - 1, e.CellBounds.Bottom - 1); } e.Handled true; } } }注意这里设置了EnableHeadersVisualStyles false如果不设置系统主题会覆盖表头的背景色你画出来的颜色可能和表格整体风格不一致。右侧边框我放在第 1 列触发时画就是为了避免第 0 列先画、第 1 列后填充背景把右边框盖掉。这类“先画被后画覆盖”的细节就是自绘方案里最容易出问题的地方。2.3 跨行合并的绘制重点区域矩形、文本和边框跨行合并比跨列合并稍微复杂一点因为合并区域的纵向范围是动态的必须先把“哪些行属于同一个合并区域”计算出来。举个例子某一列数据从上到下依次是“A、A、B、B、B、C”那么需要合并出三个区域第 01 行合并成 A第 24 行合并成 B第 5 行只有一条不合并。如果只有一个值不存在合并需求也就不需要做任何绘制处理。绘制时最稳妥的策略是合并区域内任何一个单元格触发 CellPainting都完整地重绘整个合并区域。也就是说不管是起始行、中间行还是结束行触发我画的都是同一幅画面填充整个合并区域背景、画居中的文本、画外边框。这样即使绘制顺序不可控最终画面也是一致的不会出现“后画的背景把先画的文字盖掉”的问题。3. 可直接复用的跨行合并辅助类为了不在每个窗体里复制粘贴一套代码我在项目里把跨行合并封装成了一个辅助类只需要几行就能接入现有 DataGridView。3.1 用MergeRegion记录合并区域合并区域先抽象成一个简单的数据结构包含列索引、起始行、结束行public class MergeRegion { public int ColumnIndex { get; set; } public int StartRow { get; set; } public int EndRow { get; set; } public MergeRegion(int columnIndex, int startRow, int endRow) { ColumnIndex columnIndex; StartRow startRow; EndRow endRow; } }这个类本身没有逻辑单纯用来记录“哪一列的哪几行视觉上要合并”。后续无论是计算还是绘制都围绕它展开。3.2 Recalculate扫描行数据生成合并区域合并区域必须基于当前表格显示的数据重新计算。我写了一个 Recalculate 方法接收需要合并的列索引集合遍历每一列逐个扫描相邻行值相同则归入同一个区域public class DataGridMergeHelper { private readonly DataGridView _dgv; private readonly Dictionaryint, ListMergeRegion _regions new Dictionaryint, ListMergeRegion(); public DataGridMergeHelper(DataGridView dgv) { _dgv dgv; } public void Recalculate(params int[] columnIndexes) { _regions.Clear(); int rowCount _dgv.Rows.Count; if (rowCount 0 _dgv.Rows[rowCount - 1].IsNewRow) rowCount--; foreach (int col in columnIndexes) { var list new ListMergeRegion(); if (rowCount 0) { _regions[col] list; continue; } int start 0; for (int i 1; i rowCount; i) { string prevText _dgv.Rows[i - 1].Cells[col].FormattedValue?.ToString(); string currText _dgv.Rows[i].Cells[col].FormattedValue?.ToString(); if (prevText ! currText) { if (i - start 1) list.Add(new MergeRegion(col, start, i - 1)); start i; } } if (rowCount - start 1) list.Add(new MergeRegion(col, start, rowCount - 1)); _regions[col] list; } } }这里我特意用FormattedValue而不是Value做比较是因为很多列会设置显示格式比如日期显示成“yyyy-MM-dd”实际 Value 可能带时分秒按 Value 判断可能认为两行不同按格式化后的文本判断才和人眼看到的一致。如果不希望格式化参与比较可以在绑定数据源前预处理数据这种情况用 Value 也可以但新手还是建议统一用 FormattedValue。3.3 Paint一次画完整个合并区域避免覆盖顺序问题绘制方法是核心。前面提到过要解决“后绘制覆盖先绘制”的问题我的策略是合并区域内任何一个单元格触发绘制都完整重绘整个合并区域。public void Paint(DataGridViewCellPaintingEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; if (!_regions.TryGetValue(e.ColumnIndex, out ListMergeRegion regions)) return; MergeRegion region null; foreach (var r in regions) { if (e.RowIndex r.StartRow e.RowIndex r.EndRow) { region r; break; } } if (region null) return; DataGridViewCell topCell _dgv.Rows[region.StartRow].Cells[e.ColumnIndex]; DataGridViewCell bottomCell _dgv.Rows[region.EndRow].Cells[e.ColumnIndex]; Rectangle totalRect Rectangle.Union(topCell.Bounds, bottomCell.Bounds); bool selected _dgv.CurrentCell ! null _dgv.CurrentCell.ColumnIndex e.ColumnIndex _dgv.CurrentCell.RowIndex region.StartRow _dgv.CurrentCell.RowIndex region.EndRow; Color backColor selected ? topCell.Style.SelectionBackColor : topCell.Style.BackColor; Color foreColor selected ? topCell.Style.SelectionForeColor : topCell.Style.ForeColor; using (SolidBrush brush new SolidBrush(backColor)) { e.Graphics.FillRectangle(brush, totalRect); } string text topCell.FormattedValue?.ToString() ?? ; TextRenderer.DrawText(e.Graphics, text, topCell.Style.Font, totalRect, foreColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); using (Pen pen new Pen(_dgv.GridColor)) { e.Graphics.DrawRectangle(pen, totalRect.Left, totalRect.Top, totalRect.Width - 1, totalRect.Height - 1); } e.Handled true; }有几个细节值得展开说一下。第一文本用topCell的 FormattedValue 和 Style是整个合并区域的“代表”而不是当前触发事件的单元格这样能保证视觉上始终一致。第二选中状态我判断的是CurrentCell是否落在该合并区域内只要用户点在这个区域的任意一行整个区域都显示选中背景色更接近 Excel 的交互感觉。第三边框统一用DrawRectangle画外框内部不再绘制横向边框线网格线残留问题就解决了。实测下来这个绘制方式在滚动时也比较稳因为 DataGridView 只对可见行触发绘制不可见的单元格不会反复执行。唯一的代价是如果一个合并区域跨了 50 行且全部可见那么这 50 行触发时会重复画 50 遍同一幅画面但对常规报表来说性能完全可以接受。3.4 接入DataGridView事件辅助类本身不依赖特定窗体接入只需要三步创建实例、绑定事件、数据变化后重算。具体代码如下private DataGridMergeHelper _mergeHelper; private void Form1_Load(object sender, EventArgs e) { _mergeHelper new DataGridMergeHelper(dgv); dgv.DataBindingComplete dgv_DataBindingComplete; dgv.CellPainting dgv_CellPainting; dgv.Sorted dgv_Sorted; dgv.CellValueChanged dgv_CellValueChanged; } private void dgv_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { _mergeHelper.Paint(e); } private void dgv_DataBindingComplete(object sender, DataGridViewBindingCompleteEventArgs e) { _mergeHelper.Recalculate(2, 3); } private void dgv_Sorted(object sender, EventArgs e) { _mergeHelper.Recalculate(2, 3); } private void dgv_CellValueChanged(object sender, DataGridViewCellEventArgs e) { _mergeHelper.Recalculate(2, 3); }这里第 2、3 列是我项目里需要合并的列实际使用时按自己的列索引替换即可。需要合并的列较多时建议把列索引数组定义成常量方便维护。4. 实际项目里的踩坑记录和排查思路这段是重点。再漂亮的原理代码落地时也难免遇到一些“奇怪”的显示问题下面几个坑我基本都踩过。4.1 网格线残留和边框错位很多第一次做自绘合并的同学会遇到合并后的区域内还是有横线或者边框线忽粗忽细。原因很简单DataGridView 默认的单元格边框绘制和你的自绘边框叠加了。虽然设置了 e.Handled true 后当前单元格的默认边框不再绘制但同一个合并区域内的其他单元格可能在早期版本代码里没有完全接管或者左右相邻列的默认边框仍然在画于是出现了“双线”的视觉效果。解决办法有三个方向第一确认合并区域内所有行都走到了辅助类的 Paint 方法不要只在起始行设置 e.Handled第二统一 GridColor让自绘边框颜色和默认网格线颜色一致第三如果边框问题依旧明显可以设置dgv.CellBorderStyle DataGridViewCellBorderStyle.SingleHorizontal先去掉纵向分隔线再通过自绘把合并区域的纵向边框补回来这样干扰最小。4.2 排序、选中、滚动引起的显示问题排序是很常见的操作。点击列头排序后行索引完全变了但合并区域还是按照旧索引计算的结果就是本来应该合并的列显示得乱七八糟。所以一定要在 Sorted 事件里重新调用 Recalculate。选中状态的问题是另一个典型坑。合并区域里点击非起始行时CurrentCell 落在了中间行但默认绘制已经被我们接管选中效果完全依赖 Paint 方法里的判断逻辑。我在 3.3 节用 CurrentCell 坐标判断整块区域是否选中实测交互上是接近 Excel 的。唯一要留意的是用户点击合并区域时鼠标点击位置可能无法定位到文本因为文本只画在合并区域中央而周边区域仍然响应单元格点击这不影响合并效果只是如果你需要双击编辑就会遇到“双击合并区域却定位到中间行”的困惑。我的建议是这类报表场景直接把这些列设为 ReadOnly避免编辑和合并逻辑打架。滚动闪屏在所有自绘方案里都会遇到尤其是表格数据多的时候。WinForms 的 DataGridView 自带的 DoubleBuffered 属性是 protected只能用反射打开typeof(DataGridView) .GetProperty(DoubleBuffered, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) .SetValue(dgv, true);实测下来闪屏改善很明显值得加上。4.3 大数据量下的自绘性能取舍合并区域跨的行数越多重绘次数越多这是我这个方案最大的性能隐患。假设某一列有 10 万行其中合并区域是 9 万行连续相同值虽然 DataGridView 只绘制可见行但如果你用滚轮快速滚动仍然会频繁触发大面积重绘偶尔会有迟滞感。如果遇到这种极端情况建议从两个方向优化一是把合并列放在左边或右边固定的区域避免和其他列频繁交叉重绘二是在 Paint 方法里加一个简单的裁剪判断只有当事件单元格所在矩形和合并区域有交集时才执行完整重绘肉眼基本看不出来差异。如果项目本身数据量巨大更推荐直接用虚拟模式VirtualMode配合行缓存一般报表项目用不到这个复杂度知道有这条路就行。4.4 合并只是“看起来像”导出Excel时还要真合并最后必须提醒一点自绘合并只影响屏幕显示数据层和剪贴板层面DataGridView 的单元格数据依然是“一行一份”。如果用户选中合并区域后复制粘贴到 Excel 里的还是原始的多行多列数据不是合并后的效果。所以涉及导出功能的项目一定要在导出逻辑里再做一次真实的合并。Excel 导出用 NPOI 的话可以通过CellRangeAddress在生成单元格之后合并区域sheet.AddMergedRegion(new NPOI.SS.Util.CellRangeAddress( startRow, endRow, colIndex, colIndex));导出时的合并规则最好和辅助类 Recalculate 里的规则保持一致否则会出现在界面上看着合并了、导出后却是分开的尴尬情况。最后再分享一点我自己的经验。做 DataGridView 合并单元格最重要的不是代码写得多完美而是先想清楚“合并的作用是什么”。如果只是给操作人员看报表自绘方案完全够用轻量、无依赖、好维护。但如果项目里已经引入了 DevExpress 这类成熟控件直接用它们自带的合并功能会更省心毕竟人家把选中、排序、拖拽这些边边角角都处理好了。我个人在轻量工具类项目里仍然保留这套自绘辅助类因为不引入额外组件代码走到哪都能跑。希望这篇东西能帮你少走点弯路遇到显示异常时先想想“绘制顺序”、“边框叠加”、“数据重算”这三个词基本上就能定位到问题。本文还有配套的精品资源点击获取
返回列表