☰
WPF DataGrid仿Excel筛选:MVVM+ICollectionView实现多条件模糊匹配与性能优化
2026/10/8 8:03:17 网站建设 项目流程

简介:面向WPF桌面应用开发者,演示在Visual Studio 2022与.Net6.0环境中为DataGrid控件实现仿Excel列筛选的完整工程。资源是一份可直接运行的WPF示例程序,包含XAML界面、C#逻辑、筛选信息实体类及项目配置文件,采用自定义模板实现列头下拉筛选菜单,并借助ICollectionView动态刷新结果。压缩包共76个文件,以cs源码、xaml界面、json配置及sln工程文件为主,附带缓存、资源文件与可执行程序,整体仅349KB,轻量易学。已有292人浏览学习,适合初中级WPF开发者参考交互式表格筛选思路,重点可拆解多条件筛选、视图刷新与列模板定制等关键代码片段。

1. 仿Excel筛选,WPF DataGrid到底在仿什么

做过WPF桌面应用的人,多半被DataGrid的筛选需求缠过:用户打开一个几千行的业务表,想按某个字段快速找数据,第一反应是“能不能像Excel那样,点一下列头就弹筛选框”。这个需求听起来简单,但真做起来,涉及数据源刷新、UI交互、命令绑定和性能控制,远比加一个TextBox再Filter一下复杂。本文要讲的,就是一套我在WPF+DataGrid上落地的仿Excel筛选方案,包含表头点击弹菜单、关键字模糊匹配、多条件组合、以及筛选状态持久化,目标是让你照着一路写下来,能跑出一个可用、够稳、不至于被业务骂“卡死”的筛选面板。

这套方案适合谁?你如果正在维护一个内部管理系统,或者刚接触WPF MVVM,想在DataGrid里加上“像Excel一样”的筛选,但不想引入重型第三方控件,那么下面的做法就是为你准备的。我不打算堆控件库,只用WPF自带的DataGrid、Popup、ICollectionView和一点附加属性,把功能老老实实拆开讲清楚。

2. 设计筛选功能的三层结构:数据、视图与命令怎么分工

在写任何一行XAML之前,得先想明白一件事:仿Excel筛选,到底由谁来“筛”。这直接决定代码是烂成一坨,还是后续能加条件、能维护。

2.1 为什么用ICollectionView做筛选而不是直接改List

WPF里最常见的错误做法,是把数据源List直接替换成筛选后的新List。比如在TextBox的TextChanged事件里,重新给DataGrid.ItemsSource赋一个过滤后的集合。这种写法能跑,但副作用很多:DataGrid的选中项会丢失,滚动位置会跳回顶部,排序状态也会被重置,而且每次敲一个字符就重建整个集合,数据量大时界面肉眼可见地卡。

正确做法是用ICollectionView包一层。DataGrid本身支持视图筛选,你只要拿到默认的CollectionView,设置Filter属性,UI就会自动响应刷新,而不用动原始数据集合。这样排序、选中、滚动,都在视图层处理,DataGrid的状态不会因为筛选而全部归零。

// 假设 Products 是 ObservableCollection<Product> public ICollectionView ProductView { get; set; } public MainViewModel() { ProductView = CollectionViewSource.GetDefaultView(Products); ProductView.Filter = OnProductFilter; } private bool OnProductFilter(object item) { if (!(item is Product p)) return false; // 具体筛选逻辑:根据当前条件判断 p 是否可见 return IsMatch(p); }

逻辑说明:GetDefaultView拿到的是与集合绑定的默认视图,DataGrid的ItemsSource直接绑ProductView即可。Filter是一个Predicate<object>委托,返回true表示该项目通过筛选。注意这里不能直接对Products做Remove操作,否则会改动原始数据,Excel的筛选是不删除行的,我们也要保持这个语义。

参数说明:Filter委托的入参是object,所以第一行要做类型判断。如果集合里混入了不同层的对象,还有可能在运行时抛InvalidCastException,尽量在构造时就保证集合元素类型一致。这样设计的另一个好处是,筛选条件一变,只需调用ProductView.Refresh(),而不是重新赋值ItemsSource。

2.2 MVVM下筛选条件放哪:ViewModel里维护一个“筛选项”对象

既然走MVVM,筛选条件就不该散落在窗体的各个控件里。我在实践里会定义一个FilterItem类,字段包括属性名、匹配文本、是否启用,再在ViewModel里维护一个ObservableCollection<FilterItem>,每个字段一条。这样“给哪个字段加筛选”“筛什么值”就变成数据,可以被命令、序列化、甚至被多窗口共享。

public class FilterItem { public string PropertyName { get; set; } public string Keyword { get; set; } public bool IsActive { get; set; } }

然后ViewModel里的筛选主方法,会遍历所有启用的FilterItem,逐条判断当前数据行是否满足。这是一个“与”关系:多列同时有筛选条件时,必须全部命中才显示。这一步就保证了多条件筛选的组合行为。

2.3 给DataGrid列头挂上“点击事件”:命令绑定与附加属性

要把“点击列头”变成一个可绑定的命令,常见做法是用DataGridColumnHeader的Style加上InputBindings,或者写一个附加属性把MouseLeftButtonDown转成ICommand。我一般用附加属性,因为可以顺便把“当前点击的是哪一列”传进命令参数,省得在View里写事件处理代码。

public static class DataGridHeaderBehavior { public static readonly DependencyProperty CommandProperty = DependencyProperty.RegisterAttached( "Command", typeof(ICommand), typeof(DataGridHeaderBehavior), new PropertyMetadata(null, OnCommandChanged)); public static void SetCommand(DependencyObject obj, ICommand value) => obj.SetValue(CommandProperty, value); public static ICommand GetCommand(DependencyObject obj) => (ICommand)obj.GetValue(CommandProperty); private static void OnCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is DataGridColumnHeader header) { header.MouseLeftButtonDown -= OnHeaderClick; if (e.NewValue is ICommand cmd) { header.MouseLeftButtonDown += OnHeaderClick; } } } private static void OnHeaderClick(object sender, MouseButtonEventArgs e) { if (sender is DataGridColumnHeader header && header.CommandParameter is string columnName && header.Command is ICommand cmd && cmd.CanExecute(columnName)) { cmd.Execute(columnName); } } }

逻辑说明:这段代码做了一个附加属性Command,挂在DataGridColumnHeader上。当用户点下列头,就执行ViewModel里对应字段的筛选命令。其中的CommandParameter必须传入列名(即数据对象的属性名),ViewModel拿到这个列名才知道要对哪个字段做筛选。

参数说明:DataGridColumnHeader在DataGrid中默认就是一个可以被点击的区域,但它没有现成的Click事件,所以必须走MouseLeftButtonDown。注意这个事件在DataGrid排序时也会触发,如果你的列同时启用了SortMemberPath,点击列头会先排序再弹筛选框,体验上有点冲突。处理办法是弹筛选框前判断一下当前列是否还有排序功能,或者干脆在启用筛选的列上禁用排序,后面避坑章我会细讲。

2.4 为什么用Popup而不是自定义窗口做筛选面板

刚开始做这个功能时,我习惯用Window来做筛选面板。但Window有个问题:它是顶级窗口,焦点切换时会闪任务栏,而且关闭逻辑要单独处理。后来改成Popup,把筛选面板嵌在列头下方,完美贴合Excel的交互,不需要额外窗体,也不用管ShowDialog的模态问题。

<Popup x:Name="FilterPopup" PlacementTarget="{Binding ElementName=DataGrid}" Placement="Bottom" StaysOpen="False" AllowsTransparency="True" IsOpen="{Binding IsFilterOpen}"> <Border BorderBrush="#888" BorderThickness="1" Background="White" Padding="8"> <StackPanel Width="220"> <TextBlock Text="模糊搜索" FontSize="12" Foreground="#666"/> <TextBox x:Name="FilterTextBox" Margin="0,4,0,8" Padding="4"/> <Button Content="确定" Padding="8,4" Command="{Binding DataContext.ApplyFilterCommand}" CommandParameter="{Binding Text, ElementName=FilterTextBox}"/> </StackPanel> </Border> </Popup>

逻辑说明:Popup的IsOpen绑定到ViewModel的IsFilterOpen属性,点下列头时置true,确定或点击外部时置false。PlacementTarget指向DataGrid本身,Placement="Bottom"让面板出现在表格下方,但真正需要对齐的是点击的那一列,这需要在打开时动态计算偏移,下一章详细说。

参数说明:StaysOpen="False"很关键,它允许用户点击Popup外部区域时自动关闭,符合Excel筛选菜单的交互习惯。AllowsTransparency="True"是让Popup可以带圆角和阴影的前提,但也会让Popup失去hwnd,导致输入法在某些系统上有点问题,建议在中文输入环境下测试一遍再决定是否开启。

3. 表头点击弹菜单:从弹出位置到筛选面板的布局

筛选面板的布局不需要复杂,一个关键字输入框、一个确定按钮、一个清除按钮就够了。真正要琢磨的是面板怎么出现在正确的列下方——这是仿Excel体验里最容易被忽略的细节。

3.1 计算列头相对DataGrid的位置,动态定位Popup

Popup的Placement默认是按PlacementTarget的底部对齐的。如果你直接拿整个DataGrid当PlacementTarget,筛选面板会固定在表格正下方,而不是你点击的那一列下面。要做到“点哪列就弹哪列下面”,得算一下列头的坐标。

private void OpenFilterPopup(DataGridColumn column, DataGridColumnHeader header) { var grid = DataGrid; Point point = header.TransformToAncestor(grid).Transform(new Point(0, header.ActualHeight)); FilterPopup.PlacementTarget = grid; FilterPopup.Placement = PlacementMode.Relative; FilterPopup.HorizontalOffset = point.X; FilterPopup.VerticalOffset = point.Y; FilterPopup.IsOpen = true; }

逻辑说明:TransformToAncestor把列头左上角相对于DataGrid的坐标算出来,再把这个坐标填进HorizontalOffset和VerticalOffset。这样无论列头在表格的哪个位置,面板都能紧贴列头底部弹出。注意这里用的是PlacementMode.Relative,它表示偏移量是相对于PlacementTarget的坐标,配合算出来的point正好。

参数说明:Transform(new Point(0, header.ActualHeight))的ActualHeight是指列头底部有一个像素的间隙,可以让面板和列头之间不出现“空中断裂”的感觉。如果DataGrid有横向滚动,偏移量会跟着滚动自动更新,TransformToAncestor已经包含了滚动矩阵的变化,不需要额外处理。

3.2 筛选面板上放什么:关键字输入、确定、清除三个控件的布局细节

面板高度不宜太高,Excel的筛选菜单也就是一列文本加搜索框。我会在Popup里放三件东西:一个带水印的输入框、一个“确定”按钮、一个“清除筛选”按钮。这里有个体验细节:输入框默认获得焦点,用户打开菜单就可以直接打字,不用再点一下输入框。

<StackPanel Width="220"> <TextBlock Text="搜索当前列" FontSize="12" Foreground="#666"/> <TextBox x:Name="FilterKeywordBox" Margin="0,4,0,8" Padding="4" VerticalContentAlignment="Center"/> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right"> <Button Content="清除" Padding="8,4" Margin="0,0,8,0" Command="{Binding DataContext.ClearFilterCommand}" CommandParameter="{Binding DataContext.CurrentColumnName, RelativeSource={RelativeSource AncestorType=Window}}"/> <Button Content="确定" Padding="12,4" Command="{Binding DataContext.ApplyFilterCommand}" CommandParameter="{Binding Text, ElementName=FilterKeywordBox}"/> </StackPanel> </StackPanel>

逻辑说明:这几个控件都在Popup内,DataContext默认不会自动继承外层Window的DataContext,所以按钮绑定时我用RelativeSource AncestorType=Window把Context指回窗口的DataContext,否则命令永远找不到ViewModel。

参数说明:FilterKeywordBox没有做输入事件绑定,而是等用户点“确定”才把文本传给命令。这有一个好处:不会在用户输入过程中频繁触发刷新,避免数据量大时卡顿。想做成实时筛选也可以,在TextChanged里每次调用ApplyFilterCommand,但要做好防抖,否则还是会卡。

3.3 让筛选面板的DataContext能拿到当前列名:附加属性配合命令参数

Popup里要拿到“当前点击的是哪一列”,不能靠DataContext,因为Popup的DataContext可能是空的。我的做法是给Popup挂一个附加属性CurrentColumnName,在点击列头时往这个属性里写值,这样按钮命令的参数就能取到它。

public static class PopupBehavior { public static readonly DependencyProperty CurrentColumnNameProperty = DependencyProperty.RegisterAttached( "CurrentColumnName", typeof(string), typeof(PopupBehavior), new PropertyMetadata(null)); public static void SetCurrentColumnName(DependencyObject obj, string value) => obj.SetValue(CurrentColumnNameProperty, value); public static string GetCurrentColumnName(DependencyObject obj) => (string)obj.GetValue(CurrentColumnNameProperty); }

逻辑说明:附加属性的值在OpenFilterPopup时直接设置,PopupBehavior.SetCurrentColumnName(FilterPopup, columnName)。然后Popup里的“清除”按钮通过CommandParameter把这个值传给ViewModel。这样即便面板里的输入框没有事件代码,也能把“针对哪一列进行操作”这个信息传出去。

参数说明:附加属性本质上就是一个依赖属性,存的值可以被子元素在绑定中取到。但注意它不会自动传进Popu的元素作用域,所以按钮绑定时还是得用RelativeSource或者ElementName指到Popup本身,不能只写{Binding CurrentColumnName}。

4. 多条件筛选与“模糊匹配”的实现:从关键字到可见行

面板能弹出来了,接下来的核心就是筛选逻辑。这一章讲的是“多列同时筛选时,条件之间怎么组合”,以及“模糊匹配里那些容易让人翻车的细节”。

4.1 单列筛选和全局筛选的切换逻辑

仿Excel的特点是:每个列可以独立筛选,列头会出现一个漏斗图标。而全局筛选则是所有列共用一个关键字,比如“在所有列里搜张三”。一个项目里往往两者都要,我建议做成两个开关:默认是单列筛选,如果一个列都没设条件,就自动退化为全局筛选。

private bool IsMatch(Product p) { // 如果当前列没有筛选条件,则走全局搜索 if (string.IsNullOrWhiteSpace(CurrentColumnName)) { return string.IsNullOrWhiteSpace(GlobalKeyword) || p.Name.Contains(GlobalKeyword) || p.Category.Contains(GlobalKeyword); } // 单列筛选:按当前列名匹配 var filterItem = FilterItems.FirstOrDefault(f => f.PropertyName == CurrentColumnName); if (filterItem == null || !filterItem.IsActive) return true; var prop = p.GetType().GetProperty(filterItem.PropertyName); if (prop == null) return true; var value = prop.GetValue(p)?.ToString() ?? string.Empty; return value.IndexOf(filterItem.Keyword, StringComparison.OrdinalIgnoreCase) >= 0; }

逻辑说明:这段代码是筛选入口。先判断当前是否有指定列,没有就按全局搜索处理;有就走单列筛选。如果FilterItems里还没有当前列的任何条件,就直接返回true,表示这一列不拦截任何行。这是一种“白名单”逻辑:只有你明确设置了条件的那一列才启用拦截,其他列保持放行。

参数说明:GetProperty用的是反射,在数据量大时会有性能损耗。如果你的业务表超过一万行,建议在ViewModel里预先缓存每个类型的PropertyInfo字典,别每次筛选都重新反射。这里我为了演示简洁没有做缓存,但实际项目一定要加,不然筛选一次就要几十毫秒。

4.2 多条件组合:用“与”关系聚合并列条件

当用户在第一列搜索“华东”,第二列搜索“2024”,筛选逻辑要把这两个条件同时满足的行才显示。实现上就是遍历所有启用的FilterItem,任何一个不满足就返回false。

private bool IsMatchAll(object item) { if (!(item is Product p)) return false; foreach (var filter in FilterItems.Where(f => f.IsActive)) { var prop = p.GetType().GetProperty(filter.PropertyName); if (prop == null) continue; var text = prop.GetValue(p)?.ToString() ?? string.Empty; if (!text.Contains(filter.Keyword, StringComparison.OrdinalIgnoreCase)) return false; } return true; }

逻辑说明:这个方法的语义是“全满足才可见”。比如一个条件查“华东”,另一个条件查“2024”,那么一条“华北 2023”的记录会被拦截,因为第一个条件不满足。很多人在Excel里用“自定义筛选”也是这个默认逻辑。

参数说明:注意continue那句,如果遇到某个列在类里找不到对应属性,就跳过。这么做比直接返回true更安全,因为未来加字段时,旧筛选条件不会导致整个表格白屏。但如果你希望“列不存在就当作不匹配,行不显示”,可以改成return false,这个看业务预期。

4.3 模糊匹配里的大小写、空格和空值问题

模糊匹配最容易踩的三个坑:大小写、字符串首尾空格、值为null或DBNull。Excel的筛选默认忽略大小写,而C#的Contains是大小写敏感的,直接套用会导致“ABC”搜不出“abc”。空格问题也常见,用户从Excel粘贴来的关键字后面带一个看不见的\t,你会搜不到任何结果。

private static bool FuzzyMatch(string source, string keyword) { if (string.IsNullOrWhiteSpace(source)) return false; if (string.IsNullOrWhiteSpace(keyword)) return true; source = source.Trim(); keyword = keyword.Trim(); // 支持 SQL 风格的 % 通配符:把 % 转成正则 if (keyword.Contains("%") || keyword.Contains("_")) { var pattern = "^" + Regex.Escape(keyword) .Replace("%", ".*") .Replace("_", ".") + "$"; return Regex.IsMatch(source, pattern, RegexOptions.IgnoreCase); } return source.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0; }

逻辑说明:先用Trim()把首尾空格清掉,再用OrdinalIgnoreCase做忽略大小写匹配。如果关键字里带了%或_,就按SQL风格的模糊匹配处理——%代表任意多个字符,_代表任意单个字符。这是从业务那边传过来的需求,他们习惯用SQL语法筛选,这里直接翻译成正则表达式。

参数说明:RegexOptions.IgnoreCase能解决大小写问题,但性能比IndexOf(string, StringComparison)慢不少。如果你只做普通模糊匹配,就用IndexOf;只有确实需要%通配符时才走正则分支。另一个细节是Regex.Escape必须先调用,否则关键字里如果带了[、.这类正则元字符,会被当成语法解析。

4.4 手动刷新视图的时机:什么时候需要调用Refresh

Filter委托设置好之后,还要知道什么时候刷新。ICollectionView不会自动感知你的筛选条件变化,必须在条件更新后主动调用Refresh()。很多人漏了这一步,写完Filter怎么都不生效,就是因为它没刷新。

public void ApplyFilter(string columnName, string keyword) { var filterItem = FilterItems.FirstOrDefault(f => f.PropertyName == columnName); if (filterItem == null) { filterItem = new FilterItem { PropertyName = columnName }; FilterItems.Add(filterItem); } filterItem.Keyword = keyword; filterItem.IsActive = !string.IsNullOrWhiteSpace(keyword); ProductView.Refresh(); }

逻辑说明:这个方法是命令的最终出口。它把当前列名和关键字更新到对应的FilterItem里,然后通知视图重新跑一遍Filter。注意IsActive的设置——关键字为空时不启用条件,否则用户清空输入框后,筛选会把所有行拦截掉。

参数说明:ProductView.Refresh()是同步操作,大数据量时UI线程会被阻塞。如果你有两万行以上的数据,建议把Refresh放到Dispatcher.BeginInvoke里,或者用后台线程先收集匹配索引再一次性刷新UI,但后者复杂度会明显上升,先评估业务数据量再决定要不要做。

5. 仿Excel筛选功能的5个常见翻车点与排查

功能写完了,不等于能用。这里整理筛选功能上线后最容易出现的5类事故,都是我在实际项目里踩过的。每一条按现象、原因、解决方法展开,值得贴在手边当排查清单。

5.1 点击列头没反应,命令根本没被执行

现象:鼠标点列头,Popup没弹出来,后台也没有任何调用日志。一开始怀疑是命令绑定问题,但怎么改都没效果。

原因:DataGridColumnHeader的MouseLeftButtonDown事件被DataGrid内部的排序逻辑处理了,附加事件处理器没有执行——这是因为DataGrid会在这个事件上调用e.Handled=true?其实不是,真正的原因是DataGridColumnHeader的默认控件模板里,ButtonBase只响应Click事件,而我把事件挂在了MouseLeftButtonDown,这个事件被内部用于开始拖拽列头调整宽度的逻辑吞掉了。

解决:改用DataGridColumnHeader的Click事件或者用PreviewMouseLeftButtonDown(隧道事件)来接收点击。实际项目中我两个都用过,PreviewMouseLeftButtonDown更可靠,因为它发生在冒泡阶段之前,即使列头里有一个Button把它吃掉,也能先触发到我们的附加行为。把附加属性里的header.MouseLeftButtonDown改成header.PreviewMouseLeftButtonDown即可。

5.2 筛选后选中行还在,但DataGrid滚动位置跳到底部

现象:用户筛选出行后,明明选了其中一条,滚动条却自动跑到了最底部,有时候还会闪现一条空白行。

原因:CollectionView.Refresh()会重置视图的当前项,而DataGrid的SelectedItem绑定被重置后,如果没有设置IsSynchronizedWithCurrentItem,它会尝试保持原选中项但失配,内部就会把“当前项”移到集合末尾,导致滚动条跟着动。

解决:在XAML上给DataGrid加IsSynchronizedWithCurrentItem="False",并在筛选后手动重新选中需要保留的行。如果想要更稳,可以在筛选前记录SelectedItem的ID,筛选完再按ID重新定位行。这是Excel不会做的“后悔药”:筛选动作不会把用户选择清空,但WPF会,所以必须自己保。

5.3 数据量大时,每敲一个字符卡顿半秒

现象:一万行数据、十个字段,用户每在筛选框里输入一个字符,界面就卡一下。输入快了甚至直接无响应。

原因:每次TextChanged都直接调Refresh(),而Filter委托里用了反射拿属性值,一万行乘以十个字段就是十万次反射调用,UI线程被占满。

解决:三种手段配合。第一,在TextChanged里加上DispatcherTimer做300毫秒防抖;第二,把GetProperty缓存为PropertyInfo字典;第三,如果数据超过两万行,不要用ICollectionView.Filter,改成在数据源层维护一个索引集合,只刷新命中项。这套组合拳下来,我的项目里三万行数据的筛选延迟从500毫秒降到了80毫秒左右。

5.4 筛选面板在多显示器环境下弹到屏幕外

现象:用户把窗口拖到副屏,点列头筛选,面板没有跟随到副屏,而是弹回主屏角落,甚至部分内容在屏幕外看不见。

原因:Popup默认的Placement行为是基于HwndSource的主窗口句柄计算的。当主窗口跨显示器时,TransformToAncestor计算出的坐标是以窗口为坐标原点,但Popup使用的是屏幕坐标,两者在多显示器缩放下对不上。

解决:不追复杂方案,最简单的是在OpenFilterPopup里算完坐标后,再判断一下popupWidth + point.X + window.Left是否超过SystemParameters.WorkArea.Right,超过就水平方向少偏移一截。更彻底的办法是把筛选面板从Popup换成自定义Window,设置WindowStartupLocation="Manual"并手动指定Left/Top,这能完全避免Popup的坐标错位,代价是失去内嵌视觉效果。

5.5 筛选图标记不住:列头漏斗图标刷新不及时

现象:给列头做了漏斗图标,筛选条件清空后,图标没有恢复成默认状态,列头看起来还是“已筛选”状态。

原因:漏斗图标是通过DataGridColumnHeader的Style触发器控制的,触发器绑定的是FilterItems集合里的某个条件状态。但ObservableCollection<FilterItem>在IsActive属性变化时,没有给UI发通知——FilterItem类没有实现INotifyPropertyChanged。

解决:给FilterItem实现INotifyPropertyChanged,在IsActive和Keyword的setter里调用OnPropertyChanged。同时,列头图标的绑定路径要写全,比如Binding FilterItems/IsActive,并且加上Mode=OneWay。这一步最容易被人忽略,因为逻辑上筛选是生效的,只是图标状态没刷新,属于“黑匣子”式的诡异问题。

6. 进阶:筛选状态持久化与批量导入筛选条件

最后这一章不是收尾,是一个能直接提升体验的进阶技巧:把当前筛选条件保存到配置文件里,下次启动程序时自动恢复。Excel能做到“打开文件保留筛选”,WPF默认做不到,需要自己写。

6.1 用JSON序列化FilterItems集合,存到用户目录

筛选条件本质上就是一组FilterItem对象。把它序列化成JSON,写到Environment.SpecialFolder.ApplicationData对应的目录下,是最省事也最稳定的持久化方案。不需要数据库,不需要注册表。

public void SaveFilters() { string dir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MyApp", "Filters"); Directory.CreateDirectory(dir); var json = JsonConvert.SerializeObject(FilterItems.ToList(), Formatting.Indented); File.WriteAllText(Path.Combine(dir, "datagrid_filters.json"), json); } public void LoadFilters() { string dir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MyApp", "Filters"); string path = Path.Combine(dir, "datagrid_filters.json"); if (!File.Exists(path)) return; var list = JsonConvert.DeserializeObject<List<FilterItem>>(File.ReadAllText(path)); FilterItems.Clear(); foreach (var item in list) { FilterItems.Add(item); } ProductView.Refresh(); }

逻辑说明:SaveFilters在窗口关闭时调用,或者在用户点“保存筛选方案”的按钮里调用。LoadFilters在窗口初始化时先执行,只后Refresh()让已加载的筛选条件作用到表格上。注意这里没有判断“字段是否还存在”,如果业务表结构变了,加载到未知列名就会在筛选时报反射错误,需要在LoadFilters里加一次字段名校验。

参数说明:存储路径用ApplicationData而不是InstallDir,原因是安装目录可能没有写权限,而且用户目录天然是隔离的。如果同一个程序要支持多套筛选方案,就把datagrid_filters.json换成带方案名的文件名,比如scheme_1.json。

6.2 一键清除所有筛选条件,并恢复完整数据行

这个功能在测试时几乎天天用到:所有列的筛选条件都清了,表格回到全量显示。我一般在工具栏放一个“重置筛选”按钮,它同时做四件事:清空FilterItems集合、重置全局关键字、调用Refresh()、再清空所有列头的漏斗图标状态。

public void ResetAllFilters() { FilterItems.Clear(); GlobalKeyword = string.Empty; CurrentColumnName = string.Empty; ProductView.Refresh(); // 通知列头图标状态更新 OnPropertyChanged(nameof(FilterItems)); }

逻辑说明:Refresh()是核心,前面三步只是把状态归零。最后一行OnPropertyChanged(nameof(FilterItems))是为了让列头的漏斗图标触发器重新评估,否则图标会残留旧状态。

6.3 给筛选命令加上“批量导入”入口:从Excel复制多列关键字直接筛选

业务经常在Excel里排好一批关键字,希望一次性筛出来。这个场景不罕见,但实现起来要注意格式解析。我做的批量导入是:用户复制Excel里的两列(第一列是字段名列,第二列是关键字),程序自动解析\t分隔的文本,逐行生成FilterItem并执行筛选。这一步带来的效率提升比任何单个优化都大。

public void ImportFiltersFromClipboard(string csvText) { var lines = csvText.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries); var newFilters = new List<FilterItem>(); foreach (var line in lines) { var parts = line.Split('\t'); if (parts.Length != 2) continue; var colName = parts[0].Trim(); var keyword = parts[1].Trim(); if (string.IsNullOrWhiteSpace(colName) || string.IsNullOrWhiteSpace(keyword)) continue; newFilters.Add(new FilterItem { PropertyName = colName, Keyword = keyword, IsActive = true }); } FilterItems.Clear(); FilterItems.AddRange(newFilters); ProductView.Refresh(); }

逻辑说明:键盘上的Ctrl+C复制到剪贴板的表格内容,列之间是\t,行之间是换行符。按这个规则解析,不会丢数据,也不会被Excel的单元格引号干扰。如果某一行没填列名或关键字,直接跳过,不让错误数据污染筛选条件。

参数说明:这段代码假设用户复制的第一列是字段名,第二列是关键字。如果业务里是反过来,调整下标即可。另外FilterItem如果后续要实现“或”逻辑(同列多个关键字之间是或关系),得在IsMatch里对这个字段单独做多值判断,现在的实现是同列只有一个关键字。这个边界我建议先和业务确认,不然做完“与”逻辑才发现他们要“或”,返工成本不小。

最后说一个我的习惯:每次交付这类筛选功能,我都会留一个“测试用例集”,专门覆盖空值、大小写、两列同时筛选、一万行数据防抖,这四关全过,才敢把功能交给业务验收。筛选这种看似简单的功能,恰恰是WPF项目里最容易埋雷的部分,一次卡顿就能让用户对整个系统失去耐心。希望这篇笔记能帮你少踩几个我已经踩过的坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询