简介:本资源是一个基于WPF与.NET 6.0实现DataGrid仿Excel筛选功能的完整可运行实例,面向WPF桌面应用开发者及中高级C#学习者,解决数据表格交互中缺乏原生多条件筛选能力的痛点,适用于需高效处理结构化数据的管理类、报表类桌面软件开发场景。压缩包共76个文件,含15个核心C#逻辑文件(如Student.cs、FilterInfo.cs、MainWindowViewModel.cs)、2个XAML界面文件、1个SLN解决方案及配套CSProj工程配置,辅以DLL依赖、JSON配置与编译产物(PDB、EXE等),整体仅349KB,轻量易集成。已有290人学习下载,资源结构清晰,包含从UI层(MainWindow.xaml)到数据层(ObservableCollection绑定)、筛选逻辑(Filtering事件+ICollectionView.Refresh)再到菜单样式定制的全链路代码,开箱即用,便于理解WPF数据绑定机制与自定义筛选交互设计。
1. 这不是加个下拉框就完事的“Excel筛选”——WPF DataGrid 真实筛选链路必须打通三层视图隔离
很多开发者在 WPF 中第一次尝试给 DataGrid 加 Excel 风格筛选时,会直接在列头放个 Button,点开弹个 ComboBox,选完条件就ItemsSource = filteredList。结果很快发现:排序失效、分组丢失、滚动位置重置、多列筛选逻辑错乱,甚至 ObservableCollection 的CollectionChanged事件被反复触发导致 UI 卡顿。根本原因在于——WPF 的 DataGrid 不是直接渲染数据源,而是通过 ICollectionView 做中间抽象层。.NET 6.0 虽优化了 DataGrid 渲染性能,但并未改变这一核心契约。真正的仿 Excel 筛选,必须在ICollectionView层拦截并重写Filter委托,同时确保SortDescriptions和GroupDescriptions与筛选逻辑共存;否则你做的只是“假筛选”,用户拖动滚动条或点击列头排序时,UI 就会瞬间打回原形。本实例基于 .NET 6.0 + VS2022 构建,不依赖第三方控件库,全程使用原生 WPF 数据绑定机制,重点解决FilterInfo.cs如何动态构建表达式树、MainWindowViewModel.cs怎样维护多列 AND 关系筛选状态、以及DataGridTextColumn的CanUserSort与Filtering事件如何协同避免事件冲突——这才是企业级数据桌面应用中可落地、可维护、可扩展的筛选实现路径。
2. 从 ICollectionView.Filter 入手:为什么必须绕过 ItemsSource 直接操作视图
2.1 理解 WPF 数据绑定的三层结构:数据源 → 视图 → 控件
WPF 的数据绑定并非“数据源直连 UI”,而是一个明确分层模型:
- 数据源层:
ObservableCollection<Student>或DataTable,负责存储原始数据和通知变更; - 视图层(ICollectionView):由
CollectionViewSource.GetDefaultView()创建,默认提供排序、分组、筛选能力,且同一数据源可存在多个独立视图; - 控件层(DataGrid):仅绑定到某个
ICollectionView实例,所有交互(排序、滚动、编辑)均作用于该视图,而非原始集合。
提示:若直接修改
ItemsSource(如dataGrid.ItemsSource = new ObservableCollection<Student>(filtered)),等于切断了 DataGrid 与原始视图的连接,导致ICollectionView.Refresh()失效、SortDescriptions清空、CurrentItem丢失。这是新手最常踩的深坑。
2.2 实现 Filter 委托:用 ExpressionTree 动态构建类型安全的筛选逻辑
ICollectionView.Filter是一个Predicate<object>委托,接收每一行数据(object类型),返回true表示保留。硬编码if (item is Student s && s.Name.Contains("张"))无法支持运行时列名和条件变化。本实例采用ExpressionTree动态编译,关键代码如下:
// FilterInfo.cs 中的核心方法 public static Func<object, bool> BuildFilterExpression<T>(string propertyName, string condition, object value) { var param = Expression.Parameter(typeof(object), "item"); var castExpr = Expression.Convert(param, typeof(T)); var propertyExpr = Expression.Property(castExpr, propertyName); // 根据 condition 类型生成不同表达式:Contains / Equals / StartsWith / GreaterThan 等 Expression body; switch (condition) { case "Contains": var containsMethod = typeof(string).GetMethod("Contains", new[] { typeof(string) }); body = Expression.Call(propertyExpr, containsMethod, Expression.Constant(value?.ToString())); break; case "Equals": body = Expression.Equal(propertyExpr, Expression.Constant(value)); break; case "GreaterThan": body = Expression.GreaterThan(propertyExpr, Expression.Constant(value)); break; default: body = Expression.Constant(true); // 默认不过滤 break; } var lambda = Expression.Lambda<Func<object, bool>>(body, param); return lambda.Compile(); }逻辑说明:
BuildFilterExpression<Student>("Name", "Contains", "张")返回一个可执行委托,该委托在ICollectionView.Filter中被调用时,自动将object安全转换为Student,再反射获取Name属性值,最后调用string.Contains()。参数说明:propertyName必须与Student.cs中属性名完全一致(区分大小写);condition是预定义枚举字符串,避免拼写错误;value支持string/int/DateTime等基础类型,Expression.Constant()会自动处理装箱。
2.3 在 ViewModel 中聚合多列筛选:AND 逻辑的线性组合与状态持久化
单列筛选容易,但真实业务场景要求“姓名包含张 AND 年龄大于25 AND 状态=在职”。ICollectionView.Filter只接受一个Predicate<object>,因此需将多个条件合并为一个闭包。MainWindowViewModel.cs中的关键设计:
private readonly ObservableCollection<FilterInfo> _activeFilters = new(); private ICollectionView _collectionView; public void ApplyFilters() { if (_collectionView == null) return; // 构建复合 Filter 委托:所有 activeFilters 的条件 AND 组合 _collectionView.Filter = item => { foreach (var filter in _activeFilters) { var filterFunc = filter.BuildFilterDelegate(item.GetType()); if (!filterFunc(item)) return false; // 任一条件不满足即排除 } return true; }; _collectionView.Refresh(); // 强制刷新视图,触发 UI 重绘 } // 添加筛选条件(由列头按钮触发) public void AddFilter(string propertyName, string condition, object value) { var existing = _activeFilters.FirstOrDefault(f => f.PropertyName == propertyName); if (existing != null) _activeFilters.Remove(existing); _activeFilters.Add(new FilterInfo { PropertyName = propertyName, Condition = condition, Value = value }); ApplyFilters(); } // 清除某列筛选 public void ClearFilter(string propertyName) { _activeFilters.RemoveAll(f => f.PropertyName == propertyName); ApplyFilters(); }参数说明:
_activeFilters是ObservableCollection<FilterInfo>,支持 UI 绑定显示当前激活的筛选项(如“姓名: 包含 张”);ApplyFilters()每次重建Filter委托,确保逻辑最新;_collectionView.Refresh()是唯一正确触发 UI 更新的方式,不可省略。注意:此设计天然支持“清除单列”和“全部清除”,且状态保存在 ViewModel 内存中,可轻松扩展为 JSON 序列化到本地文件。
2.4 防止筛选与排序冲突:禁用 AutoGenerateColumns 后的手动列配置
AutoGenerateColumns="True"会自动生成列并绑定SortMemberPath,但一旦手动添加Filtering事件,排序点击会触发两次Sorting事件(一次来自 DataGrid 内部,一次来自自定义逻辑),导致ICollectionView.SortDescriptions被错误覆盖。解决方案是彻底禁用自动生成,并显式配置每列:
<!-- MainWindow.xaml --> <DataGrid x:Name="dataGrid" ItemsSource="{Binding View}" AutoGenerateColumns="False" CanUserSort="True" SelectionMode="Single"> <DataGrid.Columns> <DataGridTextColumn Header="姓名" Binding="{Binding Name}" SortMemberPath="Name" Width="120"/> <DataGridTextColumn Header="年龄" Binding="{Binding Age}" SortMemberPath="Age" Width="80"/> <DataGridTextColumn Header="入职日期" Binding="{Binding JoinDate, StringFormat='yyyy-MM-dd'}" SortMemberPath="JoinDate" Width="120"/> <!-- 注意:此处不设置 Filtering 事件!事件绑定在后台代码中 --> </DataGrid.Columns> </DataGrid>逻辑说明:
SortMemberPath显式指定排序依据属性,确保ICollectionView.SortDescriptions正确更新;CanUserSort="True"保持列头排序箭头可用;Filtering事件不在 XAML 中声明,而是在MainWindow.xaml.cs的Loaded事件中为每列动态附加,避免 XAML 解析时DataGridTextColumn实例未完全初始化导致的 NullReferenceException。
3. 列头筛选菜单的实现:用 ControlTemplate 替换 DataGridColumnHeader
3.1 为什么不能只加个 Button?——DataGridColumnHeader 的模板限制
DataGridColumnHeader默认模板仅包含文本和排序箭头,其ContentTemplate不支持直接嵌入Popup或复杂布局。若强行在Header属性中塞入StackPanel,会导致列宽计算异常、鼠标悬停样式丢失、排序箭头错位。正确做法是重写整个ControlTemplate,注入筛选触发器。
3.2 自定义 ColumnHeaderTemplate:内联 Popup + 筛选条件面板
在MainWindow.xaml的<Window.Resources>中定义:
<Style x:Key="FilterableColumnHeaderStyle" TargetType="{x:Type DataGridColumnHeader}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type DataGridColumnHeader}"> <Grid Background="{TemplateBinding Background}"> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="Auto"/> </Grid.ColumnDefinitions> <!-- 列标题文本 --> <ContentPresenter Grid.Column="0" VerticalAlignment="Center" Margin="5,0,0,0"/> <!-- 筛选下拉按钮 --> <ToggleButton x:Name="FilterButton" Grid.Column="1" Width="20" Height="20" Margin="0,0,5,0" Content="▼" IsChecked="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Tag, Mode=TwoWay}"/> <!-- 筛选弹出层 --> <Popup x:Name="FilterPopup" Placement="Bottom" PlacementTarget="{Binding ElementName=FilterButton}" StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade"> <Border Background="White" BorderBrush="#CCCCCC" BorderThickness="1" CornerRadius="3" Padding="5"> <StackPanel MinWidth="180"> <TextBlock Text="筛选条件" FontWeight="Bold" Margin="0,0,0,5"/> <ComboBox x:Name="ConditionCombo" ItemsSource="{Binding DataContext.FilterConditions, RelativeSource={RelativeSource AncestorType=Window}}" SelectedItem="{Binding DataContext.SelectedCondition, RelativeSource={RelativeSource AncestorType=Window}, Mode=TwoWay}" Margin="0,0,0,5"/> <TextBox x:Name="ValueBox" Text="{Binding DataContext.FilterValue, RelativeSource={RelativeSource AncestorType=Window}, Mode=TwoWay}" Margin="0,0,0,5"/> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right"> <Button Content="确定" Click="OnFilterApplyClick" Margin="0,0,5,0" Width="60"/> <Button Content="取消" Click="OnFilterCancelClick" Width="60"/> </StackPanel> </StackPanel> </Border> </Popup> </Grid> <ControlTemplate.Triggers> <Trigger Property="IsMouseOver" Value="True"> <Setter Property="Background" Value="#F0F0F0"/> </Trigger> <Trigger SourceName="FilterButton" Property="IsChecked" Value="True"> <Setter TargetName="FilterPopup" Property="IsOpen" Value="True"/> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter> </Style>逻辑说明:
ToggleButton作为筛选开关,IsChecked绑定到DataGridColumnHeader.Tag(用于临时存储列名);Popup使用StaysOpen="False"确保点击外部自动关闭;ComboBox和TextBox的DataContext通过RelativeSource回溯到Window,从而绑定MainWindowViewModel中的筛选配置;ControlTemplate.Triggers实现悬停变色和按钮联动弹窗。
3.3 在后台代码中关联列与筛选逻辑:动态注入列名和事件
MainWindow.xaml.cs中,在dataGrid_Loaded事件里遍历列并设置HeaderStyle和Tag:
private void DataGrid_Loaded(object sender, RoutedEventArgs e) { foreach (var column in dataGrid.Columns) { // 为每列设置自定义 Header 模板 column.HeaderStyle = (Style)FindResource("FilterableColumnHeaderStyle"); // 将列绑定的属性名存入 Tag,供 Popup 内按钮使用 if (column is DataGridBoundColumn boundColumn && boundColumn.Binding is Binding binding) { column.HeaderStyle.Setters.Add(new Setter(DataGridColumnHeader.TagProperty, binding.Path.Path)); } } } private void OnFilterApplyClick(object sender, RoutedEventArgs e) { var header = VisualTreeHelper.GetParent(sender as DependencyObject) as DataGridColumnHeader; if (header?.Tag is string propertyName && this.DataContext is MainWindowViewModel vm && !string.IsNullOrEmpty(vm.SelectedCondition)) { vm.AddFilter(propertyName, vm.SelectedCondition, vm.FilterValue); // 关闭 Popup var popup = FindVisualChild<Popup>(header); if (popup != null) popup.IsOpen = false; } } // 辅助方法:在视觉树中查找子元素 private static T FindVisualChild<T>(DependencyObject parent) where T : DependencyObject { if (parent == null) return null; for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) { var child = VisualTreeHelper.GetChild(parent, i); if (child is T result) return result; var found = FindVisualChild<T>(child); if (found != null) return found; } return null; }参数说明:
binding.Path.Path获取Binding="{Binding Name}"中的"Name"字符串,存入Tag;OnFilterApplyClick通过VisualTreeHelper向上查找DataGridColumnHeader,再读取Tag得到列名;FindVisualChild是 WPF 视觉树遍历的标准写法,避免LogicalTreeHelper在模板中失效的问题。
4. 筛选状态可视化与调试技巧:让隐藏的 Filter 委托“看得见”
4.1 在 UI 上显示当前激活的筛选条件
用户需要明确知道“我现在筛了什么”。在MainWindow.xaml中添加筛选状态栏:
<StatusBar Grid.Row="1" Height="25"> <StatusBarItem> <TextBlock Text="{Binding ActiveFilterCount, StringFormat='已启用 {0} 个筛选'}"/> </StatusBarItem> <Separator/> <StatusBarItem> <ItemsControl ItemsSource="{Binding ActiveFilters}"> <ItemsControl.ItemTemplate> <DataTemplate> <TextBlock Text="{Binding DisplayText}" Margin="5,0,5,0"/> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> </StatusBarItem> </StatusBar>对应MainWindowViewModel.cs中的属性:
private int _activeFilterCount; public int ActiveFilterCount { get => _activeFilterCount; private set => SetProperty(ref _activeFilterCount, value); } private ObservableCollection<string> _activeFilterDisplays = new(); public ObservableCollection<string> ActiveFilters => _activeFilterDisplays; // 在 AddFilter/ClearFilter 后更新显示 private void UpdateFilterDisplay() { _activeFilterDisplays.Clear(); foreach (var filter in _activeFilters) { _activeFilterDisplays.Add($"{filter.PropertyName}: {filter.Condition} {filter.Value}"); } ActiveFilterCount = _activeFilters.Count; }逻辑说明:
ActiveFilters是ObservableCollection<string>,直接绑定到ItemsControl,每添加一个筛选条件,状态栏实时追加一行文本;DisplayText是FilterInfo中的只读属性,格式化输出如“姓名: 包含 张三”。
4.2 调试 ICollectionView.Filter 的黄金三步法
当筛选不生效或逻辑错误时,按以下顺序排查:
| 步骤 | 操作 | 预期结果 | 说明 |
|---|---|---|---|
| 1. 检查 Filter 是否被赋值 | 在ApplyFilters()中设断点,查看_collectionView.Filter是否为null | 非 null,且委托地址可见 | 若为 null,说明ICollectionView初始化失败,检查ItemsSource是否在InitializeComponent()后才赋值 |
| 2. 验证 Filter 委托执行逻辑 | 在Filter委托内部加日志:Debug.WriteLine($"Filtering {item} -> {result}"); | 控制台输出每行数据的判断过程 | 可快速定位是表达式编译错误(如属性名拼错)、还是值类型转换失败(如int与string比较) |
| 3. 确认 Refresh 是否触发 | 在_collectionView.Refresh()后立即检查dataGrid.Items.Count | 数值应等于筛选后数据量 | 若数量不变,说明Filter委托始终返回true,或Refresh()调用时机错误(如在INotifyPropertyChanged通知前调用) |
4.3 性能优化:大数据量下的筛选延迟处理
当ObservableCollection<Student>超过 10,000 行时,每次Refresh()触发的全量Filter委托执行会明显卡顿。本实例采用防抖(Debounce)策略:
private DispatcherTimer _debounceTimer; private void InitializeDebounceTimer() { _debounceTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; _debounceTimer.Tick += (s, e) => { _debounceTimer.Stop(); ApplyFilters(); // 延迟执行 }; } public void ScheduleFilterApply() // 替代直接调用 ApplyFilters() { _debounceTimer.Stop(); _debounceTimer.Start(); }逻辑说明:用户在
TextBox中连续输入时,ScheduleFilterApply()被高频调用,但_debounceTimer确保ApplyFilters()最多每 300ms 执行一次,避免 UI 频繁重绘。此方案比Task.Run更安全,因ICollectionView必须在 UI 线程操作。
5. 进阶技巧:支持 Excel 风格的“文本筛选”与“数字筛选”上下文菜单
5.1 根据列数据类型动态切换筛选面板
Excel 中,文本列显示“文本筛选”(包含、开头是、结尾是),数字列显示“数字筛选”(大于、介于、不等于)。本实例通过反射获取属性类型实现自动适配:
// 在 FilterInfo.cs 中扩展 public static List<string> GetAvailableConditions(Type propertyType) { if (propertyType == typeof(string)) return new List<string> { "Equals", "Contains", "StartsWith", "EndsWith", "NotEquals" }; if (propertyType == typeof(int) || propertyType == typeof(double) || propertyType == typeof(DateTime)) return new List<string> { "Equals", "GreaterThan", "LessThan", "Between", "NotEquals" }; return new List<string> { "Equals", "NotEquals" }; } // 在 MainWindowViewModel.cs 中 public ObservableCollection<string> FilterConditions { get { if (string.IsNullOrEmpty(_currentPropertyName)) return new ObservableCollection<string>(); var prop = typeof(Student).GetProperty(_currentPropertyName); return new ObservableCollection<string>(GetAvailableConditions(prop?.PropertyType ?? typeof(string))); } }逻辑说明:
GetAvailableConditions()根据Student.cs中属性的实际类型(typeof(string)/typeof(int))返回不同的条件列表;FilterConditions是ObservableCollection<string>,绑定到ComboBox,实现“选中姓名列 → 下拉显示文本条件;选中年龄列 → 下拉显示数字条件”。
5.2 “介于”条件的双值输入实现
Between条件需两个值(最小值、最大值),原TextBox无法满足。扩展FilterPopup模板,根据SelectedCondition动态切换内容:
<!-- 在 Popup 的 StackPanel 内 --> <ContentControl Content="{Binding}"> <ContentControl.Style> <Style TargetType="ContentControl"> <Style.Triggers> <DataTrigger Binding="{Binding DataContext.SelectedCondition, RelativeSource={RelativeSource AncestorType=Window}}" Value="Between"> <Setter Property="ContentTemplate"> <Setter.Value> <DataTemplate> <StackPanel> <TextBlock Text="最小值:" Margin="0,0,0,2"/> <TextBox Text="{Binding DataContext.MinValue, RelativeSource={RelativeSource AncestorType=Window}, Mode=TwoWay}" Margin="0,0,0,5"/> <TextBlock Text="最大值:" Margin="0,0,0,2"/> <TextBox Text="{Binding DataContext.MaxValue, RelativeSource={RelativeSource AncestorType=Window}, Mode=TwoWay}" Margin="0,0,0,5"/> </StackPanel> </DataTemplate> </Setter.Value> </Setter> </DataTrigger> <DataTrigger Binding="{Binding DataContext.SelectedCondition, RelativeSource={RelativeSource AncestorType=Window}}" Value="Contains"> <Setter Property="ContentTemplate"> <Setter.Value> <DataTemplate> <TextBox Text="{Binding DataContext.FilterValue, RelativeSource={RelativeSource AncestorType=Window}, Mode=TwoWay}" Margin="0,0,0,5"/> </DataTemplate> </Setter.Value> </Setter> </DataTrigger> </Style.Triggers> </Style> </ContentControl.Style> </ContentControl>参数说明:
DataTrigger监听SelectedCondition变化,当值为"Between"时,ContentControl自动切换为双TextBox模板;MinValue/MaxValue是MainWindowViewModel中新增的string属性,BuildFilterExpression方法需扩展支持Between条件的Expression.AndAlso()组合。
最终,BuildFilterExpression对Between的处理逻辑为:
case "Between": var minExpr = Expression.GreaterThanOrEqual(propertyExpr, Expression.Constant(minValue)); var maxExpr = Expression.LessThanOrEqual(propertyExpr, Expression.Constant(maxValue)); body = Expression.AndAlso(minExpr, maxExpr); break;此设计使筛选面板真正具备 Excel 的智能上下文感知能力,无需为每种类型写死界面,大幅降低后续维护成本。
本文还有配套的精品资源,点击获取