简介这是一份面向WPF开发者的DataGrid仿Excel筛选功能实例在.NET6.0与Visual Studio 2022环境下为DataGrid控件注入类似Excel的交互式筛选能力帮助改善桌面应用中大量数据的浏览与管理效率。资源包共76个文件、约349KB以15个C#源码文件为核心配合XAML界面布局、csproj/json/editorconfig等工程配置以及编译生成的dll/exe/pdb等结构清晰便于运行验证和拆解学习。内容覆盖AutoGenerateColumns自定义列、DataGridTextColumn的Filtering事件、ICollectionView视图刷新、列头下拉筛选菜单等关键实现并涉及等于/不等于/包含等筛选条件、多列组合筛选以及筛选状态保存等扩展设计。该实例已有290人学习浏览适合希望掌握WPF数据网格高级交互、提升桌面应用数据管理能力的中级开发者参考。1. WPF DataGrid 做 Excel 式筛选先明确你真正要抄的是哪两层接手内部系统改造时遇到过一个典型场景业务方指着 Excel 说“表头点一下就出筛选公司自己的 WPF 程序为什么做不到”。当时 DataGrid 里躺着五千多行人员数据列头只有排序箭头找不到数据靠 CtrlF体验被用户反复吐槽。把滚动条拉到顶、点开每个列的上下文菜单再选筛选条件那个流程放到数据密集的表格上基本不可用。这个标题背后真正要解决的是两层问题第一层叫数据过滤能力——把满足条件的行从集合里拿出来这是 WPF 里ICollectionView.Filter的老本行第二层是 Excel 风格的表头交互——点列头右侧的下拉箭头弹出可多选的值列表和搜索框。这两层必须分开做Filter 只管数据ColumnHeader 模板和 Popup 只管交互。两者解耦之后数据量大到需要异步筛选时你才有还手余地。适合正在评估手写方案和第三方控件取舍的人也适合照着做第一个能用的原型。全文会从最小可复现的方案讲起再一步步把交互和性能补到能上生产环境的程度。2. 最小跑通方案CollectionViewSource 绑定 Filter 谓词的三段代码2.1 三条路线怎么选不是非黑即白我先给结论如果你的筛选需求只是“下拉选一个值只匹配等于”用现成扩展控件最快如果业务里会出现“同时筛选部门、城市、入职年份、薪资区间并且值列表要实时联动”手写比改第三方控件源码更省事。常见做法有三条路线我对它们的取舍是这样的路线上手速度可定制程度大数据量表现适用场景Extended WPF Toolkit 的 AutoFilter最快拖进来就能用低改筛选逻辑要扒源码中默认同步过滤快速原型、简单枚举筛选CollectionViewSource.Filter 自建列头模板中等模板要自己写高每个交互都能控制高Filter 可替换也可异步复杂业务、Excel 风格多选筛选DataTable.DefaultView.RowFilter中等字符串过滤表达式中但绑定和类型转换坑多较高但 UI 交互仍需自建后台报表、非 DataGrid 场景AutoFilter 不是不能用它的坑在于多列筛选时值列表不会随其他列的筛选结果联动这一点和 Excel 的行为有明显差距。我见过不少项目先引入 AutoFilter最后发现业务要“筛选过的列表里再筛选”时又推倒重写。所以这里直接讲自建路线重点是把数据流跑通交互放下一章。2.2 先绑定数据DataGrid 的 ItemsSource 换成 View很多人写 DataGrid 绑定数据时直接ItemsSource myList这在 Excel 式筛选场景里会给后面留坑。ICollectionView上的 Filter、Sort、Group 才是 WPF 表格筛选的正确入口直接绑集合就相当于把控制权交给了 DataGrid 默认视图。改成绑 View 的代码很简单// MainWindow.xaml.cs 里初始化数据源与视图 public partial class MainWindow : Window { public ListPerson People { get; set; } new(); public ICollectionView PeopleView { get; set; } public MainWindow() { InitializeComponent(); LoadFakeData(); // 构造 5000 行测试数据 PeopleView CollectionViewSource.GetDefaultView(People); DataGrid.ItemsSource PeopleView; // 关键绑定到视图而不是 List } private void LoadFakeData() { // 实际项目中可能是数据库查询结果或 JSON 反序列化 for (int i 0; i 5000; i) { People.Add(new Person { Id i, Name $员工{i}, Dept _depts[i % _depts.Length], City _cities[i % _cities.Length], BaseSalary 5000 (i * 37 % 15000), HireDate DateTime.Today.AddDays(-(i * 13 % 1200)) }); } } }这里唯一需要注意的绑定点是CollectionViewSource.GetDefaultView(People)。同一个 List 在 DataGrid 和 ComboBox 等控件里共享同一个视图所以后面筛选一个视图下拉框里看到的数据也会跟着变。Person 类可以是 POCO属性只要公开可读DataGrid 的 AutoGenerateColumns 会自动生成列。数据量五万行以下这个写法没有明显性能问题超过五万再考虑第四章的异步策略。2.3 用 Filter 谓词把行过滤出来文本、数字、日期分开写ICollectionView.Filter需要的是一个Predicateobject返回 true 表示该行保留false 表示隐藏。它会在每次 Refresh 时对视图里的所有行执行一次所以谓词里避免做反射、字符串解析这类重活。最常见的正确姿势是提前定好过滤条件谓词内只做属性取值和比较// 一个简单的单选筛选示例按部门过滤 private void ApplyDeptFilter(string selectedDept) { PeopleView.Filter item { var person item as Person; if (person null) return false; // selectedDept 为空时不做过滤 if (string.IsNullOrEmpty(selectedDept)) return true; return person.Dept selectedDept; }; }这段代码背后的关键点是Filter 谓词会被反复执行所以item as Person这种类型转换无法避免但可以尽量少做字符串操作。如果要支持数字和日期的区间筛选可以用一个FilterRule对象集中承载条件谓词里走同一套匹配逻辑public bool IsMatch(Person p) { // 支持文本包含、数字区间、日期区间三类规则 return FieldName switch { nameof(Person.City) p.City.Contains(TextValue, StringComparison.OrdinalIgnoreCase), nameof(Person.BaseSalary) p.BaseSalary MinValue p.BaseSalary MaxValue, nameof(Person.HireDate) p.HireDate MinDate p.HireDate MaxDate, _ true }; }首次接触 Filter 的人容易踩一个坑修改了谓词之后界面上没有变化因为 Filter 属性被设置后必须手动触发刷新常用的是PeopleView.Refresh()。如果列表数据本身是 ObservableCollectionFilter 不会自动重新执行记住这句话能省半小时排查时间。3. 把列头改成筛选下拉模板、弹层、多选值列表的完整实现3.1 列头模板把“排序区域”和“漏斗按钮”拆开Excel 筛选的交互核心是列头的小漏斗点它弹出下拉点其他区域仍然是排序。WPF DataGrid 默认的列头模板整体都会触发排序所以必须重写DataGridColumnHeader的 ControlTemplate把内容区域和筛选按钮拆到两个格子。Window.Resources ControlTemplate x:KeyExcelHeaderTemplate TargetTypeDataGridColumnHeader Border Background{TemplateBinding Background} BorderBrush{TemplateBinding BorderBrush} BorderThickness{TemplateBinding BorderThickness} Padding{TemplateBinding Padding} Grid Grid.ColumnDefinitions ColumnDefinition Width* / ColumnDefinition WidthAuto / /Grid.ColumnDefinitions !-- 左侧放列标题点击走原有排序逻辑 -- ContentPresenter Grid.Column0 VerticalAlignmentCenter ContentSource.Content / !-- 右侧漏斗按钮点击弹出筛选面板 -- ToggleButton Grid.Column1 x:NameFilterButton Width18 Height18 Margin2,0,0,0 CursorHand ToolTip筛选 ClickFilterButton_Click Path DataM0,0 L10,0 L6,6 L6,10 L4,8 L4,6 Z Fill#444 / /ToggleButton /Grid /Border /ControlTemplate /Window.Resources这个模板里有两个关键设计一是用 ToggleButton 而不是普通 Button因为筛选面板打开时按钮要保持选中状态方便用户看出哪一列正在筛选二是 Click 事件里必须处理路由事件穿透。DataGridColumnHeader默认在鼠标按下时就开始排序如果漏斗按钮的 Click 不拦截会出现“点漏斗也排序弹层打开后列顺序已经变了”的怪异表现。所以在 Click 里写e.Handled true是必须的。模板要在 DataGrid 上生效可以全局指定也可以针对列指定。用全局 ColumnHeaderStyle 适合所有列都带筛选的情况DataGrid x:NameMainGrid AutoGenerateColumnsTrue ItemsSource{Binding PeopleView} DataGrid.ColumnHeaderStyle Style TargetTypeDataGridColumnHeader Setter PropertyTemplate Value{StaticResource ExcelHeaderTemplate} / /Style /DataGrid.ColumnHeaderStyle /DataGrid给列头模板加上后的第一个直接效果是列头变成两段式布局。左侧点住拖动可以调整列宽右侧漏斗独立点击排序箭头仍显示在 ContentPresenter 附近。这里有个容易被忽略的细节——ContentPresenter的ContentSourceContent必须保留否则列标题文字不会显示。3.2 弹层与值列表一个 Popup 复用到所有列筛选面板需要的是一个临时浮动层WPF 里最合适的是 Popup。一个常见做法是在每个列头模板里都放一个 Popup但这样布局资源消耗大且定位麻烦。更干净的做法是窗体级只放一个 Popup点击漏斗时动态设置它的 PlacementTarget并往里填充当前列的数据。下面这个 XAML 放在窗体根部Popup x:NameFilterPopup StaysOpenFalse AllowsTransparencyTrue PopupAnimationFade PlacementBottom PlacementTarget{Binding ElementNameMainGrid} Border Width240 Padding8 BackgroundWhite BorderBrush#D0D0D0 BorderThickness1 CornerRadius4 StackPanel CheckBox x:NameSelectAllCheckBox Content全选 CheckedSelectAllCheckBox_Changed UncheckedSelectAllCheckBox_Changed / TextBox x:NameSearchTextBox Margin0,6,0,0 Padding4 TextChangedSearchTextBox_TextChanged / ListBox x:NameFilterValueListBox Height180 Margin0,6,0,0 SelectionModeMultiple / StackPanel OrientationHorizontal HorizontalAlignmentRight Margin0,8,0,0 Button Content确定 ClickApplyFilter_Click Padding10,3 / Button Content清空筛选 ClickClearFilter_Click Margin8,0,0,0 Padding10,3 / /StackPanel /StackPanel /Border /Popup代码里做了三件准备全选复选框、搜索文本框、支持多选的 ListBox。这个结构基本复刻了 Excel 的筛选面板。弹层内容跟列走所以点击漏斗时要做的事是把 Popup 的PlacementTarget指向当前漏斗按钮填充值列表然后IsOpen true。private DataGridColumn _currentFilterColumn; private string _currentFilterField; private void FilterButton_Click(object sender, RoutedEventArgs e) { e.Handled true; // 阻止排序 var toggle sender as ToggleButton; var header FindAncestorDataGridColumnHeader(toggle); _currentFilterColumn header.Column; _currentFilterField GetPropertyNameFromHeader(_currentFilterColumn); FilterPopup.PlacementTarget toggle; FilterPopup.IsOpen true; LoadDistinctValuesForCurrentColumn(); }FindAncestor是沿可视树往上找父级的小工具函数网上很多实现这里不展开。从列头到实际绑定属性名的映射建议在列创建时用DataGridBoundColumn.Binding解析private string GetPropertyNameFromHeader(DataGridColumn column) { if (column is DataGridBoundColumn boundColumn boundColumn.Binding is Binding b) { return b.Path.Path; // 比如 Dept、BaseSalary } return string.Empty; }这一步非常关键AutoGenerateColumns 模式下列的 Header 文字和属性名不一定一致如果 Header 是“部门”而绑定路径是 Dept解析错了 Filter 谓词永远匹配不上。3.3 值列表联动刷新像 Excel 那样“越筛越精确”Excel 里最容易被忽略但极影响体验的行为是列 A 筛选“研发部”后再点列 B 的下拉列 B 的值列表只显示研发部对应行里的值。也就是说值列表要基于“其他列已经生效的筛选条件”来计算而不是全量 Distinct。这个联动逻辑是仿 Excel 筛选的灵魂一半的仿品都死在这里。private void LoadDistinctValuesForCurrentColumn() { // 在后台线程取 Distinct避免 UI 卡顿数据量小可直接同步 var otherFilters _activeFilters .Where(f f.Key ! _currentFilterField) .ToList(); var values People .Where(p otherFilters.All(f f.Value.IsMatch(p))) // 其他列过滤 .Select(p GetPropertyValue(p, _currentFilterField)) // 取当前列值 .Where(v v ! null) .Distinct() .OrderBy(v v) .ToList(); FilterValueListBox.ItemsSource values; // 恢复当前列已勾选项 for (int i 0; i FilterValueListBox.Items.Count; i) { FilterValueListBox.SelectionMode SelectionMode.Multiple; FilterValueListBox.SelectedItems.Add(FilterValueListBox.Items[i]); } }注意这里的_activeFilters是当前已经生效的筛选条件字典。取 Distinct 时只排除当前列本身其他列条件全部生效。这带来的现象是先筛部门再开城市列表没选到的城市不会出现。Excel 本身就是这个逻辑。有个并发问题行数超过几万时上面这段全同步跑会让弹层打开前卡一两秒。实际项目中我用的是Task.Run(() 计算 Distinct)拿到结果后通过Dispatcher.BeginInvoke回填 ListBox。后面第四章会详细写异步版本。输入搜索框过滤值列表里的项也走这个套路但不用再碰原始数据集直接对当前ItemsSource那几百个值做内存过滤就行。4. 数据量大时怎么办防抖刷新、并行取 Distinct 与三处关键参数4.1 先看清卡顿来源两次全表扫描CollectionViewSource 的筛选本质是把原始集合过一遍谓词每次 Refresh 都是 O(n)。数据量大后最常见的表现是两个场景卡顿打开筛选弹层时求值列表要遍历一遍全量数据取 Distinct点击确定应用筛选时Refresh 又遍历一遍。两个操作叠加3 万行数据就可能卡到肉眼可见。我在一个五万行的生产项目里测过打开弹层秒级延迟用户第一反应是“程序死了”。另一个隐蔽的坑是Filter 谓词里如果写了person.Dept.Contains(searchText, StringComparison.OrdinalIgnoreCase)这类带大小写转换的操作每次比较都会生成临时字符串5 万行 x 10 次比较GC 压力直接反映到 UI 线程的卡顿上。所以谓词要写得尽量轻枚举字段用比较字符串包含过滤只在用户输入搜索词时才启用数值和日期用区间判断。4.2 防抖 版本号解决异步筛选的乱序覆盖写异步筛选第一个容易翻车的点用户连续改筛选条件第一次 Task.Run 还没跑完第二次条件又提交了结果后提交的 Task 先回来界面显示的是旧条件的结果。解决这个竞态在个人项目里靠猜在生产项目里必须用版本号。做法是每次过滤前给条件加一个自增序号Task 完成时比较序号只有最新序号的结果才允许落盘private int _filterVersion; private void ApplyFiltersAsync() { var version _filterVersion; // 每次新条件版本号 1 var snapshot _activeFilters.ToDictionary( f f.Key, f (ColumnFilter)f.Value.Clone()); Task.Run(() { var matched People .Where(p snapshot.Values.All(rule rule.IsMatch(p))) .ToList(); // 后台完成匹配 return matched; }).ContinueWith(t { // 版本号校验如果不是最新版本丢弃结果 if (version ! _filterVersion) return; if (t.IsFaulted) { /* 记录日志UI 提示 */ return; } var result t.Result; // 把匹配结果交给一个轻量集合视图 _filteredList.Clear(); _filteredList.AddRange(result); // 通知 DataGrid 刷新视图 PeopleView.Refresh(); }, TaskScheduler.FromCurrentSynchronizationContext()); }注意_filterVersion是在 UI 线程自增的后台线程不修改它只读取所以不需要加锁。ContinueWith 用FromCurrentSynchronizationContext把回填动作调度回 UI 线程。_filteredList建议用ListT加AddRange不要用ObservableCollection一行行 Add——那样每一次 Add 都会触发 CollectionChangedDataGrid 会按“插入一行”的方式刷新性能远差于一次 Refresh。真正要绑定到 DataGrid 的还是PeopleView但它的Source指向_filteredList这个新集合。这样原始People保持不变只是换了视图源。如果坚持用 ICollectionView.Filter 而不换聚合源也有替代方案后台线程算出命中行的索引集合然后 UI 线程只把 Filter 谓词替换成“查索引集合 O(1)”。这个方案不破坏 DataGrid 的编辑状态但实现更绕适合 DataGrid 里大量行处于编辑态的复杂业务。4.3 DataGrid 的三处参数虚拟化与延迟加载DataGrid 本身对大数据量是能扛的问题出在默认参数对筛选场景并不友好。两三万行不至于卡但一旦你和 Filter 组合就得关注以下三处参数参数默认值筛选场景建议说明EnableRowVirtualizationTrue保持 True行虚拟化保证滚动条顺滑EnableColumnVirtualizationTrue保持 True列多时启用注意与冻结列冲突ScrollUnitPixel大数据量可改 Item按项滚动时虚拟化命中率更高但滚动体验略跳ItemsPanelVirtualizingStackPanel不换换成 StackPanel 会让虚拟化失效这也是常见的“优化后更卡”根源还有一个建议如果弹层打开时要拉取 Distinct不要在 UI 线程做也不要和 Filter 刷新串行。我一般会开一个ManualResetEventSlim控制弹层打开时只做一次后台加载缓存结果到字典里——同一个列第二次打开直接读缓存只有数据源整体变化时才清缓存。因为这个细节用户连续切换不同列的筛选下拉时弹层打开速度会稳定在几十毫秒而不是每次重查两万行。5. 避坑仿 Excel 筛选最容易翻车的 6 个现场与排查顺序5.1 漏斗按钮点了没反应列头还莫名其妙排序了现象模板替换后点击漏斗排序箭头变动弹层不出现或一闪而过。原因ToggleButton.Click事件沿路由冒泡到DataGridColumnHeader的排序逻辑同时 Popup 因为StaysOpenFalse在鼠标移出弹层的瞬间自动关闭两者叠加看起来像“没反应”。另一个常见诱因是模板里的ToggleButton没有设置FocusableFalse点击后焦点切换导致 Popup 关闭。解决Click 里必须e.Handled truePopup 打开后延迟一点点时间设置焦点比如FilterPopup.IsOpen true; SearchTextBox.Focus();放在 Dispatcher.BeginInvoke 里。排查顺序是先看 Click 是否触发再看 Popup 的 IsOpen 状态是否被立即置回 false。5.2 Popup 定位错乱多列快速切换时弹到屏幕外现象点击第一列漏斗正常快速点击第五列漏斗弹层显示在错误位置或直接超出窗口边界。原因PlacementTarget在按钮被重绘或列头滚动后指向了一个已失效的可视元素。尤其是 DataGrid 横向滚动后旧按钮已经被虚拟化回收Popup 定位仍引用旧坐标。解决每次打开弹层前重新赋值PlacementTarget为当前按钮并调用FilterPopup.InvalidateMeasure()刷新布局如果窗口跑到了多显示器边界给 Popup 加一个MaxHeight和VerticalOffset限制让内容始终在屏内。5.3 值列表显示的是一堆 System.DataRowView现象数据源来自 DataTable 时Distinct 取出来的类型是DataRowView下拉框里每行都长这样没法看。原因GetPropertyValue对DataRowView反射拿不到属性直接 ToString 出了类型全名。DataTable 场景和 POCO 场景的值提取逻辑完全不同。解决判断item is DataRowView drv时用drv.Row[field]POCO 场景才走反射或表达式树。两者统一收敛到一个ValueExtractor类里用接口隔离Filter 谓词和 Distinct 计算都走同一套提取器避免两处逻辑不一致导致“筛选结果跟列表显示对不上”。5.4 分组视图下筛选出来空组不消失现象DataGrid 开了GroupStyle和ICollectionView.GroupDescriptions应用筛选后不符合条件的组只剩组头下面没有任何行组头占着位置。原因ListCollectionView的筛选和分组同时生效时空组不会自动移除这是 WPF 视图的老行为。Excel 的分组筛选里空组是隐藏的所以用户会认为程序有 bug。解决筛选应用之后重设 GroupDescriptions——先把集合Clear()再把PropertyGroupDescription重新加回来强制视图重新计算分组。如果组很多且不想丢滚动位置可以记录当前滚动偏移重设分组后ScrollIntoView恢复。5.5 正在编辑单元格时应用筛选刚输入的值丢了现象用户正在某个单元格里打字还没按回车旁边人点了另一列的筛选按钮应用后单元格内容变回旧值输入全丢。原因DataGrid 的编辑状态是绑定在行上的Refresh()时行被重新加载未提交的编辑内容随之丢失。解决在打开筛选弹层或应用筛选之前调用强制提交private void BeforeFilterApply() { MainGrid.CommitEdit(DataGridEditingUnit.Row, true); }这个调用会把当前编辑行的所有变更写回数据源Filter 再执行时拿到的就是最新值。如果数据源不允许脏数据入库也要至少CancelEdit()后再刷新避免界面和内存中的值不一致造成的“筛选结果对不上”。5.6 列头模板在隐藏列和“一行两行显示”场景下失配现象项目里为了适配窄屏做过DataGrid列头换行或者RowDetails展开两行显示套上自定义列头模板后漏斗按钮显示不全或者列头高度被撑高到异常。原因ControlTemplate 里Grid的尺寸没有约束列头自动换行时Height被内容撑开漏斗按钮在 Grid 第二列被挤到看不见。隐藏列的场合Visibility为 Collapsed 的列其模板仍然参与布局导致列头整体宽度计算异常。解决模板里给 Grid 加Height26之类的固定值或者用MaxHeight约束隐藏列时在数据绑定层直接用ColumnVisibility绑定来控制不要依赖模板自身的 Visible 状态。这个坑排查起来最花时间因为视觉上看起来像模板写错了实际上是对接的列状态机制冲突。6. 进阶把筛选状态做成可持久化的规则用单测守住过滤器6.1 筛选规则从界面代码中抽出来一个纯函数类很多仿 Excel 筛选做到能用就停了但切换到别的窗口再切回来筛选条件全部清空用户得重新设置。Excel 本身在保存工作簿后重开筛选箭头和条件都还在。这里给你的 DataGrid 筛选也做同样的事把筛选条件定义为FilterRule列表序列化成 JSON 存到本地配置。public class FilterRule { public string Field { get; set; } // 属性名如 Dept public string Op { get; set; } // eq / contains / range public Liststring Values { get; set; } // 多选值 public double? Min { get; set; } // 数值/日期区间下限 public double? Max { get; set; } // 数值/日期区间上限 }窗口关闭时把_activeFilters序列化重新打开时反序列化并依次应用。这里有一个细节要强调JSON 里存属性名和操作符不是为了省事而是为了让筛选逻辑可以被单元测试直接调用不用启动 WPF 窗口。筛选逻辑写得再漂亮如果必须打开程序手动验证回归成本迟早会让你放弃维护。6.2 用单测验证规则组合一小时内守住回归把谓词逻辑放进静态方法后测试可以完全不依赖 UI。下面是一个 xUnit 测试验证“部门研发部 工资区间 8000~12000”的组合[Fact] public void DeptFilter_And_SalaryRange_ShouldReturnExpectedRows() { var people new ListPerson { new Person { Dept 研发部, BaseSalary 9000 }, new Person { Dept 研发部, BaseSalary 15000 }, new Person { Dept 市场部, BaseSalary 9000 } }; var rules new ListFilterRule { new FilterRule { Field Dept, Op eq, Values new Liststring { 研发部 } }, new FilterRule { Field BaseSalary, Op range, Min 8000, Max 12000 } }; var result people.Where(p rules.All(r r.IsMatch(p))).ToList(); Assert.Single(result); Assert.Equal(研发部, result[0].Dept); }这个测试的价值在于以后谁动了筛选逻辑运行一遍测试就能知道哪些组合被改坏了。我每次接到带筛选功能的改动都会先把现有规则写成类似用例再动实现。筛选这种功能表面简单实际边界组合多靠手点根本点不全。最后说一个我的习惯DataGrid 仿 Excel 筛选永远先做数据链路再做交互皮肤最后才处理样式细节。顺序反了往往会在调试套路问题上浪费大量时间。希望帮到你。本文还有配套的精品资源点击获取