简介:面向C# WinForms开发者的DataGridView树形列表实现示例,聚焦控件本身不直接支持层级展示时,借助自定义数据模型、事件处理与绘制逻辑模拟展开折叠效果的完整工程,适合已具备基础控件用法、希望补齐树形表格能力的中级开发者参考。压缩包共32个文件、约68KB,以cs源码为主,辅以config配置、resx/resources资源、sln与csproj工程文件及可执行程序,保留Visual Studio 2012项目的组织方式,便于打开对照调试。已有1960人学习,说明该实现思路在同类需求中有一定参考面。读者可从中了解树节点模型如何与DataGridView列绑定,观察通过CellClick切换节点状态、动态调整列可见性以及OnRowPrePaint绘制缩进与展开图标的做法,并梳理窗体设计器、配置文件与运行产物之间的配合关系。示例项目结构紧凑,适合拆解后迁移到目录树、组织架构或分级权限等业务界面。
1. 设备台账的四层结构,把 TreeView 逼到了墙角
设备台账里有四层:系统、子系统、部件、零件。甲方要求一屏之内既看到每层的型号、数量、单价,又能像资源管理器那样折叠收起。TreeView 一上手就卡住——它只能显示一列文本,多列对齐根本没戏,客户又明确不要“点一下弹一个详情窗口”的交互。DataGridView 树形列表这条路的可行性在于:控件本身支持多列、单元格编辑、虚拟模式,缺的只是层级缩进和展开折叠,而这两件事都能在 CellPainting 里补出来。这套方案适合三类人:做 WinForms 桌面端管理系统的从业者、要交课程设计或毕业设计的学生,以及手上有个“树形多列表格”需求但不想引入第三方控件库的开发者。下面从数据模型开始,一路写到怎么验收。
2. 数据模型与总体架构:扁平表加 ParentId 的邻接表
先把数据结构定死,后面所有麻烦都能少一半。树形表格最容易走的歪路是直接拿嵌套的对象树去绑 DataGridView,父子引用互相持有,序列化时循环引用,刷新时深拷贝,一改就炸。我在两个项目里都见过这种写法,最后都推倒重来。
2.1 节点模型:只存数据,不存视图状态
节点实体只保留业务字段和父子关系,展开状态、选中状态、排序键一律不进实体。理由是实体要参与序列化、要落库、要被业务层复用,一旦掺进IsExpanded这种界面字段,导出 Excel 的时候就会莫名其妙多一列,接口传参也会被人问“这个字段是干嘛的”。
// 树节点的最小实体:父子关系 + 业务字段 + 排序键 public class TreeNodeItem { public int Id { get; set; } public int ParentId { get; set; } // 0 表示根节点,避免用 null 判断 public string Name { get; set; } // 显示名称 public string Spec { get; set; } // 规格型号,多列展示用 public decimal Quantity { get; set; } // 数量 public int SortNo { get; set; } // 同级排序号,不能靠 Id 大小排 public int Level { get; set; } // 层级,构建可见行时计算,不落库 public bool HasChildren { get; set; } // 冗余标记,省掉每次判断都去查子集 }ParentId = 0表示根,比用int?省事,SQL 里WHERE ParentId = 0直接能查,也不会在绑定的时候因为 null 值触发格式化异常。Level和HasChildren是运行时算出来的展示字段,落库表里不需要。
对应到数据库,一张自关联表就够了:
CREATE TABLE EquipmentNode ( Id INTEGER PRIMARY KEY AUTOINCREMENT, ParentId INTEGER NOT NULL DEFAULT 0, Name NVARCHAR(64) NOT NULL, Spec NVARCHAR(64), Quantity DECIMAL(18,2) DEFAULT 0, SortNo INTEGER DEFAULT 0 ); CREATE INDEX IX_EquipmentNode_Parent ON EquipmentNode(ParentId, SortNo);索引建在(ParentId, SortNo)上,是因为构建可见行时每次都是“按父节点取子集再按 SortNo 排序”,这个查询会随着展开操作反复执行,几千个节点时有没有索引差别很明显。
2.2 可见行构建:一次深度优先,把树拍平
DataGridView 不认识树,它只认识行。所以每次展开、折叠、增删、筛选之后,都要重新算一遍“当前应该显示哪些行、按什么顺序、各自在第几层”。这个动作我叫它拍平。
// 从虚拟根 0 开始做深度优先,把当前可见节点按显示顺序塞进 output private void BuildVisibleRows(List<TreeNodeItem> all, int parentId, int level, HashSet<int> expanded, List<TreeNodeItem> output) { // 同级按 SortNo 排,SortNo 相同再按 Id 兜底,保证顺序稳定 var children = all.Where(n => n.ParentId == parentId) .OrderBy(n => n.SortNo) .ThenBy(n => n.Id) .ToList(); foreach (var node in children) { node.Level = level; // 层级在这里统一赋值 node.HasChildren = all.Any(n => n.ParentId == node.Id); // 顺带标记有没有子节点 output.Add(node); // 只有展开且确实有子节点,才继续往下钻 if (expanded.Contains(node.Id) && node.HasChildren) BuildVisibleRows(all, node.Id, level + 1, expanded, output); } }参数说明:all是全量节点,只加载一次常驻内存;expanded是展开节点 Id 的集合,由视图层维护;output就是最终要绑给 DataGridView 的列表。这个递归的深度等于树的层数,不是节点数,四层五层的业务场景完全不用担心爆栈——真正会爆栈的是拿父子对象互相引用做递归遍历,那种写法遇到脏数据形成环就死循环了。
HasChildren在这里顺带算出来,比在绘制事件里临时Any()一次要划算得多。绘制事件每帧每行都会触发,里面做 LINQ 查询是性能杀手,这一点后面还会提到。
提示:如果节点量级上万,
all.Any(...)这一句会变成 O(n²),提前用一次GroupBy(ParentId)建个字典查子集,构建时间能从秒级降到几十毫秒。
2.3 三层职责划分:数据、状态、绘制各管一段
这套实现我一般拆成三层,边界清楚,出问题的时候能立刻定位是哪一层的锅。
| 层 | 负责什么 | 不负责什么 | 典型成员 |
|---|---|---|---|
| 数据层 | 全量节点、父子关系、增删改持久化 | 不关心是否展开、是否可见 | List<TreeNodeItem> _allNodes |
| 状态层 | 展开集合、当前选中 Id、排序键、筛选条件 | 不碰节点业务字段 | HashSet<int> _expanded、int _currentId |
| 绘制层 | 缩进、加号减号、文本、命中测试 | 不修改数据、不查数据库 | CellPainting、GetToggleRect |
把展开状态从节点实体里剥出来放到_expanded集合,最大的好处是“重新加载数据后展开状态还在”。刷新按钮点一下,数据从库里重新拉一遍,节点对象全是新的,但_expanded里存的还是那些 Id,重新拍平之后用户看到的树还是原来展开的样子。如果把IsExpanded塞在实体里,刷新一次整棵树的展开状态就全丢了,用户得从头点一遍,这种体验在验收演示时特别容易翻车。
状态层还要记一个_currentId,因为grid.DataSource一旦重新赋值,选中行会跳回第一行。每次重绑之前记下当前选中节点的 Id,重绑之后再按 Id 找回来,这个小动作在第 4 章的重建流程里会反复用到。
三层之间只允许单向依赖:绘制层读状态层和数据层,状态层读数据层,数据层谁都不认识。反过来一旦让数据层去调grid.Refresh(),整个结构就烂了,后面想换控件或者加个导出功能都要动业务代码。
3. 树形渲染与交互实现:缩进、加减号与展开折叠的几何一致性
渲染这块的核心矛盾只有一个:画的时候算了一遍按钮位置,点的时候还得再算一遍,两处算法不一致,就会出现“看着点在加号上,实际没反应”或者“点文字结果把树折了”。我的做法是把几何计算抽成一个纯函数,绘制和命中测试都调它。
3.1 用 CellPainting 自绘缩进和加减号
DataGridView 的默认绘制会把单元格文本从左边距开始画,没有缩进的概念。要做出树形效果,就得接管第一个单元格的绘制过程:先让控件把背景和选中态画好,再自己往上叠缩进、加减号和文本。
// 几何参数集中在一处,绘制和点击命中都从这里取,避免两套算法 private const int IndentStep = 18; // 每层缩进步长,配合 9pt 字体比较舒服 private const int ToggleSize = 12; // 加减号方框边长 private const int ToggleGap = 4; // 方框与文字的间隙 private const int LeftPadding = 4; // 单元格左内边距 private Rectangle GetToggleRect(Rectangle cellBounds, int level) { int left = cellBounds.Left + LeftPadding + level * IndentStep; int top = cellBounds.Top + (cellBounds.Height - ToggleSize) / 2; // 垂直居中 return new Rectangle(left, top, ToggleSize, ToggleSize); }绘制事件只处理第一列,其余列交给控件默认逻辑,这样数字右对齐、日期格式化这些都不用自己重写。
private void grid_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { // 只接管第一列的数据行,表头行 RowIndex 为 -1 要放过 if (e.RowIndex < 0 || e.ColumnIndex != colName.Index) return; var node = grid.Rows[e.RowIndex].DataBoundItem as TreeNodeItem; if (node == null) return; e.PaintBackground(e.CellBounds, true); // 先把背景、选中高亮画好 var toggle = GetToggleRect(e.CellBounds, node.Level); if (node.HasChildren) { // 用字符画加减号,省掉图片资源和 DPI 适配的麻烦 string mark = _expanded.Contains(node.Id) ? "−" : "+"; TextRenderer.DrawText(e.Graphics, mark, grid.Font, toggle, e.CellStyle.ForeColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); } // 文字从加减号右边开始,叶子节点同样按层级缩进,视觉上才对得齐 int textLeft = toggle.Right + ToggleGap; var textRect = new Rectangle(textLeft, e.CellBounds.Top, e.CellBounds.Right - textLeft, e.CellBounds.Height); TextRenderer.DrawText(e.Graphics, node.Name, e.CellStyle.Font, textRect, e.CellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); e.Handled = true; // 必须置位,否则默认绘制会再画一层文字,出现重影 }几个容易忽略的点:e.PaintBackground一定要先调,否则选中行的蓝色高亮会被自己画的文字盖住;文本绘制必须用TextRenderer而不是e.Graphics.DrawString,后者在 ClearType 下渲染出来的字发虚、和同一行其他列的字重对不上,肉眼一看就是“这列的字体不一样”;EndEllipsis保证名称过长时出省略号而不是直接截断。
IndentStep取 18 不是随便定的,9pt 微软雅黑一个字宽大约 12 像素,缩进 18 像素既能看出层级,四层下来也就 72 像素,不会把名称挤没。如果树的层级可能到七八层,建议降到 14 或者加横向滚动。
3.2 命中测试:让点击和绘制共用一套坐标
展开折叠的触发逻辑写在CellMouseDown里,关键就一句:拿到的矩形必须和绘制时是同一个函数算出来的。
private void grid_CellMouseDown(object sender, DataGridViewCellMouseEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex != colName.Index) return; var node = grid.Rows[e.RowIndex].DataBoundItem as TreeNodeItem; if (node == null || !node.HasChildren) return; // 注意第三个参数 false:要取单元格在控件坐标系里的实际位置 var cellRect = grid.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false); var toggle = GetToggleRect(cellRect, node.Level); // 点在文字区域就交还给默认逻辑,让用户能正常选中、编辑 if (!toggle.Contains(e.Location)) return; if (_expanded.Contains(node.Id)) _expanded.Remove(node.Id); else _expanded.Add(node.Id); ReloadVisibleRows(); // 展开状态变了,必须重新拍平 }GetCellDisplayRectangle的第三个参数容易搞错:传true返回的是包含列头偏移的坐标,传false才是相对控件左上角的位置,而e.Location正是相对控件左上角的。这两个坐标系一旦搞混,表现就是“第一行能点,往下滚几行就点不准了”,偏移量刚好等于滚动条滚过的距离,很好辨认。
ReloadVisibleRows每次都重建整张可见列表,不做增量插入删除。原因是增量的算法要处理好几种边界:折叠时删掉所有后代行、展开时在正确位置插入、插入后行索引全部要重算。代码量翻三倍,bug 多一倍,而重建几千行的耗时通常不到 50 毫秒,用户根本感觉不到。这笔账我算过好几次,每次都选重建。
private void ReloadVisibleRows() { var flat = new List<TreeNodeItem>(); BuildVisibleRows(_allNodes, 0, 0, _expanded, flat); int keepId = _currentId; // 记住当前选中,重绑后恢复 grid.DataSource = new BindingList<TreeNodeItem>(flat); RestoreSelection(keepId); // 按 Id 找回选中行 }3.3 编辑态保护与键盘操作
自绘之后,单元格进入编辑状态会有一个坑:编辑框是控件自己画的,它不知道左边有缩进,编辑时光标会跑到最左边,和显示状态对不上。稳妥的做法是让第一列干脆不可编辑,名称修改走右键菜单或者详情面板;真要就地编辑,就得在EditingControlShowing里给编辑控件加左内边距,把缩进补上。
private void grid_CellBeginEdit(object sender, DataGridViewCellCancelEventArgs e) { // 正在编辑时禁止折叠,否则行被重建,编辑控件会野在那儿不释放 if (_isReloading) { e.Cancel = true; return; } if (e.ColumnIndex == colName.Index) e.Cancel = true; // 第一列只读 }键盘操作补两个就够用:Ctrl + +展开当前节点,Ctrl + -折叠,回车切换展开。三个快捷键加起来不到二十行,但演示的时候特别出效果,评委看的就是这种顺手不顺手。
private void grid_KeyDown(object sender, KeyEventArgs e) { if (grid.CurrentRow == null) return; var node = grid.CurrentRow.DataBoundItem as TreeNodeItem; if (node == null || !node.HasChildren) return; if (e.Control && e.KeyCode == Keys.Oemplus) { _expanded.Add(node.Id); ReloadVisibleRows(); } if (e.Control && e.KeyCode == Keys.OemMinus) { _expanded.Remove(node.Id); ReloadVisibleRows(); } }4. 增删改与绑定一致性:让树的形状跟着数据走
树形表格真正难的不是画出来,是改了数据之后还能画对。新增一个子节点,父节点要自动展开、新行要出现在正确位置、同级排序要正确;删除一个节点,所有后代的展开状态要从集合里清掉、选中行不能指向已经消失的节点。这几个动作我在验收前被问过好几次,都是现场操作暴露出来的。
4.1 新增子节点:位置、展开与选中三件事
private void AddChildNode(TreeNodeItem parent, TreeNodeItem child) { child.ParentId = parent == null ? 0 : parent.Id; // 排序号取同级最大值加一,而不是用当前时间戳,避免并发时撞号 int maxSort = _allNodes.Where(n => n.ParentId == child.ParentId) .Select(n => n.SortNo).DefaultIfEmpty(0).Max(); child.SortNo = maxSort + 1; _allNodes.Add(child); // 先入内存集合 _repo.Insert(child); // 再落库,Id 由库回填 if (parent != null) _expanded.Add(parent.Id); // 父节点自动展开,否则用户看不见新行 _currentId = child.Id; // 新行选中,方便连续录入 ReloadVisibleRows(); }顺序很关键:先入内存再落库,是因为Id要等数据库自增回填,如果在落库前就ReloadVisibleRows,拍平的时候拿到的Id还是 0,_expanded集合里存的全是 0,整棵树的展开状态就乱套了。这类问题在单机版上偶尔能跑通,一旦换成真库就必现。
同级排序用“最大 SortNo + 1”,比用时间戳靠谱。时间戳在毫秒级并发时可能重复,而且跨机器时钟不同步;SortNo 是整数,写个拖拽排序也好改。
4.2 删除节点:级联、撤销与展开集合清理
private void DeleteNode(TreeNodeItem node) { // 先收集所有后代 Id,递归一层就够,不要写成深层递归 var toDelete = new List<TreeNodeItem>(); var queue = new Queue<TreeNodeItem>(); queue.Enqueue(node); while (queue.Count > 0) { var cur = queue.Dequeue(); toDelete.Add(cur); foreach (var c in _allNodes.Where(n => n.ParentId == cur.Id)) queue.Enqueue(c); } foreach (var n in toDelete) { _allNodes.Remove(n); _expanded.Remove(n.Id); // 展开集合必须同步清理,否则 Id 复用时会莫名展开 _repo.Delete(n.Id); } _currentId = node.ParentId; // 删完跳到父节点,比直接跳第一行自然 ReloadVisibleRows(); }_expanded.Remove(n.Id)这一句看着多余,实际是后悔药。业务库里删掉的 Id 有可能被后续插入复用(尤其是 SQLite 的AUTOINCREMENT没开时),如果集合里残留着旧 Id,新节点一建出来就自带展开状态,用户会看到“刚加的东西底下莫名其妙冒出一堆”。
删除我一般给两条路:单条删除立即生效但要二次确认,批量删除走“先标记后提交”,中间给一次撤销机会——把被删节点缓存到_deletedBuffer里,点撤销再塞回去。这个缓冲的实现成本很低,但演示时的说服力比什么都强。
4.3 刷新与外部数据变更
刷新按钮触发的整表重载,要守住一条:展开状态和选中行可以丢,但业务上的“当前操作节点”不能丢。
private void RefreshAll() { _isReloading = true; // 置位期间禁止编辑和折叠 int keepId = _currentId; _allNodes = _repo.LoadAll(); // 从库里全量重拉 // 把库里已经不存在的 Id 从展开集合里剔掉 var validIds = new HashSet<int>(_allNodes.Select(n => n.Id)); _expanded.RemoveWhere(id => !validIds.Contains(id)); _currentId = keepId; ReloadVisibleRows(); _isReloading = false; }_allNodes重新赋值之后,之前所有引用旧节点对象的变量全成了野指针。所以我在这个类里立了个规矩:任何地方都不缓存TreeNodeItem引用,只缓存Id,用的时候现查。这条规矩执行下来,刷新相关的 bug 少了九成。
绑定用BindingList<T>而不是List<T>,是因为BindingList实现了IBindingList,控件能感知变更。但即便如此,我也是重建整个BindingList再赋给DataSource,不往旧列表里Add——因为DataGridView绑定树形拍平列表时,增量Add触发的行插入位置和SortNo顺序对不上,行会插到末尾再跳回中间,视觉上闪一下。
5. 避坑清单:五类最容易翻车的问题
这些坑我基本都踩过,或者接手别人的代码时帮着填过。每条按“现象、原因、解决”写,遇到对得上的直接照方抓药。
5.1 点列头排序,层级全乱
现象:用户点一下“数量”列头,表格按数量重排,缩进还在,但父子关系彻底错位,子行跑到父行上面去了。
原因:DataGridView默认的列头排序是对绑定列表整体重排,它根本不知道Level和ParentId是怎么回事,排完就把拍平顺序破坏了。
解决:把SortMode全部设成Programmatic,接管ColumnHeaderMouseClick,先按指定列对同级节点排序并回写SortNo落库,再重新拍平。也就是说排序是改数据,不是改视图顺序。
foreach (DataGridViewColumn c in grid.Columns) c.SortMode = DataGridViewColumnSortMode.Programmatic; private void grid_ColumnHeaderMouseClick(object sender, DataGridViewCellMouseEventArgs e) { string col = grid.Columns[e.ColumnIndex].DataPropertyName; // 只对同级节点重排 SortNo,父子关系不受影响 foreach (var group in _allNodes.GroupBy(n => n.ParentId)) { int i = 1; foreach (var n in group.OrderBy(x => GetSortValue(x, col))) n.SortNo = i++; } ReloadVisibleRows(); }5.2 绘制事件里做查询,滚动就卡
现象:节点数过千以后,拖动滚动条像在放幻灯片,CPU 占用飙升。
原因:CellPainting每帧对每个可见单元格都会触发,里面只要有一个 LINQ 查询或者 new Font,代价就乘以几百倍。最常见的是在绘制里写_allNodes.Any(n => n.ParentId == node.Id)判有没有子节点。
解决:HasChildren和Level在拍平阶段一次性算好挂在节点上,绘制事件里只做纯绘制,不查集合、不建对象、不拼字符串。字体从e.CellStyle.Font取,不要每次new Font(...)。
5.3 折叠时行高突变、选中态丢失
现象:展开再折叠同一节点,行高突然从 22 变成 18,或者选中行跳回第一行。
原因:折叠触发的重绑把DataSource换成了新列表,RowTemplate.Height如果在别处被改过就会生效;选中丢失是因为控件按行索引记选中,索引变了自然就跳。
解决:RowTemplate.Height只在初始化时设一次,后面谁都不许改;重绑前记_currentId,重绑后按 Id 找回行索引再CurrentCell = grid.Rows[i].Cells[0]。恢复选中这一步别省,演示时连着展开三层再折叠,选中不跳,观感差别很明显。
5.4 编辑框跑到缩进区,光标和文字对不上
现象:双击名称进入编辑,编辑框里的文字从最左边开始,和旁边显示态差了 18 像素,看起来很别扭。
原因:自绘只改了显示,编辑控件是控件自己创建的TextBox,它不知道有缩进。
解决:第一列设成只读,改名走详情面板;如果必须就地编辑,在EditingControlShowing里给编辑控件加Padding.Left = level * IndentStep,并在CellEndEdit里恢复,否则这个 padding 会带到下一个编辑的单元格上。
5.5 脏数据形成环,拍平直接死循环
现象:从老系统导进来的数据里,A 的父是 B,B 的父是 A,一打开界面程序就假死。
原因:深度优先拍平时没有访问标记,遇到环就是无限递归,栈溢出或者卡死。
解决:拍平时传一个visited集合,进节点前先判断是否已访问;同时在数据导入环节加一次环检测,把有问题的记录打日志跳过,而不是让界面去背这个锅。脏数据永远会存在,能在入口拦住的就别留给渲染层。
private void BuildVisibleRows(..., HashSet<int> visited) { foreach (var node in children) { if (!visited.Add(node.Id)) continue; // 已访问过,判定为环,跳过 ... } }6. 验收与答辩:用一张表证明这棵树站得住
树形表格的验收不能靠“你看它能展开”,要靠可复述的指标。我一般准备一张对照表,现场逐条过,评委问什么都能指到行上。
| 验收项 | 判定标准 | 怎么演示 |
|---|---|---|
| 层级缩进 | 四层节点左边界依次递增 18 像素,叶子与同级对齐 | 全展开后横向看边界 |
| 展开折叠 | 点击加减号命中率 100%,点文字不误触 | 连续点十个节点的加减号 |
| 多列对齐 | 名称列缩进后,其余列数据右对齐不偏移 | 拖动列宽观察 |
| 排序稳定 | 点列头排序后父子关系不变 | 按数量排序后展开三层 |
| 增删一致 | 新增子节点父级自动展开,删除后展开集合无残留 | 连续增删十个节点 |
| 刷新保持 | 刷新后展开状态与选中行保持 | 展开三层后点刷新 |
| 脏数据容错 | 导入含环数据不卡死,日志有记录 | 预置一条坏数据现场导入 |
| 性能 | 5000 节点首次加载 < 1 秒,展开响应 < 100 毫秒 | 用秒表或日志打时间戳 |
性能这一项建议在代码里埋点,而不是现场掐表。加载前后各取一次Stopwatch,把毫秒数打到日志里,答辩时直接翻日志给人看,比嘴上说“挺快的”有力得多。
演示脚本按三步走:第一步展示四层结构和多列数据,说明为什么不用 TreeView;第二步现场新增一个部件、改数量、删掉它,展示树的形状跟着数据走;第三步导入一条父子成环的脏数据,展示程序不崩、日志有记录。三步控制在三分钟以内,中间不切窗口、不重编译。
有一个细节值得单独说:展示“删除后跳父节点”这个行为时,很多人会顺手点刷新,结果选中行没了,看着像 bug。我的做法是删除后不刷新,让跳转结果留在屏幕上解释一句“这里故意跳到父节点,方便继续操作”——同一个动作,解释和不解释,评分差一档。
从那以后我每次交付带树形结构的界面,都强制自己先跑一遍这张表,尤其是脏数据那一条,宁愿在开发机上把程序搞崩十次,也不要在演示现场崩一次。希望帮到你。
本文还有配套的精品资源,点击获取