简介面向C# WinForms开发者的DataGridView树形结构示例项目主要解决表格控件无法直接显示层次数据的问题适用于需要在表格中展示分类层级或父子关系的业务场景。项目基于Visual Studio 2012环境通过自定义树形数据模型、扩展DataGridView单元格绘制及事件处理实现了行内展开与折叠、父节点与子节点区分显示、缩进图标绘制等完整交互效果。压缩包为RAR格式共有32个文件其中包含10个C#源码文件、3个项目配置文件和编译生成的exe可执行程序等整体体积仅68KB结构清晰便于直接阅读和对照运行。目前已有1951人学习下载适合需要快速掌握DataGridView扩展技巧的中级WinForms开发者。压缩包内提供的grid_test完整工程从树形数据模型定义、DataGridView自定义渲染、数据绑定到CellClick点击展开等关键步骤均有具体实现并包含工程文件、可执行程序及调试辅助文件可直接运行观察效果也可作为模板迁移到实际业务项目中。1. DataGridView 控件显示树结构表格还是树这不是二选一做 WinForms 数据管理的老项目经常遇到这种需求左边是部门层级右边要按部门显示人数、启用状态还要能直接勾选。第一反应是换 TreeView可换完就发现列宽没了、就地编辑没了复选框得手工拼也有人把树拍平给名称前面加一串空格结果一排序就乱、一收起就懵。DataGridView 控件显示树结构解决的就是这个尴尬保留 DataGridView 的表格能力用自绘把缩进、展开按钮和层级线补回来。这套思路适合正在改造老系统、不想为一个功能引入第三方控件库的 WinForms 开发者也适合想搞懂 DataGridView 自绘和命中测试的人。它不是炫技而是表格和树各让一步的工程选择。2. 先别急着写代码三种方案取舍与自绘树的底层逻辑第一次接到这种需求人的直觉是去 NuGet 找个现成控件。后来发现思路可以更简单DataGridView 本身不认树但树的所有信息——深度、叶子、展开状态——都能算出来。只要把画树这件事拆成三步把树结构扁平化成行集合在单元格重绘时按深度画缩进和按钮在鼠标点击时精确判断是否点中了按钮。这个思路不依赖第三方包一个普通 DataGridView 就能实现。2.1 常见做法对比自绘、TreeView 拼装、第三方 TreeList先说结论三种做法里我推荐继承 DataGridView 自绘原因看下面这张表方案实现成本表格能力性能主要风险继承 DataGridView 自绘中等完整保留可控要处理重绘与命中测试TreeView 加右侧控件拼列表低但凑合大面积丢失差表头和列宽难对齐第三方树表格 / TreeList 控件低依赖控件通常更好授权、升级、集成成本TreeView 拼装是我见过翻车率最高的做法。行数一多左右两个控件滚动不同步勾选状态还要在两套数据之间来回同步如果窗口支持横向滚动表头、缩进、列宽根本对不齐这属于典型的“看起来简单做起来全是坑”。第三方 TreeList 控件适合新项目从头规划。但很多存量业务窗体里已经有一堆定制列、按钮列、只读逻辑整体替换等于把已经验证过的功能重写一遍风险全在业务侧。自绘方案看着要写代码但工作是一次性的做完以后原有列、编辑、复选框、单元格样式照旧能用所以我对老项目一律建议选自绘。2.2 自绘树躲不开的三个数据Depth、IsLeaf、IsExpanded自绘不是把一棵树的图片画上去而是让每一行知道自己处在树的哪一层、下面有没有孩子、自己是不是展开状态。这三个数据缺一不可Depth 决定缩进多少IsLeaf 决定要不要画加减号IsExpanded 决定子节点要不要出现在行集合里。我一般把这些状态放在业务模型上而不是放在行对象上因为展开再折叠再展开时行对象会重建只有模型上的状态不会丢。public class DeptNode { public int Id { get; set; } public int ParentId { get; set; } public string Name { get; set; } ; public int UserCount { get; set; } public bool Enabled { get; set; } public ListDeptNode Children { get; set; } new(); public bool IsExpanded { get; set; } public bool IsLeaf Children null || Children.Count 0; }参数说明Id 和 ParentId 用于从数据库表还原父子关系Children 是内存里的树结构IsExpanded 单独存在模型上因为它是业务状态——用户展开了一个部门切换页签再回来应该保持原样IsLeaf 做成计算属性而不是字段避免子节点加载前后维护两套数据。2.3 树结构扁平化折叠状态决定行集合把树拍平成行是后续所有步骤的地基。拍平规则只有一条遇到展开节点就继续往下走遇到折叠节点就停下。这样产生的行集合和 DataGridView 的行一一对应既是显示数据也是命中测试的依据。public class TreeRowInfo { public DeptNode Node { get; set; } public int Depth { get; set; } public bool IsLeaf Node.IsLeaf; public TreeRowInfo(DeptNode node, int depth) { Node node; Depth depth; } } private void Flatten(DeptNode node, int depth) { _rows.Add(new TreeRowInfo(node, depth)); // 关键判断节点没展开整棵子树不进行集合 if (!node.IsExpanded || node.IsLeaf) return; foreach (var child in node.Children) Flatten(child, depth 1); }参数说明depth 从 0 开始根节点的深度就是 0每向下走一层加 1node.IsExpanded 为 false 时直接 return子树完全不可见。这段递归同时决定了缩进和可见性所以叫“树结构扁平化”在实现里的样子就是这样。为什么不用 DataTable 或直接绑 DataSource因为展开折叠要频繁增删行DataTable 的行集合操作反而笨重。List 可以任意 Insert、RemoveRange和 DataGridView 的非绑定模式配合很顺手。3. 把 List 树绑进 DataGridView行构建与列数据映射3.1 从扁平记录还原树字典一次搞定父子关系绑定之前先想数据从哪来。多数 WinForms 项目里数据来自 DataTable 或 ORM 的 List 结构是 Id、ParentId、Name 这种平铺记录。要做的事是把平铺记录还原成内存里的树。常见做法是用字典按 Id 建索引遍历一次把子节点挂到父节点的 Children 上。public ListDeptNode BuildTree(ListDeptNode flatList) { var nodesById flatList.ToDictionary(n n.Id); var roots new ListDeptNode(); foreach (var node in flatList) { if (node.ParentId 0) { roots.Add(node); continue; } if (nodesById.TryGetValue(node.ParentId, out var parent)) parent.Children.Add(node); else Debug.WriteLine($孤儿节点{node.Id}父节点 {node.ParentId} 不存在); } return roots; }参数说明ParentId 为 0 表示根节点这个规则要和数据库约定一致ToDictionary 要求 Id 绝对唯一重复 Id 会直接抛异常所以数据清洗得在此之前完成无效 ParentId 的节点如果静默丢掉后期排查很费劲Debug.WriteLine 留一条线索是必要的。数据库那边我通常会按 ParentId、SortNo 排序再取保证同一父节点下的孩子顺序稳定。顺序不稳定树显示就会跳来跳去。3.2 重新填表全量重建的直观写法行集合和 DataGridView 的行一一对应最直观的写法是每次结构变化后整体清空重建。几百行数据的树性能完全够代码也好读。private void RebuildRows() { _rows.Clear(); foreach (var root in _roots) Flatten(root, 0); this.DataSource null; // 先断开绑定否则 Rows.Clear() 会报错 this.Rows.Clear(); foreach (var info in _rows) { int r this.Rows.Add(); this.Rows[r].Cells[treeColumnIndex].Value info.Node.Name; this.Rows[r].Cells[userCountColumnIndex].Value info.Node.UserCount; this.Rows[r].Cells[enabledColumnIndex].Value info.Node.Enabled; } }逻辑说明先重建 _rows 模型再一次性灌进控件DataSource 必须置 null否则非绑定模式下 Rows.Clear 和 Rows.Add 会被禁止。列索引用常量或枚举直接写数字 0、1、2 在后续插入列时会全错位这是 DataGridView 用法里最容易忽略的细节。全量重建的缺点是会清掉当前滚动位置和选中行展开一个深层的节点后表格跳到顶部体验很糟。所以这个版本只适合“窗口刚打开时加载一次”的场景交互式的展开折叠建议用第 4 章的局部插入方案。3.3 绑定列与 0/1 转 CheckBox能交给列的不要自己画树列自己画其它列尽量交给 DataGridView 原生的列类型。比如状态列最省事的做法是建一个 DataGridViewCheckBoxColumn直接绑定 bool 类型的模型属性。WinForm DataGridView 将 List 的一列 0 和 1 的值显示为 CheckBox 时不要在 CellPainting 里画编辑回写会非常痛苦。DataGridViewCheckBoxColumn enabledCol new DataGridViewCheckBoxColumn { HeaderText 启用, DataPropertyName Enabled, Width 60 };逻辑说明DataPropertyName 对应 DeptNode 里的 Enabled 属性。如果数据库字段是 int 类型的 0/1在建 DeptNode 时转换一次Enabled reader.GetInt32(5) 1。这样控件自带的格式化、编辑、校验都正常工作。还有一种做法是给 CheckBox 列设 TrueValue 1、FalseValue 0直接喂字符串“1”“0”能用但不干净。一旦中间有人把数据库字段改成 “Y/N”你就得改三处地方。实体转换虽然多写一行但后续不用再碰。4. 画缩进、画按钮、画连线让二维表格看起来是棵树4.1 树列的重绘入口OnCellPainting 里自己画内容自绘的核心是重写 OnCellPainting。树列的所有单元格都走自定义绘制先画背景再画线和按钮最后从按钮右侧开始画文字。protected override void OnCellPainting(DataGridViewCellPaintingEventArgs e) { // 只处理树列其它列交给系统默认 if (e.RowIndex 0 || e.ColumnIndex ! treeColumnIndex) { base.OnCellPainting(e); return; } // 画背景和选中背景保证行高亮还在 e.Paint(e.CellBounds, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border | DataGridViewPaintParts.SelectionBackground); TreeRowInfo info _rows[e.RowIndex]; Rectangle buttonRect GetExpandButtonRect(e.RowIndex); DrawTreeLines(e.Graphics, e.CellBounds, info); DrawExpandButton(e.Graphics, buttonRect, info.Node.IsExpanded, info.IsLeaf); // 文字从按钮右侧开始画缩进由此产生 Rectangle textRect new Rectangle( buttonRect.Right 4, e.CellBounds.Y, e.CellBounds.Width - (buttonRect.Right - e.CellBounds.X) - 4, e.CellBounds.Height); TextRenderer.DrawText( e.Graphics, e.FormattedValue?.ToString() ?? , e.CellStyle.Font, textRect, e.CellStyle.ForeColor, TextFormatFlags.Left | TextFormatFlags.VerticalCenter); e.Handled true; }逻辑说明调用 e.Paint 时不包含 Content因为默认文字会从单元格最左边画没有缩进文字起点是 buttonRect.Right 4整棵树的层级感全靠这个 x 坐标撑起来。e.Handled true 告诉系统不要再画默认内容否则文字会重影。4.2 展开按钮和层级线用手画不依赖图片资源展开按钮有两种选择用图片资源或者用 Pen 直接画加减号。我建议手画省去切图、打包、多分辨率适配一堆事。private void DrawExpandButton(Graphics g, Rectangle r, bool isExpanded, bool isLeaf) { if (isLeaf) return; // 叶子不画按钮但是文字位置要留出来 using (Pen pen new Pen(Color.FromArgb(120, 120, 120))) { g.DrawRectangle(pen, r); int cx r.X r.Width / 2; int cy r.Y r.Height / 2; // 横线固定折叠状态加一条竖线 g.DrawLine(pen, r.X 3, cy, r.Right - 3, cy); if (!isExpanded) g.DrawLine(pen, cx, r.Y 3, cx, r.Bottom - 3); } } private void DrawTreeLines(Graphics g, Rectangle cellBounds, TreeRowInfo info) { if (info.Depth 0) return; using (Pen pen new Pen(Color.FromArgb(210, 210, 210))) { // 画当前行经过的每层竖线 for (int d 0; d info.Depth; d) { int x cellBounds.X rootIndent d * indentWidth indentWidth / 2; g.DrawLine(pen, x, cellBounds.Y, x, cellBounds.Bottom); } // 父线到按钮之间的水平短线 int parentLineX cellBounds.X rootIndent (info.Depth - 1) * indentWidth indentWidth / 2; int buttonRightX cellBounds.X rootIndent info.Depth * indentWidth expandButtonSize; int midY cellBounds.Y cellBounds.Height / 2; g.DrawLine(pen, parentLineX, midY, buttonRightX, midY); } }参数说明drawTreeLines 里每条竖线的 x 等于“根缩进 层号乘层宽 层宽的一半”这样才能打在上一段缩进的中间水平短线从父线拉到按钮右侧视觉上像树枝长出来。顺序上先画线再画按钮按钮会把交叉点盖住边角干净。线条颜色用浅灰 210比边框灰再淡一点不然整个表格会很脏。4.3 点击命中与局部展开折叠不重建整行集合表格自绘的下一步是让按钮能点。命中测试最干净的位置是重写 OnCellMouseClick因为回调里的 e.Location 是相对单元格左上角的坐标和画按钮时用的坐标口径一致。protected override void OnCellMouseClick(DataGridViewCellMouseEventArgs e) { if (e.RowIndex 0 e.ColumnIndex treeColumnIndex e.Button MouseButtons.Left) { TreeRowInfo info _rows[e.RowIndex]; Rectangle btnRect GetExpandButtonRect(e.RowIndex); if (info ! null !info.IsLeaf btnRect.Contains(e.Location)) { ToggleNode(e.RowIndex); return; // 点的是按钮不触发默认选中 } } base.OnCellMouseClick(e); } private void ToggleNode(int rowIndex) { DeptNode node _rows[rowIndex].Node; if (node.IsExpanded) CollapseNode(rowIndex); else ExpandNode(rowIndex); }展开和折叠最好不要整表重建而是局部插入和删除行。DataGridView 直接操作 Rows 时滚动位置和选中状态基本不动体验会自然很多。private void ExpandNode(int rowIndex) { TreeRowInfo parentInfo _rows[rowIndex]; parentInfo.Node.IsExpanded true; int insertAt rowIndex 1; foreach (var child in parentInfo.Node.Children) insertAt InsertNode(insertAt, child, parentInfo.Depth 1); } private int InsertNode(int rowIndex, DeptNode node, int depth) { _rows.Insert(rowIndex, new TreeRowInfo(node, depth)); this.Rows.Insert(rowIndex, new DataGridViewRow()); DataGridViewRow row this.Rows[rowIndex]; row.Cells[treeColumnIndex].Value node.Name; row.Cells[userCountColumnIndex].Value node.UserCount; row.Cells[enabledColumnIndex].Value node.Enabled; rowIndex; if (node.IsExpanded node.Children.Count 0) foreach (var child in node.Children) rowIndex InsertNode(rowIndex, child, depth 1); return rowIndex; } private void CollapseNode(int rowIndex) { DeptNode node _rows[rowIndex].Node; node.IsExpanded false; int count CountVisibleChildren(rowIndex); for (int i 0; i count; i) { _rows.RemoveAt(rowIndex 1); this.Rows.RemoveAt(rowIndex 1); } } private int CountVisibleChildren(int rowIndex) { int depth _rows[rowIndex].Depth; int count 0; for (int i rowIndex 1; i _rows.Count _rows[i].Depth depth; i) count; return count; }逻辑说明InsertNode 返回新的插入位置递归时能连续向下排CollapseNode 只移除比当前行深度更大的连续行遇到同层或更浅的行就停。这套局部插入方案要求 DataSource 保持 null手动管理 Rows否则 Rows.Insert 会被控件禁止。4.4 树列参数表缩进宽度、按钮尺寸、行高这几个值需要放在一起调单独改一个都会显得别扭参数建议值影响rootIndent8 到 12根节点文字到单元格左缘的距离indentWidth16 到 20每深一级多缩进的像素也决定竖线间距expandButtonSize13 到 16点击热区大小太小不容易点中RowTemplate.Height24 到 28行高至少要比按钮大 6 像素我第一次做的时候只调了缩进宽度忘了行高结果按钮贴着上下边框字也挤。后来固定用一套初始值rootIndent 10、indentWidth 18、expandButtonSize 14、RowTemplate.Height 26再根据实际字号微调。提示如果把它封装成独立控件这些参数最好暴露成公共属性不要在每个窗体里写死常量。项目大了以后统一调样式会省很多事。5. 五个避坑点展开错位、排序打乱、DPI 缩放和大数据卡顿5.1 展开后整张表回到顶部当前行找不到了现象是点击某个深层节点展开后滚动条直接跳到第一行用户完全对不上刚才看的是哪一级。原因是全量重建行集合时Rows.Clear 会重置滚动位置树结构扁平化后的行数和原来不一致控件根本没有“保持当前位置”的概念。解决分两种。如果用了第 4 章的局部插入删除这个现象根本不会出现如果某些操作必须整体重建就在重建前记录 FirstDisplayedScrollingRowIndex重建后再恢复int first this.FirstDisplayedScrollingRowIndex; RebuildRows(); if (first 0 first this.Rows.Count) this.FirstDisplayedScrollingRowIndex first;注意这只对“行数变化不大”的情况有效。如果展开的节点下有几百行滚动位置即使恢复也对不准原来的行所以核心还是局部插入。5.2 选中高亮把按钮和层级线盖住了现象是选中某一行时深色背景把加减号、竖线全部盖掉只看到文字悬浮在上面。原因是重绘代码里 e.Paint 只画了普通背景没画 SelectionBackground或者画了但画线按钮的逻辑在它之前被覆盖。解决方法是 OnCellPainting 开头就把选中背景画上去然后严格按照“先线、再按钮、再文字”的顺序绘制。注意 e.Handled 不能在画线之前就设为 true否则后面的 Graphics 操作虽然执行了但控件可能执行额外的清理逻辑表现就是内容闪烁或者被盖掉。5.3 点击列头排序父子行当场散架现象是用户点了“用户数”列头表格按数字重排树形缩进还在但父子行完全错开竖线指到无关的行上。原因是 DataGridView 默认的 Sort 是物理重排行集合树的父子关系是按行顺序隐含的一行重排就全乱。解决方法是给所有非树列设置 SortMode DataGridViewColumnSortMode.NotSortable。如果产品经理坚持要排序正确处理不是对网格行排序而是先把当前节点下的子列表单独排序再用排完序的列表重新 BuildTree最后刷新。树表格只适合“当前可见层内排序”全局排序在树结构里没有意义这个预期要在需求阶段就对齐。5.4 高 DPI 屏幕上按钮错位、文字折行现象是在 150% 缩放的显示器上画出来的按钮比鼠标点击区域小一圈行高也偏矮文字经常掉到下一行。原因是自绘坐标全部写死了像素值没有跟随系统 DPI 缩放。解决方式是在 OnHandleCreated 里计算一次缩放系数所有尺寸都乘上去protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); float scale 1f; using (Graphics g CreateGraphics()) scale g.DpiX / 96f; rootIndent (int)(10 * scale); indentWidth (int)(18 * scale); expandButtonSize (int)(14 * scale); this.RowTemplate.Height (int)(26 * scale); }如果项目支持 PerMonitorV2切换显示器后还要重新计算如果用的老框架只支持系统级缩放OnHandleCreated 里算一次就够了。这个坑在远程桌面环境下尤其明显两台机器 DPI 不一致控件行为立刻不同。5.5 几千行树展开很卡滚动时重绘频繁现象是根节点下挂了三千多个子节点点击展开要卡两秒拖动滚动条时画面闪烁严重。原因是展开时一次插入了全部子孙行而且每个单元格绘制时都 new 了 Pen 和 BrushGC 压力很大。短期的解决方法是展开只加载一层子节点真正被展开时才去查库Pen 和 Brush 在控件里缓存复用不要每个 CellPaint 里 new。再往后把 TreeGridView 的 DoubleBuffered 设成 true可以减少重绘闪烁。如果单层就有几千行上面这些都不够得换 VirtualMode这个放到下一章说。6. 大数据量下的树表格懒加载和 VirtualMode 的取舍6.1 只展开一层懒加载的接入点几百行的树不需要懒加载。一旦目录、部门这类数据上万第一次 BuildTree 就会占掉大量时间。常见做法是树节点先空着 Children展开前再查库填入。private void EnsureChildrenLoaded(DeptNode node) { if (node.ChildrenLoaded) return; node.Children LoadChildrenFromDb(node.Id); node.ChildrenLoaded true; }调用点在 ExpandNode 之前先补数据再局部插入。需要注意 DeptNode 里增加一个 bool 类型的 ChildrenLoaded 字段避免每次展开都重复查库。这套配合局部插入子表一次性构建进 Rows是目前性价比最高的做法。我一般直接在数据访问层做分页或按 ParentId 查询不把整个表 load 出来。6.2 VirtualMode 下的树表格行索引只是游标如果某个节点下真的有几万行局部插入也会卡。VirtualMode 是最后的办法DataGridView 不维护实际行只是在需要绘制某一行时回调 CellValueNeeded我们直接把 _rows 当作索引器用。// 启动时 grid.VirtualMode true; grid.RowCount _rows.Count; // 回调里取值 void grid_CellValueNeeded(object sender, DataGridViewCellValueEventArgs e) { TreeRowInfo info _rows[e.RowIndex]; if (e.ColumnIndex treeColumnIndex) e.Value info.Node.Name; else if (e.ColumnIndex userCountColumnIndex) e.Value info.Node.UserCount; else if (e.ColumnIndex enabledColumnIndex) e.Value info.Node.Enabled; }展开折叠时先改 _rows再赋 RowCount控件会自动刷新。VirtualMode 适合只读、展示为主的场景原地编辑和行选中要额外维护不建议一上来就上。做这类树表格久了我的习惯是控件层只认 TreeRowInfo业务层只交根节点中间用这次讲的 TreeGridView 桥接。几百行的表用局部插入几万行的目录再上 VirtualMode勾选、编辑、过滤照旧走 DataGridView 原生能力。这套东西最值钱的地方不是代码而是想明白了“树”和“表格”各自该管什么。希望这次整理能帮你少踩几个坑。本文还有配套的精品资源点击获取