WinForms DataGridView 多列树形表格:自绘缩进与展开折叠

发布时间:2026/11/6 3:52:55
WinForms DataGridView 多列树形表格:自绘缩进与展开折叠 简介面向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(ListTreeNodeItem all, int parentId, int level, HashSetint expanded, ListTreeNodeItem 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 三层职责划分数据、状态、绘制各管一段这套实现我一般拆成三层边界清楚出问题的时候能立刻定位是哪一层的锅。层负责什么不负责什么典型成员数据层全量节点、父子关系、增删改持久化不关心是否展开、是否可见ListTreeNodeItem _allNodes状态层展开集合、当前选中 Id、排序键、筛选条件不碰节点业务字段HashSetint _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 ListTreeNodeItem(); BuildVisibleRows(_allNodes, 0, 0, _expanded, flat); int keepId _currentId; // 记住当前选中重绑后恢复 grid.DataSource new BindingListTreeNodeItem(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 ListTreeNodeItem(); var queue new QueueTreeNodeItem(); 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 HashSetint(_allNodes.Select(n n.Id)); _expanded.RemoveWhere(id !validIds.Contains(id)); _currentId keepId; ReloadVisibleRows(); _isReloading false; }_allNodes重新赋值之后之前所有引用旧节点对象的变量全成了野指针。所以我在这个类里立了个规矩任何地方都不缓存TreeNodeItem引用只缓存Id用的时候现查。这条规矩执行下来刷新相关的 bug 少了九成。绑定用BindingListT而不是ListT是因为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 的父是 BB 的父是 A一打开界面程序就假死。原因深度优先拍平时没有访问标记遇到环就是无限递归栈溢出或者卡死。解决拍平时传一个visited集合进节点前先判断是否已访问同时在数据导入环节加一次环检测把有问题的记录打日志跳过而不是让界面去背这个锅。脏数据永远会存在能在入口拦住的就别留给渲染层。private void BuildVisibleRows(..., HashSetint visited) { foreach (var node in children) { if (!visited.Add(node.Id)) continue; // 已访问过判定为环跳过 ... } }6. 验收与答辩用一张表证明这棵树站得住树形表格的验收不能靠“你看它能展开”要靠可复述的指标。我一般准备一张对照表现场逐条过评委问什么都能指到行上。验收项判定标准怎么演示层级缩进四层节点左边界依次递增 18 像素叶子与同级对齐全展开后横向看边界展开折叠点击加减号命中率 100%点文字不误触连续点十个节点的加减号多列对齐名称列缩进后其余列数据右对齐不偏移拖动列宽观察排序稳定点列头排序后父子关系不变按数量排序后展开三层增删一致新增子节点父级自动展开删除后展开集合无残留连续增删十个节点刷新保持刷新后展开状态与选中行保持展开三层后点刷新脏数据容错导入含环数据不卡死日志有记录预置一条坏数据现场导入性能5000 节点首次加载 1 秒展开响应 100 毫秒用秒表或日志打时间戳性能这一项建议在代码里埋点而不是现场掐表。加载前后各取一次Stopwatch把毫秒数打到日志里答辩时直接翻日志给人看比嘴上说“挺快的”有力得多。演示脚本按三步走第一步展示四层结构和多列数据说明为什么不用 TreeView第二步现场新增一个部件、改数量、删掉它展示树的形状跟着数据走第三步导入一条父子成环的脏数据展示程序不崩、日志有记录。三步控制在三分钟以内中间不切窗口、不重编译。有一个细节值得单独说展示“删除后跳父节点”这个行为时很多人会顺手点刷新结果选中行没了看着像 bug。我的做法是删除后不刷新让跳转结果留在屏幕上解释一句“这里故意跳到父节点方便继续操作”——同一个动作解释和不解释评分差一档。从那以后我每次交付带树形结构的界面都强制自己先跑一遍这张表尤其是脏数据那一条宁愿在开发机上把程序搞崩十次也不要在演示现场崩一次。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询