简介:这份C#树型TreeDataGridView例子Demo代码面向具备一定WinForms基础的C#开发者,用于解决层次化数据既需树形展开折叠、又需表格多列展示的界面需求。由于TreeGridView并非.NET标准控件,示例通过组合TreeView与DataGridView并借助事件绑定实现节点选中后联动刷新表格数据,涵盖数据模型绑定、AfterSelect事件处理、节点展开折叠逻辑,以及加载保存树状态、键盘导航、动态生成列等实用扩展思路。压缩包共35个文件,约157KB,以vb源码、exe可执行文件、pdb调试符号、xml与resx资源文件、dll类库及sln解决方案为主,另含项目配置与界面截图,结构完整可直接运行调试。目前已有897人学习下载,适合希望快速掌握树形表格控件集成方式、对照源码理解数据绑定与事件驱动机制的开发者参考借鉴。
1. 从一张“看起来像树”的表格说起:这个 Demo 到底解决了什么
如果你在 WinForms 里做过设备管理、组织架构、物料 BOM 或者权限菜单,大概率遇到过同一个需求:左边一棵树,右边一张表,或者干脆在一张表格里把层级关系折叠展开。TreeView 能折叠但列太少,DataGridView 列多但不支持层级,于是“树型表格”这个控件就成了刚需。这份 C# 树型 TreeDataGridView 例子 Demo 代码,核心就是给出一套可运行的实现,让你不用从零去啃 TreeView 与 DataGridView 的绘制逻辑,直接看到节点展开、列对齐、父子联动是怎么落地的。
它适合两类人:一类是刚接手 WinForms 老项目、需要快速补一个树形列表的开发者;另一类是想搞清楚“树控件和表格控件到底怎么缝合”的熟手。Demo 本身不依赖第三方商业控件,走的是原生控件组合或自绘的路子,拿到手就能在 Visual Studio 里跑起来看效果,再按自己的数据结构改。
2. 树型表格的两种实现路线:组合控件与自绘,选哪个
2.1 组合方案:TreeView 加 DataGridView 的列同步
最常见的做法是把 TreeView 和 DataGridView 并排放在一个容器里,用 TreeView 显示层级,用 DataGridView 显示当前选中节点的明细。这种方案实现成本低,但缺点也很明显:两边的行高、滚动条、展开状态很难完全对齐,用户拖动滚动条时容易出现“树动了表没动”的割裂感。Demo 里如果采用这种路线,重点会放在同步逻辑上,比如监听 TreeView 的 AfterSelect 事件去刷新 DataGridView 的数据源。
// 监听树节点选中,刷新右侧表格 private void treeView1_AfterSelect(object sender, TreeViewEventArgs e) { // e.Node.Tag 里通常挂着一个业务对象或主键 var nodeData = e.Node.Tag as DeviceNode; if (nodeData == null) return; // 用节点数据去查明细,绑定到 DataGridView dataGridView1.DataSource = LoadDetailByNodeId(nodeData.Id); }这段代码的逻辑很直白:树负责导航,表负责展示。参数上要注意Tag的用法,很多人习惯把整个实体对象塞进去,方便是方便,但要注意对象生命周期,别在节点移除后还持有引用导致内存回收不掉。常见做法是只存主键,需要时再查。
2.2 自绘方案:在 DataGridView 里直接画展开折叠
另一种更“原生”的思路是继承 DataGridView,重写 OnCellPainting 或者用自定义列,把第一列当成树列来画。展开折叠的加减号、缩进、连线都自己绘制,行数据本身就是扁平的,靠一个 ParentId 字段维护层级。这种方案的好处是滚动、选中、编辑全部统一在表格体系内,不会出现两个控件打架的情况。
// 自定义树列,重写绘制逻辑(简化示意) protected override void OnCellPainting(DataGridViewCellPaintingEventArgs e) { base.OnCellPainting(e); if (e.ColumnIndex == 0 && e.RowIndex >= 0) { // 根据行的层级 Level 计算缩进 int level = GetRowLevel(e.RowIndex); int indent = level * 16; // 绘制展开折叠图标 Rectangle iconRect = new Rectangle(e.CellBounds.Left + indent, e.CellBounds.Top + 4, 12, 12); e.Graphics.DrawRectangle(Pens.Gray, iconRect); // 绘制节点文本 string text = e.Value?.ToString() ?? ""; e.Graphics.DrawString(text, e.CellStyle.Font, Brushes.Black, e.CellBounds.Left + indent + 16, e.CellBounds.Top + 2); e.Handled = true; } }这里的参数关键是Level和indent,缩进量一般取 16 到 20 像素,太小层级看不清,太大深层节点会跑出可视区。e.Handled = true表示自己接管绘制,别再让默认逻辑画一遍。自绘方案对新手不太友好,但一旦跑通,后续加复选框、图标、不同颜色都更自由。
2.3 选型建议:看数据量和交互复杂度
如果数据量在几百行以内、交互只是“点树看表”,组合方案足够,改起来也快。如果数据上千行、需要行内编辑、排序、拖拽,自绘方案更稳。Demo 代码通常会把两种都覆盖到,或者至少给出一种完整实现加另一种的思路说明。拿到代码后先跑起来看它走的是哪条路,再决定是直接改还是借鉴思路重写。
提示:不管选哪种,先把数据模型里的 Id 和 ParentId 设计清楚,层级关系最好在内存里就构建好,别每次展开都去查数据库。
3. 把 Demo 跑起来:环境、数据绑定与展开折叠的实操
3.1 环境准备与项目结构确认
拿到 Demo 后,第一件事是确认目标框架。WinForms 项目常见的是 .NET Framework 4.x 和 .NET 6/8 两种,前者用旧版 csproj,后者用 SDK 风格。如果 Demo 是 .NET Framework 的,用 Visual Studio 2019 以上打开一般没问题;如果是 .NET 6+,需要装对应 SDK。打开后先看解决方案里有没有多个项目,通常是一个主程序加一个控件库。
# 查看项目使用的目标框架(在项目根目录执行) find . -name "*.csproj" -exec grep -H "TargetFramework" {} \;这条命令帮你快速定位每个项目跑在哪个框架下。如果输出里有net48或net472,说明是 Framework 项目;如果是net6.0-windows或net8.0-windows,就是新版。框架不匹配会导致一堆引用报错,先解决这个再往下走。
3.2 数据模型与绑定:别把层级关系写死在界面里
树型表格的核心是数据模型。Demo 里一般会有一个类似TreeNodeData的类,包含 Id、ParentId、Name、Level 等字段。绑定的时候,要么一次性把所有数据查出来在内存里组装成树,要么按需加载子节点。前者适合数据量小、层级浅的场景,后者适合大数据量。
// 扁平数据转树形结构的常见写法 public class TreeNodeData { public int Id { get; set; } public int ParentId { get; set; } public string Name { get; set; } public int Level { get; set; } public List<TreeNodeData> Children { get; set; } = new List<TreeNodeData>(); } // 构建树 public List<TreeNodeData> BuildTree(List<TreeNodeData> flatList) { var lookup = flatList.ToLookup(x => x.ParentId); foreach (var item in flatList) { item.Children = lookup[item.Id].ToList(); } // 返回根节点(ParentId 通常为 0 或 -1) return lookup[0].ToList(); }ToLookup在这里比多次Where查询高效得多,它一次性建立分组索引。Level字段可以在构建时递归赋值,也可以根据 ParentId 链计算。注意根节点的 ParentId 要和数据里的约定一致,有的用 0,有的用 -1,还有的用 null,改之前先看 Demo 里的判断条件。
3.3 展开折叠与滚动同步的关键参数
如果是组合方案,展开折叠由 TreeView 自己管,你只需要在节点展开时决定要不要加载子节点。如果是自绘方案,展开折叠就要自己维护一个HashSet<int>记录哪些节点是展开的,然后根据这个集合决定哪些行可见。
// 自绘方案里控制行可见性的简化逻辑 private HashSet<int> expandedIds = new HashSet<int>(); private List<TreeNodeData> GetVisibleRows(List<TreeNodeData> allNodes) { var result = new List<TreeNodeData>(); foreach (var node in allNodes) { result.Add(node); if (expandedIds.Contains(node.Id)) { // 递归加入子节点 result.AddRange(GetVisibleRows(node.Children)); } } return result; }expandedIds用HashSet而不是List,是因为展开状态查询非常频繁,HashSet的 O(1) 查找在节点多的时候优势明显。每次点击加减号,就切换这个集合里的 Id,然后重新计算可见行并刷新表格。滚动同步在组合方案里要监听 TreeView 的滚动事件去设置 DataGridView 的 FirstDisplayedScrollingRowIndex,这块 Demo 里如果有实现,重点看它怎么处理滚动条边界,别让索引越界。
注意:自绘方案里重绘频率很高,
GetVisibleRows不要在每次 CellPainting 里调用,应该在展开折叠时算好缓存起来。
4. 避坑与排查:树型表格最容易翻车的五个地方
4.1 展开后行高错乱或文字被截断
现象是展开某个节点后,子行的高度和父行不一致,或者文字下半截被切掉。原因通常是自绘时用了固定的行高,但字体或 DPI 缩放导致实际需要的高度更大。解决方法是把RowTemplate.Height设成一个足够的值,或者在CellPainting里根据e.Graphics.MeasureString动态计算。更稳妥的做法是监听DpiChanged事件,按缩放比例调整行高。
4.2 滚动时树列缩进跳动
组合方案里,TreeView 和 DataGridView 各自有滚动条,滚动不同步时缩进看起来会“跳”。原因是两个控件的滚动事件没有联动。解决方法是把 DataGridView 的Scroll事件和 TreeView 的Scroll事件互相绑定,用FirstDisplayedScrollingRowIndex和TopNode做同步。如果用的是自绘方案,这个问题不存在,因为只有一个滚动体系。
4.3 数据量大时展开卡顿
现象是几百个节点展开时界面卡住一两秒。原因多半是每次展开都重新查数据库或者重新构建整棵树。解决办法是首次加载就把所有数据查出来缓存在内存,展开折叠只操作可见性集合。如果数据实在太大,就做分页加载,展开时才异步查子节点,查完再刷新。
4.4 编辑单元格后层级关系丢失
在自绘方案里,如果直接绑定了扁平列表,用户编辑了某一行的 ParentId,树结构不会自动重算。原因是可见行列表是缓存过的。解决办法是在CellEndEdit事件里判断被编辑的是不是 ParentId 列,如果是就重建树并刷新。别指望控件自动帮你维护层级,这块逻辑得自己写。
4.5 节点图标或复选框状态不同步
现象是勾选了父节点,子节点没跟着勾选,或者图标在展开后消失了。原因是自绘时状态没有持久化到数据模型里,只存在绘制逻辑的临时变量里。解决办法是把勾选状态、图标索引都放到TreeNodeData里,绘制时从数据模型读,而不是从控件属性读。这样无论怎么展开折叠、滚动刷新,状态都不会丢。
5. 进阶技巧:把树型表格做成可复用的控件
5.1 封装成独立控件库
Demo 跑通之后,下一步通常是把它抽成一个独立的类库,方便在多个项目里引用。做法是新建一个 Windows 窗体控件库项目,把自绘的 DataGridView 子类或者组合控件放进去,对外暴露DataSource、ExpandedIds、ImageList等属性。这样业务项目只需要引用 DLL,不用每次都拷贝一堆代码。
// 对外暴露的公共属性示例 public class TreeGridControl : DataGridView { public HashSet<int> ExpandedIds { get; set; } = new HashSet<int>(); public ImageList NodeIcons { get; set; } // 重新绑定数据时重置展开状态 public void SetData(List<TreeNodeData> data) { this.DataSource = GetVisibleRows(data); this.Refresh(); } }封装时要注意别把业务逻辑写进控件里,控件只负责展示和交互,数据怎么来、怎么存交给调用方。ExpandedIds暴露出去是为了让调用方能保存和恢复用户的展开状态,比如下次打开界面时保持上次的展开层级。
5.2 用双缓冲减少闪烁
自绘控件在展开折叠时容易闪烁,尤其是节点多的时候。解决办法是开启双缓冲。对于 DataGridView 子类,可以在构造函数里设置DoubleBuffered = true,但 DataGridView 的这个属性是 protected 的,需要通过反射或者派生类来开。
// 在派生类构造函数里开启双缓冲 public TreeGridControl() { // DataGridView 的 DoubleBuffered 是 protected,派生类里可以直接设 this.DoubleBuffered = true; this.SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true); }OptimizedDoubleBuffer和AllPaintingInWmPaint一起用,能把绘制过程先画到缓冲区再一次性输出,闪烁基本就没了。如果还有残留,检查一下CellPainting里有没有调用base.OnCellPainting导致画了两遍。
5.3 验证方法:用边界数据压一遍
控件改完之后,别只用正常数据测。准备几组边界数据:只有一个根节点、层级超过十层、某个节点有上百个子节点、ParentId 指向不存在的节点。跑一遍看会不会崩、缩进会不会跑出屏幕、展开折叠是否还正常。我一般会写一个简单的控制台程序生成这些数据,直接喂给控件,比手工点快得多。
// 生成深层级测试数据 public static List<TreeNodeData> GenerateDeepData(int depth, int childrenPerNode) { var list = new List<TreeNodeData>(); int id = 1; void AddNode(int parentId, int currentDepth) { if (currentDepth > depth) return; for (int i = 0; i < childrenPerNode; i++) { var node = new TreeNodeData { Id = id++, ParentId = parentId, Name = $"节点{id}" }; list.Add(node); AddNode(node.Id, currentDepth + 1); } } AddNode(0, 1); return list; }这个递归生成器能快速造出深层级、多子节点的数据,用来压测缩进计算和展开性能。depth控制层数,childrenPerNode控制每层子节点数,两个参数一调就能模拟不同规模。从那以后我每次改完树型控件,都强制用这组边界数据跑一遍再提交,省得测试阶段被翻车现场打脸。希望帮到你。
本文还有配套的精品资源,点击获取