简介:面向 WinForms 开发者的 DataGridView 树形列表实现示例,解决表格控件无法直接展示层次数据的痛点。资源以 Visual Studio 2012 + C# 为环境,提供完整项目与源码,涵盖树节点模型定义、控件扩展、数据绑定、列显隐控制、绘制展开折叠图标及事件刷新等关键环节,适合正在学习 C# 数据展示或需要在界面中呈现树状信息的开发者。压缩包共 32 个文件,以 cs 源码、config 配置、resx/resources 资源文件为主,辅以可运行的 exe、pdb 调试符号及工程文件,整体约 68KB,轻量便于直接打开 grid_test 项目对照学习。已有 1952 人浏览学习,是快速理解 DataGridView 自定义扩展的实用参考。通过阅读源码和调试运行,你能掌握事件驱动下动态显隐列的技巧,以及自定义单元格绘制树形缩进的方法;示例目录结构清晰,可直接移植到实际项目中。
1. 树结构进 DataGridView:为什么我说这是件反直觉的事
如果你的项目里有一个老资格的 WinForms 界面,业务方突然提出“把部门树、菜单树或者分类树塞进 DataGridView 里显示”,先别急着答应,也别急着拒绝。DataGridView 本身是二维表格控件,它没有 TreeView 那样的节点概念,每一行就是一条独立记录,行与行之间没有任何父子关系。而树结构的核心恰恰是父子层级、展开折叠、缩进连接线,这三样东西 DataGridView 默认一样都不提供。
我见过不少团队在这件事上硬刚原生能力:有人把树拍平塞进表格,层级信息全丢,用户看不出来谁是谁的上级;有人直接换 TreeView,结果丢失了原本的列头、筛选、编辑和导出能力,业务方不接受;还有人去搜第三方树网格控件,发现要么收费、要么停止维护、要么和现有代码风格冲突。最后绕了一圈,还是回到 DataGridView 上做扩展。这篇文章就围绕“DataGridView 控件显示树结构”这件事,把数据准备、绘制方案、交互实现、性能优化和踩坑点一次讲透。适合那些不想换控件、又必须保留表格能力的 WinForms 开发者,也适合刚接手这类需求、想评估工作量的新手。
2. 先搭数据链路:把父子关系压平成树表格
2.1 数据源设计:Id、ParentId、Level 三件套
树形数据在业务系统里最常见的存储方式是两张表或者一张带父级字段的表,典型的字段是 Id、ParentId、Name、SortNo。这类数据的特点是:数据库里存的是扁平记录,层级关系靠 ParentId 指向父记录。DataGridView 直接绑定这样的扁平列表,显示出来就是无序清单,用户看不出层级。所以第一步是把扁平记录转换成带层级信息的行集合,每一行除了原始业务字段,还要额外携带 Level、IsParent、IsExpanded 这类展示辅助字段。
我一般会定义一个专门用于绑定的行模型,而不是直接绑业务实体。原因有两点:第一,业务实体往往有很多不需要展示的字段,直接绑定会把列弄得很乱;第二,Level、IsParent 这些展示逻辑不该污染业务对象。模型大概长这样:
public class TreeGridRow { public string Id { get; set; } // 节点唯一标识 public string ParentId { get; set; } // 父节点标识,根节点为 null public string Name { get; set; } // 显示名称 public int Level { get; set; } // 缩进层级,根为 0 public bool IsParent { get; set; } // 是否有子节点 public bool IsExpanded { get; set; } // 当前展开状态 public int SortNo { get; set; } // 同级排序号 }参数说明:Level 用于自绘时计算缩进像素,IsParent 用于决定是否显示展开按钮,IsExpanded 用于记录当前节点的展开状态。注意 IsExpanded 是展示状态,不是业务数据,重建数据源时需要用字典单独保存,否则每次刷新都会丢失用户的展开操作。SortNo 用来保证同级节点的显示顺序稳定,没有这个字段,树在多次刷新后顺序可能乱跳。
这种设计还有个好处:如果后续要把树导出成 Excel 或传给别人看,直接把这个列表拍平输出就行,树的父子关系已经有 Level 标记,还原层级很方便。
2.2 递归构造:从扁平列表生成带层级行的绑定表
有了行模型,接下来要把扁平的 List 递归展开。这个步骤是整条链路的地基,后面绘制、展开折叠、懒加载都依赖它。常见做法是写一个递归方法:从根节点集合开始,遍历每个节点,把它转成 TreeGridRow 加入结果列表,然后继续往下一层找它的子节点。伪代码里最容易出错的是“找子节点”的实现方式,如果每层都用nodes.Where(n => n.ParentId == current.Id)去全表扫描,数据量大的时候会非常慢。我一般会先按 ParentId 分组建一个字典,把查找子节点的成本从 O(n) 降到 O(1)。
// 构造树行列表:nodes 是扁平数据,parentId 是当前父节点 private List<TreeGridRow> BuildTreeRows( List<CategoryNode> nodes, string parentId, int level, Dictionary<string, List<CategoryNode>> childrenMap) { var result = new List<TreeGridRow>(); if (!childrenMap.TryGetValue(parentId ?? string.Empty, out var children)) return result; // 同级按 SortNo 排序,保证顺序稳定 foreach (var node in children.OrderBy(n => n.SortNo)) { var row = new TreeGridRow { Id = node.Id, ParentId = parentId, Name = node.Name, Level = level, SortNo = node.SortNo, IsParent = childrenMap.ContainsKey(node.Id), IsExpanded = false }; result.Add(row); if (row.IsParent) // 只有父节点才继续递归 { result.AddRange( BuildTreeRows(nodes, node.Id, level + 1, childrenMap)); } } return result; }逻辑说明:方法先查当前父节点的直接子级集合,拿不到就说明是叶子,直接返回。拿到后按 SortNo 排序,逐个转成 TreeGridRow,遇到有子节点的行就递归处理下一层。注意 childrenMap 的 Key 用parentId ?? string.Empty来兼容根节点的 ParentId 为空的情况。IsExpanded 在这里先默认 false,真正的展开状态由调用方传入并在外层维护。
调用方式是把原始列表按 ParentId 分组,然后对每个根节点调用这个方法,把结果合并。构造完成后,把 List 直接赋给 DataGridView 的 DataSource 即可先跑通一版:表格里能看到每个节点的 Level 数字,层级关系已经体现在数据里。这一步验证的是数据转换有没有 bug,界面上还是一张普通表,不着急画线。
3. 画树的三种做法:自绘缩进是我最推荐的一条路
3.1 方案对比:三方控件、缩进自绘、多列伪树
把树的层级灌进 DataGridView 之后,下一个问题是“怎么让用户看得出来这是树”。现阶段的 Level 字段只是数据,视觉上没有任何层级提示。业界常见做法有三种,我先说结论:最推荐的是“自绘缩进”方案,即在 CellPainting 事件里根据 Level 画缩进和连接线。原因后面展开。
第一种:替换成第三方树网格控件。这类控件本质是继承 DataGridView 加了树逻辑,功能完整,有展开按钮、连接线、复选框,看起来最美观。但问题是很多这类组件已经多年不更新,.NET 版本和系统兼容性存在风险,而且引入后无法深度定制绘制细节,遇到需求变化容易卡死。如果项目允许引第三方包、且团队没有多余精力维护绘制逻辑,这个方案可以接受,但需要评估维护成本。
第二种:多列伪树,即把第一列用来做缩进,把名称拆到第二列,用空格填充出层级感。这种方案实现最廉价,但效果最差:没有连接线,没有展开按钮,也不能折叠,本质上只是“看起来有点缩进的表格”。它适合一次性展示只读树,用户没有交互诉求的场景。
第三种:自绘缩进方案,也是我推荐的做法。保留 DataGridView 的全部原生能力,把名称列设成未绑定列,在 CellPainting 里自己绘制缩进、展开按钮和连接线。它有三个明显优点:不引入三方依赖;展开折叠交互逻辑完全可控;列头排序、选中、编辑这些 DataGridView 原生能力全部保留。代价是绘制代码有一定复杂度,需要处理坐标计算和重绘时机,这部分我在下一节展开。
3.2 核心绘制事件:CellPainting 里画缩进、连接线和展开按钮
自绘方案的绘制集中在 DataGridView 的 CellPainting 事件里。绘制内容是三样东西:根据 Level 计算的左缩进、展开按钮(加号或减号)、从按钮到名称之间的连接线。这段代码是整个方案的核心,也最容易出问题,先看实现:
private const int IndentWidth = 18; // 每一级缩进的像素宽度 private const int ExpandBoxSize = 14; // 展开按钮的边长 private void dataGridView1_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { // 只处理名称列,表头行不处理 if (e.RowIndex < 0 || e.ColumnIndex != ColumnName.Index) return; var row = dataGridView1.Rows[e.RowIndex]; var node = row.DataBoundItem as TreeGridRow; if (node == null) return; // 先画背景,否则后面的自绘会覆盖系统选中高亮色 e.PaintBackground(e.CellBounds, true); int top = e.CellBounds.Top + (e.CellBounds.Height - ExpandBoxSize) / 2; int boxLeft = e.CellBounds.Left + node.Level * IndentWidth; using (var pen = new Pen(Color.Gray)) { if (node.IsParent) { // 父节点画一个小方框作为展开按钮区域 e.Graphics.DrawRectangle(pen, boxLeft, top, ExpandBoxSize, ExpandBoxSize); // 加号/减号标记 string mark = node.IsExpanded ? "-" : "+"; using (var font = new Font(dataGridView1.Font.FontFamily, 9)) { e.Graphics.DrawString(mark, font, Brushes.DarkSlateGray, boxLeft + 3, top - 1); } } // 从展开按钮右侧到名称文字之间画一条横线 int lineX = boxLeft + ExpandBoxSize; int textLeft = lineX + 6; using (var textBrush = new SolidBrush(dataGridView1.ForeColor)) { e.Graphics.DrawLine(pen, lineX, top + ExpandBoxSize / 2, textLeft, top + ExpandBoxSize / 2); e.Graphics.DrawString(node.Name, dataGridView1.Font, textBrush, textLeft, e.CellBounds.Top + 4); } } e.Handled = true; // 阻止默认绘制,避免文字重叠 }逻辑说明:首先排除表头行和无关列,然后取当前行的绑定对象 TreeGridRow。用e.PaintBackground先画好背景,这一步非常重要,因为后续e.Handled = true会禁用系统的默认绘制,如果不先画背景,选中行的蓝色高亮会丢失。然后是展开按钮的绘制:只有 IsParent 为 true 的节点才画方框和加号减号,叶子节点直接画名字。缩进量由node.Level * IndentWidth计算,Level 越大缩进越多。连接线是一段从按钮右侧到文字左侧的灰色横线,视觉上把名称和折叠按钮串起来。
需要设置的几个关键点:名称列必须是未绑定列(DataPropertyName 为空或指向 Level 以外的字段),否则 DataGridView 会自动覆盖自绘内容。行的高度建议固定或设置 MinimumHeight,避免文字顶部对齐出差错。绘制用的字体、颜色不要写死,尽量从 dataGridView1.Font 和 ForeColor 取,这样跟随主题变化时不会出现不协调。
3.3 展开折叠交互:点击命中判断与数据重建
绘制完成只是静态效果,用户点击加号要能展开子级、点击减号要能收起。DataGridView 没有提供树节点的点击事件,需要在 CellClick 或 CellMouseClick 里手动判断“点击位置是否落在展开按钮范围内”。这里有个坐标计算细节:e.Location 是相对于单元格左上角的坐标,要判断按钮位置,必须把单元格本身的显示位置加进来,用 GetCellDisplayRectangle 拿到单元格在 DataGridView 客户区中的实际坐标,再叠加缩进偏移。
private void dataGridView1_CellClick(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex != ColumnName.Index) return; var row = dataGridView1.Rows[e.RowIndex]; var node = row.DataBoundItem as TreeGridRow; if (node == null || !node.IsParent) return; // 计算展开按钮在当前单元格里的左边界 Rectangle cellRect = dataGridView1.GetCellDisplayRectangle( e.ColumnIndex, e.RowIndex, false); int boxLeft = cellRect.Left + node.Level * IndentWidth; // 用鼠标相对于控件左上角的位置来判断命中 Point mousePos = dataGridView1.PointToClient(Cursor.Position); if (mousePos.X < boxLeft || mousePos.X > boxLeft + ExpandBoxSize || mousePos.Y < cellRect.Top || mousePos.Y > cellRect.Bottom) return; ToggleExpand(node.Id); // 展开/折叠切换,内部维护状态 }参数说明:GetCellDisplayRectangle 的第三个参数 false 表示返回未裁剪的单元格显示位置,这比直接算 CellBounds.Left 更可靠,因为当 DataGridView 出现水平滚动条时,CellBounds 可能不是实际可见位置。命中判断用的是鼠标相对整个控件的坐标,和 cellRect.Left 处于同一坐标系,不会出现偏移。
ToggleExpand 的逻辑是维护一个 HashSet 或字典 keyed by 节点 Id,保存当前展开的节点集合,然后重建数据源。重建的过程基于 2.2 节的 BuildTreeRows,但增加一个过滤条件:如果一个父节点不在展开集合里,就不再递归进入它的子级。这样展开和折叠本质上都是“重新构造绑定列表并重新赋值 DataSource”。这个方法简单可靠,缺点是大树全量重建时有性能损耗,如何优化放到第 5 章细说。
还有一个容易遗漏的点:展开折叠后要重新定位选中行。如果用户在第三级节点上点击展开,重建数据源后行索引全部变化,如果不处理,选中状态会消失。比较稳妥的做法是在重建前记录当前节点的 Id,重建后按 Id 找到新行号并设置 CurrentCell,这个细节也是第 4 章要展开的坑之一。
4. DataGridView 树结构的 4 类常见翻车现场与排查
4.1 坑一:Row.Visible 不存在,折叠只能靠重建数据源
现象:第一次实现折叠功能的人,会在网上搜“DataGridView 隐藏行”,然后试图写dataGridView1.Rows[i].Visible = false,结果编译直接报错,因为 DataGridViewRow 根本没有 Visible 属性,它是 DataGridViewBand 的成员,而 DataGridViewRow 不公开。
原因:DataGridView 的可见性管理不像 ListView 那样按行控制。行是否显示取决于绑定的数据源里有没有这条记录,不是控件自己的属性。尤其在使用 DataSource 绑定模式时,行集合完全由数据源驱动,控件层面没有提供行级隐藏接口。
解决:接受“重建数据源”的做法。把要显示的节点重新构造一份列表,赋值给 DataSource,不显示的节点压根不放进列表里。这样既符合绑定模式的规则,也方便后续加入懒加载逻辑。注意重建后要恢复滚动位置和选中行,否则用户每次折叠都会跳回顶部,体验很差。
4.2 坑二:绘制错位与残留,滚动后画面像“鬼影”
现象:自绘完成后的表格在初始状态看起来正常,一旦上下滚动或拖动水平滚动条,发现连接线错位、文字重叠、部分行出现旧内容残留,看起来像重影。
原因:CellPainting 在滚动时会反复触发,每次绘制用的坐标必须基于当前可见区域计算。常见错误是直接存了一个绝对坐标,滚动之后坐标失效;另一个高频原因是e.Handled = true之前没有调用 PaintBackground,导致背景上叠了旧内容,新画面没盖全。
解决:确保每次绘制都用 e.CellBounds 作为基准,不要缓存坐标;绘制前先e.PaintBackground(e.CellBounds, true)把背景清干净。如果问题只在水平滚动时出现,检查是不是缩进宽度设置过大,导致树的连接线画到了单元格外面被裁剪,必要时把名称列的 MinimumWidth 设大一些。
4.3 坑三:展开折叠后选中行丢失,键盘操作直接失灵
现象:展开一个节点后,原来选中的那一行失去高亮,继续按方向键时焦点停留在旧行号上,但视觉上选中的是另一行,非常诡异。
原因:重建数据源后,Rows 集合整体刷新,行号和内容重新映射。旧选中行号可能仍然落在某个节点的位置,但这个位置已经换了内容;如果重建后的总行数少了,旧行号甚至越界,CurrentCell 会变成 null,整个控件的选中态被清空。
解决:重建数据源之前,记录当前节点的 Id 和当前列索引。重建完成后遍历新数据源找同 Id 的行,设置dataGridView1.CurrentCell = dataGridView1.Rows[newIndex].Cells[columnIndex]。如果找不到,说明该行被折叠隐藏了,就把选中状态定位到它的父节点,至少保证用户知道当前位置在哪。
4.4 坑四:列头排序把整棵树“拍扁”
现象:树的层级显示正常,用户点了一下名称列的列头排序,整棵树突然变成无序表格,父子关系全乱了,展开按钮也没了意义。
原因:DataGridView 的默认排序是“整列排序”,它整行移动数据,不关心你这列数据之间是否存在父子引用。一旦按某个业务列排序,子节点会被甩到父节点前面或隔得很远,树结构被彻底打散。
解决:树形显示的场景下,最好的做法是禁用用户触发的列头排序,即把需要保留层级的那几列设为SortMode = DataGridViewColumnSortMode.NotSortable。如果业务确实需要排序,就只能在“同一父节点下做同级排序”,即点击列头时,只重排当前层级的兄弟节点,不跨层级移动。这个逻辑不能在原生事件里直接改,需要先捕获ColumnHeaderMouseClick,阻止默认排序,然后修改数据源里同级节点的 SortNo 并重建树。
5. 大数据量下的保命操作:虚模式与懒加载
5.1 虚模式:让万级节点不再卡顿
当树的节点总数超过几千行,每次展开折叠都重建数据源,会发现明显的卡顿,尤其是带有自绘和大量行的场景。这时候第一个要开的开关是 DataGridView 的 VirtualMode。虚模式的核心思路是:DataGridView 不保存行数据,只在界面需要显示某一行时,通过 CellValueNeeded 事件向你要数据。界面只显示可视区域的几十行,所以不管你背后有多少数据,绘制压力都只和屏幕上的行数有关,和总行数无关。
开启虚模式的步骤:设置dataGridView1.VirtualMode = true,同时把RowCount设为当前可见节点总数,并处理 CellValueNeeded 事件。在事件里根据行索引从你的可见列表里取节点,填充各列的值。要注意的是虚模式下很多基于 DataBoundItem 的习惯写法不能用,比如从 row.DataBoundItem 取节点,这个属性不会自动赋值,需要你自己维护“行号到节点”的映射,通常用一个 List 保存当前所有可见节点,索引和行号一一对应。
dataGridView1.VirtualMode = true; dataGridView1.RowCount = visibleNodes.Count; dataGridView1.CellValueNeeded += (s, e) => { if (e.RowIndex < 0 || e.RowIndex >= visibleNodes.Count) return; var node = visibleNodes[e.RowIndex]; switch (e.ColumnIndex) { case 0: e.Value = node.Name; break; case 1: e.Value = node.Level; break; case 2: e.Value = node.IsParent ? "是" : "否"; break; } };逻辑说明:visibleNodes 是当前所有展开可见节点的列表,每次展开折叠后先重建这个列表,再更新 RowCount 并调用InvalidateRow刷新。CellValueNeeded 里不能做耗时操作,这里只是从内存列表取值,速度很快。注意虚模式开启后,单元格编辑保存逻辑也要走 CellValuePushed 事件,否则用户改了内容不落回数据源,这个误踩的频率很高。
5.2 懒加载:只在展开时加载直接子级
虚模式解决的是“绘制压力”,但没有解决“构造压力”。如果原始数据有上万条,BuildTreeRows 每次全量递归展开所有子节点,光是构造列表就要几十毫秒,叠加虚模式也救不回来。所以大数据量下必须配合懒加载:初始只加载根节点,用户点击展开某个节点时,才动态拉取它的下一级子节点。
实现上,我给每个父节点维护“是否已加载过”的标记。第一次展开时,从数据库或缓存查询该节点的直接子级,补进 childrenMap;后面再次展开直接用内存缓存,不再查库。这样每次重建 visibleNodes 时,循环遍历的节点数量控制在“当前展开层级的节点数”,而不是全树节点数。懒加载还有一个额外收益:首屏加载时间大幅缩短,用户不用等待整棵树构造完才能看到第一屏。
需要警惕的是懒加载和括号内“延时空白”的配合。点击展开后到子级数据返回前,要确保 UI 不被锁死,常见做法是把加载动作放到异步任务里,加载完成回到 UI 线程刷新数据源。否则大数据量下,点哪个节点都像卡死。
5.3 双缓冲与无效刷新隔离
自绘方案最怕的另一个问题是闪烁。树节点多时,展开折叠会触发整表重绘,画面会明显闪一下。最直接的解法是开启 DataGridView 的双缓冲。DataGridView 支持双缓冲,但属性没有直接对外开放,需要反射或者继承重写。常见做法是建一个子类,在构造函数里设置双缓冲参数:
public class TreeDataGridView : DataGridView { public TreeDataGridView() { DoubleBuffered = true; // Disable 内部的重绘闪烁保护 } }参数说明:DoubleBuffered 是 Control 的受保护属性,外部访问不到,只能通过继承来改。开启后控件内部先在内存画布上完成整块绘制,再一次性刷新到屏幕,闪烁问题基本消失。如果你不方便改控件类型,也可以在 CellPainting 里尽量减少不必要的 Graphics 操作,比如同一行多次触发绘制时,用状态变量判断内容没有变化就跳过绘制。但最省事的还是双缓冲,这个开关值得优先开。
此外,不要动辄调用dataGridView1.Refresh()。展开折叠后只需要更新 RowCount、调用 Invalidate 刷新受影响区域即可。整表 Refresh 在自绘树场景下会触发所有 CellPainting,白耗 CPU。把“数据重建”和“界面刷新”分开,数据重建不自动触发重绘,手动在逻辑完成后统一刷新一次,能明显改善卡顿感。
6. 三个让树格更像正经树控件的小习惯
自绘和性能都搞定之后,剩下的都是体验细节,但这些细节决定了用户愿不愿意用、业务方会不会来找你改需求。第一个习惯是让左右方向键承担折叠展开操作。键盘党用户会下意识地在树上按右键展开,按左键折叠,如果你不处理,左右键只会左右移动列焦点,非常怪。在 KeyDown 事件里捕获 Keys.Right 和 Keys.Left,判断当前行是否是父节点、是否已展开,然后复用 ToggleExpand 逻辑即可。这样键盘鼠标两套操作都能用,用户迁就成本为零。
第二个习惯是根据内容自适应行高,但要有度。名称列的内容长短不一时,行高一刀切会导致长文本被截断。常见做法是设置AutoSizeRowsMode = AllCells,但树节点多时全表自动行高会让性能回退到解放前。折中方案是只对名称列开启AutoSizeMode = DisplayedCells,并且固定行高的最小值。这样行高只会在一开始计算一遍,不会随展开折叠反复重算,既保证可读性,又不会拖慢交互。
第三个习惯是记住滚动位置。展开折叠后如果视图跳回顶部,用户在一个深层节点上操作会非常崩溃。我一般会在展开折叠前记录当前最顶部的可见行号对应的节点 Id,重建数据源后,用FirstDisplayedScrollingRowIndex找到该行位置,尽量恢复原来的滚动位置。代码上就是两个字段:prevTopId 和 prevTopIndex,重建后遍历可见节点找到匹配行,设置FirstDisplayedScrollingRowIndex。注意这个属性要在数据源赋值之后、界面刷新之前设置,顺序反了会失效。
这三个习惯加上前面的自绘逻辑,DataGridView 显示树结构基本就能达到 TreeView 的操作手感,同时保留表格的全部能力。回想我自己最早做这个需求时,在排序和滚动位置上栽过跟头,后来做成了固定的处理模板才稳定下来。如果你正被这个需求卡住,可以按这篇文章的顺序推进:先搭数据模型,再画缩进,再补交互,最后处理性能。数据层不出错,后面的问题都是可排查的;数据层乱了,画得再好看也是错的。希望帮到你。
本文还有配套的精品资源,点击获取