简介:面向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 List<DeptNode> 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,每向下走一层加 1;node.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 List<DeptNode> BuildTree(List<DeptNode> flatList) { var nodesById = flatList.ToDictionary(n => n.Id); var roots = new List<DeptNode>(); 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 树列参数表:缩进宽度、按钮尺寸、行高
这几个值需要放在一起调,单独改一个都会显得别扭:
| 参数 | 建议值 | 影响 |
|---|---|---|
| rootIndent | 8 到 12 | 根节点文字到单元格左缘的距离 |
| indentWidth | 16 到 20 | 每深一级多缩进的像素,也决定竖线间距 |
| expandButtonSize | 13 到 16 | 点击热区大小,太小不容易点中 |
| RowTemplate.Height | 24 到 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 和 Brush,GC 压力很大。
短期的解决方法是:展开只加载一层,子节点真正被展开时才去查库;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 原生能力。这套东西最值钱的地方不是代码,而是想明白了“树”和“表格”各自该管什么。希望这次整理能帮你少踩几个坑。
本文还有配套的精品资源,点击获取