首页 / 资讯中心 / 文章详情

DataGridView合并单元格:CellPainting视觉合并实战

DataGridView合并单元格:CellPainting视觉合并实战 ★ FEATURED ARTICLE
简介围绕 C# 与 .NET Windows Forms 中 DataGridView 控件的单元格合并实现这份材料面向需要制作复杂表格布局、标题行、汇总行或分组表头的桌面应用开发者。压缩包共 22 个文件约 44KB内含 6 个 cs 源码文件、3 个 exe 可执行程序及 pdb 调试符号并配有 resx/resources 资源、csproj/sln 工程与解决方案文件以及 settings、manifest、txt 等配置说明便于直接打开工程对照运行与调试。已有 349 人学习下载。示例重点演示通过自定义绘制完成视觉合并涉及 Paint/OnPaint 事件处理、单元格内容对齐、列宽自动调整、行头主副表头合并以及在合并区域放置按钮、文本框等控件的思路读者可据此理解逻辑合并与视觉合并的差异掌握数据独立存储、绘制边界控制和效果排查方法减少复杂表格界面开发中的试错成本。适合初、中级开发者在实际项目中参考复用。1. DataGridView 合并单元格原生控件没有开关先别急着找属性做仓储报表时把相同仓库的几十行合并成一个视觉块是 WinForms 里最常被问到的一类需求。DataGridView 没有 CellMerge 属性翻遍属性面板也找不到第一次做的人很容易在这里翻车。这套方案用 CellPainting 做视觉合并不改数据源适合只读展示、分组报表和轻度交互的桌面端。它解决的是“看起来合并”不是“数据真的变成一行”所以导出、复制、排序这些边界要提前想清楚。下面从选型、代码接入、参数调到踩坑排查按能复现的顺序拆开。2. 先选路线视觉合并、数据层合并与第三方控件的边界2.1 CellPainting 视觉合并的绘制模型DataGridView 的每个单元格都是独立绘制的绘制入口在CellPainting事件。默认流程会先画背景、边框再画内容把e.Handled设为true就等于告诉控件“这个格子你别管了我自己画”。视觉合并的思路很直接扫描某一列相邻行值相同的行归成一个区域只在区域首行绘制一段跨行矩形和文本区域内其他行跳过默认绘制。这样数据源一行没少界面上看起来是一个合并块。这个模型的好处是接入成本低不用改 DataTable 结构也不用重写绑定逻辑。缺点是它只改“画法”不改“数据”合并区域下方那些单元格里仍然有值复制、导出、键盘导航、编辑都会按真实单元格走。常见做法是配合ReadOnly true、AllowUserToAddRows false把交互收窄到只读展示。如果业务需要编辑合并块就得额外处理当前单元格跳转和值同步否则会出现“改了首行、下面行还是旧值”的玄学现象。另一个容易误用的点是CellFormatting。有人把重复值格式化成空字符串以为这样就是合并结果复制出来是空的导出也丢值内部横线还在。CellFormatting改的是显示内容不是布局它不会让两个单元格变成一个矩形。视觉合并必须落到绘制层CellFormatting最多用来做空值占位不能替代CellPainting。2.2 数据层合并与第三方控件的适用条件数据层合并是在绑定前把相同值行压成一行其他列用换行、汇总或子表展示。它适合只读报表、打印稿、导出文件因为数据本身就是合并后的形态复制和打印不会出现重复值。代价是丢失明细行排序、筛选、分组都要自己维护遇到“点击合并块展开明细”就得再设计一套交互。对于财务月报、库存汇总这类最终产物数据层合并更稳对于需要保留每一行明细的台账视觉合并更合适。第三方表格控件通常直接提供MergeCells或类似属性编辑、打印、导出都有现成支持。选型时不要只看“能不能合并”要看排序后是否自动重算、复制是否带合并信息、高 DPI 下边框是否对齐、打印是否分页正确。预算允许且项目长期维护商业控件能省不少血泪经验只是临时做一个内部小工具用CellPainting自己画更轻。方案数据是否改变编辑支持导出/打印适用场景CellPainting 视觉合并否需额外处理需按区域去重只读台账、分组展示数据层合并是天然支持直接输出汇总报表、打印稿第三方控件视控件通常完整通常完整长期维护、复杂交互2.3 合并区域的建模列、起始行、行数、显示值不管用哪种绘制方式合并区域都要先建模。最小字段是列索引、起始行索引、行数、显示值。列索引按显示顺序算插入隐藏列后要重新核对起始行和行数描述相邻行范围显示值取首行单元格的值避免绘制时反复读数据源。扫描时只合并相邻且值相同的行空值和DBNull默认不合并否则报表里会出现大片空白块。缓存要有失效机制。数据绑定完成、排序、列宽变化、行高变化、单元格值变化都应该把缓存标记为脏下一次绘制时重建。重建成本是 O(行数 × 合并列数)几千行以内可以接受如果数据量很大可以在DataBindingComplete后一次性重建滚动时只做绘制不重复扫描。绘制矩形不要缓存每次用GetCellDisplayRectangle现算否则滚动或调整列宽后会出现错位。提示合并列索引按显示顺序算插入隐藏列后要重新核对。3. 把合并逻辑挂到控件上一个可复现的 MergeCellsHelper3.1 合并区域扫描与缓存先建一个区域模型再写辅助类骨架。下面这段代码可以直接放进 WinForms 项目编译目标是 .NET Framework 4.7.2 或 .NET 6 的 Windows 桌面项目。MergeCellRange只保存必要字段MergeCellsHelper负责事件挂接、缓存重建和绘制。using System; using System.Collections.Generic; using System.Drawing; using System.Globalization; using System.Linq; using System.Windows.Forms; public sealed class MergeCellRange { public int ColumnIndex { get; init; } public int FirstRowIndex { get; init; } public int RowCount { get; init; } public object? Value { get; init; } } public sealed class MergeCellsHelper : IDisposable { private readonly DataGridView _grid; private readonly int[] _mergeColumnIndexes; private readonly Dictionary(int Column, int Row), MergeCellRange _cellToRange new(); private readonly ListMergeCellRange _ranges new(); private bool _dirty true; private bool _painting; public MergeCellsHelper(DataGridView grid, params int[] mergeColumnIndexes) { _grid grid ?? throw new ArgumentNullException(nameof(grid)); _mergeColumnIndexes mergeColumnIndexes .Distinct() .OrderBy(i i) .ToArray(); if (_mergeColumnIndexes.Length 0) throw new ArgumentException(至少指定一个合并列索引。, nameof(mergeColumnIndexes)); _grid.CellPainting OnCellPainting; _grid.CellClick OnCellClick; _grid.CellBeginEdit OnCellBeginEdit; _grid.CellValueChanged OnCellValueChanged; _grid.DataBindingComplete OnDataChanged; _grid.Sorted OnDataChanged; _grid.Scroll OnLayoutChanged; _grid.ColumnWidthChanged OnLayoutChanged; _grid.RowHeightChanged OnLayoutChanged; _grid.SelectionChanged OnSelectionChanged; } public void RefreshMerges() { _dirty true; _grid.Invalidate(); } public bool TryGetMergeRange(int columnIndex, int rowIndex, out MergeCellRange? range) { RebuildRangesIfNeeded(); return _cellToRange.TryGetValue((columnIndex, rowIndex), out range); } private void OnDataChanged(object? sender, EventArgs e) { _dirty true; _grid.Invalidate(); } private void OnLayoutChanged(object? sender, EventArgs e) { _dirty true; _grid.Invalidate(); } private void OnSelectionChanged(object? sender, EventArgs e) { _grid.Invalidate(); } private void OnCellValueChanged(object? sender, DataGridViewCellEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; if (_mergeColumnIndexes.Contains(e.ColumnIndex)) { _dirty true; _grid.Invalidate(); } } private void RebuildRangesIfNeeded() { if (!_dirty) return; _dirty false; _ranges.Clear(); _cellToRange.Clear(); int rowCount _grid.Rows.Count; if (rowCount 0) return; int start 0; while (start rowCount _grid.Rows[start].IsNewRow) start; if (start rowCount) return; foreach (int col in _mergeColumnIndexes) { int i start; while (i rowCount) { if (_grid.Rows[i].IsNewRow) break; string key GetCellKey(i, col); int j i 1; while (j rowCount !_grid.Rows[j].IsNewRow GetCellKey(j, col) key) { j; } int count j - i; if (count 1 !string.IsNullOrEmpty(key)) { var range new MergeCellRange { ColumnIndex col, FirstRowIndex i, RowCount count, Value _grid.Rows[i].Cells[col].Value }; _ranges.Add(range); for (int r i; r j; r) _cellToRange[(col, r)] range; } i j; } } } private string GetCellKey(int rowIndex, int columnIndex) { object? value _grid.Rows[rowIndex].Cells[columnIndex].Value; return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; } public void Dispose() { _grid.CellPainting - OnCellPainting; _grid.CellClick - OnCellClick; _grid.CellBeginEdit - OnCellBeginEdit; _grid.CellValueChanged - OnCellValueChanged; _grid.DataBindingComplete - OnDataChanged; _grid.Sorted - OnDataChanged; _grid.Scroll - OnLayoutChanged; _grid.ColumnWidthChanged - OnLayoutChanged; _grid.RowHeightChanged - OnLayoutChanged; _grid.SelectionChanged - OnSelectionChanged; } // 下面三个方法在 3.2 与 4.2 中补齐 private void OnCellPainting(object? sender, DataGridViewCellPaintingEventArgs e) { } private void OnCellClick(object? sender, DataGridViewCellEventArgs e) { } private void OnCellBeginEdit(object? sender, DataGridViewCellCancelEventArgs e) { } }这段骨架的关键点在RebuildRangesIfNeeded它按每个合并列独立扫描不把多列拼成一个组合键所以“仓库”列和“物料”列可以各自合并互不干扰。GetCellKey统一转成字符串再比较避免int、decimal、DateTime混用时Equals行为不一致。\u001f分隔符没有用上是因为这里按单列扫描如果后面改组合键用它拼接可以避免值里包含逗号导致键冲突。空字符串不合并DBNull也会变成空字符串跳过避免整列空值连成一个大块。3.2 CellPainting 中绘制跨行矩形绘制方法要处理四件事算出合并区域矩形、画背景、画边框、画文本。下面这段是核心直接替换骨架里的空方法。每个合并单元格都设置e.Handled true并且都按整个区域矩形绘制这样滚动到合并区域中间时不会因为首行移出视口而出现空白。代价是区域内每个单元格都会重复画一次行数特别大时要按 4.3 的优化项收窄。private void OnCellPainting(object? sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; if (!_mergeColumnIndexes.Contains(e.ColumnIndex)) return; if (_painting) return; RebuildRangesIfNeeded(); if (!_cellToRange.TryGetValue((e.ColumnIndex, e.RowIndex), out var range)) return; _painting true; try { e.Handled true; Rectangle firstCell _grid.GetCellDisplayRectangle( range.ColumnIndex, range.FirstRowIndex, false); Rectangle lastCell _grid.GetCellDisplayRectangle( range.ColumnIndex, range.FirstRowIndex range.RowCount - 1, false); if (firstCell.Height 0 || lastCell.Height 0) { firstCell e.CellBounds; lastCell e.CellBounds; } Rectangle union Rectangle.FromLTRB( firstCell.Left, firstCell.Top, firstCell.Right, lastCell.Bottom); var style _grid.Rows[range.FirstRowIndex] .Cells[range.ColumnIndex] .InheritedStyle; bool selected IsRangeSelected(range); Color backColor selected ? style.SelectionBackColor : style.BackColor; Color foreColor selected ? style.SelectionForeColor : style.ForeColor; using (var backBrush new SolidBrush(backColor)) e.Graphics.FillRectangle(backBrush, union); using (var borderPen new Pen(_grid.Grid p a hrefhttps://download.csdn.net/download/arbinghappy/3029827 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
阅读完成 · 觉得有帮助?
咨询建站