
简介面向C# WinForms开发者的实战示例聚焦DataGridView控件不支持层次化展示这一痛点演示如何用自定义数据模型与绘制逻辑在表格中模拟树形列表。示例基于Visual Studio 2012构建涵盖树节点模型设计、列显隐控制、单元格点击展开折叠、行绘制缩进图标以及数据绑定等环节适合已掌握基础控件用法、希望进阶定制表格显示效果的中级开发者参考。压缩包共32个文件、约68KB以cs源码为主体配合sln解决方案与csproj项目文件、app.config配置、resx与resources窗体资源、编译生成的exe和pdb调试符号以及suo、cache等VS临时文件工程结构完整可直接打开调试grid_test项目中的Form1与DataGridViewGroupCell等文件承担了主要实现逻辑。已有1960人学习读者可借此掌握树形表格的展开折叠机制与自绘思路并迁移到目录树、组织架构、分级分类等业务界面。1. DataGridView 树形列表把一张平铺表格改成能展开收起的层级视图后台管理里最容易遇到的需求之一是让一个自关联的表既能按层级显示又能像普通表格一样排序、编辑、筛选。很多开发者第一反应是换控件去找第三方 TreeListView结果发现要额外引入依赖、授权不清晰、样式还跟现有主题对不上。其实 WinForms 自带的 DataGridView 完全能承担这件事只要在数据源里补上层级字段在行绑定后控制行的显示与隐藏再配合缩进和展开图标一个可展开、可收起的树形列表就出来了。这次拆的这个示例就聚焦这一件事适合正在维护 WinForms 老项目、又不想为了一个树形展示引入整套新控件的开发者。2. 方案拆解用扁平数据加视觉缩进模拟树形结构2.1 为什么不用递归控件而是改造 DataGridViewDataGridView 的本质是「行集合 列定义」它不理解父子关系。要做树无非两条路一是自己从 Control 派生一个真正的树控件二是把层级关系压平成带Level、ParentId字段的行集合再用行的可见性和缩进模拟出树的样子。这个示例选的是后者原因很实际。第一条路工作量大展开收起、滚动、列宽自适应、编辑状态全都要自己管任何一个细节没处理好在数据量大时都会暴露问题。第二条路复用了 DataGridView 已经打磨得很成熟的虚拟化、列排序、单元格编辑能力我们只需要额外维护三件事每行的层级深度、当前哪些行是可见的、展开状态记在哪个字段上。代价也要说清楚。因为底层仍是平表所以排序会打乱层级顺序筛选需要自己带上父节点父子关系的维护要靠业务代码保证。这些不是缺陷是选型时必须接受的约束后面的避坑章节会逐条讲。数据结构上一个节点至少需要这几个字段字段类型作用Idint / string节点唯一标识ParentId同 Id 类型父节点标识根节点为 null 或 0Namestring显示文本Levelint层级深度根为 0用于计算缩进HasChildrenbool是否有子节点决定要不要画展开图标IsExpandedbool当前是否展开是 UI 状态而非业务数据Level字段看似冗余其实是性能关键。如果每次刷新都靠递归去算某行属于第几层几千行数据下会明显卡顿在加载时一次性算好后续只读不写开销就摊平了。HasChildren同理用它决定图标比临时查子节点快得多。提示IsExpanded建议放在内存中的视图模型里不要直接写回数据库。展开状态是每个用户各自的浏览偏好持久化到业务表里多人使用时就会互相干扰。2.2 目录结构与文件职责示例工程按职责拆成三块避免把树逻辑塞进窗体代码里TreeGridDemo/ ├── Models/ │ └── TreeNodeItem.cs # 节点模型含 Level / HasChildren / IsExpanded ├── Services/ │ └── TreeDataService.cs # 读取平表数据、构建层级、计算 Level ├── UI/ │ ├── MainForm.cs # 窗体逻辑绑定、事件、展开收起 │ └── MainForm.Designer.cs # 控件布局 └── Program.csTreeNodeItem只放数据不碰 UITreeDataService负责把数据库里的扁平行转成带层级的列表MainForm只做绑定和交互。这样以后换数据源或者把这套逻辑搬到 WPF、Blazor只需要替换 Service 层。2.3 构建层级列表一次遍历算出 Level 与 HasChildren服务层的核心是把乱序的平表整理成「父节点在前、子节点紧随其后」的有序列表。常见做法是先按ParentId建索引再从根节点开始深度优先遍历public class TreeDataService { // 输入来自数据库的扁平节点集合 public ListTreeNodeItem BuildTree(IEnumerableTreeNodeItem flatNodes) { var all flatNodes.ToList(); // 按父节点分组便于 O(1) 找到直接子节点 var childrenLookup all .Where(n n.ParentId ! null) .GroupBy(n n.ParentId) .ToDictionary(g g.Key, g g.ToList()); var result new ListTreeNodeItem(all.Count); // 深度优先展开保证父节点一定排在子节点之前 void Walk(TreeNodeItem node, int level) { node.Level level; node.HasChildren childrenLookup.ContainsKey(node.Id) childrenLookup[node.Id].Count 0; result.Add(node); if (!node.HasChildren) return; foreach (var child in childrenLookup[node.Id]) { Walk(child, level 1); } } // 根节点ParentId 为空或 0具体以业务约定为准 foreach (var root in all.Where(n n.ParentId null || n.ParentId.Equals(default))) { Walk(root, 0); } return result; } }这段代码有几个点值得说明。childrenLookup把「找子节点」从遍历查找变成字典命中整体复杂度从 O(n²) 降到 O(n)。Walk是递归写法好处是顺序天然正确坏处是层级特别深时会栈溢出一般业务表超过十几层就该报警了真有这种数据说明模型设计本身有问题。HasChildren在遍历时顺手算好后面画图标直接读字段不再查字典。参数上flatNodes的顺序无所谓函数内部会重排根节点的判定条件是ParentId null或默认值实际项目里如果数据库用-1表示根改这一行判断即可。调用方拿到结果后直接BindingListTreeNodeItem包一层丢给 DataGridView 的DataSource即可因为后续会有增删用BindingList能自动触发刷新。3. 在 DataGridView 里画出树绑定、缩进与展开图标3.1 绑定数据并接管行绘制绑定本身很普通关键在于用CellPainting事件接管第一列的绘制把缩进和图标画上去private void BindTree(ListTreeNodeItem nodes) { _rows new BindingListTreeNodeItem(nodes); grid.DataSource _rows; // 隐藏主键、父键、层级等内部字段 grid.Columns[nameof(TreeNodeItem.Id)].Visible false; grid.Columns[nameof(TreeNodeItem.ParentId)].Visible false; grid.Columns[nameof(TreeNodeItem.Level)].Visible false; grid.Columns[nameof(TreeNodeItem.HasChildren)].Visible false; grid.RowHeadersVisible false; grid.AllowUserToAddRows false; grid.SelectionMode DataGridViewSelectionMode.FullRowSelect; grid.AutoSizeColumnsMode DataGridViewAutoSizeColumnsMode.Fill; // 第一列承载缩进和展开图标需要自定义绘制 grid.CellPainting Grid_CellPainting; grid.CellMouseClick Grid_CellMouseClick; } private void Grid_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { // 只处理第一列的文本区域且跳过表头 if (e.ColumnIndex ! 0 || e.RowIndex 0) return; var item (TreeNodeItem)grid.Rows[e.RowIndex].DataBoundItem; // 先让默认绘制把背景、选中态、边框画完 e.PaintBackground(e.CellBounds, true); // 计算图标区域 int indent item.Level * 20; var iconRect new Rectangle( e.CellBounds.Left indent 2, e.CellBounds.Top (e.CellBounds.Height - 12) / 2, 12, 12); if (item.HasChildren) { string glyph item.IsExpanded ? − : ; TextRenderer.DrawText(e.Graphics, glyph, grid.Font, iconRect, e.CellStyle.ForeColor, TextFormatFlags.HorizontalCenter); } // 文本从图标右侧开始 var textRect new Rectangle(iconRect.Right 4, e.CellBounds.Top, e.CellBounds.Right - iconRect.Right - 4, e.CellBounds.Height); TextRenderer.DrawText(e.Graphics, item.Name, grid.Font, textRect, e.CellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); e.Handled true; // 告诉 DataGridView 本单元格已画完 }e.PaintBackground必须先调用否则选中行的高亮、隔行变色都会消失这是手绘单元格最常见的翻车点。缩进量indent Level * 20是经验值20 像素在 1080P 和 4K 缩放下都还看得清如果客户要求更紧凑改成 16 并同步调整图标宽度。e.Handled true一定不能漏漏了默认绘制会覆盖你画的内容现象是「代码跑了但界面没变化」很容易让人怀疑人生。图标这里用−字符代替图片是为了让示例不依赖资源文件正式项目建议换成 ImageList 里的两态图标视觉上更统一。3.2 点击图标展开收起控制行可见性展开收起的本质是改子节点的可见性。因为表是平的不能只删一层要把后代全部标记private void Grid_CellMouseClick(object sender, DataGridViewCellMouseEventArgs e) { if (e.ColumnIndex ! 0 || e.RowIndex 0) return; var item (TreeNodeItem)grid.Rows[e.RowIndex].DataBoundItem; if (!item.HasChildren) return; // 只响应图标区域的点击避免点文字也触发展开 int indent item.Level * 20; var iconRect new Rectangle(indent 2, 0, 14, grid.RowTemplate.Height); if (!iconRect.Contains(e.Location)) return; item.IsExpanded !item.IsExpanded; ApplyVisibility(); grid.InvalidateRow(e.RowIndex); // 重画图标 } private void ApplyVisibility() { grid.SuspendLayout(); try { // 先全部视为可见再自上而下把折叠节点的后代隐藏 var hidden new HashSetint(); foreach (DataGridViewRow row in grid.Rows) { var item (TreeNodeItem)row.DataBoundItem; // 只要父节点在隐藏集合里自己也隐藏 if (item.ParentId ! null hidden.Contains(item.ParentId.GetHashCode())) { row.Visible false; hidden.Add(item.Id.GetHashCode()); continue; } if (!item.IsExpanded item.HasChildren) { row.Visible true; // 自己作为折叠头仍然显示 hidden.Add(item.Id.GetHashCode()); } else { row.Visible true; } } } finally { grid.ResumeLayout(); } }ApplyVisibility依赖一个前提行在网格中的顺序必须是父在前、子在后。这正是第 2 章BuildTree保证的两个模块在这里咬合。逐行扫描时用一个hidden集合记录「被折叠节点的 Id」后面的子节点一看到父 Id 在里面就跟着隐藏这样多层折叠也能正确传递不需要写嵌套递归去遍历 DataGridView。SuspendLayout/ResumeLayout包住整个循环是为了避免每改一次Visible就触发一次布局计算几百行数据下差别很明显。hashCode在这里只用于集合查找如果 Id 是字符串直接用字符串即可不必转换。还有一个细节row.Visible false在DataGridView上会让该行不参与绘制但绑定数据仍在排序、导出时要注意把不可见行排除掉否则用户会以为数据丢了。3.3 与排序、筛选共存时的处理顺序DataGridView 自带列头点击排序一旦点了BuildTree排好的父子顺序就废了缩进看起来会乱。稳妥做法是在排序前先把网格切成「平铺模式」或者干脆禁用自动排序用业务按钮触发服务层重新排序再重新BuildTree。如果一定要保留列排序可以在SortCompare事件里做二次排序把同一父节点下的子节点排在一起。但这样只能保证「同层有序」跨层的缩进依然对不上所以只适合单层展示的场景。筛选同理按关键字过滤时要连带把命中节点的所有祖先保留否则出现了孤立子节点缩进就没有意义。这部分逻辑建议放在 Service 层UI 层只管刷新。4. 常见问题排查树形显示对不上时先看这几处4.1 缩进全为 0所有节点挤在一层现象是界面能显示所有行但完全没有层级感。原因通常是BuildTree没被执行直接把数据库扁表绑了上去Level字段全是默认值 0。解决方式是确认绑定前一定调用了BuildTree并在调试时断点看返回集合里Level的分布。另一种可能是根节点判定条件写错导致所有节点都被当成根遍历一次就结束。4.2 父节点画了图标点击却没有反应现象是号显示正常点下去不展开。原因多半是点击坐标判断错了e.Location是相对于单元格的坐标而iconRect用的是整行坐标两者对不上就永远不命中。解决方式是在CellMouseClick里直接判断e.ColumnIndex 0且点击位置落在单元格左侧indent 20的范围内或者干脆整个第一列都当作展开热区从交互上更宽松用户也不容易点空。4.3 选中行高亮消失背景变白现象是手绘之后被选中行没有蓝色高亮看起来像没选中。原因是CellPainting里没有先调用e.PaintBackground或者调用了但e.Handled设置顺序有误。正确顺序是先PaintBackground再画图标和文字最后把e.Handled设为 true。如果仍然异常检查是否在别处又订阅了同一个事件但没有置Handled。4.4 数据量一大就卡顿滚动像幻灯片现象是几百行还能忍几千行时滚动明显掉帧。原因是每行都在事件里做字典查找或递归。解决方式是把Level、HasChildren在BuildTree阶段预计算好绘制时只读字段同时避免在CellPainting里装箱拆箱DataBoundItem转一次就存局部变量。如果数据过万建议开启VirtualMode只保留可视行这时树形逻辑要改成按需查子节点是另一套写法。4.5 折叠后导出或统计漏数据现象是用户折叠了若干节点点导出按钮结果只导出了当前可见行。原因是用grid.Rows遍历时跳过了Visible false的行。解决方式是在导出前遍历绑定的BindingList而不是网格行或者临时展开所有节点再导出。这个坑在带「按当前视图导出」字样的功能里尤其常见需求方往往默认导出的是全量。5. 二次开发改造从展示树到可编辑的业务树把展示跑通只是起点真要嵌进业务系统通常要往上加三样东西编辑、拖拽调整层级、按权限控制节点可见。这里挑最常被问到的「原地改名」和「拖拽换父级」说透。原地编辑要解决的是用户双击名称列改名改完写回模型。DataGridView 默认编辑的是显示值而显示值是手绘出来的所以要把编辑控件对准文本区域private void grid_CellBeginEdit(object sender, DataGridViewCellCancelEventArgs e) { if (e.ColumnIndex ! 0) return; var item (TreeNodeItem)grid.Rows[e.RowIndex].DataBoundItem; // 把编辑框右移避开缩进和图标视觉上不突兀 grid.Columns[0].DefaultCellStyle.Padding new Padding(item.Level * 20 16, 0, 0, 0); }Padding是 DataGridView 提供的对齐手段比自定义编辑控件省事得多编辑框会自动按这个偏移摆放。编辑结束时在CellEndEdit里把新值赋给item.Name如果有数据库再调服务层更新失败则回滚并提示不要等BindingList自己刷新否则报错时用户看到的是「改了又变回去」的玄学现象。拖拽换父级要处理三个事件MouseDown记录起点、DragOver判断落点合法性、Drop执行重挂。核心校验只有一条——不能把节点拖到自己的后代下面否则树会成环之后任何递归遍历都会死循环。这个校验必须在 Service 层用祖先链判断不能只靠 UI 层「看起来不对」public bool CanReparent(TreeNodeItem moving, TreeNodeItem target, ListTreeNodeItem all) { // 不能挂到自己身上 if (moving.Id.Equals(target.Id)) return false; // 沿目标节点向上找祖先若遇到 moving说明目标在其子树内 var current target; while (current ! null) { if (current.Id.Equals(moving.Id)) return false; current all.FirstOrDefault(n n.Id.Equals(current.ParentId)); } return true; }while循环用FirstOrDefault查父节点节点量小的时候够用如果频繁拖拽建议在构建树时顺带维护一个Id - 节点的字典把查找降到 O(1)。校验通过后再改target.ParentId重新BuildTree并刷新绑定展开状态如果希望保留需要在重建前把各节点的IsExpanded存一份再还原这个细节不做用户每次拖完都得重新点开一遍体验很差。最后提一句验证方式改完层级后不要只看界面写个小的断言把「所有节点的Level与祖先链深度一致」校验一遍再检查有没有环。我之前图省事直接看界面结果数据里混进了一条自引用记录界面照常渲染等到导出功能递归时直接栈溢出排查花了大半天。从那以后凡是动层级结构的代码我都强制先跑一遍祖先链校验再提交。希望这些能帮你在自己的项目里少走两段弯路。本文还有配套的精品资源点击获取