WPF DataGrid仿Excel列头筛选:基于ICollectionView的完整实现 简介面向 WPF 桌面应用开发者的 DataGrid 仿 Excel 筛选完整实例基于 Visual Studio 2022 与 .NET 6.0 实现解决表格数据量大时检索效率低、交互不直观的问题适合中高级 .NET 开发者用于项目功能改造与技术储备。资源包共 76 个文件RAR 压缩后约 349KB源码以 C#15 个 .cs、XAML 界面和 JSON 配置等为主项目文件与辅助配置共存包含了解决方案、数据模型、筛选逻辑、视图模型及可直接运行的编译输出目录层次清楚便于定位与对照检查。已有 290 人学习下载。通过阅读和运行该项目开发者可以掌握利用 DataGridTextColumn 的 Filtering 事件、ICollectionView.Refresh 方法以及自定义列头下拉菜单模板实现筛选交互的完整流程同时可理解关闭 AutoGenerateColumns 以定制列行为、在列头设置下拉按钮入口、组合多列筛选条件等设计要点并可将基础等于、包含、大于等条件扩展为保存与恢复筛选状态的功能。该示例对需要处理大量数据、追求类似 Excel 操作体验的 WPF 应用很有借鉴价值。1. 仿 Excel 的按列筛选给 WPF DataGrid 补上最后一公里桌面端做数据管理最常被业务追问的一个需求就是表格能不能像 Excel 那样点一下列头下拉箭头按等于、包含、大于这种条件把数据筛出来这个实例项目就是给 WPF 的 DataGrid 补上这一层能力。工程里包含完整的 Student 数据模型、FilterInfo 筛选条件类和 MainWindowViewModel不依赖任何第三方控件直接用 .NET 6.0 和 Visual Studio 2022 就能跑通“点击列头 → 选择算子 → 输入值 → 视图立刻刷新”的完整链路。适合正在做后台管理、订单查询、数据报表这类 WPF 应用的开发者也适合想从 WinForm 迁到 WPF、又不想引入重型控件的团队参考。2. 为什么是 DataGrid 加 ICollectionView选型和原理要先立住2.1 三条路子为什么最后选了改原生 DataGrid很多团队看到“DataGrid 加筛选”的第一反应是换控件。三条路都走过的人会告诉你各有各的坑用 ListView 自绘排序的 Comparer、列宽拖拽、行虚拟化全部要自己从零写筛选没做完光基础设施就够喝一壶上第三方 DataGrid功能确实全筛选菜单、条件格式都有但授权成本和程序集体积对内部工具类项目不太友好光是说服领导批预算就够呛剩下的就是原生 DataGrid 改列头模板这条路——排序、列宽调整、行虚拟化、剪贴板导出都是控件自带的缺的只是“列头下拉箭头 过滤逻辑”这一层。方案排序/列宽/虚拟化筛选开发量成本ListView 自绘全部手写大无授权耗时长第三方 DataGrid内置小有授权费体积大原生 DataGrid 改列头模板内置中等无授权可控所以这个实例工程的做法是最务实的不引入额外依赖把改动收敛在 DataGrid 的 ColumnHeaderStyle 模板和 ViewModel 里。对大多数业务系统来说与其纠结控件选型不如把原生控件的扩展点吃透。2.2 ICollectionView 才是筛选真正的入口WPF 里 DataGrid 显示的数据表面上是绑了一个集合实际绑的是集合的“视图”——ICollectionView。排序、分组、过滤这些操作全属于视图层的职责原始集合本身不会被动一刀。这也是“仿 Excel 筛选”能成立的基础筛选不是把不满足条件的行删掉而是让视图暂时不显示它们。拿到视图的标准写法是这样var view CollectionViewSource.GetDefaultView(vm.Students); view.Filter o Match(o as Student); view.Refresh();逻辑说明Filter委托返回true保留行返回false隐藏行。Refresh()让视图按最新的Filter重新遍历一遍数据源并刷新界面。注意顺序先拿视图、再挂 Filter、最后 Refresh三步缺一不可。参数说明GetDefaultView对同一个集合多次调用返回的是同一个实例所以重复赋值Filter不会叠加出多个视图。另外Filter委托的执行频率是“每次 Refresh 时对集合的每个元素各调用一次”数据量一大这里就是性能关键点——别在 Match 里做数据库查询或磁盘 IO。2.3 为什么不能用 Remove 去“筛选”筛选功能最常见的误用是把不匹配的行直接 Remove 出 ObservableCollection。界面效果确实出来了但一刷新数据就全没了因为被删掉的元素没有索引想恢复只能重新加载整个集合这就成了没有后悔药的操作。而且 ObservableCollection 的增删会触发 DataGrid 的 CollectionChanged 动画一次筛掉几千行的场景界面会肉眼可见地卡一下。筛选的语义应该是“展示口径的临时变化”而不是“数据的物理删除”。数据源保持完整用户才能随时改条件、恢复全量、导出原始数据。这个设计原则决定了后面所有代码的走向。3. 核心实现筛选条件类与匹配函数这个实例工程把筛选逻辑收敛在两个文件里FilterInfo.cs描述“一条筛选条件”MainWindowViewModel.cs负责把多条条件组合起来、驱动视图刷新。最关键的是匹配逻辑被做成一个 Match 函数而不是散落在各个按钮事件里这是整个功能能撑起多列组合筛选的骨架。3.1 FilterInfo一条条件不止是“等于”和“包含”筛选条件最少要表达三件事对哪个属性筛、用什么算子、筛的值是多少。示例里的FilterInfo大致是这个形态public enum FilterOperator { Equal, NotEqual, Contains, NotContains, GreaterThan, LessThan, GreaterThanOrEqual, LessThanOrEqual } public class FilterInfo { public string PropertyName { get; set; } public FilterOperator Operator { get; set; } public string Value { get; set; } }逻辑说明PropertyName对应的是 Student 的属性名比如 Name、Score、EnrollDate而不是列头文本。列头是展示层的称呼可以随便改成“姓名”“总分”但筛选逻辑只认属性路径。Value统一用 string 存因为界面上输入框拿到的天然是字符串具体比较时再按属性类型转换。参数说明Operator用枚举而不是字符串好处是下拉框可以直接绑定枚举列表避免拼错条件还查不出来。Value允许为空空值代表“该列不过滤”这个约定在 Match 函数里会用到。3.2 Match 函数一次遍历完成全部判断匹配是筛选的心脏工程里的实现大概是这样public bool Match(object item) { if (item null || string.IsNullOrWhiteSpace(Value)) return true; var prop item.GetType().GetProperty(PropertyName); if (prop null) return true; object raw prop.GetValue(item); switch (Operator) { case FilterOperator.Equal: return SafeEquals(raw, Value); case FilterOperator.Contains: return raw?.ToString()?.IndexOf(Value, StringComparison.OrdinalIgnoreCase) 0; case FilterOperator.GreaterThan: return SafeCompare(raw, Value) 0; case FilterOperator.LessThan: return SafeCompare(raw, Value) 0; // NotEqual、NotContains 同理取反不再重复 default: return true; } } private bool SafeEquals(object raw, string value) { if (raw null) return false; if (raw is double d double.TryParse(value, out var dv)) return d dv; if (raw is DateTime dt DateTime.TryParse(value, out var tv)) return dt.Date tv.Date; return string.Equals(raw.ToString(), value, StringComparison.OrdinalIgnoreCase); } private int SafeCompare(object raw, string value) { if (raw null) return -1; if (raw is double d double.TryParse(value, out var dv)) return d.CompareTo(dv); if (raw is int i int.TryParse(value, out var iv)) return i.CompareTo(iv); if (raw is DateTime dt DateTime.TryParse(value, out var tv)) return dt.CompareTo(tv); return string.Compare(raw.ToString(), value, StringComparison.OrdinalIgnoreCase); }逻辑说明IndexOf 判断“包含”时用了OrdinalIgnoreCase忽略大小写这样搜“zhang”能匹配“Zhang”符合业务直觉。SafeEquals 和 SafeCompare 先对数字、日期类型做强类型转换再比较避免“20”和“9”按字符串比较时“9”反而更大的问题——这是筛选最容易翻车的地方后面避坑章会单独说。参数说明PropertyName传错时Match 返回 true 是刻意为之。运行期属性名写错直接抛异常会让用户一筛选就白屏不如当作“这条规则没有生效”静默忽略。调试期想抓这类错误临时在prop null分支里抛InvalidOperationException就行。3.3 ViewModel多列条件用 AND 组合单条条件能工作之后多列同时筛选才是产品级需求。MainWindowViewModel 里维护了ObservableCollectionFilterInfo并暴露一个ICollectionView给界面绑定public ICollectionView View { get; set; } public bool Pass(Student s) { foreach (var f in Filters) { if (!f.Match(s)) return false; } return true; } public void ApplyFilters() { View.Filter o Pass(o as Student); View.Refresh(); }逻辑说明Pass 遍历所有条件任何一个不满足就返回 false这是 AND 语义也就是多列筛选同时生效。如果想支持“任一条件命中就算”的 OR 场景把 Pass 改成先收集每个条件的 Match 结果、最后做一次合并即可。但我不建议在同一版里混用 AND 和 OR——那需要给 FilterInfo 加分组字段复杂度立刻上来示例工程没做是有道理的。参数说明View属性直接作为 DataGrid 的 ItemsSource。注意这里没有把ObservableCollectionStudent给 DataGrid而是把视图给了它。筛选之后业务集合 Students 仍然完整排序、导出都不会受影响。4. 完整落地从建项目到跑通筛选说完了原理这章把工程完整走一遍。用 Visual Studio 2022 创建 WPF 应用程序目标框架选 .NET 6.0。工程结构按 MVVM 走Student.cs 放数据模型FilterInfo.cs 放筛选条件MainWindowViewModel.cs 放视图模型MainWindow.xaml 放列头模板和 DataGrid。这种组织方式清爽筛选逻辑集中在 VM 里XAML 只负责呈现。4.1 数据模型和视图模型先接线Student 模型故意设计了四种常见属性类型字符串、数字、日期、布尔覆盖业务里绝大多数筛选场景public class Student { public string Name { get; set; } public double Score { get; set; } public DateTime EnrollDate { get; set; } public bool IsActive { get; set; } }逻辑说明属性类型越杂越能倒逼出 SafeEquals 和 SafeCompare 里的类型转换逻辑。Score 用 double 是因为成绩可能有 88.5 这种小数IsActive 是布尔筛选菜单里通常映射成“是/否”这个映射可以放在 FilterInfo 里另加字段不影响主体逻辑。ViewModel 里初始化测试数据并挂上筛选条件监听public class MainWindowViewModel { public ObservableCollectionStudent Students { get; } new(); public ICollectionView View { get; } public ObservableCollectionFilterInfo Filters { get; } new(); public MainWindowViewModel() { for (int i 0; i 200; i) { Students.Add(new Student { Name $学生{i}, Score 60 i % 40, EnrollDate DateTime.Today.AddDays(-i * 7), IsActive i % 3 ! 0 }); } View CollectionViewSource.GetDefaultView(Students); Filters.CollectionChanged (s, e) ApplyFilters(); } }逻辑说明构造里先在 Students 塞了 200 条测试数据再拿默认视图。Filters.CollectionChanged挂在 ViewModel 上新增、移除、修改筛选条件都会自动触发 ApplyFilters省去“改完还要手动点刷新”的麻烦。参数说明View必须在这里通过 GetDefaultView 拿不能在 XAML 里直接绑 Students 再让绑定系统自己生成视图——否则你会得到两个独立的视图实例一个挂在界面上一个挂在 Filter 上筛选永远不生效第 5 章会细讲这个坑。4.2 XAMLDataGrid 骨架与列头模板MainWindow.xaml 里先写 DataGrid 骨架DataGrid ItemsSource{Binding View} AutoGenerateColumnsFalse CanUserAddRowsFalse IsReadOnlyTrue HeadersVisibilityColumn DataGrid.Columns DataGridTextColumn Header姓名 Width1.2* Binding{Binding Name} / DataGridTextColumn Header成绩 Width* Binding{Binding Score} / DataGridTextColumn Header入学日期 Width1.5* Binding{Binding EnrollDate, StringFormatyyyy-MM-dd} / /DataGrid.Columns /DataGrid逻辑说明ItemsSource直接绑 VM 的View绕开了“数据源和视图不一致”的坑。IsReadOnly设 True 是因为这是个查询列表场景真要编辑就去掉这一行不影响筛选。Binding 里的StringFormat只影响显示格式不影响 SafeEquals 按 DateTime 比较的逻辑。参数说明列宽用比例单位*是为了窗口拉伸时列能跟随变宽。Width*和Width1.5*这类写法在 DataGridTextColumn 里是合法且常用的。列头模板是筛选入口的重头戏。DataGridColumnHeader 默认只显示一个标题文本我们要换成“标题 下拉箭头 弹层”的组合。这个模板会套到每一列上所以弹层里的数据要绑定到当前列头而不是整个窗口的 DataContextDataGrid.ColumnHeaderStyle Style TargetTypeDataGridColumnHeader Setter PropertyTemplate Setter.Value ControlTemplate TargetTypeDataGridColumnHeader Grid BackgroundTransparent Grid.ColumnDefinitions ColumnDefinition Width* / ColumnDefinition WidthAuto / /Grid.ColumnDefinitions TextBlock Grid.Column0 Text{Binding Column.Header} VerticalAlignmentCenter Margin6,0 / ToggleButton x:NameFilterToggle Grid.Column1 Width18 Height18 VerticalAlignmentCenter ToolTip筛选 Content☰ / Popup IsOpen{Binding IsChecked, ElementNameFilterToggle} PlacementTarget{Binding ElementNameFilterToggle} StaysOpenFalse Border BackgroundWhite BorderBrush#888 BorderThickness1 Padding8 StackPanel TextBlock Text筛选条件 FontWeightBold / ComboBox x:NameFilterOp IsEditableFalse / TextBox x:NameFilterValue Width140 Margin0,4 / Button Content应用 Tag{Binding} ClickFilterApply_Click Margin0,4,0,0 / /StackPanel /Border /Popup /Grid /ControlTemplate /Setter.Value /Setter /Style /DataGrid.ColumnHeaderStyle逻辑说明模板里TextBlock的绑定是{Binding Column.Header}因为此时 DataContext 是 DataGridColumnHeader 本身它的Column属性就是当前列。应用按钮的Tag绑到当前 DataContext也就是列头对象code-behind 里靠它取属性路径。说句实话模板里的 DataContext 理解和普通控件完全不同90% 的绑定问题都出在这层作用域上。参数说明Popup 用StaysOpenFalse保证点击外部自动关闭避免用户漏关面板。IsOpen通过 ElementName 绑定到 ToggleButton 的IsChecked点一下开、再点一下关和 Excel 列头下拉的交互习惯一致。筛选图标没有引入 FontAwesome直接用文本符号“☰”少一个依赖样式也不突兀。4.3 code-behind 只写取参逻辑不写业务筛选弹层既然在列头模板里参数就要从模板里取。把“取列头、找控件、构造条件”这一步放在 code-behind 是务实做法硬套 RelayCommand 反而要把 UI 模板细节泄漏给 ViewModel得不偿失private void FilterApply_Click(object sender, RoutedEventArgs e) { var btn sender as Button; if (btn?.Tag is DataGridColumnHeader header header.Column is DataGridBoundColumn col btn.Parent is StackPanel panel) { var combo panel.Children.OfTypeComboBox().FirstOrDefault(); var text panel.Children.OfTypeTextBox().FirstOrDefault(); var op (combo?.SelectedItem as FilterOperator?) ?? FilterOperator.Equal; var vm DataContext as MainWindowViewModel; vm.Filters.Clear(); var filter new FilterInfo { PropertyName col.Binding.Path.Path, Operator op, Value text?.Text.Trim() }; vm.Filters.Add(filter); } }逻辑说明先用Tag拿到按钮对应的列头对象再从Column.Binding.Path.Path取绑定属性名作为 PropertyName。弹层里的 ComboBox 通过遍历panel.Children找到而不是用x:Name引用——因为多个列实例的控件名会冲突用名字容易取到错的那一个。参数说明当前实现里vm.Filters.Clear()再 Add 是单列筛选语义。想支持多列同时筛选应改成“按属性路径查重、有则替换无则新增”避坑章会给出具体改法。4.4 跑通一遍的最小操作启动窗口后点“成绩”列头的箭头弹层里选“大于”输入 80点应用。DataGrid 立刻只显示 80 分以上的记录。此时看 VM 的 Students集合里还是 200 条——这就是视图过滤和数据源分离的效果。再点一次箭头把条件改回“等于”输入 90列表变成满分记录。这一遍跑通说明绑定链路、条件类、匹配函数三块都正常后面就是产品化打磨和踩坑的环节了。5. 避坑DataGrid 筛选最常见的五个翻车点筛选功能看着不复杂真做一遍会把下面这些坑全踩一遍。每一条按现象、原因、解决三个角度写代码上知道该防哪里。5.1 AutoGenerateColumns 忘记关列重复渲染现象DataGrid 里明明只写了三列“姓名、成绩、日期”运行出来却有六列新列和旧列数据重复。原因AutoGenerateColumns的默认值是 TrueDataGrid 根据 ItemsSource 的属性又自动生成了一遍列和手工定义的列叠加了。这个坑在新手项目里几乎必现因为模板默认就是 True。解决在 DataGrid 标签上显式写AutoGenerateColumnsFalse。如果手写列的目的就是给每列配不同的 Header、宽度、可见性这一行必须出现。关掉自动生成后原来手工定义的所有列属性才会真正生效。5.2 两个视图实例筛选永远不生效现象Filter 属性设了Refresh 也调了界面一行没变。调试时 Filter 委托确实在执行可界面数据纹丝不动堪称筛选功能里的玄学问题。原因XAML 里ItemsSource绑定的是 Students 集合代码里又通过 GetDefaultView(Students) 拿到另一个视图并挂 Filter。绑定系统在 ItemsSource 赋值时也会生成视图两个视图是独立实例你挂 Filter 的哪个根本不在界面上。解决统一入口只留一个视图。要么 XAML 里 ItemsSource 直接绑View属性要么后台把 view 赋给 DataGrid.ItemsSource不要绑集合。我自己的习惯是永远把 ICollectionView 暴露成View属性所有对视图的操作都走它绑定也只绑它从根上消灭第二实例。从那以后我再没犯过这个错。5.3 数字和日期被当字符串比较筛选结果错乱现象筛“成绩大于 80”正常筛“入学日期晚于 2025-01-01”时明明有对应记录却筛不出来。排序也乱9 月排到了 1 月前面。原因Match 里如果直接对raw.ToString()和 Value 做 string.Compare日期和数字全部按字典序比较。“2025-09-01”在字符串意义上小于“2025-01-15”所以怎么筛都错。同理“80”和“100”里“100”反而比“80”小。解决必须走 3.2 节的SafeCompare先 TryParse 成 double、DateTime 类型再比较转换失败才退回字符串。这里没有捷径我每次写筛选都会提醒自己字符串比较只适合姓名、编号这类纯文本列数值和日期必须专人专办。5.4 多列条件互相覆盖只剩最后一列生效现象先筛了“姓名包含张”再筛“成绩大于 80”第一列的条件没了只剩成绩条件。原因4.3 里Clear()之后再 Add 单条件把整个筛选列表清空了重建。单列筛选是功能多列筛选才是产品而这个实现卡在了“单列”阶段。解决把 Clear 改成按列更新var existing vm.Filters.FirstOrDefault(f f.PropertyName filter.PropertyName); if (existing ! null) { existing.Operator filter.Operator; existing.Value filter.Value; } else { vm.Filters.Add(filter); }逻辑说明先按PropertyName找已有条件找到就只更新算子和值没找到才新增。因为FilterInfo需要实现 INotifyPropertyChanged否则替换 Operator 和 Value 后界面不会自动刷新这一点在改的时候要一并处理。5.5 列头模板里绑定不到 ViewModel下拉框一片空白现象Popup 里的 ComboBox 想在 ViewModel 里拿枚举列表写{Binding DataContext.OperatorOptions}却一直是空界面不报错下拉灰的。原因DataGridColumnHeader 模板的 DataContext 是列头对象不是窗口的 DataContext。你在模板里写DataContext.OperatorOptions解析的是列头的 DataContext.OperatorOptions自然是 null。这是列头模板最容易误解的地方。解决两种做法。一种是 RelativeSource 向上找窗口级 DataContextComboBox ItemsSource{Binding DataContext.OperatorOptions, RelativeSource{RelativeSource AncestorTypeWindow}} /另一种是我偏好的模板内联定义枚举选项不让绑定跨容器层级。列头模板不是普通控件模板它的 DataContext 链路厘不清后面所有绑定问题都会连锁出现。拿张纸把绑定链路画出来再写代码比遇到空白再排查省时间。6. 进阶把筛选功能做成能交付的产品筛选稳定之后还差三件事状态持久化、大数据量的性能保护、与隐藏列的联动。逐个拆开讲。筛选状态保存与恢复。常见做法是把 Filters 序列化成 JSON存到用户目录启动时读回来自动恢复public void SaveFilters(string path) { var json JsonSerializer.Serialize(Filters.Select(f new { f.PropertyName, f.Operator, f.Value }).ToList()); File.WriteAllText(path, json); } public void LoadFilters(string path) { if (!File.Exists(path)) return; var list JsonSerializer.DeserializeListFilterInfo(File.ReadAllText(path)); Filters.Clear(); foreach (var f in list) Filters.Add(f); }逻辑说明序列化只存“属性名、算子、值”三个要素属性名不能存列头文本原因前面反复说过——列头是展示属性才是逻辑。Load 恢复后Filters.CollectionChanged自动触发 ApplyFilters不需要手动刷新。路径一般放Environment.SpecialFolder.ApplicationData下按公司名加应用名建目录避免权限问题。数据量大之后的性能问题。几千行的桌面应用Filter 委托每次遍历做反射取值几乎无感。但到十万行以上每敲一个字符就 Refresh 一次界面会有明显的卡顿。两个手段一是防抖TextBox 输入变化后延迟 300ms 再 ApplyFilters用 DispatcherTimer 实现二是把反射换成按属性类型的取值器缓存常见做法是Dictionarystring, Funcobject, object构造时反射取一次 PropertyInfo之后全部走委托不重复 GetProperty。示例工程数据量是教学级项目真要上线这一步不能省。筛选与隐藏列的联动。DataGrid 列的 Visibility 设为 Collapsed 之后筛选条件还管不管它我的建议是隐藏列不参与筛选。用户把一列勾掉再筛选条件面板里还留着一列容易造成“为什么筛选没生效”的误解。给 FilterInfo 加一个 IsVisible 标记或者筛选面板只绑定可见列半小时能改完体验差距很明显。说到底仿 Excel 筛选这件事代码只是表象真正值钱的是对“视图层过滤”和“数据源完整”的理解。从那以后凡是带列头筛选的表格我每次都强制走三件事先拿 View 再绑 ItemsSource、Filter 委托里写全条件、SafeCompare 做类型安全比较。筛得对比筛得快重要状态能存、能还才算个能交付的功能。希望帮到你。本文还有配套的精品资源点击获取