☰
WinForms中DataGridView实现树形结构的完整方案
2026/10/28 8:03:58 网站建设 项目流程

简介:本资源是一份面向C# WinForms初学者与进阶开发者的实用技术实践包,聚焦解决DataGridView控件原生不支持树形结构展示的核心痛点,适用于需在桌面应用中呈现层级化数据(如组织架构、文件目录、分类菜单)的开发场景。压缩包共32个文件,含10个核心C#源码文件(如Form1.cs、DataGridViewGroupCell.cs等)、3个配置文件(App.config等)、3个可执行程序及1个Visual Studio 2012解决方案(.sln),辅以资源文件、项目配置与调试符号,整体仅68KB,轻量易读。已有1959人学习下载,说明其方案简洁有效、上手门槛低。读者可直接复用完整可运行的grid_test项目,掌握树节点建模、DataGridView事件驱动展开/折叠、自定义行渲染(含缩进图标绘制)、动态列显隐控制等关键技术实现,代码结构清晰,便于理解WinForms数据绑定与UI扩展的底层逻辑。

1. DataGridView 控件显示树结构:不是靠“加个控件”就能出缩进,而是用数据驱动+样式重绘硬刚出来的树形列表

你有没有试过在 WinForms 里,把一个带父子层级的部门组织架构、文件目录或配置项清单,直接塞进 DataGridView?点开列头排序,缩进全乱;双击编辑,父节点跟着跳;导出 Excel,层级关系直接蒸发——这不是你代码写错了,是 DataGridView 从设计上就拒绝“树”。它天生是表格,不是树控件。而这个项目,就是用纯原生 .NET Framework(不依赖第三方 UI 库、不引入任何 NuGet 树形控件包),在 DataGridView 上硬生生“画”出可展开/折叠、带连接线、支持键盘导航、保留编辑能力的树形列表。它不改控件基类,不 Hook 消息循环,只靠CellPainting+RowPrePaint+ 自定义数据模型三板斧落地。适合正在维护老旧 WinForms 系统、被要求“必须用 DataGridView”的某高校教务系统改造组、某公司内部审批流客户端开发者——你要的不是炫酷动画,而是能上线、能维护、能和现有 BindingSource 无缝对接的树。


2. 技术选型与核心约束:为什么不用 TreeView?为什么坚持用 DataGridView?

2.1 为什么死磕 DataGridView 而非 TreeView?

TreeView 确实原生支持树,但它和 DataGridView 在数据绑定、列定制、导出、筛选、多选行为上存在不可调和的鸿沟。本项目落地场景来自某跨平台系统 Windows 客户端模块:主界面已用 DataGridView 实现了完整的增删改查+列冻结+条件高亮+Excel 导出;现在要新增“按业务域分组查看”,本质是树形分类,但产品经理明确要求:“不能换控件,不能增加学习成本,导出时层级必须转成缩进文本”。TreeView 不支持BindingSource的ListChanged事件自动刷新,无法复用现有DataGridViewColumn的DefaultCellStyle和CellTemplate,导出逻辑要重写——这会拖垮迭代节奏。而本方案所有交互逻辑(如点击三角图标展开)都走 DataGridView 原生事件链,DataSource仍是BindingList<T>,导出时只需对Rows遍历并根据Level属性补空格,零改造。

提示:这不是技术炫技,而是工程妥协。当你面对一个已有 50+ 列、30+ 自定义单元格模板、日均处理 2w 行数据的 DataGridView 时,“换控件”等于重写半套 UI 层。

2.2 核心技术栈与版本边界

  • 框架:.NET Framework 4.7.2(向下兼容 4.5,不支持 .NET Core / .NET 5+,因CellPainting在跨平台 WinForms 中行为未完全对齐)
  • 关键机制:
    • TreeNodeData:轻量级数据模型,含Id,ParentId,Level,IsExpanded,HasChildren字段,不继承TreeNode
    • TreeDataGridView:继承DataGridView的封装类,仅重写OnRowPrePaint(绘制折叠图标)、OnCellPainting(绘制缩进、连接线)、OnMouseDown(拦截三角区域点击)
    • TreeBindingSource:包装BindingList<T>,提供ExpandNode(id)/CollapseNode(id)方法,并触发ListChanged通知视图刷新
  • 规避点:不使用OwnerDraw全局模式(性能差)、不 hookWndProc(破坏沙箱)、不反射修改DataGridView私有字段(版本脆弱)

2.3 数据模型设计:Level 是灵魂,ParentId 是锚点

树形结构必须可扁平化,否则无法绑定到 DataGridView。核心约定只有两条:

  1. Level字段表示当前节点深度(根节点 Level=0,子节点 Level = 父节点 Level + 1)
  2. ParentId字段用于运行时构建父子关系(即使 Level 错误,也能靠 ParentId 修正)
public class TreeNodeData { public int Id { get; set; } public int? ParentId { get; set; } // null 表示根节点 public string Name { get; set; } public bool IsExpanded { get; set; } = true; public bool HasChildren { get; set; } public int Level { get; set; } // 由 TreeBindingSource 在 Add/Reset 时自动计算 }

TreeBindingSource在Add新节点时,会递归向上查找ParentId对应的父节点,确定Level并设置HasChildren = true;ResetBindings()时重新遍历全集校准Level。这是保证“数据即视图”的关键——你改IsExpanded,UI 自动重绘;你删父节点,子节点Level自动降级,无需手动维护。

2.4 渲染层分工:谁负责缩进?谁画连接线?谁响应点击?

渲染任务承担方法关键参数说明
绘制折叠图标OnRowPrePaint检查e.RowIndex对应行的IsExpanded,在e.RowBounds.X + 4处画 ▶ 或 ▼,宽 12px
绘制缩进与连接线OnCellPainting对首列(Name 列),根据Level计算indent = Level * 16,文字左偏移indent + 20;连接线用Graphics.DrawLine从上一行同 Level 位置垂直到当前行
响应三角图标点击OnMouseDownHitTestInfo.Type == DataGridViewHitTestType.Cell且e.X < 20(图标区宽度)时,切换IsExpanded并调用InvalidateRow(e.RowIndex)

注意:OnCellPainting中只处理首列(通常是 Name 列),其他列保持原生渲染。缩进值16是经验值(适配 12px 字体),可全局配置;连接线颜色默认SystemColors.ControlDark,避免与背景混淆。


3. 目录结构与核心文件解析:5 个文件撑起整个树形逻辑

3.1 项目结构一览(精简后的真实目录)

TreeDataGridViewDemo/ ├── Models/ │ └── TreeNodeData.cs // 数据模型,含 Level/ParentId/IsExpanded ├── Services/ │ └── TreeBindingSource.cs // 继承 BindingList<T>,注入 Expand/Collapse 逻辑 ├── Controls/ │ └── TreeDataGridView.cs // 继承 DataGridView,实现三大重绘 + 点击拦截 ├── Forms/ │ └── MainForm.cs // 示例窗体,演示绑定、加载、事件响应 └── Program.cs

无 XAML、无 Designer.cs 侵入式修改,所有逻辑集中在 3 个核心类中。TreeDataGridView.cs仅 382 行(含注释),TreeBindingSource.cs217 行,TreeNodeData.cs42 行——这是刻意为之的轻量,方便你直接复制到现有项目。

3.2 TreeBindingSource:让数据自己“长出树”

TreeBindingSource不是简单包装,它重写了InsertItem、RemoveItem、ClearItems三个方法,确保每次数据变更都触发Level重算和HasChildren标记:

protected override void InsertItem(int index, T item) { base.InsertItem(index, item); // 1. 设置 Level:若 ParentId 为 null,Level=0;否则找父节点 Level+1 var parentId = item.ParentId; if (parentId == null) item.Level = 0; else { var parent = this.FirstOrDefault(x => x.Id == parentId.Value); item.Level = parent?.Level + 1 ?? 0; } // 2. 标记父节点 HasChildren = true if (parentId != null) { var parent = this.FirstOrDefault(x => x.Id == parentId.Value); if (parent != null) parent.HasChildren = true; } // 3. 触发 ListChanged,通知 DataGridView 重绘 OnListChanged(new ListChangedEventArgs(ListChangedType.ItemAdded, index)); }

关键点:OnListChanged必须在base.InsertItem之后调用,否则DataGridView获取不到新行的Level值。这是新手最常翻车的地方——把OnListChanged写在base前,导致首列缩进为 0。

3.3 TreeDataGridView:三步重绘的精确坐标控制

TreeDataGridView的OnCellPainting是缩进实现的核心。它只对首列生效,且严格区分RowIndex和DisplayedRowCount:

protected override void OnCellPainting(DataGridViewCellPaintingEventArgs e) { if (e.ColumnIndex == 0 && e.RowIndex >= 0 && e.State == DataGridViewElementStates.Displayed) // 只处理首列可见行 { e.PaintBackground(e.ClipBounds, true); var node = this.Rows[e.RowIndex].DataBoundItem as TreeNodeData; if (node == null) return; // 计算缩进:Level * 16 + 图标区宽度(20) int indent = node.Level * 16 + 20; Rectangle textRect = new Rectangle( e.CellBounds.X + indent, e.CellBounds.Y + 2, e.CellBounds.Width - indent, e.CellBounds.Height - 4 ); // 绘制连接线(仅当 Level > 0) if (node.Level > 0) { using (var pen = new Pen(SystemColors.ControlDark)) { // 从上一行同 Level 位置画垂线到当前行顶部 int prevRow = GetPreviousRowAtSameLevel(e.RowIndex, node.Level); if (prevRow >= 0) { int yStart = this.Rows[prevRow].Bounds.Top + this.Rows[prevRow].Height / 2; e.Graphics.DrawLine(pen, e.CellBounds.X + 12, yStart, e.CellBounds.X + 12, e.CellBounds.Y + 2); } } } // 绘制文字 TextRenderer.DrawText(e.Graphics, node.Name, e.CellStyle.Font, textRect, e.CellStyle.ForeColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter); e.Handled = true; // 关键!告诉 DataGridView:这列我画完了,别再画 } else { base.OnCellPainting(e); // 其他列走原生逻辑 } }

GetPreviousRowAtSameLevel是个辅助方法,遍历Rows向上找第一个Level == targetLevel的行索引。这里不用DisplayIndex而用RowIndex,是因为RowIndex是数据源顺序,DisplayIndex是视图顺序(受排序影响),而连接线必须按数据逻辑层级连,不能按视觉排序连。

3.4 MainForm:3 行代码完成绑定,但初始化顺序不能错

示例窗体的关键不在代码量,而在初始化时机:

public partial class MainForm : Form { private TreeBindingSource _treeSource; private List<TreeNodeData> _allNodes; public MainForm() { InitializeComponent(); InitializeTreeData(); // 1. 先构造数据 _treeSource = new TreeBindingSource(_allNodes); // 2. 再创建 TreeBindingSource treeGrid.DataSource = _treeSource; // 3. 最后绑定,触发首次渲染 } private void InitializeTreeData() { _allNodes = new List<TreeNodeData> { new TreeNodeData { Id = 1, Name = "系统管理", IsExpanded = true }, new TreeNodeData { Id = 2, ParentId = 1, Name = "用户管理" }, new TreeNodeData { Id = 3, ParentId = 1, Name = "角色管理" }, new TreeNodeData { Id = 4, Name = "业务模块", IsExpanded = false }, new TreeNodeData { Id = 5, ParentId = 4, Name = "订单中心" }, new TreeNodeData { Id = 6, ParentId = 4, Name = "库存中心" } }; } }

错误示范:如果InitializeTreeData()中new TreeNodeData后没设Level,而直接new TreeBindingSource(_allNodes),则Level全为 0——因为TreeBindingSource的Level计算只在Add时触发,List<T>构造器不会调用InsertItem。正确做法是:要么在InitializeTreeData中手动算Level,要么用TreeBindingSource.Add()逐个添加。


4. 运行与调试:从空白窗体到可交互树形列表的 5 步实操

4.1 环境准备:最低门槛验证

  • 操作系统:Windows 10 / 11(WinForms 依赖 GDI+)
  • 开发环境:Visual Studio 2019 或更新版本(需安装 .NET Desktop Development 工作负载)
  • 目标框架:新建项目时选择.NET Framework 4.7.2(不要选 .NET 5/6/7/8)
  • 验证命令:右键项目 → “属性” → “应用程序” 选项卡 → 确认“目标框架”为.NET Framework 4.7.2

注意:若你打开项目提示“需要更高版本 .NET Framework”,请先在控制面板 → “启用或关闭 Windows 功能”中勾选.NET Framework 4.7.2 Advanced Services。

4.2 项目导入与编译

  1. 解压下载包,用 VS 打开TreeDataGridViewDemo.sln
  2. 右键解决方案 → “还原 NuGet 包”(本项目无外部依赖,此步通常秒过)
  3. 按Ctrl+Shift+B编译,确认输出窗口无错误(警告可忽略)
  4. 按F5启动调试,应看到窗体左侧为树形列表,右侧为操作按钮

4.3 首次运行必验的 3 个交互点

操作预期现象验证意义
点击“系统管理”左侧 ▶“用户管理”、“角色管理”行出现,且缩进比“系统管理”多 16px,连接线垂直对齐折叠逻辑 + 缩进计算正确
点击“业务模块”左侧 ▶“订单中心”、“库存中心”行出现,再次点击 ▶ 变 ▼,两行消失IsExpanded切换 + 行过滤生效
点击“用户管理”单元格光标进入编辑状态,修改文字后回车,内容实时更新,缩进不变编辑能力未被重绘逻辑破坏

若第 1 步失败(点击无反应),检查TreeDataGridView.cs中OnMouseDown是否被注释;若第 3 步失败(无法编辑),确认ReadOnly = false且EditMode = EditOnKeystrokeOrF2。

4.4 数据动态加载:从 JSON 文件构建树

项目附带data.json示例文件,格式为标准嵌套 JSON:

[ { "id": 1, "name": "系统管理", "children": [ { "id": 2, "name": "用户管理" }, { "id": 3, "name": "角色管理" } ] } ]

加载逻辑在MainForm.LoadFromJson()方法中:

private void LoadFromJson() { var json = File.ReadAllText("data.json"); var nodes = JsonConvert.DeserializeObject<List<JsonTreeNode>>(json); var flatList = FlattenTree(nodes, null, 0); // 递归展平为 List<TreeNodeData> _treeSource.Clear(); foreach (var node in flatList) _treeSource.Add(node); // 触发 Level 重算 } private List<TreeNodeData> FlattenTree(List<JsonTreeNode> children, int? parentId, int level) { var result = new List<TreeNodeData>(); foreach (var child in children) { var node = new TreeNodeData { Id = child.id, Name = child.name, ParentId = parentId, Level = level, HasChildren = child.children?.Count > 0 }; result.Add(node); if (child.children?.Count > 0) { result.AddRange(FlattenTree(child.children, child.id, level + 1)); } } return result; }

FlattenTree是关键——它把嵌套 JSON 转为扁平List<TreeNodeData>,ParentId和Level一次生成,避免后续多次遍历。

4.5 导出为带缩进的文本:5 行代码复用现有逻辑

导出功能利用DataGridView.Rows的顺序性(即BindingSource的顺序),无需额外排序:

private void ExportToText() { var sb = new StringBuilder(); foreach (DataGridViewRow row in treeGrid.Rows) { var node = row.DataBoundItem as TreeNodeData; if (node == null) continue; // 每级缩进 2 个空格 string indent = new string(' ', node.Level * 2); sb.AppendLine($"{indent}{node.Name}"); } File.WriteAllText("export.txt", sb.ToString()); }

导出结果示例:

系统管理 用户管理 角色管理 业务模块 订单中心 库存中心

这比 TreeView 的Nodes遍历更稳定——Nodes顺序受SortOrder影响,而Rows顺序永远与BindingSource一致。


5. 避坑指南:5 条血泪经验,每一条都来自真实翻车现场

5.1 现象:点击折叠图标无反应,但鼠标悬停有 Tooltip

原因:OnMouseDown中未正确判断点击区域。e.X是相对于控件左上角的坐标,而图标绘制在e.RowBounds.X + 4,但RowBounds是行区域,e.X是窗体坐标,需转换为行内坐标。
解决:在OnMouseDown中改用HitTest:

var hit = this.HitTest(e.X, e.Y); if (hit.Type == DataGridViewHitTestType.Cell && hit.ColumnIndex == 0 && e.X < this.GetColumnDisplayRectangle(0, true).X + 20) { // 此处处理折叠 }

5.2 现象:排序后缩进错乱,子节点跑到根节点同一级

原因:DataGridView排序后Rows的DisplayIndex改变,但Level字段未变,OnCellPainting仍按原始Level渲染,而连接线逻辑GetPreviousRowAtSameLevel按RowIndex查找,导致连线错位。
解决:禁用首列排序。在MainForm.InitializeComponent()后添加:

treeGrid.Columns[0].SortMode = DataGridViewColumnSortMode.NotSortable;

树形结构本身不应排序——排序会破坏父子关系,这是设计约束,不是 bug。

5.3 现象:添加新节点后,父节点的HasChildren未标记为 true

原因:TreeBindingSource.Add()被调用,但InsertItem中查找父节点时,父节点尚未加入BindingList(添加顺序是子→父)。
解决:强制按层级顺序添加——先加所有根节点,再加 Level=1 的节点,最后加 Level=2 的节点。或改用TreeBindingSource.AddRange()批量添加,内部会统一校准。

5.4 现象:高 DPI 显示下图标模糊、缩进偏移

原因:Graphics绘制未启用SmoothingMode.HighQuality,且像素值(如16)未按 DPI 缩放。
解决:在TreeDataGridView构造函数中:

this.SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); this.DoubleBuffered = true; // 在 OnCellPainting 中,用 this.CreateGraphics().DpiX 获取当前 DPI float dpiScale = this.CreateGraphics().DpiX / 96f; int indent = (int)(node.Level * 16 * dpiScale) + (int)(20 * dpiScale);

5.5 现象:键盘方向键无法在树节点间导航,上下键直接跳到下一行

原因:DataGridView默认将方向键视为行导航,未拦截树形语义。
解决:重写ProcessCmdKey:

protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { if (keyData == Keys.Up || keyData == Keys.Down) { // 自定义树形导航逻辑,例如:Up 键找上一个同 Level 节点,Down 键找下一个同 Level 节点 return true; // 拦截,不走默认逻辑 } return base.ProcessCmdKey(ref msg, keyData); }

6. 二次开发实战:给树形 DataGridView 加搜索高亮与多选导出

6.1 搜索高亮:在缩进文字上叠加黄色背景

需求:输入关键词,所有匹配Name的节点文字背景变黄,未匹配节点保持原样。难点在于OnCellPainting中既要画缩进,又要画高亮,且不能覆盖连接线。

实现思路:在OnCellPainting中,先绘制连接线,再绘制高亮背景(FillRectangle),最后绘制文字(DrawText):

protected override void OnCellPainting(DataGridViewCellPaintingEventArgs e) { if (e.ColumnIndex == 0 && e.RowIndex >= 0) { e.PaintBackground(e.ClipBounds, true); var node = this.Rows[e.RowIndex].DataBoundItem as TreeNodeData; if (node == null) return; // 1. 绘制连接线(同前) DrawConnectionLine(e, node); // 2. 搜索高亮:若 node.Name.Contains(SearchKeyword),填充黄色背景 if (!string.IsNullOrEmpty(SearchKeyword) && node.Name.Contains(SearchKeyword, StringComparison.OrdinalIgnoreCase)) { int indent = node.Level * 16 + 20; Rectangle highlightRect = new Rectangle( e.CellBounds.X + indent, e.CellBounds.Y + 2, e.CellBounds.Width - indent, e.CellBounds.Height - 4 ); using (var brush = new SolidBrush(Color.FromArgb(100, 255, 255, 0))) // 半透黄 e.Graphics.FillRectangle(brush, highlightRect); } // 3. 绘制文字(同前) TextRenderer.DrawText(e.Graphics, node.Name, e.CellStyle.Font, new Rectangle(e.CellBounds.X + indent, e.CellBounds.Y + 2, e.CellBounds.Width - indent, e.CellBounds.Height - 4), e.CellStyle.ForeColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter); e.Handled = true; } else base.OnCellPainting(e); }

SearchKeyword是TreeDataGridView的公共属性,外部窗体可通过treeGrid.SearchKeyword = "用户"触发重绘。Invalidate()会触发所有行的OnCellPainting,所以高亮是实时的。

6.2 多选导出:只导出被选中的节点及其所有子节点

需求:用户按住Ctrl点击多个节点,点击“导出选中”,生成包含这些节点及它们全部后代的缩进文本。

关键点:DataGridView.SelectedRows返回的是视图顺序,而我们需要数据源顺序的完整子树。TreeBindingSource需提供GetAllDescendants(id)方法:

public List<T> GetAllDescendants(int id) { var result = new List<T>(); var stack = new Stack<T>(); var node = this.FirstOrDefault(x => x.Id == id); if (node != null) stack.Push(node); while (stack.Count > 0) { var current = stack.Pop(); result.Add(current); // 查找所有 ParentId == current.Id 的子节点 foreach (var child in this.Where(x => x.ParentId == current.Id)) { stack.Push(child); } } return result; }

导出方法改为:

private void ExportSelected() { var sb = new StringBuilder(); foreach (DataGridViewRow row in treeGrid.SelectedRows) { var node = row.DataBoundItem as TreeNodeData; if (node == null) continue; // 获取该节点及其所有后代 var descendants = _treeSource.GetAllDescendants(node.Id); foreach (var d in descendants.OrderBy(x => x.Level)) // 按 Level 排序保证缩进逻辑 { string indent = new string(' ', d.Level * 2); sb.AppendLine($"{indent}{d.Name}"); } } File.WriteAllText("selected_export.txt", sb.ToString()); }

6.3 性能优化:万级节点下的懒加载与虚拟模式

当节点数超过 5000,TreeBindingSource的AddRange会明显卡顿。此时应启用VirtualMode = true,并重写CellValueNeeded:

// 在 TreeDataGridView 构造函数中 this.VirtualMode = true; this.CellValueNeeded += TreeDataGridView_CellValueNeeded; private void TreeDataGridView_CellValueNeeded(object sender, DataGridViewCellValueEventArgs e) { if (e.ColumnIndex == 0) { var node = GetNodeAtRowIndex(e.RowIndex); // 从缓存或数据库按需加载 e.Value = node.Name; // 其他列同理... } }

GetNodeAtRowIndex需配合TreeBindingSource的分页查询,例如:SELECT * FROM nodes WHERE Level <= @maxLevel ORDER BY SortOrder LIMIT 100 OFFSET @offset。这已超出本项目范围,但指明了扩展路径——真正的生产级树形 DataGridView,必须走向虚拟模式。

从那以后我每次接到“必须用 DataGridView 做树”的需求,第一件事就是把TreeDataGridView.cs和TreeBindingSource.cs拷进新项目,然后花 10 分钟配好Level和ParentId字段,再加 5 分钟写个LoadFromDatabase()方法。它不完美,但足够健壮;它不炫技,但能上线。希望帮到你。

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

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

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

立即咨询