简介:面向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 变成 false,Filter 委托里跳过它就行。如果设计成删掉条件对象,视图模型里就得维护集合的新增删除,反而啰嗦。
2.3 Filter 委托:多个条件如何合并和逐项执行
在 WPF 里,DataGrid 绑定到数据源后,如果数据源是ObservableCollection<T>,那么它的视图是ICollectionView,可以通过CollectionViewSource.GetDefaultView(数据源)拿到。Filter 属性挂一个委托,这个委托接收一个 object 参数,返回 bool。返回 true 的行保留,false 的行隐藏。本实例的做法是把筛选逻辑写成Predicate<object>,遍历所有启用的 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:Name="dataGrid" AutoGenerateColumns="False" ItemsSource="{Binding Students}" CanUserAddRows="False" SelectionMode="Single"> <DataGrid.Columns> <DataGridTextColumn Header="姓名" Binding="{Binding Name}"> <DataGridTextColumn.HeaderTemplate> <DataTemplate> <StackPanel Orientation="Horizontal" MouseLeftButtonUp="OnHeaderClick"> <TextBlock Text="姓名" /> <TextBlock Text="▼" Foreground="Gray" Margin="6,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.OfType<TextBlock>().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 ObservableCollection<Student> Students { get; set; } private readonly List<FilterInfo> _filters = new List<FilterInfo>(); public MainWindowViewModel() { Students = new ObservableCollection<Student>(); } 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 确实跑完了,但没有任何条件满足“IsActive=true”,于是返回 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 万行就改用List<Student>配合List.FindAll()生成一个新集合,直接把 DataGrid 的 ItemsSource 切到过滤后的新列表。代价是失去了和 ObservableCollection 的双向联动,不过对纯展示场景来说完全可接受。
验证筛选结果对错,我习惯用一组边界数据测试:含 null 值的行、全是空字符串的行、首尾带空格的字符串、小于等于边界值的数字、跨年的日期。每加一个运算符就过一遍,最后对比 Excel 对同一份 CSV 数据的筛选结果,两边一致才算收工。从那以后,我每次做这种列表筛选功能,都强制自己先把 FilterInfo 的数据结构、比较逻辑的 null 处理和 Refresh 的调用时机想清楚,再动手写模板。这三件事想通了,功能完成度直接到八成。希望这篇拆解能帮到你,少走两趟翻车的弯路。
本文还有配套的精品资源,点击获取