简介面向C# WinForms开发者的实用教程资源针对DataGridView控件无法直接展示层次化数据的问题给出在Visual Studio 2012环境中通过自定义扩展实现树形列表的完整示例。资源共32个文件以10个cs源代码文件为核心涵盖窗体逻辑、数据模型与自定义单元格绘制另附3个config配置文件、3个exe可执行文件及pdb调试符号压缩包整体仅68KB轻量易用。已有1952人学习下载。源码基于grid_test项目包含Form1.cs、DataGridViewGroupCell.cs等关键模块完整演示了树节点模型构建、CellClick事件处理、动态列显隐与OnRowPrePaint自定义渲染等核心步骤适合希望深入理解DataGridView扩展机制、提升WinForms列表交互开发水平的初中级开发者参考学习。1. DataGridView 显示树结构为什么不用 TreeView也要在表格里做树显示树形数据大多数人的第一反应是 TreeView。但当你需要同时看到每一层的数量、金额、状态、更新时间并且要按列排序、批量选中、直接编辑时TreeView 就撑不住了。DataGridView 里显示树结构解决的是“既要层级关系又要明细字段”的场景——某公司的组织架构花名册、权限管理里的菜单列表、财务报表里的分组合并行都属于这一类。适合谁适合在 WinForms 里维护业务系统、不想为了一个树形表格引入整套第三方控件的开发者。理解成本有但回报也明确不依赖商业组件表格的分页、编辑、粘贴等既有能力全部保留。下面按数据准备、绘制、交互、避坑、进阶这条路线把一条完整可复现的树形表格实现路径走通。2. 数据摆平自引用 DataTable 与可见行集合的构建树形结构要落到 DataGridView 里第一步从来不是画线画图标而是把一棵树拍平成“行”。DataGridView 的行天然是扁平的所以要在内存里维护一个当前可见行的集合每个节点占一行行上要有它的层级、是否叶子、是否展开。这些字段不能靠界面上猜必须在绑定时就明确写进 DataRow 里。2.1 自引用表结构Id、ParentId、SortOrder 是不可少的骨架自引用表是树形数据最常见的落盘形态。一张表里既有 Id 又有 ParentId顶层的 ParentId 为 0其余指向上一级的 Id。这里有个很多人一开始会忽略的点除了上下级关系必须带一个 SortOrder 字段否则同一父节点下的兄弟顺序无法稳定复现递归出来的树每次可能都不一样。我一般还会加一个 IsDeleted 逻辑删除标记。做树形界面时如果父节点被逻辑删掉子节点按真实业务可能还需要显示在历史记录里提前留一个过滤条件比后面改数据源要省事得多。private DataTable _treeTable; private void InitTreeTable() { _treeTable new DataTable(MenuTree); _treeTable.Columns.Add(Id, typeof(int)); _treeTable.Columns.Add(ParentId, typeof(int)); _treeTable.Columns.Add(Name, typeof(string)); _treeTable.Columns.Add(Budget, typeof(decimal)); _treeTable.Columns.Add(SortOrder, typeof(int)); _treeTable.Columns.Add(IsDeleted, typeof(bool)); // 内部列不直接对应业务字段只用于树形展示 _treeTable.Columns.Add(_Level, typeof(int)); _treeTable.Columns.Add(_Expanded, typeof(bool)); _treeTable.Columns.Add(_IsLeaf, typeof(bool)); }这段代码定义了一个权限菜单树的示例结构。Id 是节点唯一标识ParentId 指向父节点SortOrder 决定同一层级的显示顺序Budget 这样的业务字段就是“在表格里做树”的原因——TreeView 显示不了多列业务值。下划线开头的三列是内部列用于记录层级、展开状态、是否叶子它们不会出现在业务逻辑里只服务 UI 层。2.2 从扁平数据递归出可见行先分组再递归别反复 Select拿到自引用表后最常见的错误写法是在循环里反复调用dt.Select(ParentId ...)。这个方法每次都会全表扫描数据量到几千行时就已经能感觉到卡顿。正确做法是先按 ParentId 做一次分组把子节点列表缓存到字典里递归时直接查字典。private Dictionaryint, ListDataRow _childrenMap; private Dictionaryint, bool _expandState new Dictionaryint, bool(); private void PrepareChildrenMap(DataTable dt) { _childrenMap dt.AsEnumerable() .Where(r !(bool)r[IsDeleted]) .GroupBy(r (int)r[ParentId]) .ToDictionary(g g.Key, g g.OrderBy(r (int)r[SortOrder]).ToList()); } private ListDataRow BuildVisibleRows(DataTable dt) { ListDataRow visible new ListDataRow(); if (!_childrenMap.ContainsKey(0)) return visible; foreach (DataRow node in _childrenMap[0]) { AppendNode(node, 0, visible); } return visible; } private void AppendNode(DataRow node, int level, ListDataRow visible) { int id (int)node[Id]; bool hasChildren _childrenMap.ContainsKey(id); bool expanded _expandState.TryGetValue(id, out var state) ? state : true; node[_Level] level; node[_Expanded] expanded; node[_IsLeaf] !hasChildren; visible.Add(node); if (hasChildren expanded) { foreach (DataRow child in _childrenMap[id]) { AppendNode(child, level 1, visible); } } }BuildVisibleRows 从 ParentId 为 0 的顶层节点开始递归。核心逻辑在 AppendNode先查 childrenMap 判断有没有子节点再根据 _expandState 字典决定是否继续往下深入。这个字典的默认值是 true也就是树默认全展开如果需要默认折叠把默认值改成 false 即可。visible 集合里的 DataRow 顺序就是之后绑定到 DataGridView 的行顺序也是绘制层级线的依据。2.3 绑定方式隐藏内部列避免修改业务表结构BuildVisibleRows 返回的 List 可以绑定到 DataGridView但默认会显示出所有列包括 _Level、_Expanded 和 _IsLeaf。我的习惯是绑定后立刻把这些内部列设为 Visible false再把业务列的 HeaderText 改成中文。提醒一点这里绑定的是 List DataGridView 会把每一行显示为 DataRowView读取单元格值时用((DataRowView)row.DataBoundItem).Row就能拿回原始 DataRow。之后在绘制和命中判断里都通过这个 DataRow 去取层级和状态不要依赖 row.Index因为排序和滚动会改变行索引。grid.DataSource new BindingSource(visibleRows, ); grid.Columns[Id].Visible false; grid.Columns[ParentId].Visible false; grid.Columns[_Level].Visible false; grid.Columns[_Expanded].Visible false; grid.Columns[_IsLeaf].Visible false; grid.Columns[Name].HeaderText 菜单名称; grid.Columns[Budget].HeaderText 预算金额; grid.Columns[Budget].DefaultCellStyle.Format N2;用 BindingSource 包一层是为了让 List 的列映射稳定避免直接绑定 List 时自动生成列的时序问题。到这里表格里已经能看到完整的扁平行数据虽然还没有任何树形外观但数据层的准备工作已全部完成。3. 绘制树形外观CellPainting 里画缩进、箭头和层级线数据绑定好了接下来要让行“看起来像一棵树”。DataGridView 本身没有内置树形列但它的 CellPainting 事件给了我们完全的自由度只要接管绘制缩进、加号减号、虚线条都能画出来。3.1 覆写 CellPainting先画背景再画图标最后画文本关键的一课不要自己从头画整个单元格。事件参数提供了 PaintBackground我们只需要在它的后面插入自定义绘制最后用 TextRenderer 画文本。private void grid_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex ! grid.Columns[Name].Index) return; DataGridViewRow row grid.Rows[e.RowIndex]; DataRowView drv (DataRowView)row.DataBoundItem; DataRow node drv.Row; int level (int)node[_Level]; bool isLeaf (bool)node[_IsLeaf]; bool isExpanded (bool)node[_Expanded]; // 先绘制默认背景和选中高亮 e.PaintBackground(e.ClipBounds, true); Rectangle cell e.CellBounds; int indentStep 18; int indent 12 level * indentStep; // 绘制加号/减号图标区域10x10垂直居中 Rectangle iconRect new Rectangle( cell.X indent, cell.Y cell.Height / 2 - 5, 10, 10); if (!isLeaf) { e.Graphics.DrawRectangle(Pens.DimGray, iconRect); int cx iconRect.X iconRect.Width / 2; int cy iconRect.Y iconRect.Height / 2; e.Graphics.DrawLine(Pens.Black, cx - 3, cy, cx 3, cy); // 横线 if (!isExpanded) { e.Graphics.DrawLine(Pens.Black, cx, cy - 3, cx, cy 3); // 折叠状态加竖线 } } // 绘制文本文本起点比图标再向右偏移18像素 Rectangle textRect new Rectangle( cell.X indent 22, cell.Y, cell.Width - indent - 22, cell.Height); string name node[Name] DBNull.Value ? : node[Name].ToString(); TextRenderer.DrawText(e.Graphics, name, row.InheritedStyle.Font, textRect, row.InheritedStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); e.Handled true; }这段代码是树形显示的核心。注意顺序先 PaintBackground再画自己的图标最后用 TextRenderer 画文本并设置 e.Handled true 阻止默认绘制。indentStep 18 是常见的缩进宽度末级节点会依次向右让出空间。这里没有画父子之间的层级虚线因为虚线是最容易出问题的部分我放在避坑章节单独讲。3.2 展开状态统一从 DataRow 读界面不存第二份状态很多人在这个步骤上翻车绘制时从单元格的某个字段读展开状态交互时又通过 Tag 去切换状态最后状态维护了两三份不同步就开始各种诡异错乱。我的原则是状态只存一份就是 DataRow 里的 _Expanded 列绘制和点击都只认这一份。private void RefreshGrid() { ListDataRow visible BuildVisibleRows(_treeTable); grid.DataSource null; // 先断开避免增量更新带来的旧行残留 grid.DataSource new BindingSource(visible, ); }RefreshGrid 是后面所有交互的出口。切换展开状态后唯一要做的就是重新构建可见行并重新设置 DataSource。DataGridView 对 List 的增量刷新机制并不友好直接置空再赋值最简单可靠。注意刷新后原有选中行会丢失如果需要保留选中位置要按 Id 重新定位这个逻辑在键盘操作一节里给出。3.3 行高与缩进的联动字体行高变化的处理如果所有节点都在同一列显示行高不必变。但假如在 Name 列里同时显示了图标和文本行高不够时图标会和文本重叠。常见做法是给 Grid 设置一个统一行高我一般用grid.RowTemplate.Height 24让 10x10 的图标加 3 像素边框有足够呼吸空间。如果是中文系统默认字体24 像素的行高配合 TextRenderer 的 VerticalCenter 绘制视觉上最稳。如果你的业务里确实需要让某些层级的行更高可以在 RowsAdded 事件里按行重新设置private void grid_RowsAdded(object sender, DataGridViewRowsAddedEventArgs e) { for (int i e.RowIndex; i e.RowIndex e.RowCount; i) { DataRow node ((DataRowView)grid.Rows[i].DataBoundItem).Row; if ((int)node[_Level] 0) grid.Rows[i].Height 28; } }这里只对顶层节点做了差异化行高算是个可选的增强。要注意 RowsAdded 在 DataSource 赋值时会被批量触发如果逻辑复杂建议用 BeginEdit/EndEdit 包住 DataSource 的赋值来减少触发次数。4. 展开与折叠命中判定和刷新时的状态同步绘制搞定后用户最自然的操作是点击那个加号图标。这里最隐蔽的问题不是点击事件本身而是坐标系的换算。DataGridViewCellMouseEventArgs 的 Location 是相对于 Grid 控件左上角的坐标而我们在绘制时使用的是 cellBounds 的坐标必须把 cellBounds 的偏移量加回去才能让“画在哪”和“点到哪”保持一致。4.1 命中区域计算绘制和命中共用同一个坐标公式为了不再拆一遍缩进计算我通常把“拿到某一行的图标矩形”抽成一个私有方法。绘制时调用它命中也调用它这样永远不会出现画的是一个矩形、点的是另一个矩形的情况。private Rectangle GetIconRect(DataGridView grid, int rowIndex, int colIndex) { DataGridViewRow row grid.Rows[rowIndex]; DataRow node ((DataRowView)row.DataBoundItem).Row; int level (int)node[_Level]; Rectangle cell grid.GetCellDisplayRectangle(colIndex, rowIndex, false); int indent 12 level * 18; return new Rectangle(cell.X indent, cell.Y cell.Height / 2 - 5, 10, 10); }这里 GetCellDisplayRectangle 的第三个参数传 false表示返回完整的单元格矩形而不是裁剪到可视区的矩形。传 true 在某些滚动场景下会得到一个被截断的矩形导致点击区域比看到的图标小。这一行的差别往往是“点加号没反应”的元凶。4.2 鼠标点击切换先改状态再重建行集命中判断本身很简单难点在于点击后要让界面和状态同步更新。private void grid_CellMouseClick(object sender, DataGridViewCellMouseEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex ! grid.Columns[Name].Index) return; DataRow node ((DataRowView)grid.Rows[e.RowIndex].DataBoundItem).Row; if ((bool)node[_IsLeaf]) return; Rectangle iconRect GetIconRect(grid, e.RowIndex, e.ColumnIndex); if (!iconRect.Contains(e.Location)) return; // 翻转展开状态 node[_Expanded] !(bool)node[_Expanded]; RefreshGrid(); }逻辑顺序值得强调先判断节点是否是叶子是叶子直接返回因为叶子没有可展开的子节点之后判断点击位置是否落在图标矩形内避免用户点文本也被误判为切换。只有状态真的改变了才调用 RefreshGrid。这里不判断当前选中行因为展开/折叠和选中是两件事用户完全可以在不选中某行的情况下直接点它的加号。4.3 键盘操作方向键与空格键的补充方案鼠标点击做完了键盘操作也不能完全不做否则对习惯键盘操作的用户来说这个控件是残废的。这里不需要实现得很复杂方向键上下仍然用来移动选中行空格键用来切换展开/折叠就可以了。private void grid_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode Keys.Space grid.CurrentCell ! null) { DataRow node ((DataRowView)grid.CurrentRow.DataBoundItem).Row; if (!(bool)node[_IsLeaf]) { node[_Expanded] !(bool)node[_Expanded]; RefreshGrid(); // 刷新后定位回同一节点 int id (int)node[Id]; foreach (DataGridViewRow row in grid.Rows) { DataRow r ((DataRowView)row.DataBoundItem).Row; if ((int)r[Id] id) { grid.CurrentCell row.Cells[grid.Columns[Name].Index]; break; } } } e.Handled true; } }刷新后定位回同一节点这段是因为 RefreshGrid 把 DataSource 整个替换了原 CurrentRow 对象已经失效。按 Id 重新找回这一行是避免刷新后选中行跳到第一行的最直接办法。如果你的树里有重名节点务必用 Id 而不是 Name 来匹配。5. 避坑指南滚动错位、状态丢失与大节点卡顿树形 DataGridView 的坑比普通 DataGridView 多一整个量级。下面几条是实际项目中最常踩的按现象、原因、解决的方式列出。5.1 现象鼠标滚轮后加号图标和文本错位原因自定义绘制时混用了控件坐标和单元格坐标滚动时 DataGridView 会重用行对象绘制内容和绑定行对不上。解决所有绘制必须基于 e.CellBounds 的相对坐标并且确保 Grid 开启了双缓冲。开启双缓冲的代码很简单放到构造函数里typeof(DataGridView).GetProperty(DoubleBuffered, BindingFlags.Instance | BindingFlags.NonPublic)?.SetValue(grid, true);注意DoubleBuffered 属性是保护成员外部访问需要通过反射这段反射代码放在 Form 的构造函数里执行一次即可。不开双缓冲的树形表格滚动时出现残影的概率极高。5.2 现象节点一多展开/折叠卡到怀疑人生原因BuildVisibleRows 里先 Select 再递归或者 RefreshGrid 频繁触发整个 DataSource 重绑。解决子节点用字典分组缓存前面已经做了同时把几千行以上的数据源切到虚拟模式。权限管理模块里菜单表到三四千个节点是常态展开和折叠明显卡顿。虚拟模式下 DataGridView 不再持有数据副本每次需要格子内容时通过 CellValueNeeded 回调按行索引取值grid.VirtualMode true; grid.CellValueNeeded (s, e) { e.Value visibleRows[e.RowIndex][grid.Columns[e.ColumnIndex].DataPropertyName]; };VirtualMode 的要点是继承 DataGridView 或者设置属性后不能再用 DataSource 赋值而是让表格按需取数。配合树形 DataRow只需要保证 visibleRows 的顺序稳定即可。凡是超过 3000 行的树我都建议直接换虚拟模式能省掉一大半的滚动卡顿。5.3 现象编辑一个单元格后保存的数据把父节点的展开状态冲掉了原因把可见行的 DataRow 和业务 DataTable 混用编辑时直接修改了正在用于构建树的 DataTable导致树结构变化后状态字典失效。解决内部状态字段写在单独字典里和业务 DataRow 解耦private Dictionaryint, bool _expandState; // 只存展开/折叠 private Dictionaryint, int _levelCache; // 只存层级每次改状态时更新字典而不依赖 DataRow 里的临时列。这样即使刷新后同一行 DataRow 对象的引用变化了通过 Id 依然能恢复它原来的展开状态和层级。简单说DataRow 的 _Expanded 列只作为“这一次绑定周期的显示状态”真正的持久状态永远在字典里。5.4 现象点列头排序后整个树塌缩成一团原因DataGridView 的默认排序打乱了可见行顺序而树是靠行顺序连同缩进来表达结构的排序一乱层级关系全毁了。解决在所有列上关闭排序功能需要排序的场景自己做排序按钮排完按新的排列顺序重新 BuildVisibleRows再刷新一次。foreach (DataGridViewColumn col in grid.Columns) col.SortMode DataGridViewColumnSortMode.NotSortable;如果一定要支持排序常见的做法是在点击列头时只对“当前可见的同一层级的兄弟节点”排序排序后重新构建整棵树的可见行顺序而不是直接让 DataGridView 自己排。5.5 现象绘制层级虚线时线穿过了文字原因画竖线时直接画一整条没有避开图标和文字区域。解决先预留出图标和文本的矩形范围用减法算出需要画线的区域再画线。还有一个更省事的做法不画虚线只画每行左侧的缩进和加号箭头用缩进深浅表达层级关系。树形结构在信息传达上缩进比虚线更早被用户感知虚线是锦上添花不是必需品。6. 从能用变好用懒加载、复选框与节点图标如果你照着上面的流程已经跑通了一个树形 DataGridView接下来这三个增强方向是让它从“能看”变成“能用在生产环境”的关键。6.1 懒加载子节点在展开时才拉取当树的节点来自数据库且总量很大时一次性把整棵树拉进来是浪费。常见的做法是先只加载前两级展开某个节点时再异步拉取它的子节点。改动的核心只有一处就是 AppendNode 里遇到已展开但没有子节点数据的行时触发异步查询private void EnsureChildrenLoaded(DataRow node) { int id (int)node[Id]; if (_childrenMap.ContainsKey(id) || _loadingSet.Contains(id)) return; _loadingSet.Add(id); Task.Run(() LoadChildrenFromDb(id)) .ContinueWith(t { ListDataRow rowList t.Result; if (rowList.Count 0) { _childrenMap[id] rowList; RefreshGrid(); } _loadingSet.Remove(id); }, TaskScheduler.FromCurrentSynchronizationContext()); }这里必须用一个 _loadingSet 记住正在加载的节点避免用户连续快速点击同一个加号时发出多个重复请求。LoadChildrenFromDb 返回 List 加载结果合并进 _childrenMap 后调用 RefreshGrid新出现的子节点就会出现在表格里。6.2 复选框的三态联动权限树里几乎都要复选框而且通常要求父子联动。这个联动逻辑不复杂但递归方向容易写反。正确顺序是先更新子节点再回推父节点private void SetChecked(DataRow node, bool check) { node[_Checked] check; if (_childrenMap.TryGetValue((int)node[Id], out var children)) { foreach (DataRow child in children) SetChecked(child, check); // 向下全部设置 } UpdateParentState(node); // 再回推父节点 }三态的判断规则是所有子节点选中则为选中部分选中则为半选全不选则为不选。绘制三态复选框时需要把 Indeterminate 状态画成灰色方块。这里注意复选框列建议单独加一列不要在 Name 列里挤排否则点击命中和绘制都会有额外负担。6.3 图标与最后的收尾展开/折叠箭头可以用系统自带图标或者字体图标替代手绘方框。比较简单的做法是直接在绘制代码里画字符因为字符不需要额外资源文件string symbol isLeaf ? : (isExpanded ? − : ); TextRenderer.DrawText(e.Graphics, symbol, new Font(Segoe UI, 10f), iconRect, Color.DimGray, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);我个人习惯是把绘制、命中、状态恢复这三件事各封装成一个方法并让它们共用同一个缩进常量。因为树形控件的最大隐患永远是“绘制和命中不在同一套坐标上”。这个习惯帮我在后来至少三个带树形结构的表格项目里省下了大量排查时间。希望帮到你。本文还有配套的精品资源点击获取