简介一份面向 C# WinForms 开发者的 DataGridView 树形列表实现示例解决表格控件难以直接展示层次化数据的问题适合需要呈现树状信息、希望扩展表格能力的初中级开发者。示例通过自定义渲染与事件处理模拟树形效果并非简单数据绑定。资源附完整 grid_test 工程核心代码覆盖自定义 DataGridViewGroupCell 单元格、树形节点数据模型、CellClick 折叠/展开事件、列显隐动态切换、OnRowPrePaint 自绘缩进图标以及 DataSource/DataPropertyName 绑定等关键环节整个工程在 Visual Studio 2012 下可编译运行源码注释清晰便于直接移植或改造。压缩包共 32 个文件以 10 个 .cs 源码为主配合 .resx 窗体资源、.Designer.cs 设计器文件、.csproj/.sln/.config 工程配置及编译生成的 exe/pdb整体仅 68KB轻量且目录清晰。已有 1948 人学习/下载通过该示例可快速掌握在 DataGridView 内模拟树形结构的方法避开布局刷新、节点状态同步等常见问题。 做管理系统的朋友应该都有过这种经历数据库里存的是父子关系的数据产品经理却希望在一个表格里看到完整的层级展开、折叠、缩进一个都不能少。直接用TreeView吧列字段展示、排序、单元格内编辑这些又跟不上硬塞进DataGridView吧它原生根本不支持树结构。最近我又在一个菜单管理模块里遇到了这个需求于是把DataGridView控件显示树结构的自绘方案完整做了一遍顺便把过程中踩过的坑整理出来。这个需求在WinForms开发里相当常见方案本身也不复杂核心就是数据扁平化加自定义绘制今天这篇就围绕这两件事展开从设计思路、核心代码到排查经验一次性讲清楚。1. 项目背景为什么要把树结构“塞进”DataGridView1.1 业务场景与核心需求先说说我遇到的具体场景。某个后台管理系统的菜单权限模块数据库里的菜单表用ParentId做父子关联层级最深能到四层。产品经理提的需求很明确要在同一个表格里展示菜单名称、排序号、路由地址、状态还要能像文件资源管理器那样一层层展开、折叠。这种需求你仔细一琢磨就会发现一个矛盾。用TreeView可以很轻松处理展开折叠但树控件的列能力太弱展示多字段信息要拼字符串想给某一列单独做样式设置、加链接、控制宽度就很费劲。反过来DataGridView的列能力强得没话说但它的数据源是一个扁平的列表天生不认父子关系。所以这个项目的核心需求可以总结成三点一是保留DataGridView的多列展示和编辑能力二是实现树的层级缩进和展开折叠三是不引入第三方商业控件在纯微软技术栈里解决。1.2 技术方案对比为什么不直接用TreeView把市面上的做法摆在一起对比其实就三条路。第一直接用TreeView搭配多列的自绘工具条或者用TreeListView之类扩展。这个方案最快但TreeView的列宽调整、单元格级选中态、列头排序这些体验都偏弱遇到用户习惯表格操作的项目很吃亏。第二引入第三方控件比如DevExpress的TreeList、ComponentOne的FlexGrid功能确实强大但一套授权费用不低而且项目里如果已经有DataGridView的现成交互迁移成本也不小。第三就是在DataGridView里做自绘树结构。不做任何控件替换只是把数据在内存里“拍扁”再用行缩进和加减号让用户看到树的形态。这个方案的优势是零依赖、完全可控缺点是需要自己处理绘制和事件命中。我最后选了第三条路原因很实际这个项目本身就在大量使用DataGridView主表、子表都是它不想为了一个菜单管理页引入重量级依赖。如果你只是要快速交付TreeView完全够用但如果产品经理后续大概率会加更多列、做行内编辑、搞导出自绘方案的前期投入是值得的。2. 核心设计思路三个关键决策2.1 数据模型用TreeItem自引用结构解决“父子关系”要让DataGridView显示树第一步不是写绘制代码而是把数据源理顺。我这里定义了一个TreeItem类专门承载树节点信息。public class TreeItem { public int Id { get; set; } public int ParentId { get; set; } public string Name { get; set; } public int Order { get; set; } public int Level { get; set; } public bool IsExpanded { get; set; } public bool HasChildren { get; set; } public ListTreeItem Children { get; set; } new ListTreeItem(); // 额外字段可以继续往上挂比如 RouteUrl、Status 等 public object Tag { get; set; } }这个结构相信写过菜单表的人都不陌生。Children集合在初始化的时候从原始表里灌进去Level字段是后面递归的时候算出来的。HasChildren其实可以直接用Children.Count 0来代替但我喜欢显式存一个字段后面绘制加减号的时候少一次判断代码也更直白。实际用的时候如果数据来自数据库的DataTable先把它转换成List 字段按名称映射即可。这一步虽然啰嗦但换来的是后面递归逻辑的极大简化。2.2 显示顺序深度优先扁平化保证父子相邻DataGridView的行本身没有“层级”概念所以我们要做的第二件事就是把树结构的节点按照深度优先的顺序展开成一个List然后直接绑定给DataGridView。深度优先这个选择很重要。只有深度优先遍历才能保证一个父节点的所有子孙都跟在它后面折叠的时候把这一段子序列整体隐藏掉展开的时候把这一段子序列重新插回来。如果按广度优先父子行会被其他兄弟节点隔开折叠逻辑会乱套。这个扁平化的过程也是计算Level的时机。每往下一层Level加1后面画缩进时就用这个值乘以缩进宽度。2.3 交互与绘制把“树的样子”画在单元格上做完扁平化DataGridView里已经有一串顺序正确的行了。这时候如果不做任何处理它们看起来就是一堆普通行分不清父子关系。所以第三件事也是这个方案最核心的一步在CellPainting事件里重绘树列单元格。绘制的内容有三样按Level值计算的左侧缩进、展开折叠的加减号、以及父子之间的连接线可选。这里有一个很重要的决策缩进不是往文本前面加空格而是绘制时直接通过坐标计算偏移量。用空格有几个坏处一是字体不是等宽字体的话对不齐二是排序后重新绘制时机不可控三是复制单元格内容时会把空格也复制走。用坐标偏移则完全没有这些问题绘制是实时的数据本身始终是干净的文本。3. 完整代码实现一个可以抄走的树形DataGridView3.1 基础数据准备从原始List构建树假设你已经从数据库拿到了一张List 里的数据每条只有Id、ParentId、Name、OrderChildren和Level都还没填充。先把它整理成真正的树结构private ListTreeItem BuildTree(ListTreeItem source) { var dict source.ToDictionary(t t.Id); var roots new ListTreeItem(); foreach (var item in source) { item.Children new ListTreeItem(); if (item.ParentId 0 || !dict.ContainsKey(item.ParentId)) { roots.Add(item); // ParentId为0的或者父节点找不到的一律当根处理 } else { dict[item.ParentId].Children.Add(item); } } foreach (var item in source) { item.Children item.Children.OrderBy(c c.Order).ToList(); item.HasChildren item.Children.Count 0; } return roots.OrderBy(r r.Order).ToList(); }关于根节点的判定我给两个条件ParentId为0或者父节点在当前列表里不存在。第二种情况在脏数据场景下非常常见比如有人手欠删掉了一级菜单子菜单的ParentId还指向一个不存在的Id。如果不做容错这条数据会直接丢失用户还找不到原因。把它当根节点处理至少数据还能显示出来算是一种兜底策略。3.2 核心代码深度优先扁平化与绑定树构建好之后写一个深度优先的递归方法把树“拍扁”成一个Listprivate ListTreeItem FlattenTree(ListTreeItem roots) { var result new ListTreeItem(); void Walk(TreeItem node, int level) { node.Level level; result.Add(node); if (!node.IsExpanded || !node.HasChildren) return; foreach (var child in node.Children) { Walk(child, level 1); } } foreach (var root in roots) { Walk(root, 0); } return result; }这段代码是整个方案的骨架。每次展开或折叠一个节点后调用一次重新生成可见行列表再重新绑定DataGridView。我用的是C#的局部函数写法在方法内部定义Walk不用额外声明私有方法递归的时候也不必传result参数写起来干净不少。重新绑定的时候有一个细节需要注意Directly给DataGridView.DataSource赋值后会丢掉滚动位置和当前选中行用户折叠几个节点后表格跳回第一行体验很差。所以我把绑定封装成一个RebindTree方法先记住当前节点和滚动位置绑定后再恢复private void RebindTree() { var scrollItem dgv.FirstDisplayedScrollingRowIndex 0 ? dgv.Rows[dgv.FirstDisplayedScrollingRowIndex].DataBoundItem as TreeItem : null; var currentItem dgv.CurrentRow?.DataBoundItem as TreeItem; _visibleList FlattenTree(_roots); dgv.DataSource null; dgv.DataSource _visibleList; if (currentItem ! null) { var index _visibleList.IndexOf(currentItem); if (index 0) dgv.CurrentCell dgv.Rows[index].Cells[treeColumnIndex]; } if (scrollItem ! null) { var scrollIndex _visibleList.IndexOf(scrollItem); if (scrollIndex 0) { try { dgv.FirstDisplayedScrollingRowIndex scrollIndex; } catch { /* 索引越界直接忽略 */ } } } }保存滚动位置时我存的是当前显示在顶部那一行对应的TreeItem对象而不是行索引。这个区别很关键。重新绑定后行数是会变的比如展开某个父节点后行数变多行索引对应的内容早就不是原来那行了。用对象去查新索引才能做到“位置不变”的体验。3.3 展开折叠与命中检测展开折叠的入口放在CellClick事件里。判断条件有三个点击的是树列、点击的行有子节点、点击位置落在加减号按钮的矩形范围内。private void dgv_CellClick(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex ! treeColumnIndex) return; var item dgv.Rows[e.RowIndex].DataBoundItem as TreeItem; if (item null || !item.HasChildren) return; var cellRect dgv.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false); var indent item.Level * TreeIndent; var btnRect new Rectangle( cellRect.Left indent 2, cellRect.Top (cellRect.Height - TreeNodeBtnSize) / 2, TreeNodeBtnSize, TreeNodeBtnSize); if (btnRect.Contains(dgv.PointToClient(Cursor.Position))) { item.IsExpanded !item.IsExpanded; RebindTree(); } }这里TreeIndent是每级缩进的像素值我习惯设成25TreeNodeBtnSize是加减号按钮的边长设成14。为什么按钮要做成14x14这个尺寸差不多是9pt字体下能清晰显示加减号的最小尺寸再小手指在触摸屏上就点不准了再大又显得笨重。如果你的目标机器有125%或150%的DPI缩放建议按当前屏幕的DeviceDpi做一次等比放大不然按钮在高分屏上会偏小。还有一点要特别说明命中检测这里用的是GetCellDisplayRectangle配合PointToClient(Cursor.Position)。CellClick事件的e.Location其实已经是相对单元格的坐标理论上可以直接用e.Location算但在某些主题下这个坐标会有几个像素的偏移我遇到过两三次点击误差后来干脆统一用鼠标屏幕坐标反算最稳。3.4 绘制递归层级与加减号接下来是最有视觉成就感的部分让每个单元格呈现出树的形态。private void dgv_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex ! treeColumnIndex) return; var item dgv.Rows[e.RowIndex].DataBoundItem as TreeItem; if (item null) return; // 只让系统画背景和边框内容我们自己画 e.Paint(e.CellBounds, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border | DataGridViewPaintParts.SelectionBackground | DataGridViewPaintParts.ErrorIcon); var indent item.Level * TreeIndent; var left e.CellBounds.Left; // 第1步画展开/折叠按钮 if (item.HasChildren) { var btnRect new Rectangle( left indent 2, e.CellBounds.Top (e.CellBounds.Height - TreeNodeBtnSize) / 2, TreeNodeBtnSize, TreeNodeBtnSize); e.Graphics.DrawRectangle(Pens.Gray, btnRect); // 横线加减号共用 e.Graphics.DrawLine(Pens.Black, btnRect.Left 3, btnRect.Top btnRect.Height / 2, btnRect.Right - 3, btnRect.Top btnRect.Height / 2); // 折叠状态才画竖线展开状态竖线是不存在的 if (!item.IsExpanded) { e.Graphics.DrawLine(Pens.Black, btnRect.Left btnRect.Width / 2, btnRect.Top 3, btnRect.Left btnRect.Width / 2, btnRect.Bottom - 3); } } // 第2步画父子之间的连接线 if (item.Level 0) { var lineX left indent 8; // 从当前行顶部画到行尾形成上下连续的竖线 e.Graphics.DrawLine(Pens.Silver, lineX, e.CellBounds.Top, lineX, e.CellBounds.Bottom); // 从竖线横向引出到文本 e.Graphics.DrawLine(Pens.Silver, lineX, e.CellBounds.Top e.CellBounds.Height / 2, left indent TreeNodeBtnSize 6, e.CellBounds.Top e.CellBounds.Height / 2); } // 第3步画文本 var textLeft left indent TreeNodeBtnSize 10; var textRect new Rectangle(textLeft, e.CellBounds.Top, e.CellBounds.Right - textLeft - 4, e.CellBounds.Height); TextRenderer.DrawText(e.Graphics, item.Name, dgv.Font, textRect, dgv.ForeColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter); e.Handled true; }看着代码多其实逻辑很清晰。第1步画加减号的小方框第2步画父子之间的虚线感连接线第3步画名称文本。顺序不能乱先画按钮再画线最后画文字这样可以保证连线从按钮下方穿过时视觉上在背景层文字始终在最上层可读。连接线这条要特别注意很多人做树形表格时会忽略但没了它层级感弱一大截。我这里的实现是给每个子节点行都画一条贯穿的竖线这样上一行的线尾自然接着下一行的线头视觉上就连起来了。横线从竖线中间向右引出到文本起点看起来就像是“一根枝条上长出一片叶子”。虽然不算精细但胜在简单可靠。这里有个绘制细节为什么文本用TextRenderer.DrawText而不是Graphics.DrawStringTextRenderer在WinForms里和控件的字体渲染方式一致文字更清晰而且对中文显示效果明显更好。同样的字号DrawString画出来的中文在低分辨率屏幕上会有毛边TextRenderer就好很多。如果你之前遇到DataGridView里自绘文字发虚多半就是用了DrawString。4. 常见问题与排查技巧实录4.1 高频问题速查表自绘方案的坑主要集中在绘制时序、事件命中和状态恢复三个方面。我把实际开发中遇到的高频问题整理成一张表方便你排查。现象原因解决方案节点文本和背景重叠发黑默认绘制了ContentForeground自定义绘制时又画了一遍文本绘制时用e.Paint只传入Background和Border两个枚举值按钮点击没反应判断的是点击列但用户点在了文本上而不是加减号上用GetCellDisplayRectangle和鼠标坐标做按钮矩形命中检测展开折叠后表格跳回第一行重新绑定DataSource导致滚动位置丢失用DataBoundItem对象记录顶部行恢复时重新查找索引有子节点的行不显示加减号HasChildren没有在构建树时赋值在BuildTree中遍历每个节点Children.Count 0即赋值父节点折叠后子行还留在界面上扁平化时没有判断IsExpanded检查FlattenTree的递归逻辑IsExpanded为false必须return高DPI屏幕上按钮特别小按钮尺寸是写死的像素值根据DeviceDpi动态放大缩放系数 dpi / 964.2 深挖三个“真坑”第一个坑是选中态的背景覆盖。我最初在CellPainting里用e.Graphics.FillRectangle自己刷背景刷完才发现选中行的蓝色背景不见了。如果你也用类似写法千万别整个画背景老老实实用e.Paint(e.CellBounds, DataGridViewPaintParts.Background | DataGridViewPaintParts.SelectionBackground)把背景交给系统画你只管画线、画按钮、画文本这样所有主题下的选中态都自动正常。第二个坑是编辑状态的冲突。如果树列允许用户双击编辑你会发现双击单元格后弹出的TextBox会把自绘内容盖掉这没问题但有一个奇怪的现象你在编辑框里改完文字按回车单元格里显示的还是旧文字。这是因为自绘代码在e.Handled true之后DataGridView不会再触发文本重绘而TextBox编辑结束后的提交逻辑被我们短路了。解决办法是在CellEndEdit事件里主动调用一次dgv.InvalidateRow(e.RowIndex)强制刷新这一行。第三个坑是列头排序。DataGridView默认允许点击列头排序但对树形数据来说排序后整棵树的父子顺序会被打乱后果非常灾难。比如一个父节点下有A、B两个子节点排序列名后变成B在A前面但父节点只认自己Children里的顺序再次展开时又恢复成A、B用户看着排序图标以为排好了一展开全乱。我的处理策略是在树列上禁用排序其他业务列做排序时只对“同一父节点下的子节点”内部排序而不是对整个DataGridView排序。如果你不做列头排序直接在DataGridView的属性面板里把树列的SortMode设为NotSortable就行。5. 性能优化与后续扩展5.1 数据量变大时怎么办自绘方案的数据绑定走的是全量重绑路线当节点数在几千个以内时RebindTree没什么压力肉眼察觉不到延迟。但节点数上到两三万每次展开折叠都会感觉到半秒左右的卡顿主要开销在List重建和DataGridView重新布局行。优化方向有几个。第一直接在RebindTree前后加SuspendLayout和ResumeLayout可以减少布局计算次数这个改动最便宜随手就该写。第二把IsExpanded状态从对象字段改成HashSet 保存所有折叠节点的Id这样每次扁平化时判断更快但收益有限。第三如果数据量实在大这个方案就到头了更合理的做法是换用虚拟模式VirtualMode或者考虑把树表单独做成一个分页的三级联动组件不要让用户一次性看全两万个节点。从产品角度讲两万行的树形表格本身就超出了正常人的阅读范围分页和筛选才是这类场景的正解。5.2 树形表格的其他扩展方向这个自绘方案做扎实之后往上的扩展空间其实很大。合并单元格是很多人问过的功能。比如父节点占一整行子节点的某个状态列可以按父级汇总这类需求可以在CellPainting里判断当前行是不是父节点行的最后一行画一个跨行的矩形遮住分隔线模拟合并效果再配合CellValueChanged做汇总计算就能做出树形分组报表的体验。懒加载也是值得做的方向。数据从数据库查出来的时候不一次性加载所有节点只在用户首次展开某个父节点时才去查它的直接子节点然后插入到当前行下面。实现上只需要在CellClick触发展开动作前检查该节点Children集合是否已经加载没加载就调一次异步方法填充Children再接着走RebindTree。这个改造对提升首屏加载速度帮助非常大适合层级深、每个节点孩子数量多的场景。还有一个方向是状态联动。比如某个父节点被勾选后其所有子节点跟着勾选反选时子节点逐个取消。这需要你在TreeItem里加两个字段Checked和Indeterminate在勾选事件里递归设置整棵子树的状态再重绘。代码不复杂但用户体验提升非常明显很多权限配置页面都需要这种能力。我在做这个菜单管理模块的时候一开始也纠结过要不要上第三方控件后来还是靠自绘方案把问题解决了。整个过程最深的体会是DataGridView看起来死板但它的扩展性其实比想象中强很多只是一般人未必会去碰CellPainting这一层。你只要理解了“数据扁平化 自定义绘制”这个组合拳以后遇到任何类似的表格内树形结构需求心里就有底了。最后再提醒一句所有自绘的逻辑都集中在CellPainting和CellClick两个事件里尽量抽成独立的类或者方法别全堆在窗体的代码文件里不然维护起来真的会想哭。本文还有配套的精品资源点击获取