ARTICLE DETAIL

资讯详情

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

WPF DataGrid仿Excel筛选:基于ICollectionView的动态过滤实现

WPF DataGrid仿Excel筛选:基于ICollectionView的动态过滤实现 简介面向WPF开发者的DataGrid仿Excel筛选功能完整实例WPF的DataGrid是桌面端表格展示与编辑的核心控件但默认功能缺少灵活的筛选交互该实例基于Visual Studio 2022与.NET 6.0演示在DataGrid中嵌入类似Excel的下拉筛选菜单支持等于、不等于、包含、大于、小于等条件并能多列组合过滤帮助解决大规模数据展示时快速定位与数据管理的效率问题适合正在学习WPF数据绑定或需要重构表格交互的中级开发人员。资源包为RAR压缩格式共76个文件含15个C#源文件、2个XAML界面文件以及JSON配置、DLL引用、工程配置和编译缓存等压缩后仅349KB其中C#源文件承载业务逻辑与过滤算法XAML定义界面布局与列模板配置和引用文件则便于项目直接恢复运行环境。该实例已有292人学习作为紧凑型Demo工程具有较高的参考价值。实现上通过设置AutoGenerateColumns为False自定义列并为DataGridTextColumn的Filtering事件编写处理逻辑借助ICollectionView的Refresh方法同步更新视图工程内清晰分离数据模型、筛选条件与视图模型覆盖MVVM模式下的数据绑定与命令用法从项目搭建、列定制、事件绑定到视图刷新均有完整代码支撑。读者既能掌握关键筛选机制也能在此基础上扩展模糊搜索、下拉多选等贴近业务的功能直接复用于实际桌面应用。1. 为什么要给 WPF DataGrid 做仿 Excel 的筛选做 WPF 桌面应用的人迟早会遇到一个尴尬DataGrid 自带排序、自带列宽拖拽但就是没有像 Excel 那样的列头下拉筛选。数据一多用户就开始抱怨“找个值翻半天”你就得自己在列头加筛选逻辑。这个实例做的就是这件事在 .NET 6.0 Visual Studio 2022 环境下用 WPF 的 DataGrid 控件实现一个可复用的类 Excel 筛选功能点击列头下拉箭头弹出等于、不等于、包含、不包含、大于、小于等条件的筛选菜单选定后视图立刻刷新支持多列条件叠加。它解决的是大量数据行场景下的快速定位问题适合做后台管理工具、数据看板、ERP 类桌面应用的开发者。整个思路围绕 AutoGenerateColumns 关闭后的自定义列配合 ICollectionView 的 Filter 属性做行过滤不是靠重写整个控件而是在模板和事件上做扩展。2. 筛选的实现思路为什么落在列模板和 ICollectionView 上2.1 原生的 DataGrid 到底缺了什么WPF 的 DataGrid 功能其实不弱排序、冻结列、行细节、虚拟化都有但筛选这件事原生控件没有直接在列头提供入口。常见的替代做法有三种一是放到工具栏上做全局筛选框简单但不够直观用户得知道先选列再输入二是在列头绑定一个 TextBox侵入性强而且列头空间本来就紧张三是像 Excel 一样在列头放一个下拉箭头点击弹出菜单勾选条件——这就是本实例的做法。选这条路的原因很直接交互习惯贴近 Excel用户上手零成本而且 WPF 的模板机制足够支撑这个需求不需要拿第三方控件也不用换 DataGrid 实现。核心思路拆开是三件事第一把列头模板换成可点击的按钮加下拉箭头样式第二弹出一个 Popup 或 ContextMenu 承载筛选条件第三把筛选条件翻译成 ICollectionView.Filter 的委托逻辑让视图自己过滤数据源。这里有个容易混淆的点很多人以为要给 DataGridTextColumn 挂 Filtering 事件实际上原生列没有这个事件实例里的做法是在列的 HeaderTemplate 里挂交互事件再把条件回传到视图模型最后作用在 CollectionView 上。注意ICollectionView.Filter 过滤的是视图不是数据源。这意味着原始集合不变Filter 失败或切换条件后集合里的数据还在只是视图不显示。这点对后续的取消筛选逻辑很关键。2.2 FilterInfo 这个模型是整套筛选逻辑的地基筛选条件要能被组合、传递、保存就不能散落在事件处理里得有个统一的数据结构。实例里有个FilterInfo.cs这就是筛选条件的最小单元。一个完整的筛选条件需要四样东西作用在哪一列PropertyName、用什么比较方式Operator、对比的值Value、以及这个条件当前是否启用IsActive。运算符不直接存字符串用枚举定义这样后续写 Filter 委托时不用做一堆 if else 套字符串。枚举设计上等于、不等于、包含、不包含、大于、小于、为空、不为空就够了。为空和不为空看起来简单实际是踩坑高发区——数据源里的 null 和空字符串不是一回事后续章节会专门讲。为什么条件最小单元里要带 IsActive因为多列筛选时用户可能先给“状态”列加了一个条件又去给“金额”列加条件如果某列的筛选菜单被直接关掉条件对象还在但 IsActive 变成 falseFilter 委托里跳过它就行。如果设计成删掉条件对象视图模型里就得维护集合的新增删除反而啰嗦。2.3 Filter 委托多个条件如何合并和逐项执行在 WPF 里DataGrid 绑定到数据源后如果数据源是ObservableCollectionT那么它的视图是ICollectionView可以通过CollectionViewSource.GetDefaultView(数据源)拿到。Filter 属性挂一个委托这个委托接收一个 object 参数返回 bool。返回 true 的行保留false 的行隐藏。本实例的做法是把筛选逻辑写成Predicateobject遍历所有启用的 FilterInfo 条件逐条计算任何一条不满足就返回 false用 AND 逻辑合并。private bool FilterOnItem(object item) { var data item as Student; if (data null) return false; foreach (var filter in _filters.Where(f f.IsActive)) { var propertyValue GetPropertyValue(data, filter.PropertyName); if (!EvaluateFilter(propertyValue, filter)) { return false; } } return true; }这段代码里最关键的是最后那个 foreach只有所有启用条件都满足才返回 true。GetPropertyValue用反射按属性名取值好处是新增列时不用改筛选逻辑坏处是反射有性能开销。数据量在几万行以内没问题如果上了几十万行可以在首次筛选时用表达式树缓存取值委托。EvaluateFilter是一个按 Operator 分派的比较方法后面实现章节会展开。提示Filter 委托抛异常是会中断 UI 的。写EvaluateFilter时务必对 null 值做前置判断否则某列有空值用户又点了“大于”一运行就崩。3. 仿 Excel 筛选的完整实现从 XAML 模板到过滤逻辑落地3.1 关闭自动列生成把列头模板换成可点击结构实现的第一步是关闭AutoGenerateColumns。如果不关DataGrid 会按数据类的属性自动生成列列头文字就是属性名完全不可控。关闭之后每一列都要手写DataGridTextColumn指定Header和Binding。这里有个细节Header不只是用户看到的列名还要在HeaderTemplate里嵌入触发筛选的按钮区域所以列的展示名要单独放到一个可辨识的地方比如 Tag 属性或者一个自定义的 HeaderModel。DataGrid x:NamedataGrid AutoGenerateColumnsFalse ItemsSource{Binding Students} CanUserAddRowsFalse SelectionModeSingle DataGrid.Columns DataGridTextColumn Header姓名 Binding{Binding Name} DataGridTextColumn.HeaderTemplate DataTemplate StackPanel OrientationHorizontal MouseLeftButtonUpOnHeaderClick TextBlock Text姓名 / TextBlock Text▼ ForegroundGray Margin6,0,0,0 / /StackPanel /DataTemplate /DataGridTextColumn.HeaderTemplate /DataGridTextColumn /DataGrid.Columns /DataGrid这个模板的逻辑是列头整体变成可点击区域点下去触发OnHeaderClick事件通过sender找到当前列对应的属性名和列名。下拉箭头那个▼文本是仿 Excel 最直观的视觉信号用户看到这个箭头就知道这一列能筛而且箭头可以做成选中状态后变颜色指示“当前列有筛选条件”。写这一层模板时最容易犯的错是把事件挂到 TextBlock 上而不是 StackPanel 上那样只有文字区域可点箭头旁边大片空白点不动体验很拧巴。3.2 FilterInfo.cs条件结构、运算符枚举与值解析模板可点击之后需要一个数据结构承载用户选择的筛选条件。FilterInfo的核心成员包括列名、显示列名、运算符、值的文本形式以及是否启用。为什么要存“值的文本形式”因为用户在筛选菜单里输入的就是字符串但数据源里的属性可能是 int、double、DateTime真正的比较要等运行时把字符串转成属性类型的值。让 FilterInfo 持有原始字符串比较时再转换是最稳妥的设计。public enum FilterOperator { Equals, NotEquals, Contains, NotContains, GreaterThan, LessThan, IsEmpty, IsNotEmpty } public class FilterInfo { public string PropertyName { get; set; } public string HeaderText { get; set; } public FilterOperator Operator { get; set; } public string FilterValue { get; set; } public bool IsActive { get; set; } public override string ToString() { return ${HeaderText} {Operator} {FilterValue}; } }ToString重写不是顺手写的它在 UI 上很有用——比如在界面上展示“当前筛选条件”列表或者在保存/恢复筛选状态时序列化成一行文本直接string.Join( , filters.Select(f f.ToString()))就能显示给人看。列名和显示名分开是因为绑定要用属性名Name、Age、Score而菜单标题和条件展示要用中文列名“姓名”“年龄”“分数”两个字段各司其职。3.3 筛选菜单弹出逻辑找对列、定位点击来源点击列头后要弹出一个 Popup 或者 ContextMenu。本实例用 Popup 更合适因为 ContextMenu 的样式偏右键菜单而且 Popup 可以完全自定义面板布局等于是把 ComboBox 的筛选选项、TextBox 输入框、按钮等装进一个 Popup 里。弹出前必须在点击事件里拿到两个关键信息列对应数据源的哪个属性、列头显示什么文字。private void OnHeaderClick(object sender, MouseButtonEventArgs e) { var stackPanel sender as StackPanel; var textBlock stackPanel?.Children.OfTypeTextBlock().FirstOrDefault(); var headerText textBlock?.Text; var column dataGrid.Columns.FirstOrDefault(c c.Header.ToString() headerText); if (column is DataGridTextColumn textColumn) { var binding textColumn.Binding as Binding; var propertyName binding?.Path.Path; filterPopup.DataContext new FilterPopupViewModel { PropertyName propertyName, HeaderText headerText, ExistingFilter _viewModel.GetActiveFilter(propertyName) }; filterPopup.PlacementTarget stackPanel; filterPopup.IsOpen true; } }这里用FirstOrDefault按 Header 文字找列实际上有一个风险如果两列 Header 一样会拿到第一列。常见做法是在写 XAML 时给每列设置唯一的Tag值作为属性名Header 仅负责显示点击事件里直接读 Tag 拿属性名绕开重名问题。ExistingFilter的传递是为了回显——用户之前已经给这一列设过筛选条件再次打开菜单时应该选中原运算符、填回原值而不是一切从头选。这个回显做到位整个功能才谈得上“能用”。3.4 视图模型里执行过滤绑定 ICollectionView 并刷新视图筛选菜单里用户点“确定”后条件会写进视图模型的筛选集合然后执行CollectionViewSource.GetDefaultView(Students).Refresh()。Refresh 会重新跑一次 Filter 委托行显示与隐藏一次性更新。注意一个关键操作顺序先更新 FilterInfo 的 IsActive 和条件值再调用 Refresh否则委托读到的还是旧值。public class MainWindowViewModel { public ObservableCollectionStudent Students { get; set; } private readonly ListFilterInfo _filters new ListFilterInfo(); public MainWindowViewModel() { Students new ObservableCollectionStudent(); } public void ApplyFilter(FilterInfo filter) { var existing _filters.FirstOrDefault(f f.PropertyName filter.PropertyName); if (existing ! null) { existing.Operator filter.Operator; existing.FilterValue filter.FilterValue; existing.IsActive filter.IsActive; } else { _filters.Add(filter); } var view CollectionViewSource.GetDefaultView(Students); view.Filter FilterOnItem; view.Refresh(); } private bool EvaluateFilter(object propertyValue, FilterInfo filter) { if (filter.Operator FilterOperator.IsEmpty) { return propertyValue null || string.IsNullOrWhiteSpace(propertyValue.ToString()); } if (propertyValue null) return false; switch (filter.Operator) { case FilterOperator.Equals: return propertyValue.ToString() filter.FilterValue; case FilterOperator.Contains: return propertyValue.ToString().Contains(filter.FilterValue); case FilterOperator.GreaterThan: if (propertyValue is IComparable comparable) { return comparable.CompareTo(ConvertValue(filter, propertyValue.GetType())) 0; } break; } return false; } }这段代码里有两个值得注意的设计点。第一IsEmpty的判断放到了所有逻辑最前面因为它允许属性值为 null 时仍然生效而其他运算符尤其是不等于遇到 null 时容易产生歧义第二propertyValue.ToString()作为 Equals 和 Contains 的统一比较手段虽然类型转换上不够严谨但在数据展示场景已经够用。如果你要支持真正的数值精确比较Equas 这里可以用Convert.ChangeType把 FilterValue 转成 propertyValue 的类型再做 Equals代价是类型不一致时会抛异常需要 try-catch 包一层。4. 避坑与排查筛选功能常见的 4 个翻车现场4.1 筛选后新增数据行不出现甚至偶发崩溃现象应用筛选条件后往 ObservableCollection 里 Add 一条新记录界面不刷新有时操作快一点直接抛异常。原因Filter 委托挂上之后ICollectionView 不会自动因为集合变化而重新过滤。ObservableCollection 的集合变更事件确实会通知视图但视图收到后只做增量刷新不会重新评估每一行是否满足 Filter。更棘手的是某些操作路径下手动 Refresh 和集合自动刷新撞在一起视图内部状态冲突。解决每次 Add、Remove、Edit 后如果当前有激活的筛选条件主动调用一次view.Refresh()。如果数据是分批加载或者有定时器轮询把 Refresh 统一封装到视图模型里所有修改数据的入口都走这个封装方法。注意不要试图在 Filter 委托内部修改集合那会导致运行时异常。委托的职责只是判断“这一行留还是走”绝对不能碰数据源本身。4.2 用 Contains 筛选时字母大小写对不上现象用户输入“zhang”筛选不出“Zhang San”数据明显在里面但就是筛不出来。原因string.Contains默认区分大小写。对名字、备注、地址这类字符串列用户的预期通常是忽略大小写。WPF 里的字符串比较如果用StringComparison.Ordinal或默认重载等价于区分大小写。解决把 Equals 和 Contains 统一改成忽略大小写用IndexOf(filter.FilterValue, StringComparison.OrdinalIgnoreCase) 0替代Contains。代价是性能比直接 Contains 略低但对几万行的数据量来说毫秒级差异完全感觉不到。4.3 数字列和日期列的值比较结果不对现象按“年龄大于 30”筛选结果把 3、13、30 都筛出来了肉眼一看就是错的。原因Enum 运算符分支里用IComparable.CompareTo时如果 FilterValue 存的是字符串而属性值是 int直接拿字符串和 int 比较比较的不是数值大小是字符串序或者直接抛异常。这就是把“值存成字符串”带来的代价比较前必须转换类型。解决在 EvaluateFilter 里封装一个ConvertValue方法把字符串转成目标列的实际类型。转成功再比较转失败则返回 false 并记录一条日志——这一步顺手把“用户输入了无法解析的值”这个边界情况也兜住了。DateTime 列同理解析格式要统一到CultureInfo.InvariantCulture否则不同系统区域设置下会解读出不同的日期。4.4 取消筛选后数据不回来现象用户把所有筛选条件清掉后DataGrid 还是只显示一部分行重启程序才恢复。原因Filter 委托没有置空。很多人的取消逻辑只是把筛选条件删掉但view.Filter还挂着那个委托。委托里的 foreach 确实跑完了但没有任何条件满足“IsActivetrue”于是返回 true理论上所有行都会显示——这是正确的情形。另一种更隐蔽的写法是把 Filter 改成新的 lambda 而不是赋 null或者 FilterInfo 列表被清空但委托里访问了被 dispose 掉的集合导致异常。还有一种真实场景筛选菜单界面关闭时误触发了一次 ApplyFilter把一个空条件写进了激活列表。解决取消筛选时必须显式执行两个动作——清空激活条件集合再view.Filter null; view.Refresh();。不要只做其中一个。清空条件后如果 Filter 委托里还引用了旧集合对象会变成时间炸弹下一次新增行时崩一次。5. 多列筛选与条件状态恢复的进阶处理到这个阶段单列筛选的模板、事件、过滤逻辑都已经能跑通了接下来最值得花时间的是两个进阶能力的落地。先说多列筛选的组合规则。Excel 的多列筛选默认是 AND 关系本实例也沿用这个规则——所有启用条件逐条叠过滤实现上不需要额外设计。真正要注意的是交互层的反馈当两列以上都有激活条件时用户应该能在界面上看到“当前生效了哪些筛选条件”否则筛选变成黑匣子用户无从判断数据变少是哪一列造成的。可以在 DataGrid 上方加一条筛选状态栏把 FilterInfo 集合里 IsActive 的条目以文本形式排列出来每条后面跟一个移除按钮。条件保存与恢复这里常见做法是把 FilterInfo 序列化成 JSON 字符串存到配置文件中。.NET 6.0 里直接用System.Text.Json就能做不用引第三方包。序列化字段就是 PropertyName、HeaderText、Operator、FilterValue、IsActive 五个属性反序列化回来直接塞进筛选集合然后 Refresh 一次界面就回到上次筛选的状态。这个能力在实际项目里很受欢迎——用户每天打开工具都要看同一批过滤后的数据每次进来手动重设五个筛选条件体验会非常差。[ { PropertyName: Score, HeaderText: 分数, Operator: GreaterThan, FilterValue: 80, IsActive: true }, { PropertyName: Name, HeaderText: 姓名, Operator: Contains, FilterValue: Zhang, IsActive: true } ]至于性能边界需要说句实话。几万行的数据集Filter 委托每行做几次字符串比较和数值转换毫秒级就能跑完用户没有感知。但数据量到几十万行而且整行包含多个长文本列时反射取值的特性和ToSting()的比较方式会放大开销。我一般会在筛选前判断集合的行数超过 5 万行就改用ListStudent配合List.FindAll()生成一个新集合直接把 DataGrid 的 ItemsSource 切到过滤后的新列表。代价是失去了和 ObservableCollection 的双向联动不过对纯展示场景来说完全可接受。验证筛选结果对错我习惯用一组边界数据测试含 null 值的行、全是空字符串的行、首尾带空格的字符串、小于等于边界值的数字、跨年的日期。每加一个运算符就过一遍最后对比 Excel 对同一份 CSV 数据的筛选结果两边一致才算收工。从那以后我每次做这种列表筛选功能都强制自己先把 FilterInfo 的数据结构、比较逻辑的 null 处理和 Refresh 的调用时机想清楚再动手写模板。这三件事想通了功能完成度直接到八成。希望这篇拆解能帮到你少走两趟翻车的弯路。本文还有配套的精品资源点击获取
返回列表