☰
WinForms原生DataGridView实现树形表格:扁平化数据与自定义绘制详解
2026/9/29 17:10:50 网站建设 项目流程

简介:面向WinForms开发者的实用教程资源,演示如何在DataGridView控件中呈现树形结构,解决表格控件不支持层次化数据展示的常见痛点。资源基于Visual Studio 2012与C#实现,核心思路清晰:先定义包含Name与Children属性的TreeNode数据模型,再扩展DataGridView,重写OnRowPrePaint绘制展开/折叠图标,利用CellClick事件切换节点状态,并动态调整列Visible属性,使父节点与子节点按需显示或隐藏,从而在保留排序、编辑等原生能力的同时,模拟出可交互的树形列表效果。工程grid_test提供可编译运行的完整示例,关键步骤均有代码对应,便于跟随调试和二次修改。压缩包共32个文件,含10个cs源码、3个config配置、3个exe可执行程序、2个resx与2个resources资源文件,以及sln、csproj等项目文件,整体仅68KB,结构紧凑,方便定位核心代码。已有1951人学习过此资源,适合希望掌握DataGridView自定义渲染与交互技巧的WinForms中级开发者。参考源码可迅速理解树形列表从数据模型到界面交互的完整逻辑,并灵活迁移到后台菜单、组织架构、目录管理等需要展示层级数据的业务场景,是一份小而精的实战示例。

1. DataGridView 到底能不能显示树结构:一个被问了十年的伪命题

把树塞进 DataGridView,这需求在 WinForms 项目里出现的频率高得离谱。树控件(TreeView)放左边、表格放右边,这是最常规的布局。但偏偏有大量场景要求树和表格是一体的:物料清单要有层级缩进还要看数量、科目余额表既要父子级又要显示金额、权限配置界面需要在同一行里同时操作节点和勾选状态。你搜 DataGridView 控件显示树结构,搜出来的方案大多是一个思路:自己画。绝大多数人第一次接手这种需求时都默认要换控件,要么引入第三方表格组件,要么干脆左边树右边表凑合。但真正做过的都知道,DataGridView 自己就能干这事,前提是你想清楚它根本不会给你一个现成的树,你能用的只有行、单元格和绘制事件。这篇文章就是把这条自己画的自定义控件改造路线拆开讲清楚,从数据扁平化到缩进绘制,从展开折叠到性能坑,全部基于原生 DataGridView,不需要引入任何额外控件库。

2. 先理解树结构在表格里的三种形态:嵌套、缩进和扁平化

2.1 为什么 DataGridView 没有内置树节点,以及替代方案的取舍

DataGridView 的设计目标一直是二维表格,它没有 TreeView 那种 TreeNodes 集合。微软官方从没打算让它在行上挂子行,在 WinForms 里要实现树表合一,天然只有三条路。第一条是分层嵌套:把子行直接插到父行下面,用行背景色或缩进区分层级。第二条是横竖双视角:左边一个 TreeView,右边一个 DataGridView,选中节点后右边刷新对应行。第三条是彻底扁平化:只用一张表存储所有节点,每行记录自己的层级深度和父子 id,渲染时通过单元格样式模拟树形。相比之下,嵌套行在 DataGridView 里根本没有官方接口支持,动态插入删除子行会让取消选中次序、滚动条位置、增删状态全部失控,维护成本非常高。TreeView 加 DataGridView 的联动方案在只需要树导航时确实简单,但用户一旦要求同一行里编辑字段、双击整行、跨层级拖拽,这个方案立刻露馅,因为表和树是分离的,表格的选中态和树的选中态要做双花同步。最终我认为可维护的方案只有扁平化这一条:在内存里维护一棵树,渲染时把树按先序遍历拍平成列表,DataGridView 只是这棵树的投影。

2.2 树结构扁平化的数据契约:从对象模型到行列映射

扁平化之前要把内存对象设计好。常见做法是每个节点都带三个关键字段:Id、ParentId、Children 集合。实际代码可以这样建模:

public class TreeNodeItem { public string Id { get; set; } // 节点唯一标识 public string ParentId { get; set; } // 父节点标识,根节点为 null public string Name { get; set; } // 显示文本 public int Value { get; set; } // 业务数据,如金额、数量 public int Depth { get; set; } // 当前节点在树中的深度,根为 0 public bool HasChildren { get; set; } // 是否包含子节点 public bool IsExpanded { get; set; } // 当前节点展开状态 public List<TreeNodeItem> Children { get; set; } // 直接子节点集合 }

TreeView 用 TreeNode 和 Nodes 集合天然管层级,但 DataGridView 要的是平铺的数据源,因此需要把 TreeNodeItem 转成 List,转换用迭代式深度优先遍历而不是递归,这点很重要,后面会在避坑章节专门解释。转换的核心逻辑是保持树的先序顺序,让每个父节点后的所有子节点紧跟在它下面,这样视觉上才是一棵连续的树。加号减号的展开状态不要改动数据源集合本身,只在渲染层决定输出多少行。IsExpanded 标记存在对象里,每次数据刷新时判断。Depth 字段决定缩进宽度,它不用在节点创建时手工填,而是遍历时层数加一传下去。

2.3 树形样式的最小视觉模型:缩进、图标和连接线

UI 层要做的事情有三件:缩进、展开折叠标识、可选的关系连接线。缩进直接用单元格 Padding 实现最稳。给行设置一个统一的左边距,数值等于 Depth 乘以一个固定步长,界面上立即出现层级感。展开折叠标识用一个小图片列实现,每一行的第一个单元格左边画加减号或三角箭头。连接线属于加分项,要用 CellPainting 在缩进区域画垂直竖线和横线,注意画线区域要放在缩进区域,避免覆盖文字。我的经验是连接线在浅色背景下画浅灰细线即可,颜色可以写成常量方便后续维护。如果嫌画线麻烦或者树层数本身不超过两层,连接线完全可以不做,只保留缩进和加减号,这种取舍在项目里经常能省不少调试时间。

3. 在 WinForms 里手工打造树形 DataGridView:两个核心事件搞定界面

3.1 先定义展开状态存储:用 HashSet 记住每个节点的展开标记

使用原生 DataGridView 显示树状结构时,我们需要把原本的树形数据在逻辑上拉平,同时保存每个节点的层级关系,目的是在表格中仍然能模拟树的交互效果。这里的核心思想是维护一个展开状态存储表,让界面重绘时只显示必要节点。代码可以这样设计:

private HashSet<string> _expandedIds = new HashSet<string>(); private List<TreeNodeItem> _displayList = new List<TreeNodeItem>(); private List<TreeNodeItem> _plainNodeList = new List<TreeNodeItem>(); private int _treeIndentWidth = 22; // 每层缩进像素 // 构建显示列表:只有展开节点的子节点才进入列表 private void RebuildDisplayList() { _plainNodeList = new List<TreeNodeItem>(); _displayList = new List<TreeNodeItem>(); // 假设原始树已经平铺在 _allNodes 中,且已有 ParentId 和 Children 层级关系 foreach (var root in _allNodes.Where(n => n.ParentId == null)) { AppendNodeToDisplay(root, 0); } } private void AppendNodeToDisplay(TreeNodeItem node, int depth) { node.Depth = depth; _displayList.Add(node); _plainNodeList.Add(node); if (!_expandedIds.Contains(node.Id)) return; node.IsExpanded = true; if (node.Children == null) return; foreach (var child in node.Children) { AppendNodeToDisplay(child, depth + 1); } }

逻辑说明:这条代码做的事情是递归遍历树结构,但把每个节点的子节点是否显示交给 _expandedIds 判断。当父节点在 _expandedIds 中时,子节点才会被追加进 _displayList,否则只显示父节点自身。每次界面刷新、增删节点、修改展开状态后都要调用一次 RebuildDisplayList 来重新生成 _displayList。这么多节点放进 DataGridView 的数据源之前,把 _displayList 赋给 DataSource 即可。这里必须指出 _displayList 和 _plainNodeList 的区别是,_displayList 真正绑到界面,_plainNodeList 保留全部节点的引用,用于查找指定节点的深度和子节点信息,查找时不需要再遍历界面数据源。

参数说明:_treeIndentWidth 是视觉上的核心参数,一般取 18 到 26 之间,太密会造成多层缩进时层级不清晰,太宽会白白占用横向空间。HashSet 的展开标记也可以替换为在 TreeNodeItem 上加 bool 属性,但用 HashSet 的好处是默认不展开、反查快、不污染业务节点,选择哪种方案取决于你需不需要序列化保存界面状态。如果希望把用户上次的展开状态存到配置文件里,建议直接改成在对象上维护字段,因为有现成的序列化通道。

3.2 行缩进和加号与减号的 CellPainting 绘制法

有了 Depth 字段,缩进绘制可以在 CellPainting 里统一完成。DataGridView 的 CellPainting 事件每一格重绘时都会触发,我们只需要判断当前绘制的是否是第一列。第一列左边的空白区域用缩进宽度乘深度作为左边距,同时确认树上每个节点的展开折叠按钮绘制。核心代码如下:

private void dgv_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex != 0) return; var item = dgv.Rows[e.RowIndex].DataBoundItem as TreeNodeItem; if (item == null) return; var rect = e.CellBounds; var indent = (item.Depth + 1) * _treeIndentWidth; var textRect = new Rectangle(rect.Left + indent + 18, rect.Top, rect.Width - indent - 18, rect.Height); // 先让系统绘制背景、选中高亮等基础元素 e.PaintBackground(e.ClipBounds, true); using (var brush = new SolidBrush(dgv.DefaultCellStyle.ForeColor)) { // 绘制节点文本 e.Graphics.DrawString(item.Name, dgv.Font, brush, textRect); } // 若含子节点,绘制展开/折叠标记 if (item.HasChildren) { var markerRect = new Rectangle(rect.Left + indent, rect.Top + (rect.Height - 12) / 2, 12, 12); DrawExpandMarker(e.Graphics, markerRect, _expandedIds.Contains(item.Id)); } // 画一条浅色连接线以增强层级感 using (var pen = new Pen(Color.FromArgb(220, 220, 220))) { int lineX = rect.Left + indent - 4; e.Graphics.DrawLine(pen, lineX, rect.Top, lineX, rect.Bottom); } e.Handled = true; } private void DrawExpandMarker(Graphics g, Rectangle bounds, bool isExpanded) { using (var b = new SolidBrush(Color.White)) using (var p = new Pen(Color.Gray)) { g.FillRectangle(b, bounds); g.DrawRectangle(p, bounds); // 加减号 int midY = bounds.Top + bounds.Height / 2; g.DrawLine(p, bounds.Left + 3, midY, bounds.Right - 3, midY); if (!isExpanded) g.DrawLine(p, bounds.Left + bounds.Width / 2, bounds.Top + 3, bounds.Left + bounds.Width / 2, bounds.Bottom - 3); } }

这份代码的核心点在几个地方。首先在 CellPainting 里自定义了绘制并利用 e.Handled = true 阻止了默认绘制,因此需要注意必须先调用 e.PaintBackground 来保证选中背景不会消失。其次绘制文本时需要手动计算 textRect,因为缩进已经改变了文本绘制起点,若用默认的 e.PaintContent 会把文字也画到最左边。最后 DrawExpandMarker 用加号减号并填充白色背景,防止树形标记与文字混在一起。连接线的位置固定取缩进区域左侧 4 像素处,因为一般文字第一个字的区域从缩进后的像素点开始,左边这一条空白线就作为树形连接线的空间。

3.3 点击加减号触发展开折叠:用 HitTest 精准判断点击目标

鼠标点击在加减号上才触发展开,点击在文本上就进入正常编辑或选中。直接判断 DataGridView 当前选中单元格的坐标与加号矩形是否相交,直观且简单:

private void dgv_CellClick(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex != 0) return; var item = dgv.Rows[e.RowIndex].DataBoundItem as TreeNodeItem; if (item == null || !item.HasChildren) return; // 计算点击时加号所在矩形 var rect = dgv.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false); var indent = (item.Depth + 1) * _treeIndentWidth; var markerRect = new Rectangle(rect.Left + indent, rect.Top + (rect.Height - 12) / 2, 12, 12); var mousePos = dgv.PointToClient(Cursor.Position); if (markerRect.Contains(mousePos)) { ToggleNode(item); } } private void ToggleNode(TreeNodeItem node) { if (_expandedIds.Contains(node.Id)) _expandedIds.Remove(node.Id); else _expandedIds.Add(node.Id); RebuildDisplayList(); dgv.DataSource = null; dgv.DataSource = _displayList; }

顺带说明 ToggleNode 里 DataSource 重绑定的必要性。直接改 _displayList 在界面上看不见任何动静,必须把 DataSource 断开再重挂才行。用 BindingList 或 BindingSource 包裹后会更柔顺,也可以避免一些闪烁问题。

4. 数据源设计和层级构建:树结构扁平化的落地细节

4.1 从 List 构建父子关系:一次遍历搞定 Children 挂接

大多数业务场景下数据源来自一张数据库表,里面有 Id 和 ParentId。把这张平表变成一棵内存树,和从 TreeNodeItem 的 Children 集合反向拍平一样重要。常见做法是先用字典按 Id 索引每个节点,再遍历一遍把子节点挂到父节点上:

var dict = new Dictionary<string, TreeNodeItem>(); foreach (var row in dbRows) { var node = new TreeNodeItem { Id = row.Id, ParentId = row.ParentId, Name = row.Name, Children = new List<TreeNodeItem>() }; dict[row.Id] = node; } foreach (var node in dict.Values) { if (node.ParentId != null && dict.ContainsKey(node.ParentId)) { dict[node.ParentId].Children.Add(node); dict[node.ParentId].HasChildren = true; } } var rootNodes = dict.Values.Where(n => n.ParentId == null || !dict.ContainsKey(n.ParentId)).ToList();

这里有个隐藏细节:如果 ParentId 指向的父节点不存在,比如数据有脏数据,那么按常规写法这个节点会被当成孤儿丢掉。上面的写法把找不到父节点的节点也归入 rootNodes,这样至少用户还能看到这条数据,不至于界面凭空少行。HasChildren 标志在挂接子节点时同步设置,不需要单独二次遍历去数子节点数量。整个构建过程一次循环索引,一次循环挂接,时间复杂度是 O(n),数据量到一万行以内感受不到延迟。

4.2 过滤和排序场景下的树结构保持策略

普通 List 绑定到 DataGridView 后用户可以点列头排序,但在树结构场景下,列头自动排序应该直接禁用。自动排序会按字段重排行,整个层级全乱。需要排序时要在 RebuildDisplayList 之前对每个节点的 Children 集合内部排序,再重建显示列表。比如按名称排序就写成对 Children 按 Name 排序,这样树的层级关系还在,每个父节点下面的子节点内部次序变了。过滤要相对小心:拦截器绝不能直接过滤掉父节点而留子节点,否则子节点会因找不到父节点提升为顶层。正确的过滤策略是先标出匹配节点,再把所有匹配节点的祖先链完整保留。界面展示时如果匹配,自动展开祖先链,保证用户能看到命中的行。个人的实践是给 TreeNodeItem 加两个临时字段:IsMatched 和 IsParentOfMatched。在构建好目标树后做一次后序遍历计算这两个值,然后更新 _expandedIds,这样命中节点永远可见。

4.3 高亮选中和当前节点展开状态的联动

树形表格里选中某一行后,用户通常想知道它的完整路径。做法是在 SelectionChanged 事件里把当前节点的所有祖先全部展开。为了避免每次选择都触发重建,应该先判断当前路径上的节点是不是都已经在 _expandedIds 中,如果有缺失才重建:

private void dgv_SelectionChanged(object sender, EventArgs e) { if (dgv.CurrentRow == null) return; var item = dgv.CurrentRow.DataBoundItem as TreeNodeItem; if (item == null) return; var path = new List<string>(); var cur = item; while (cur != null) { path.Insert(0, cur.Id); cur = GetParent(cur); // 从 _plainNodeList 或字典中查找父节点 } bool needRebuild = false; foreach (var id in path) { if (id != item.Id && !_expandedIds.Contains(id)) { _expandedIds.Add(id); needRebuild = true; } } if (needRebuild) RebuildAndBind(); }

这段代码妙在它只在路径上的节点有未展开时重建界面,常态下点击已可见子节点完全没有额外开销。同时要留意 GetParent 的实现不能用深拷贝或再次遍历所有对象找 ParentId,应该维护一个字典 _idToNodeMap,否则每次点击都是 O(n) ,数据量大时明显卡顿。

5. 常见问题排查与避坑指南:树形 DataGridView 最易翻车的五个场景

5.1 现象:滚动条拖动时卡顿严重,展开折叠响应慢

原因:每次展开折叠都全量重建 DataSource,并且行数多时没有启用虚拟模式。

解决:把 DataSource 换成 BindingList,用 BeginEdit 包裹重建动作,或者在数据行数超过五百条时开启 VirtualMode,配合 RowCount 和 CellValueNeeded 提供数据。同时推荐把 RebuildDisplayList 里的对象创建逻辑稳定化,减少 GC 压力。

5.2 现象:点击加减号没反应,加了 e.Handled = true 后连选中都失效

原因:CellPainting 里设置的 Handled = true 让整个绘制周期被接管,点击事件依旧触发,但只有当加号区域精准命中时才处理。

解决:详见上面的 HitTest 代码。这里要特别检查 GetCellDisplayRectangle 的最后一个参数,传 false 代表不含滚动条偏移,传 true 会把滚动偏移也算进去。如果把 true 和 PointToClient(Cursor.Position) 放在一起,坐标会算错,常见表现是点击无效。这个参数在行首列宽较大时更容易让人踩坑。

5.3 现象:树结构在列排序后父子关系错乱

原因:DataGridView 自动排序把同一父节点下的兄弟行全部按列值重排,但树的先序顺序被打破,子节点出现在别的地方。

解决:设置 DataGridView 的列 SortMode 为 NotSortable,然后在 RebuildDisplayList 前对节点的 Children 手动排序。手动排序时要注意对每一层递归执行,否则只排了顶层,子层还是原始顺序。

5.4 现象:删除节点后界面残留子节点,或者子节点变成顶层节点

原因:删除节点时只删了 _displayList 里的行,没删 _allNodes 和 _idToNodeMap。导致孤儿子节点在下一次 RebuildDisplayList 时被当根节点输出。

解决:删除操作必须同时处理业务数据源、Children 集合引用和 Dictionary 索引。惯用做法是先找到父节点并从它的 Children 里移除目标节点,然后递归删除目标节点的所有后代引用。删除后调用 RebuildDisplayList。检查删除后的行数是否符合预期,是验证树结构正确的重要步骤。

5.5 现象:缩进行的文字被截断,或者单元格背景色错乱

原因:CellPainting 里 TextRect 计算把缩进之后的可视区域算宽了,绘制文本超长时背景色和文字颜色没有正确恢复。

解决:用 e.Graphics.DrawString 时传给它的矩形宽度要用 cellBounds.Width 减缩进再减掉右侧的固定间距。画笔和画刷记得手动 Dispose,或使用 using 块,防止 GDI+ 资源占用累积导致绘制越来越慢。此外 DataGridView 开启双缓冲会让绘制性能提升明显,可以用反射方式开启 DoubleBuffered 属性。

6. 大规模数据下的性能进阶:用 DataGridView 虚拟模式把树表撑到上万行

树形表格数据量一旦超过一千行,普通模式下的全量重绘就会露出马脚。滚动时行高闪烁、展开折叠像幻灯片,本质原因是每一行都从绑定的数据源里取值并调用默认绘制方法,重复创建了大量 GDI 对象。解决这个问题只有一个正路:开启 DataGridView 的 VirtualMode。虚拟模式下网格不保存任何数据,它只知道自己有多少行,需要显示哪一行时就触发 CellValueNeeded 事件来问你要值。这个机制和树形结构的显示需求简直是绝配,因为我们要的本来就是一棵投影树,展示的行随展开状态变化,每一次滚动只需要重绘可见的二十来个行。

// 开启虚拟模式 dgv.VirtualMode = true; dgv.RowCount = _displayList.Count; dgv.CellValueNeeded += Dgv_CellValueNeeded; private void Dgv_CellValueNeeded(object? sender, DataGridViewCellValueEventArgs e) { var item = _displayList[e.RowIndex]; switch (dgv.Columns[e.ColumnIndex].Name) { case "ColName": e.Value = item.Name; break; case "ColValue": e.Value = item.Value; break; case "ColDepth": e.Value = item.Depth; break; } }

虚拟模式和普通绑定最大的区别是你得手动把值填给 e.Value。列头名称和业务字段的映射关系要理清楚,这里推荐用常量而不是字符串散落在多个事件里,避免后续改列名时漏改。启用虚拟模式后,CellPainting 事件里大部分逻辑仍然照旧工作,只是展开折叠时不能再用 DataSource = null 来刷新,而是直接更新 _displayList 后把 RowCount 设置成新数量,网格会自动重绘可见区域。从数据变更到 RowCount 更新的整体耗时比全量重建绑定快很多,实测三千行三级层级数据展开折叠到视野稳定的耗时基本在个位数毫秒。

另一个值得推荐的做法是把展开状态直接持久化到用户配置里。每次折叠节点时把节点 Id 写入 HashSet,界面关闭时序列化成 JSON 存到本地;下次打开时反序列化后调用 RebuildDisplayList,恢复上次现场。这段处理三五分钟就能写完,却是 Windows 桌面应用体验提升的重要一环,用户不用每次打开程序都把一大片树重新点开。你要记住虚拟模式不是万金油,编辑单元格时要额外处理 CellValuePushed 事件,否则用户改了值会丢失。我自己的使用习惯是树形 DataGridView 只在数据量过千或需要频繁展开折叠的界面开启虚拟模式,纯展示型小树用普通模式反而省心。毕竟 DataGridView 原本就是二维网格,能把树塞进去本身已经是取巧,保持清晰的实现边界才不会让后续维护的人骂娘。希望帮到你。

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

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

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

立即咨询