☰
DataGridView树形列表实现:缩进绘制与展开收起
2026/11/3 3:49:18 网站建设 项目流程

简介:面向C# WinForms开发者的实用示例资源,聚焦DataGridView控件本身不支持树形结构这一常见痛点,演示如何通过自定义数据模型、扩展控件行为与事件处理,在表格中模拟出可展开折叠的层次化列表。内容涉及树节点类设计、CellClick事件监听、列显隐控制、OnRowPrePaint重绘展开图标以及数据绑定等关键环节,适合已具备一定WinForms基础、希望提升控件定制能力的中级开发者参考。压缩包共32个文件,以cs源码为主,辅以config配置、resx资源、csproj项目文件及编译生成的exe、pdb等,整体约68KB,可直接在Visual Studio中打开grid_test解决方案运行调试。该资源已有1959人学习,读者可从中获得一套完整可运行的实现代码,理解树形展示的渲染与交互逻辑,并借鉴其排错与调试思路,迁移到自己的表格层次化展示场景中。

1. 树形表格不是玄学:DataGridView 里塞进一棵可展开的树

做 WinForms 后台管理系统的开发者大概都遇到过这个需求:部门有上下级、菜单有父子、物料分类有层级,产品经理一句“用表格展示,能展开收起”,就把锅甩到了你面前。DataGridView 原生只认扁平的行集合,没有 TreeView 那种节点展开的概念,于是“DataGridView 树形列表”成了搜索框里的高频词。这个项目的思路很直接:不换控件、不引第三方库,就在 DataGridView 的行模型上做文章,用缩进加展开状态模拟出树形层级,让一张普通表格具备树形列表的交互。适合手里已经有 WinForms 项目、不想为了一个树形展示引入整套 UI 框架的人,也适合想搞懂“表格行怎么伪装成树节点”的开发者。下面按能跑、能改的顺序拆开讲。

2. 架构与数据模型:树形列表是怎么在扁平行集合上长出来的

2.1 核心思路:扁平数据 + 层级字段 + 可见性控制

DataGridView 的数据源本质是一个IList,每一行对应一个对象。树形展示的关键不在于控件本身,而在于我们给每个数据对象补上三个字段:唯一标识、父级标识、层级深度。渲染时按树的先序顺序把节点铺进列表,收起某个节点时,把它所有后代行从 DataGridView 的Rows里移除或设为不可见,展开时再按顺序插回去。这样表格看到的永远是“当前应该显示的行”,树的结构藏在数据里。

常见做法是定义一个轻量的节点包装类,而不是直接改业务实体:

public class TreeNodeItem { public string Id { get; set; } // 节点唯一标识 public string ParentId { get; set; } // 父节点标识,根节点为 null 或空 public int Level { get; set; } // 层级深度,根为 0 public bool IsExpanded { get; set; } // 当前是否展开 public bool HasChildren { get; set; } // 是否有子节点,决定是否画展开图标 public object Data { get; set; } // 原始业务对象,列绑定从这里取 }

Level决定缩进宽度,HasChildren决定单元格里画不画那个小三角,IsExpanded是展开收起的开关。业务实体不污染,包装层负责树形语义,后面换数据源也不用动界面代码。

2.2 目录结构:一个最小可运行工程的组成

这个项目不需要复杂分层,一个 WinForms 工程加几个职责清晰的类就够。我一般会拆成下面这样,方便二次开发时定位:

TreeGridDemo/ ├── Forms/ │ └── MainForm.cs // 主窗体,放 DataGridView 和工具栏 ├── Models/ │ ├── TreeNodeItem.cs // 树节点包装类 │ └── Department.cs // 示例业务实体 ├── Services/ │ └── TreeDataService.cs // 造树、扁平化、查找子节点 ├── Grid/ │ └── TreeGridHelper.cs // 缩进绘制、展开收起、图标绘制 └── Program.cs

TreeDataService只关心数据怎么从父子关系变成有序列表,TreeGridHelper只关心界面怎么表现,MainForm负责把两者接起来。这种拆法的好处是:以后要换成从数据库读层级数据,只改 Service;要换图标样式,只改 Helper。

2.3 数据装载流程:从父子关系到有序行列表

装载分三步:先把扁平的业务数据按ParentId组装成树,再对树做先序遍历得到有序列表,最后绑定到 DataGridView。先序遍历保证父节点永远排在子节点前面,这是后续“收起时删连续区间”的前提。

public List<TreeNodeItem> BuildOrderedList(List<Department> source) { var nodes = source.Select(d => new TreeNodeItem { Id = d.Id, ParentId = d.ParentId, Level = 0, Data = d }).ToList(); var lookup = nodes.ToLookup(n => n.ParentId); // 按父级分组,O(n) 建索引 var result = new List<TreeNodeItem>(); void Walk(string parentId, int level) { foreach (var node in lookup[parentId]) { node.Level = level; node.HasChildren = lookup[node.Id].Any(); result.Add(node); Walk(node.Id, level + 1); // 递归下钻,层级加一 } } Walk(null, 0); // 从根节点开始,根节点 ParentId 为 null return result; }

ToLookup把“找某节点的所有子节点”从每次遍历变成哈希查找,数据量大时差别明显。Walk是标准的深度优先先序遍历,level参数一路传下去,子节点自然比父节点深一层。注意根节点的ParentId约定为null,如果你的数据里用空字符串或0表示根,把Walk(null, 0)改成对应值即可。

2.4 绑定与列配置:让第一列承担树形职责

绑定前要把 DataGridView 设成手动列模式,避免自动生成列把包装类字段全暴露出来。第一列专门用来画缩进和展开图标,其余列绑定业务字段。

grid.AutoGenerateColumns = false; grid.Columns.Clear(); grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "colName", HeaderText = "名称", Width = 260 }); grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "colCode", HeaderText = "编码", DataPropertyName = "Data.Code" // 通过包装类的 Data 属性取业务字段 }); grid.DataSource = orderedList; // orderedList 为 BuildOrderedList 的结果

DataPropertyName支持Data.Code这种点路径,绑定表达式会顺着包装类找到原始对象。第一列不设DataPropertyName,因为它的内容由CellPainting事件自己画,这样缩进和图标才能精确控制。

3. 核心绘制与交互:缩进、图标、展开收起三件事

3.1 CellPainting 里画缩进和展开图标

树形效果全靠第一列的绘制。CellPainting事件在单元格绘制前触发,我们接管它,先画背景,再按Level偏移画文本,最后在文本左侧画展开图标。

private void Grid_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex != 0) return; // 只处理第一列数据行 e.PaintBackground(e.CellBounds, true); var node = (TreeNodeItem)grid.Rows[e.RowIndex].DataBoundItem; int indent = node.Level * 18; // 每层缩进 18 像素 int iconSize = 12; int textLeft = e.CellBounds.Left + 4 + indent + iconSize + 4; if (node.HasChildren) { var iconRect = new Rectangle( e.CellBounds.Left + 4 + indent, e.CellBounds.Top + (e.CellBounds.Height - iconSize) / 2, iconSize, iconSize); // 展开画减号,收起画加号,用简单线条避免依赖图片资源 using (var pen = new Pen(Color.DimGray, 1.5f)) { e.Graphics.DrawRectangle(pen, iconRect); e.Graphics.DrawLine(pen, iconRect.Left + 3, iconRect.Top + iconSize / 2, iconRect.Right - 3, iconRect.Top + iconSize / 2); if (!node.IsExpanded) e.Graphics.DrawLine(pen, iconRect.Left + iconSize / 2, iconRect.Top + 3, iconRect.Left + iconSize / 2, iconRect.Bottom - 3); } } TextRenderer.DrawText(e.Graphics, node.Data.ToString(), e.CellStyle.Font, new Point(textLeft, e.CellBounds.Top + 4), e.CellStyle.ForeColor, TextFormatFlags.NoPadding); e.Handled = true; // 告诉控件这一格已经画完,别再默认绘制 }

indent是层级乘以固定像素,层级越深文本越靠右,视觉上就是树。图标用DrawRectangle加两条线拼出加减号,省掉图片资源,部署时少一个坑。e.Handled = true必须设,否则默认绘制会把文本再画一遍,出现重影。textLeft里预留了图标宽度,保证文本不压图标。

3.2 点击图标触发展开收起

展开收起的入口在CellMouseClick,判断点击位置是否落在图标矩形内,是则切换状态并重建可见行。

private void Grid_CellMouseClick(object sender, DataGridViewCellMouseEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex != 0) return; var node = (TreeNodeItem)grid.Rows[e.RowIndex].DataBoundItem; if (!node.HasChildren) return; int indent = node.Level * 18; var iconRect = new Rectangle(4 + indent, 0, 12, grid.Rows[e.RowIndex].Height); if (!iconRect.Contains(e.Location)) return; // 没点中图标就不处理 node.IsExpanded = !node.IsExpanded; RefreshVisibleRows(); }

e.Location是相对单元格的坐标,所以iconRect的 X 从单元格左边缘算起,Y 从 0 开始。这里只切换当前节点的状态,真正的行增删交给RefreshVisibleRows,职责分开,逻辑不容易乱。

3.3 RefreshVisibleRows:按展开状态重建可见行

最稳的做法不是逐行删插,而是根据所有节点的IsExpanded重新算一遍“哪些节点可见”,然后整体重绑。数据量在几千行以内,重绑的开销完全可以接受,代码也简单得多。

private void RefreshVisibleRows() { var visible = new List<TreeNodeItem>(); var collapsedLevel = int.MaxValue; // 记录当前处于收起状态的层级 foreach (var node in _allNodes) // _allNodes 是先序遍历的完整列表 { if (node.Level <= collapsedLevel) collapsedLevel = int.MaxValue; // 回到收起节点的同级或更上层,恢复可见 if (node.Level > collapsedLevel) continue; // 位于收起节点内部,跳过 visible.Add(node); if (node.HasChildren && !node.IsExpanded) collapsedLevel = node.Level; // 从这里开始,更深层级全部隐藏 } grid.DataSource = visible; }

这段循环利用先序列表的特性:一个节点的所有后代在列表里紧跟在它后面,且Level都大于它。一旦遇到收起的节点,把collapsedLevel设成它的层级,后续Level更大的行全部跳过,直到遇到同级或更上层才恢复。整个过程一趟循环,不需要递归查找子树,也不用维护额外的索引。

3.4 行高与选中态:两个容易忽略的细节

树形列表的行高建议固定,比如RowTemplate.Height = 28,因为图标是按行高居中的,行高随内容变化会导致图标位置跳动。选中态方面,SelectionMode设成FullRowSelect,用户点任意一列都选中整行,符合树形列表的操作习惯。另外把AllowUserToAddRows关掉,否则底部会多出一行空白行,DataBoundItem为 null,绘制事件里取节点会直接空引用。

4. 避坑与排查:树形表格上线前必须过的五道坎

4.1 展开后行错位、图标对不上

现象是点开一个节点,下面的行整体偏移,图标画到了错误的行上。原因通常是CellPainting里用e.RowIndex去取DataBoundItem,但重绑数据源后行索引和节点列表的对应关系变了,或者绘制时数据源正在更新。解决方式是绘制里始终通过grid.Rows[e.RowIndex].DataBoundItem取当前行真实绑定的对象,不要用外部列表按下标取;重绑前先grid.DataSource = null再赋新值,避免中间态触发绘制。

4.2 点击图标没反应,或者整行都在切换

现象是点了文字也展开,或者点了图标毫无反应。原因多半是iconRect的坐标算错:e.Location是单元格内坐标,如果误当成整行坐标,判断永远不命中;反过来把判断范围放大到整格,就会点哪都触发。解决方式是严格按“单元格左边缘 + 缩进 + 图标宽度”构造矩形,并在CellMouseClick里先判断e.ColumnIndex == 0,把交互限制在第一列。

4.3 数据量大时展开卡顿

现象是几千行数据下每次展开收起要卡半秒以上。原因是每次RefreshVisibleRows都重新new一个 List 并整体重绑,触发全表重绘。解决方式有两个方向:一是把SuspendLayout/ResumeLayout和DoubleBuffered = true打开,减少重绘闪烁;二是数据量上万时改用BindingList增量增删行,只动受影响的区间。我一般先上双缓冲,实测不够再换增量方案,避免过早复杂化。

4.4 根节点不显示或显示两遍

现象是树建出来根节点丢了,或者同一批数据出现两份。原因通常是根节点的ParentId约定不一致:造树时按null找根,但数据里根节点存的是空字符串,ToLookup分组后null组为空,根就没了;另一种是BuildOrderedList被调用了两次,两次结果拼在一起。解决方式是统一根标识,在 Service 入口做一次归一化,把空字符串、0、null都转成同一个值,并且保证装载方法只调用一次。

4.5 编辑单元格后树结构错乱

现象是用户在第一列改了名称,或者排序后展开状态丢失。原因是 DataGridView 默认允许编辑,编辑提交会触发数据源变更,而我们的可见行列表是派生数据,和完整节点列表不同步。解决方式是把第一列设成ReadOnly = true,树形列只做展示;如果业务需要改名,走右键菜单或弹窗编辑,改完调用一次RefreshVisibleRows重建,保证派生列表和源数据一致。

5. 二次开发:把树形列表接到真实业务数据上

5.1 从数据库读层级数据的改造点

示例里数据是内存造的,真实项目一般从表里读。表结构通常是Id / ParentId / Name / Sort四个字段,改造集中在TreeDataService:把BuildOrderedList的入参换成查询结果,ToLookup之前先按Sort排序,保证同级节点顺序稳定。如果层级很深或者数据量大,递归造树可能栈溢出,可以改成显式栈的迭代版本,逻辑一样,只是把递归调用换成Stack<T>压栈。另外建议在 Service 里加一个FindNode(id)方法,配合Dictionary<string, TreeNodeItem>索引,展开某个节点时能 O(1) 定位,不用每次遍历。

5.2 懒加载:子节点点开才查

层级数据多的时候,一次性全查出来既慢又占内存。改造思路是初始只加载根节点,节点第一次展开时再按ParentId查子节点,插到当前节点后面。关键是把RefreshVisibleRows里的“完整列表”换成“已加载节点列表”,并在展开前判断HasChildren && 子节点未加载,触发一次异步查询。查询回来后把新节点按先序插入_allNodes对应位置,再刷新可见行。注意异步回调要切回 UI 线程,WinForms 里用BeginInvoke包一层,否则跨线程操作控件直接抛异常。

5.3 一个验证改造是否正确的笨办法

改完树形逻辑,别急着点界面,先写个控制台断言:给定一棵固定结构的测试数据,展开全部后可见行数应等于节点总数,收起根节点后可见行数应为 1,展开第二层某个节点后可见行数应等于“根 + 该节点及其后代”。这三个数对上了,说明可见性计算没跑偏。我习惯把这组断言留在项目里当回归用例,后面改懒加载、改排序,跑一遍就知道有没有把树结构改坏。

验证场景预期可见行数说明
全部展开节点总数先序列表全量可见
收起根节点1只剩根节点本身
展开根下第二个节点根 + 该节点子树节点数其余分支仍收起

5.4 收尾的一点经验

树形表格这类需求,坑基本都集中在“派生数据和源数据不同步”上:可见行是算出来的,展开状态是挂在节点上的,编辑和排序又会动源数据,三者任何一处没对齐,界面就会翻车。我后来的习惯是,凡是派生列表,一律从源数据重算,不做局部修补,宁可多一次重绑也不留状态不一致的隐患。这套 DataGridView 树形列表的代码量不大,但把缩进绘制、可见性计算、事件坐标这几处吃透,换成其他表格控件也是同样的思路。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询