ARTICLE DETAIL

资讯详情

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

C# WinForms DataGridView 用户自定义列显示工具类设计与实现

C# WinForms DataGridView 用户自定义列显示工具类设计与实现 前阵子接到一个设备管理系统的改造需求客户的原话是这个表格太挤了我在意的就那几列你们能不能让我自己勾选要显示哪些列听起来很简单但真正做起来才发现这个用户自定义列显示的需求如果每个窗体都手写一遍就是一个无底洞。也是从那次改造之后我把这套逻辑沉淀成了一个独立的C#工具类——主窗体不再关心列显隐、列顺序、列宽怎么持久化只需要一行方法调用就能让DataGridView具备完整的用户自定义列能力。这篇文章就把这套工具类的设计思路、完整代码和踩坑过程分享出来适合正在做WinForms项目、天天和DataGridView打交道、又不想每个窗体重复造轮子的朋友。1. 为什么非要做这个工具类一次需求改造攒下的教训1.1 需求来了但代码不能崩当时项目里有十几个列表窗体每个窗体都用了DataGridView展示业务数据。客户提的需求本质上是三件事用户能勾选显示哪些列、能拖动调整列顺序、能改变列宽而且这些设置关掉软件再打开要还在。如果放在单个窗体上这三件事并不复杂——DataGridView的ColumnVisible、DisplayIndex、Width属性都是现成的拖一拖就实现了。真正麻烦的是十几个窗体都要有同样能力而且每个窗体的默认列布局还不一样。我第一反应是在每个窗体复制粘贴一套列显示逻辑写到第三个窗体的时候我就意识到不对劲光是一个列配置弹窗就要写二三百行事件绑定、配置读写、异常处理全都要重复后面万一要改逻辑十几处同步修改想想都头大。1.2 手工实现的三大硬伤我们当时的代码里其实已经有窗体级的自定义列设置了但问题非常典型。第一个问题是配置存储散落各处。有的窗体用ini文件存有的用注册表有的干脆只在内存里软件重启就丢失。第二个问题是列配置和业务代码深度耦合。列表窗体里到处是dataGridView1.Columns[单价].Visible user.IsAdmin这种代码列显示逻辑散落在按钮点击、用户权限判断、数据加载等各个角落哪天想统一调整列布局根本无从下手。第三个问题是用户设置没法迁移。用户在这台机器上辛苦调整好的列布局换台电脑就回到了默认客户对这个意见特别大。这三大硬伤让我下决心做一次彻底的重构把用户自定义列显示这件事整个抽到一个工具类里所有窗体共享一套逻辑而且主窗体调用必须简单到极致。1.3 用工具类之后的形态对比重构完成之后窗体代码变成了这样// 重构前手工实现每个窗体都要写几十行 private void Form_Load(object sender, EventArgs e) { dataGridView1.DataSource LoadOrders(); // 以下逻辑每个窗体都要复制一遍 dataGridView1.Columns[Remark].Visible false; dataGridView1.Columns[Creator].Visible false; dataGridView1.Columns[OrderId].DisplayIndex 2; dataGridView1.Columns[OrderId].Width 120; string path Path.Combine(Application.StartupPath, config.ini); // 还有读写配置、弹窗勾选等一坨…… } // 重构后一行调用 private void Form_Load(object sender, EventArgs e) { dataGridView1.ApplyColumnConfig(OrderGrid, LoadOrders()); }主窗体拿到的是一个已经按用户偏好设置好列显隐、列顺序、列宽的结果后续所有交互都交给工具类内部处理。用同事的话说这已经不是省事的问题了是根本不需要动脑了。1.4 工具类设计的三条底线在动手之前我给自己定了三条底线后来发现这三条底线恰好就是工具类被大家接受的关键。第一条调用必须一行搞定。主窗体不该知道配置文件的路径、不该知道默认列定义存在哪、也不该关心用户配置和默认配置怎么合并这些全是工具类的内部事务。第二条工具类不依赖任何具体业务窗体。它只操作DataGridView和列名业务列名通过参数传入这样工具类才能跨项目复用。第三条默认布局和用户配置必须分离。开发者在设计期拖出来的列布局是默认值用户改完了是自定义值两者不能互相覆盖但用户设置优先于默认布局。有了这三条底线后面的结构设计就有了方向。2. 工具类的核心结构配置数据与两套布局的博弈2.1 列配置的最小数据模型既然要持久化列布局第一步是定义数据模型。我设计了一个ColumnConfig类属性不多但每个属性都对应DataGridView列的一个用户可调整维度[Serializable] public class ColumnConfig { /// summary列名对应DataGridViewColumn.Name或DataPropertyName/summary public string Name { get; set; } /// summary列标题有些业务需要在运行时动态修改表头/summary public string HeaderText { get; set; } /// summary是否可见/summary public bool Visible { get; set; } true; /// summary列的显示顺序从0开始/summary public int DisplayIndex { get; set; } /// summary列宽0表示使用默认宽度/summary public int Width { get; set; } 100; /// summary保存列配置时容器的宽度用于DPI比例还原/summary public int GridWidthAtSave { get; set; } /// summary列是否允许调整宽度/summary public bool Resizable { get; set; } true; /// summary最小列宽防止用户把列拖没了/summary public int MinWidth { get; set; } 30; }GridWidthAtSave这个属性是后来补的原因后面在第4部分踩坑里会详细讲。核心思路是保存列宽的时候顺带把当时DataGridView的宽度也记下来应用的时候按比例换算这样在不同分辨率和DPI环境下列宽给人的视觉占比是接近的。2.2 默认配置与用户配置双层的意义工具类里最核心的机制是默认配置 用户配置双层结构。默认配置Default是开发者在设计器里拖出来的列布局或者手动在XML里定义的基准布局。它的作用是程序一运行即使没有任何用户配置列表也按开发者设计的样式展示。用户配置User是用户实际调整后的结果它比默认配置优先级高。为什么要分层我举个实际场景项目上线后客户说订单号列放最前面备注列默认隐藏这是默认布局的调整。如果只存一套配置一旦用户已经调整过列顺序开发者的默认布局更新就永远无法生效用户看到的还是旧布局。双层结构的好处是——更新默认配置时只要用户没有改过某一项就沿用新默认值用户明确改过的项则尊重用户的选择。具体来说应用配置的逻辑是这样的public static void ApplyColumnConfig(DataGridView grid, string gridName, object dataSource) { // 1. 绑定数据源让列先自动生成 grid.DataSource dataSource; // 2. 从嵌入资源或文件加载默认布局 ListColumnConfig defaults LoadDefaultConfig(gridName); if (defaults null || defaults.Count 0) { // 没有默认配置就把当前设计器状态记录下来作为默认 defaults CaptureCurrentLayout(grid); SaveDefaultConfig(gridName, defaults); } // 3. 先把默认布局应用一遍 ApplyLayout(grid, defaults); // 4. 加载用户配置如果存在则覆盖默认布局 ListColumnConfig userConfig LoadUserConfig(gridName); if (userConfig ! null userConfig.Count 0) { ApplyLayout(grid, userConfig, onlyOverrides: true); } }注意第4步的onlyOverrides: true它是双层的精髓。用户配置在保存时记录了用户改过哪些列、改成了什么值应用时只覆盖那些确实被用户调整过的项目而不是整份替换。这样就算默认布局更新了用户没碰过的列会跟着新默认走用户碰过的列保持用户设置。2.3 配置文件怎么落盘存储我选择了XML文件原因是WinForms项目里XML读写简单、可读性好、调试的时候打开文件一眼能看懂不太需要上SQLite或JSON这类重量级方案。文件目录结构这样组织Application.StartupPath/ ├── ColumnConfigs/ │ ├── defaults/ │ │ ├── OrderGrid.xml // 默认布局 │ │ └── ProductGrid.xml │ └── users/ │ └── admin/ │ ├── OrderGrid.xml // 用户admin对OrderGrid的自定义布局 │ └── ProductGrid.xml按用户名分目录的好处是支持多用户隔离。我不知道你们项目是什么情况反正我们客户那边好几台电脑共用服务器上的同一个程序Windows账号也各不相同我干脆用Environment.UserName区分。后面如果想做每个角色一套布局把admin换成角色名或者用户ID就行。序列化和反序列化直接用的是XmlSerializerprivate static void SaveUserConfig(string gridName, ListColumnConfig configs) { try { string dir Path.Combine( Application.StartupPath, ColumnConfigs, users, Environment.UserName); Directory.CreateDirectory(dir); string path Path.Combine(dir, gridName .xml); var serializer new XmlSerializer(typeof(ListColumnConfig)); using (var fs new FileStream(path, FileMode.Create)) { serializer.Serialize(fs, configs); } } catch (Exception ex) { // 保存失败不能影响主流程记录日志即可 Trace.WriteLine(保存列配置失败: ex.Message); } }注意保存操作不能把异常抛给调用方。用户调整列布局是一个辅助功能保存失败顶多下次打开恢复旧布局绝不能因为写文件失败导致程序崩溃。这点我在代码里特意加了try-catch也建议大家都这么做。那用户改过哪列这个信息怎么记录呢我用的方案是列配置里加一个IsUserModified的标记只有用户主动调整过的列才标记为true。监听DataGridView的事件时把对应列标记上。这样应用用户配置时只处理标记过的列天然实现了部分覆盖。public class ColumnConfig { // ……其他属性不变 public bool IsUserModified { get; set; } }3. 主窗体一行调用的实现原理从绑定到应用3.1 方法入口设计主窗体只需一个方法这句话听上去很玄实际上就是一个标准的门面模式。工具类把复杂的配置合并逻辑都藏在内部对外只暴露一个静态方法。为了让调用更自然我同时提供了一组扩展方法public static class DataGridViewColumnExtensions { /// summary /// 应用用户自定义列配置推荐扩展方法写法 /// /summary public static void ApplyColumnConfig( this DataGridView grid, string gridName, object dataSource) { DataGridViewColumnHelper.ApplyColumnConfig(grid, gridName, dataSource); } /// summary /// 保存当前列布局为用户配置 /// /summary public static void SaveUserColumnConfig( this DataGridView grid, string gridName) { DataGridViewColumnHelper.SaveCurrentLayout(grid, gridName, userModifiedOnly: true); } }扩展方法的好处是调用侧几乎是无感知的主窗体代码看起来就像DataGridView原生自带的能力一样写起来顺手读代码的人也一眼能明白。3.2 主窗体三行代码接入接入一个列表窗体核心只需要三行代码// Form_Load中加载数据并应用列配置 dataGridView1.ApplyColumnConfig(OrderGrid, LoadOrders()); // Form_FormClosing中保存用户调整结果 dataGridView1.SaveUserColumnConfig(OrderGrid);LoadOrders()返回一个DataTable。这里有一个重要的设计取舍为什么工具类要求外部传入已经绑定好的数据源而不是自己从别的地方取数据因为工具类不关心业务数据从哪来它只关心列长什么样。传数据源进来它就能拿到列集合能生成默认配置能匹配列名但对于数据本身它不碰也不改。如果你希望用户调整列布局的同时不重新加载数据也可以只传grid不传dataSource。我额外提供了一个重载// 只应用配置不重新加载数据 public static void ApplyColumnConfig(DataGridView grid, string gridName) { if (grid.DataSource null) return; var defaults LoadDefaultConfig(gridName); if (defaults null) defaults CaptureCurrentLayout(grid); ApplyLayout(grid, defaults); var userConfig LoadUserConfig(gridName); if (userConfig ! null) ApplyLayout(grid, userConfig, true); }3.3 每次调用做了什么为了让读者完全掌握工具类的行为我把一次ApplyColumnConfig调用的完整内部流程拆开来讲。步骤一绑定数据源。这一步会触发列自动生成确保后续操作有列可循。如果你的窗体设置了AutoGenerateColumns false并且自己定义了列那这一步不会覆盖你的列工具类依然能识别。步骤二准备默认配置。工具类先检查ColumnConfigs/defaults/OrderGrid.xml是否存在。不存在就把当前DataGridView的列布局抓下来存成默认配置。这里有个细节抓默认配置时要遍历的是grid.Columns而不是数据源的字段列表这样连设计器里设置过的列宽、列头文本也能一并记录下来。步骤三应用默认配置。把默认XML里的每一项设置到对应列上包括Visible、DisplayIndex、Width、HeaderText。应用DisplayIndex时必须特别小心不能一股脑乱设否则DataGridView会抛出ArgumentException这个在第4部分的踩坑环节详细说。步骤四加载用户配置并覆盖。读取users/{UserName}/OrderGrid.xml如果存在就只把其中IsUserModified true的项覆盖上去。步骤五收尾。将grid的AutoGenerateColumns设为false避免后续数据源刷新又把列重置掉然后触发一次Refresh保证界面立即更新。整个流程走下来主窗体真的就只要一行调用。工具类代码见第4部分末尾的完整文件那里包含了全部实现。4. 实测中的四个坑排查链路与最终修复4.1 AutoGenerateColumns 引发的列错乱第一个遇到的坑是列对不上号。现象程序一启动DataGridView里的列名全变成数据源的字段名了设计器里拖出来的那些列定义全不见了。比如设计器里定义了一列叫订单号列名是colOrderIdDataPropertyName是OrderId但运行后这列变成了数据库真实的OrderId字段名列宽、列头文本全部丢失。原因排查DataGridView的AutoGenerateColumns默认是true给DataSource赋值后它会根据数据源的字段自动生成列覆盖掉设计器里手动的列定义。修复方案工具类在绑定数据源之后如果检测到列都是自动生成的就用grid.Columns[i].DataPropertyName作为列标识同时把工具类记录的Name统一修正为DataPropertyName。更彻底的做法是应用完配置之后显式把AutoGenerateColumns设为false防止后续再次赋值数据源时列被重新生成。这里我建议所有用这套工具类的窗体在设计器里定义列时就把列的Name和DataPropertyName保持一致例如都叫OrderId这样工具类的列名匹配会简单很多。如果项目里已经有很多窗体列名不规范工具类里用一个字典做映射兼容。4.2 DisplayIndex 冲突导致程序直接抛异常第二个坑是最让人头疼的应用配置时程序偶发崩溃报ArgumentException: DisplayIndex 的值无效。原因DataGridView要求同一行的列DisplayIndex必须是0到N-1的不重复值。如果配置里出现两条列配置都给DisplayIndex0或者有一条列配置给的DisplayIndex超出了当前列数范围设置时就会抛异常。从配置文件层面看这个错误可能来自手工编辑XML、新老版本列定义不一致某列被删了但配置里还留着或者两个用户配置合并出错。修复方案在应用配置之前先做一次索引归一化——把要设置的列按DisplayIndex从小到大排序再把0、1、2……依次分配给排好序的列。private static void NormalizeDisplayIndex(ListColumnConfig configs) { // 过滤掉配置里引用但实际不存在的列 // 然后按DisplayIndex排序重新分配连续索引 int index 0; foreach (var cfg in configs.OrderBy(c c.DisplayIndex)) { cfg.DisplayIndex index; } }配合一个过滤逻辑把配置里引用了、但DataGridView中实际不存在的列过滤掉var validColumns configs .Where(c grid.Columns.Contains(c.Name)) .OrderBy(c c.DisplayIndex); int idx 0; foreach (var cfg in validColumns) { grid.Columns[cfg.Name].DisplayIndex idx; }这个方法能让任何配置源都能安全应用到DataGridView上我已经在很多奇怪场景下验证过了。4.3 保存时机没把握好退出时丢配置第三个坑是配置丢失。现象用户明明调整好了列布局关掉程序再打开一切回到解放前。一开始我怀疑是文件没写进去翻了半天日志发现保存方法根本没被调用。原因我看了一下当时窗体的关闭流程FormClosing事件里保存配置但用户是通过右上角X按钮关的窗体按道理FormClosing会触发。问题出在我用的保存方法是SaveCurrentLayout(grid, gridName)里面遍历grid.Columns读取列状态。而有些窗体的Closing事件里先执行了dataGridView1.DataSource null的清理逻辑导致遍历时列都没了保存了一个空配置然后这个空配置覆盖了之前正常保存的文件。修复方案一是约定保存逻辑放到BaseForm的FormClosing事件并在所有业务清理代码之前执行二是在保存方法里加保护当grid.Columns.Count 0时干脆跳过保存三是保存时追加一份临时文件备份防止覆盖坏配置后无法恢复。public static void SaveCurrentLayout(DataGridView grid, string gridName) { if (grid null || grid.Columns.Count 0) return; // 先把当前布局抓下来再写文件保证不会因为DataSource被清空而保存空配置 var configs CaptureCurrentLayout(grid); if (configs.Count 0) return; SaveUserConfig(gridName, configs); }另外Close时保存和每次列变化就保存是两种策略我最后采用了事件触发延时保存的组合。监听ColumnDisplayIndexChanged、ColumnWidthChanged、ColumnVisibleChanged、ColumnHeaderTextChanged这四类事件触发后延迟600ms写入文件避免用户拖动列宽时频繁写磁盘。具体实现我在完整文件里有思路就是用一个CancellationTokenSource做防抖。4.4 高分屏下列宽保存失真第四个坑是在带鱼屏上测试时发现的。现象在开发机1080p屏幕上把列宽调整成150换到2K屏上看明显变窄了换到150%缩放的机器上更是比例失调。原因分析列宽保存时记录的是像素值屏幕DPI不同、窗体缩放不同同样是150像素在1080p低缩放下占的表格宽度比例和在2K屏高缩放下完全不同。光存像素没有意义。修复方案保存列宽时同时记录当时DataGridView的ClientSize.Width应用时按比例换算// 保存时 cfg.Width col.Width; cfg.GridWidthAtSave grid.ClientSize.Width; // 应用时 if (cfg.Width 0 cfg.GridWidthAtSave 0) { // 按当前容器宽度等比缩放 col.Width Math.Max(cfg.MinWidth, (int)(cfg.Width * (double)grid.ClientSize.Width / cfg.GridWidthAtSave)); }这个方案虽然不是像素级精确但视觉比例是一致的。从用户体验上讲用户并不关心列宽是多少像素关心的是这列在我屏幕上大概占这么宽所以比例换算是对的。5. 还能怎么扩展配置联动导出、分组与重置工具类成套之后后面加需求就变得很轻松了。我陆续加了三块扩展这里也顺带说说思路。5.1 给DataGridView加右键菜单用户自定义列最常见的操作入口是表格表头右键菜单里面放一个子菜单列出所有列前面带勾选框public static void AttachColumnPickerMenu(DataGridView grid, string gridName) { var contextMenu new ContextMenuStrip(); // 每列一个CheckBox风格的菜单项 foreach (DataGridViewColumn col in grid.Columns) { var item new ToolStripMenuItem(col.HeaderText) { Checked col.Visible, CheckOnClick true, Tag col.Name }; item.CheckedChanged (s, e) { var name ((ToolStripMenuItem)s).Tag.ToString(); grid.Columns[name].Visible ((ToolStripMenuItem)s).Checked; grid.SaveUserColumnConfig(gridName); // 立即保存 }; contextMenu.Items.Add(item); } grid.ContextMenuStrip contextMenu; }这个右键菜单和工具类的保存逻辑天然契合——用户勾选完立刻保存下次打开自动还原。5.2 与Excel导出联动客户后来提了个需求我在界面上勾选了哪些列导出Excel的时候也要一样的列。这正好可以用同一份配置。导出时拿到用户配置过滤掉不可见列按DisplayIndex排序然后按顺序导出public static ListColumnConfig GetAppliedConfig(DataGridView grid, string gridName) { // 返回当前应该展示的列配置列表已排序、已过滤 }导出模块就不需要关心用户勾选了哪些列直接从这个方法拿列配置就行。5.3 按角色隔离配置如果项目里做了RBAC权限体系列配置可以升级为按角色存一份。只需要把目录从users/{UserName}改成roles/{RoleName}或者在配置文件里增加一个RoleId字段在加载时用当前用户角色过滤。基本思路一致不增加工具类的复杂度。完整工具类文件最后把整个工具类的完整代码放上来包含前面所有提到的功能。代码基于.NET Framework 4.7.2 / .NET 6 WinForms都可用引入了System.Xml.Serialization、System.IO、System.Linq、System.Threading.Tasks等常用命名空间。using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using System.Xml.Serialization; namespace Common.WinForms { /// summary /// DataGridView 列配置项 /// /summary [Serializable] public class ColumnConfig { public string Name { get; set; } public string HeaderText { get; set; } public bool Visible { get; set; } true; public int DisplayIndex { get; set; } public int Width { get; set; } 100; public int GridWidthAtSave { get; set; } public int MinWidth { get; set; } 30; public bool Resizable { get; set; } true; public bool IsUserModified { get; set; } } /// summary /// DataGridView 自定义列显示工具类 /// /summary public static class DataGridViewColumnHelper { private static readonly Dictionarystring, CancellationTokenSource _saveTokens new Dictionarystring, CancellationTokenSource(); #region 对外开放的静态方法 public static void ApplyColumnConfig(DataGridView grid, string gridName, object dataSource) { if (grid null) throw new ArgumentNullException(nameof(grid)); if (string.IsNullOrEmpty(gridName)) throw new ArgumentNullException(nameof(gridName)); if (dataSource ! null) { grid.DataSource dataSource; } // 1. 加载默认配置没有就基于当前列生成 ListColumnConfig defaults LoadDefaultConfig(gridName); if (defaults null || defaults.Count 0) { defaults CaptureCurrentLayout(grid); SaveDefaultConfig(gridName, defaults); } // 2. 应用默认配置 ApplyLayout(grid, defaults); // 3. 应用用户配置如果有 ListColumnConfig userConfig LoadUserConfig(gridName); if (userConfig ! null userConfig.Count 0) { ApplyLayout(grid, userConfig, onlyUserModified: true); } // 4. 防止后续再次绑定数据源时列被自动生成 grid.AutoGenerateColumns false; grid.Refresh(); // 5. 绑定事件让用户操作自动标记并保存 HookEvents(grid, gridName); } /// summary /// 保存当前列布局为用户配置并标记所有列均为用户修改 /// /summary public static void SaveCurrentLayout(DataGridView grid, string gridName) { if (grid null || grid.Columns.Count 0) return; ListColumnConfig configs CaptureCurrentLayout(grid, markAllAsUserModified: true); SaveUserConfig(gridName, configs); } /// summary /// 重置用户配置回到默认布局 /// /summary public static void ResetToDefault(string gridName) { string path GetUserConfigPath(gridName); if (File.Exists(path)) File.Delete(path); } #endregion #region 配置的读写 private static ListColumnConfig LoadDefaultConfig(string gridName) { string path Path.Combine( Application.StartupPath, ColumnConfigs, defaults, gridName .xml); return LoadFromFile(path); } private static ListColumnConfig LoadUserConfig(string gridName) { string path GetUserConfigPath(gridName); return LoadFromFile(path); } private static string GetUserConfigPath(string gridName) { return Path.Combine( Application.StartupPath, ColumnConfigs, users, Environment.UserName, gridName .xml); } private static ListColumnConfig LoadFromFile(string path) { if (!File.Exists(path)) return null; try { var serializer new XmlSerializer(typeof(ListColumnConfig)); using (var fs new FileStream(path, FileMode.Open)) { return (ListColumnConfig)serializer.Deserialize(fs); } } catch (Exception ex) { Trace.WriteLine(读取列配置失败: ex.Message); return null; } } private static void SaveDefaultConfig(string gridName, ListColumnConfig configs) { string dir Path.Combine(Application.StartupPath, ColumnConfigs, defaults); SaveToFile(dir, gridName .xml, configs); } private static void SaveUserConfig(string gridName, ListColumnConfig configs) { string dir Path.Combine( Application.StartupPath, ColumnConfigs, users, Environment.UserName); SaveToFile(dir, gridName .xml, configs); } private static void SaveToFile(string dir, string fileName, ListColumnConfig configs) { try { Directory.CreateDirectory(dir); string path Path.Combine(dir, fileName); var serializer new XmlSerializer(typeof(ListColumnConfig)); // 先写临时文件全部成功后再覆盖正式文件防止写一半程序崩溃导致配置文件损坏 string tempPath path .tmp; using (var fs new FileStream(tempPath, FileMode.Create)) { serializer.Serialize(fs, configs); } File.Copy(tempPath, path, true); File.Delete(tempPath); } catch (Exception ex) { Trace.WriteLine(保存列配置失败: ex.Message); } } #endregion #region 布局抓取与应用 private static ListColumnConfig CaptureCurrentLayout(DataGridView grid, bool markAllAsUserModified false) { var list new ListColumnConfig(); foreach (DataGridViewColumn col in grid.Columns) { var cfg new ColumnConfig { Name col.Name, HeaderText col.HeaderText, Visible col.Visible, DisplayIndex col.DisplayIndex, Width col.Width, GridWidthAtSave grid.ClientSize.Width, MinWidth col.MinimumWidth, Resizable col.Resizable ! DataGridViewTriState.False }; if (markAllAsUserModified) cfg.IsUserModified true; list.Add(cfg); } return list; } private static void ApplyLayout(DataGridView grid, ListColumnConfig configs, bool onlyUserModified false) { if (configs null) return; // 过滤掉配置里引用但实际不存在的列 var valid configs .Where(c grid.Columns.Contains(c.Name)) .ToList(); // 先按DisplayIndex排序归一化索引 var sorted valid.OrderBy(c c.DisplayIndex).ToList(); int idx 0; foreach (var cfg in sorted) { if (onlyUserModified !cfg.IsUserModified) continue; grid.Columns[cfg.Name].DisplayIndex idx; } // 再设置其余属性 foreach (var cfg in valid) { if (onlyUserModified !cfg.IsUserModified) continue; var col grid.Columns[cfg.Name]; if (!string.IsNullOrEmpty(cfg.HeaderText)) col.HeaderText cfg.HeaderText; bool wasVisible col.Visible; col.Visible cfg.Visible; // 只有从隐藏变为可见时才需要调整Width否则可能抛异常 if (cfg.Visible cfg.Width 0) { if (cfg.GridWidthAtSave 0 grid.ClientSize.Width 0) { col.Width Math.Max(cfg.MinWidth, (int)(cfg.Width * (double)grid.ClientSize.Width / cfg.GridWidthAtSave)); } else { col.Width Math.Max(cfg.MinWidth, cfg.Width); } } if (cfg.MinWidth 0) col.MinimumWidth cfg.MinWidth; if (!cfg.Resizable) col.Resizable DataGridViewTriState.False; } } #endregion #region 用户交互事件监听与防抖保存 private static void HookEvents(DataGridView grid, string gridName) { // 防止重复挂事件 grid.Tag ColumnConfigHooked: gridName; grid.ColumnDisplayIndexChanged - (s, e) OnColumnChanged(grid, gridName); grid.ColumnWidthChanged - (s, e) OnColumnChanged(grid, gridName); grid.ColumnVisibleChanged - (s, e) OnColumnChanged(grid, gridName); grid.ColumnHeaderTextChanged - (s, e) OnColumnChanged(grid, gridName); grid.ColumnDisplayIndexChanged (s, e) OnColumnChanged(grid, gridName); grid.ColumnWidthChanged (s, e) OnColumnChanged(grid, gridName); grid.ColumnVisibleChanged (s, e) OnColumnChanged(grid, gridName); grid.ColumnHeaderTextChanged (s, e) OnColumnChanged(grid, gridName); } private static void OnColumnChanged(DataGridView grid, string gridName) { // 防抖600ms内如果没有新的改动再真正保存 if (_saveTokens.TryGetValue(gridName, out var old)) { old?.Cancel(); old?.Dispose(); } var cts new CancellationTokenSource(); _saveTokens[gridName] cts; Task.Delay(600, cts.Token).ContinueWith(t { if (t.IsCanceled || grid.IsDisposed) return; if (grid.InvokeRequired) { grid.BeginInvoke(new Action(() { var configs CaptureCurrentLayout(grid, markAllAsUserModified: true); SaveUserConfig(gridName, configs); })); } else { var configs CaptureCurrentLayout(grid, markAllAsUserModified: true); SaveUserConfig(gridName, configs); } }, TaskScheduler.Default); } #endregion } /// summary /// DataGridView 扩展方法 /// /summary public static class DataGridViewColumnExtensions { public static void ApplyColumnConfig(this DataGridView grid, string gridName, object dataSource) { DataGridViewColumnHelper.ApplyColumnConfig(grid, gridName, dataSource); } public static void ApplyColumnConfig(this DataGridView grid, string gridName) { DataGridViewColumnHelper.ApplyColumnConfig(grid, gridName, null); } public static void SaveUserColumnConfig(this DataGridView grid, string gridName) { DataGridViewColumnHelper.SaveCurrentLayout(grid, gridName); } public static void ResetUserColumnConfig(this DataGridView grid, string gridName) { DataGridViewColumnHelper.ResetToDefault(gridName); DataGridViewColumnHelper.ApplyColumnConfig(grid, gridName); } } }这个工具类我用了大半年前前后后迭代了三个版本第一版只做显隐第二版加了列顺序和列宽第三版才补上IsUserModified和DPI比例还原。现在公司项目里所有列表窗体的列自定义都走这一套新窗体接入的改动量基本就是复制三行代码。如果你也正在被每个窗体一套列布局代码折磨不妨试试把逻辑收敛到这个工具类里。至少对我来说从那以后给表格加自定义列这个需求就再也没让我加过班。
返回列表