ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

WPF树状表格实现:SharedSizeGroup列对齐与MVVM绑定

WPF树状表格实现:SharedSizeGroup列对齐与MVVM绑定 简介这是一份面向WPF桌面开发者的树形表格TreeGrid实现示例基于C#与XAML编写适合已掌握WPF基础、希望为数据展示控件补充层级折叠能力的开发者参考。资源聚焦于用WPF原生控件组合出可展开、可折叠的树状表格解决普通DataGrid难以呈现父子层级数据的问题可用于文件目录、组织架构、分类清单等场景。压缩包共36个文件约66KB以12个cs源码文件为核心配合2个xaml界面文件、1个csproj工程文件及dll、resources、resx等资源另含exe、pdb、cache等编译与调试产物结构完整可直接打开运行。目前已有272人学习下载。通过阅读源码读者可了解树形节点的数据组织方式、层级缩进与边距转换的实现思路以及主窗口与转换器类的协作关系便于快速移植到自己的项目中或在此基础上扩展排序、勾选等功能。1. 从 treegrid.zip 说起WPF 里为什么还需要一棵“树状表格”如果你做过 C# 上位机、MES 客户端或者任何带层级数据的 WPF 管理界面大概率遇到过这种需求左边要能一层层展开右边还得对齐显示编码、名称、数量、状态这些列。普通 TreeView 只能显示一列文本DataGrid 又天生是平铺的于是“树状表格”就成了绕不开的东西。treegrid.zip这个包名背后通常就是一个用 WPF 和 C# 手写的 TreeGrid 控件实现核心诉求是把树形结构和表格列对齐这两件事揉在一起。它适合谁适合正在用 WPF 做设备管理、BOM 结构、组织架构、文件目录、工序路线这类层级展示的开发者。你不需要引入第三方商业控件靠 ItemsControl、HierarchicalDataTemplate、Grid 共享尺寸这几样就能搭出来。这篇笔记就按“先讲清结构再动手复现最后说坑”的顺序把 WPF 树状表格从零到能用讲透中间会带上数据绑定、MVVM、性能这些热搜里高频出现的点。2. WPF 树状表格的结构选型为什么不用现成 TreeView 硬改2.1 三种常见实现路线的取舍在 WPF 里做树状表格业内常见做法基本就三条路各有各的适用边界。第一条是改造 TreeView把 Header 换成一个横向 StackPanel 或者 Grid每列宽度写死。这条路入门最快但列宽无法随内容自适应也没法和表头对齐数据一多列就错位属于能跑但不好维护的类型。第二条是用 DataGrid 加分组把层级压成 GroupStyle 展示。它天然有列、有排序、有虚拟化但分组只有一层多级展开要靠嵌套展开逻辑和选中逻辑会变得很别扭遇到三级以上结构基本就翻车。第三条是自绘 TreeGrid用一棵 ItemsControl 承载层级每个节点模板里放一个 Grid列宽通过 SharedSizeGroup 共享表头也用同样的列定义。这条路前期要多写一点 XAML但列对齐、多级展开、虚拟化、MVVM 绑定都能自己掌控是长期项目里最稳的方案。treegrid.zip这类实现绝大多数走的就是第三条。选型时我一般看两个指标层级深度和行数。层级不超过两层、行数几百用 DataGrid 分组就够了层级三层以上或者行数上千直接上自绘 TreeGrid别在 TreeView 上反复打补丁。2.2 用 HierarchicalDataTemplate 定义节点自绘 TreeGrid 的核心是数据模板。每个节点既要显示自己的列又要能展开子节点所以模板里得同时包含“行内容”和“子级容器”。!-- 节点模板一行数据 可展开的子级 -- HierarchicalDataTemplate DataType{x:Type local:TreeNode} ItemsSource{Binding Children} Grid Grid.ColumnDefinitions ColumnDefinition WidthAuto/ ColumnDefinition Width120 SharedSizeGroupColCode/ ColumnDefinition Width200 SharedSizeGroupColName/ ColumnDefinition Width80 SharedSizeGroupColQty/ /Grid.ColumnDefinitions !-- 展开/折叠按钮绑定 IsExpanded -- ToggleButton Grid.Column0 IsChecked{Binding IsExpanded, ModeTwoWay} Width16 Height16/ TextBlock Grid.Column1 Text{Binding Code}/ TextBlock Grid.Column2 Text{Binding Name}/ TextBlock Grid.Column3 Text{Binding Qty}/ /Grid /HierarchicalDataTemplate这段模板的关键在SharedSizeGroup。它让同一列在不同层级、不同行之间共享宽度表头只要用相同的 Group 名就能和内容严格对齐。IsExpanded用 TwoWay 绑定展开状态回写到数据模型配合 MVVM 时刷新视图不会丢状态。参数上要注意WidthAuto那一列放展开按钮宽度由按钮撑开其余列给固定初值实际宽度由 SharedSizeGroup 统一。如果某列内容特别长把对应 ColumnDefinition 的Width设成Auto并加MinWidth避免被内容撑爆。2.3 表头与内容列对齐的写法表头单独放一个 Grid列定义和节点模板保持一致同样挂 SharedSizeGroup。Grid Grid.IsSharedSizeScopeTrue Grid.RowDefinitions RowDefinition HeightAuto/ RowDefinition Height*/ /Grid.RowDefinitions !-- 表头 -- Grid Grid.Row0 Grid.ColumnDefinitions ColumnDefinition WidthAuto/ ColumnDefinition Width120 SharedSizeGroupColCode/ ColumnDefinition Width200 SharedSizeGroupColName/ ColumnDefinition Width80 SharedSizeGroupColQty/ /Grid.ColumnDefinitions TextBlock Grid.Column1 Text编码/ TextBlock Grid.Column2 Text名称/ TextBlock Grid.Column3 Text数量/ /Grid !-- 树内容 -- ScrollViewer Grid.Row1 VerticalScrollBarVisibilityAuto ItemsControl ItemsSource{Binding RootNodes}/ /ScrollViewer /GridGrid.IsSharedSizeScopeTrue必须加在最外层容器上否则 SharedSizeGroup 不生效这是新手最容易漏的一步。表头和内容用同一套列定义列宽就永远同步不需要在代码里手动算宽度。提示SharedSizeGroup 只在同一个 IsSharedSizeScope 内生效跨窗口或跨 UserControl 不共享别指望它做全局列宽同步。3. 数据模型与 MVVM 绑定让树节点自己管好展开状态3.1 TreeNode 模型的最小实现数据模型要同时满足三件事能挂子节点、能记录展开状态、能通知界面刷新。所以它得实现 INotifyPropertyChanged子节点用 ObservableCollection。public class TreeNode : INotifyPropertyChanged { private bool _isExpanded; private bool _isSelected; public string Code { get; set; } public string Name { get; set; } public int Qty { get; set; } // 子节点集合增删会自动通知界面 public ObservableCollectionTreeNode Children { get; set; } new ObservableCollectionTreeNode(); public bool IsExpanded { get _isExpanded; set { if (_isExpanded value) return; _isExpanded value; OnPropertyChanged(); } } public bool IsSelected { get _isSelected; set { if (_isSelected value) return; _isSelected value; OnPropertyChanged(); } } public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged([CallerMemberName] string name null) PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); }Children用 ObservableCollection 而不是 List是因为界面要感知子节点的增删。IsExpanded和IsSelected都做了相等判断再赋值避免无意义的刷新行数多的时候这点很关键。参数说明Code、Name、Qty 对应表格三列实际项目里可以换成任意字段。如果要做多选IsSelected 配合 CheckBox 绑定即可注意父节点和子节点的选中联动要自己在 ViewModel 里写控件不会自动帮你做。3.2 ViewModel 里构建层级数据ViewModel 负责把扁平数据组装成树并暴露给界面。public class TreeGridViewModel { public ObservableCollectionTreeNode RootNodes { get; set; } new ObservableCollectionTreeNode(); public void LoadFlatData(ListFlatItem items) { // 先按 ParentId 建索引再挂子节点避免 O(n^2) var map items.ToDictionary(x x.Id, x new TreeNode { Code x.Code, Name x.Name, Qty x.Qty }); foreach (var item in items) { if (item.ParentId null) RootNodes.Add(map[item.Id]); else if (map.TryGetValue(item.ParentId.Value, out var parent)) parent.Children.Add(map[item.Id]); } } }这段逻辑的重点是先用字典建索引再一次性挂接复杂度是 O(n)。如果直接双层循环找父节点几千行数据就会明显卡顿这是血泪经验。参数上ParentId 为 null 的作为根节点。实际项目里如果数据来自数据库建议在 SQL 里就按层级排好序前端只做挂接别在 C# 里反复排序。3.3 展开全部与折叠全部的实现用户经常需要一键展开或折叠这个逻辑写在 ViewModel 里递归处理。public void SetExpandedAll(IEnumerableTreeNode nodes, bool expanded) { foreach (var node in nodes) { node.IsExpanded expanded; if (node.Children.Count 0) SetExpandedAll(node.Children, expanded); } }调用时传 RootNodes 和 true/false 即可。注意递归深度如果层级超过几十层考虑改成显式栈遍历避免栈溢出。一般业务数据不会那么深递归够用。注意展开全部之前先确认数据已经加载完否则展开的是空节点用户会以为程序卡了。4. 性能与交互细节行数上千时怎么不卡4.1 虚拟化为什么在自绘 TreeGrid 里会失效WPF 的 ItemsControl 默认不开虚拟化行数一多所有节点一次性生成内存和渲染都会爆。要开虚拟化得换用 VirtualizingStackPanel 作为 ItemsPanel并且把 ScrollViewer 的 CanContentScroll 设为 True。ItemsControl ItemsSource{Binding RootNodes} VirtualizingPanel.IsVirtualizingTrue VirtualizingPanel.VirtualizationModeRecycling ItemsControl.ItemsPanel ItemsPanelTemplate VirtualizingStackPanel/ /ItemsPanelTemplate /ItemsControl.ItemsPanel /ItemsControlVirtualizationModeRecycling让容器复用滚动时不会反复创建销毁比 Standard 模式流畅很多。但要注意树形结构里子节点是嵌套的虚拟化只对同级节点生效展开的层级越多实际渲染的行还是会上涨。所以真正的大数据量场景建议配合“懒加载”展开时才去数据库拉子节点。参数上IsVirtualizing必须为 TrueVirtualizationMode选 Recycling。如果发现滚动时内容错位检查是不是在模板里用了 SharedSizeGroup 加虚拟化的组合这两者有时会冲突可以把列宽改成固定值规避。4.2 懒加载子节点的触发时机懒加载的核心是监听 IsExpanded 变化第一次展开时去取数据。public class LazyTreeNode : TreeNode { private bool _loaded; public FuncTreeNode, TaskListTreeNode LoadChildren { get; set; } public async Task EnsureChildrenAsync() { if (_loaded) return; _loaded true; var children await LoadChildren(this); foreach (var c in children) Children.Add(c); } }在 IsExpanded 的 setter 里调用 EnsureChildrenAsync或者用行为Behavior在界面上挂。_loaded标志防止重复加载这是必须的否则每次展开都查一次库。参数说明LoadChildren 是一个委托实际项目里注入查询逻辑。异步加载时注意 UI 线程ObservableCollection 的修改必须在 UI 线程上await 之后如果不在 UI 上下文要用 Dispatcher 切回来。4.3 选中行高亮与键盘导航选中高亮靠 IsSelected 绑定背景色用 DataTrigger 实现。Grid.Style Style TargetTypeGrid Style.Triggers DataTrigger Binding{Binding IsSelected} ValueTrue Setter PropertyBackground Value#CCE8FF/ /DataTrigger /Style.Triggers /Style /Grid.Style键盘上下键导航需要自己处理 PreviewKeyDown找到当前选中节点的可见兄弟节点切换 IsSelected。这块没有现成控件支持得手写。常见做法是维护一个可见节点的扁平列表按键时移动索引。提示选中高亮用 DataTrigger 而不是直接绑 Background是因为直接绑会覆盖样式里的其他触发器后期加悬停效果会打架。5. 避坑与排查treegrid 在 WPF 里最容易翻车的几处5.1 展开后列宽突然错位现象折叠时列对齐正常展开某一层后某一列突然变宽或变窄。 原因SharedSizeGroup 在虚拟化或嵌套模板下测量时机不一致导致某一行先撑开了列宽。 解决把容易变动的列改成固定宽度或者关闭该区域的虚拟化也可以在最外层强制Grid.IsSharedSizeScopeTrue并确保只有一处定义列宽。5.2 绑定 Children 后界面不刷新现象代码里往 Children 里 Add 了节点界面没反应。 原因Children 声明成了 List 而不是 ObservableCollection或者虽然用了 ObservableCollection 但整个集合被重新赋值没走通知。 解决统一用 ObservableCollection且不要整体替换集合只做增删。如果必须替换属性要加 OnPropertyChanged。5.3 展开全部时界面假死现象点“展开全部”后界面卡住几秒。 原因递归设置 IsExpanded 触发了大量布局更新每次赋值都刷新一次。 解决批量操作前先ItemsControl挂起布局或者用 Dispatcher 分批处理更彻底的做法是只展开当前可见层级深层节点懒加载。5.4 数据量大时内存持续上涨现象反复展开折叠后内存不降反升。 原因虚拟化没开或者开了 Recycling 但模板里绑定了事件没解绑容器无法回收。 解决确认 VirtualizingPanel.IsVirtualizing 为 True检查模板里有没有挂 Loaded 事件却没在 Unloaded 里解绑事件泄漏是内存涨的常见黑匣子。5.5 异步加载后节点重复现象快速连续展开折叠子节点出现两份。 原因EnsureChildrenAsync 的 _loaded 标志在 await 之前就置位但并发调用时第二次进来时第一次还没完成或者标志置位时机不对。 解决用 SemaphoreSlim 加锁或者把 _loaded 置位放在 await 之后、添加子节点之前确保同一节点只加载一次。6. 进阶技巧把 treegrid 做成可复用的 WPF 控件写到这一步基本功能已经能跑了。但如果你想让这套东西在多个项目里复用建议把它抽成一个自定义控件而不是每次复制 XAML。我一般的做法是继承 Control用 ControlTemplate 定义结构把 ItemsSource、列定义、展开行为都做成依赖属性。列定义可以用一个ObservableCollectionTreeGridColumn依赖属性每列包含 Header、BindingPath、Width、SharedSizeGroupName。控件内部根据这个集合动态生成表头和行模板的列这样业务方只需要在 XAML 里声明列不用碰模板。验证方法很简单写一个测试窗口塞 5000 行三层数据开虚拟化测展开全部耗时和内存占用。如果展开全部在 1 秒内完成、内存稳定在几十兆基本就达标了。如果超过 3 秒回去检查是不是每次 IsExpanded 都触发了全量布局。一个具体技巧把展开状态持久化到配置里。用户下次打开界面时自动恢复上次展开的节点。实现方式是在 ViewModel 里记录展开节点的 Id 列表加载完数据后遍历设置 IsExpanded。注意要在数据挂接完成之后再设置否则找不到节点。我自己踩过最深的坑是早期图省事用 TreeView 硬改列宽结果项目做到一半列全乱返工重写成自绘 TreeGrid多花了两天。从那以后凡是层级超过两层、列超过三列的我一律直接上 SharedSizeGroup 方案不再走捷径。希望帮到你。本文还有配套的精品资源点击获取
返回列表