☰
WinForms DataGridView 树形列表实现:数据拍平、缩进渲染与展开状态持久化
2026/10/20 7:44:10 网站建设 项目流程

简介:这份资源面向C# WinForms开发者,尤其是需要在Windows Forms应用中展示层次化数据的初中级程序员,解决DataGridView原生不支持树形结构的问题。教程以Visual Studio 2012为环境,通过自定义树节点模型、扩展DataGridView控件、数据绑定、动态显示隐藏列、重写OnRowPrePaint绘制展开折叠图标以及处理CellClick事件等步骤,完整演示了在表格控件中模拟树形列表的实现思路。压缩包共32个文件,约68KB,以cs源码文件为主,辅以config配置、resx资源、csproj工程与sln解决方案等,结构紧凑,便于直接编译运行和调试。目前已有1956人学习下载,说明该方案在同类需求中具有较高参考价值。读者可从中获得一套可运行的grid_test示例工程,理解DataGridView的扩展机制与树形数据绑定技巧,掌握自定义渲染和事件驱动的展开折叠逻辑,为处理组织架构、分类目录等层次化展示场景提供可复用的代码基础。

1. 把树塞进表格:DataGridView 显示树结构到底解决什么问题

如果你做过 WinForms 的资产管理、组织架构、BOM 物料清单或者权限菜单配置,大概率遇到过这个尴尬:数据本身是树,可控件只给你一张平表。TreeView 能表达层级,但列一多就废;DataGridView 列多、排序、编辑都顺手,却天生不认父子关系。于是「DataGridView 树形列表」这个需求就冒出来了——用表格的行列能力,去渲染一棵可展开、可折叠的树。

这个方案适合谁?适合手上已经有 DataGridView 使用经验、需要在一张表里同时展示「层级 + 多列属性」的 WinForms 开发者。它不引入第三方控件,纯靠数据预处理和事件控制实现,代价是你要自己管好展开状态和行索引映射。下面按「数据怎么变成树 → 怎么画出来 → 怎么交互 → 坑在哪」的顺序拆开讲。

2. 树形列表的数据建模:从扁平集合到带层级的行

2.1 为什么不能直接把父子关系丢给 DataGridView

DataGridView 的数据源本质是一个行集合,它不理解「谁是谁的子节点」。你给它一个List<T>,它就老老实实一行一行画,父子关系完全丢失。所以核心思路是:在绑定之前,把树按当前展开状态「拍平」成一个有序的行列表,每一行额外带上层级深度、是否有子节点、是否展开这些元信息。DataGridView 只负责画这个拍平后的列表,树的语义由我们自己维护。

这个「拍平」动作是整个方案的地基。它带来两个好处:一是 DataGridView 的排序、列宽、单元格格式化全部照常可用;二是展开/折叠只需要重新计算一次列表并刷新绑定,逻辑集中在一个地方,不会散落到各个单元格事件里。

常见做法是定义一个包装类,把原始业务对象和树元信息分开:

// 树节点包装类:业务对象 + 层级元信息 public class TreeRow { public object Data { get; set; } // 原始业务对象 public int Level { get; set; } // 层级深度,根节点为 0 public bool HasChildren { get; set; } // 是否有子节点 public bool IsExpanded { get; set; } // 当前是否展开 public string NodeId { get; set; } // 节点唯一标识 public string ParentId { get; set; } // 父节点标识,根节点为 null }

Level决定缩进量,HasChildren决定要不要画展开图标,IsExpanded决定子节点是否进入拍平结果。NodeId和ParentId是重建父子关系的依据,缺了它们就没法在刷新时定位节点。

2.2 拍平算法:递归还是迭代,顺序怎么定

拍平的本质是一次深度优先遍历,遇到未展开的节点就跳过它的子树。递归写法最直观:

// 将树按展开状态拍平为行列表 public List<TreeRow> Flatten(List<TreeRow> allNodes, string parentId = null) { var result = new List<TreeRow>(); // 取出当前父节点下的直接子节点,按业务需要的顺序排列 var children = allNodes .Where(n => n.ParentId == parentId) .OrderBy(n => n.NodeId) // 这里换成你的排序字段 .ToList(); foreach (var node in children) { result.Add(node); // 先加入自己 if (node.HasChildren && node.IsExpanded) // 展开才递归子树 { result.AddRange(Flatten(allNodes, node.NodeId)); } } return result; }

逻辑说明:parentId为 null 时取根节点,递归时传入当前节点的NodeId。只有IsExpanded为 true 的节点才会把子节点带进结果,这就是折叠效果的来源。参数上,OrderBy的字段要换成你业务里真实的排序依据,比如排序号或创建时间,否则同级节点顺序会随集合顺序漂移。

如果树的层级可能很深(比如超过几百层),递归有栈溢出风险,这时改成显式栈的迭代写法更稳。我一般会在数据量超过五千行时直接上迭代版,避免线上偶发的栈溢出。

2.3 绑定数据源:BindingList 还是 List

拍平结果要绑给 DataGridView,选List<TreeRow>还是BindingList<TreeRow>取决于你要不要自动通知刷新。如果每次展开折叠都重新赋值DataSource,用List就够了;如果你希望增删节点时表格自动更新,用BindingList并实现INotifyPropertyChanged。

// 刷新表格:重新拍平并绑定 private void RefreshGrid() { var rows = Flatten(_allNodes); // _allNodes 是完整节点集合 _grid.DataSource = null; // 先清空,避免列自动重建 _grid.DataSource = rows; ApplyIndent(); // 应用缩进和图标 }

先置 null 再赋值是个小技巧:直接换 DataSource 有时会保留旧的列结构,导致列错位。置 null 强制重建,代价是列宽和自定义列会丢,所以自定义列要在绑定后重新设置,或者干脆用AutoGenerateColumns = false手动定义列。

3. 渲染层级:缩进、展开图标与列样式

3.1 用 CellPainting 画缩进和加减号

DataGridView 没有内置的树形渲染,缩进和展开图标得自己画。最稳的入口是CellPainting事件,它让你在单元格绘制前后插入自定义内容。通常选第一列作为「树列」,在这一列里画缩进空白和展开图标。

private void Grid_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.ColumnIndex != 0 || e.RowIndex < 0) return; // 只处理树列 e.PaintBackground(e.CellBounds, true); // 先画背景 var row = (TreeRow)_grid.Rows[e.RowIndex].DataBoundItem; int indent = row.Level * 16; // 每层缩进 16 像素 var textRect = new Rectangle( e.CellBounds.Left + indent + 20, // 给图标留 20 像素 e.CellBounds.Top, e.CellBounds.Width - indent - 20, e.CellBounds.Height); // 画展开/折叠图标(有子节点才画) if (row.HasChildren) { var iconRect = new Rectangle( e.CellBounds.Left + indent + 2, e.CellBounds.Top + (e.CellBounds.Height - 12) / 2, 12, 12); e.Graphics.DrawRectangle(Pens.Gray, iconRect); if (!row.IsExpanded) e.Graphics.DrawLine(Pens.Gray, iconRect.Left + 3, iconRect.Top + 6, iconRect.Right - 3, iconRect.Top + 6); // 折叠画横线 } TextRenderer.DrawText(e.Graphics, e.Value?.ToString(), e.CellStyle.Font, textRect, e.CellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); e.Handled = true; // 阻止默认绘制 }

逻辑说明:indent由Level乘以固定像素得到,层级越深越往右。图标区域固定在缩进之后,折叠状态画一条横线(减号),展开状态画一个方框(加号)。e.Handled = true必须设,否则默认绘制会把文字又画一遍,出现重影。

参数上,缩进像素 16 和图标尺寸 12 是经验值,高 DPI 屏下要按缩放比例调整,否则图标会糊或者错位。我一般用e.CellBounds.Height反推图标垂直居中位置,而不是写死。

3.2 点击图标切换展开状态

画出来的图标不会自己响应点击,得在CellMouseClick里判断点击位置是否落在图标区域内。

private void Grid_CellMouseClick(object sender, DataGridViewCellMouseEventArgs e) { if (e.ColumnIndex != 0 || e.RowIndex < 0) return; var row = (TreeRow)_grid.Rows[e.RowIndex].DataBoundItem; if (!row.HasChildren) return; int indent = row.Level * 16; var iconRect = new Rectangle(indent + 2, 0, 12, _grid.Rows[e.RowIndex].Height); if (e.X >= iconRect.Left && e.X <= iconRect.Right) // 命中图标区域 { row.IsExpanded = !row.IsExpanded; // 翻转展开状态 RefreshGrid(); // 重新拍平刷新 } }

逻辑说明:e.X是相对于单元格左边缘的坐标,和绘制时的indent用同一套计算,保证点击区域和视觉区域一致。翻转IsExpanded后调用RefreshGrid,整棵树重新拍平,子节点出现或消失。

这里有个容易忽略的点:刷新后行索引全变了,如果之前有选中行,选中状态会丢。要保留选中,得在刷新前记下选中节点的NodeId,刷新后遍历找到对应行再设Selected = true。

3.3 列样式与只读控制

树列通常要禁止编辑,否则用户双击就进了编辑态,和展开操作冲突。在绑定后统一设置:

_grid.Columns[0].ReadOnly = true; // 树列只读 _grid.Columns[0].SortMode = DataGridViewColumnSortMode.NotSortable; // 禁止排序 _grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect; _grid.RowHeadersVisible = false; // 隐藏行头,视觉更干净

树列禁止排序很关键:一旦用户点了列头排序,DataGridView 会按字符串重排,父子顺序全乱,树结构直接崩。其他数据列可以正常排序,但排序后树的层级关系就失效了,所以要么全局禁排序,要么在排序事件里重新按树顺序拍平。

4. 避坑与排查:树形列表最容易翻车的五个点

4.1 展开后行高错乱、图标错位

现象:展开某个节点后,新出现的行缩进不对,图标跑到文字上面去了。 原因:CellPainting里用了固定的行高假设,或者indent计算依赖了旧的行索引。 解决:所有绘制坐标都从e.CellBounds实时取,不要缓存行高;刷新后强制_grid.Invalidate()重绘一次。

4.2 点击图标没反应,或者点文字也触发了展开

现象:点加减号没动静,或者点节点名字反而展开了。 原因:命中判断的坐标和绘制坐标不一致,或者判断范围写得太宽。 解决:把图标区域的计算抽成一个方法,绘制和点击都调它,保证同一套坐标;命中范围严格限制在图标矩形内。

4.3 大数据量下展开卡顿

现象:几千个节点时,每次展开要等一两秒。 原因:每次刷新都重新new列表并全量绑定,DataGridView 重建了所有行。 解决:用BindingList做增量更新,或者开启双缓冲DoubleBuffered = true,再配合SuspendLayout/ResumeLayout包住刷新过程。

4.4 刷新后选中行丢失、滚动位置跳回顶部

现象:展开一个深层节点,表格唰地跳回第一行。 原因:重新赋值 DataSource 会重置滚动和选中。 解决:刷新前记录FirstDisplayedScrollingRowIndex和选中NodeId,刷新后恢复;或者用CurrentCell定位到原节点。

4.5 高 DPI 下缩进和图标糊成一团

现象:在 150% 缩放的屏幕上,图标被拉伸、文字和图标重叠。 原因:缩进像素和图标尺寸写死了,没跟随 DPI 缩放。 解决:用e.Graphics.DpiX / 96f算出缩放系数,所有像素值乘以它;图标改用矢量绘制或按缩放选合适尺寸的位图。

5. 进阶:把展开状态持久化,下次打开还原现场

基础版做完,树能展开折叠了,但用户关掉窗口再打开,所有节点又回到折叠状态,体验很差。进阶做法是把每个节点的展开状态存下来,下次加载时还原。存储介质可以是配置文件、注册表或者数据库字段,核心是「以 NodeId 为键,记录 IsExpanded」。

// 保存展开状态:遍历所有节点,记录展开的 NodeId private void SaveExpandState() { var expandedIds = _allNodes .Where(n => n.IsExpanded) .Select(n => n.NodeId) .ToList(); File.WriteAllLines("expand_state.txt", expandedIds); } // 加载时还原:读回 NodeId 集合,设置 IsExpanded private void RestoreExpandState() { if (!File.Exists("expand_state.txt")) return; var expandedIds = new HashSet<string>(File.ReadAllLines("expand_state.txt")); foreach (var node in _allNodes) node.IsExpanded = expandedIds.Contains(node.NodeId); RefreshGrid(); }

逻辑说明:只存展开的节点,没存的就是折叠,这样文件体积小。HashSet让查找是 O(1),几千个节点还原也很快。参数上,存储路径要放到用户可写目录,别写死在程序安装目录,否则权限不够会抛异常。

还原时有个顺序问题:如果父节点没展开,子节点即使标记为展开也不会显示,因为拍平时父节点折叠就跳过了整棵子树。所以还原后要保证「展开的节点,其所有祖先也必须是展开的」。稳妥做法是还原后自底向上补一遍祖先的展开状态:

// 补全祖先展开状态,避免子节点展开但父节点折叠导致不可见 private void EnsureAncestorsExpanded() { var map = _allNodes.ToDictionary(n => n.NodeId); foreach (var node in _allNodes.Where(n => n.IsExpanded)) { var current = node; while (current.ParentId != null && map.TryGetValue(current.ParentId, out var parent)) { parent.IsExpanded = true; current = parent; } } }

这段逻辑我踩过坑:早期只存了叶子节点的展开状态,结果重新打开时叶子明明标记展开却看不见,排查了半天才发现是祖先没展开。从那以后我每次做状态持久化,都强制走一遍祖先补全,再刷新界面。希望帮到你。

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

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

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

立即咨询