ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WPF DataGrid仿Excel筛选组件实战:从交互到性能优化

WPF DataGrid仿Excel筛选组件实战:从交互到性能优化 简介面向WPF与.NET 6.0开发者的工程实例演示如何为DataGrid嵌入仿Excel的列头筛选交互。项目基于VS2022搭建核心涉及关闭AutoGenerateColumns后自定义DataGridTextColumn、利用Filtering事件捕获筛选条件、通过ICollectionView的Refresh方法更新视图并给出等于、包含、大于等多条件扩展思路。资源包共76个文件、容量349KB含15个cs源码、2个xaml界面布局、可供直接运行的exe及pdb调试文件另含sln解决方案与项目配置文件结构清晰便于打开运行与对照学习。已有292人学习适合需要提升桌面数据管理效率的WPF初中级开发者通过其中的Student.cs、FilterInfo.cs和MainWindowViewModel.cs可快速理解数据模型、筛选信息封装与视图绑定逻辑实现多列组合筛选并迁移到实际项目中。1. 引子为什么 WPF 的 DataGrid 需要一套仿 Excel 筛选做 WPF 桌面应用的人多半有这种经历业务方打开界面第一句话就是“表格能不能像 Excel 那样点表头筛选”。原生的 DataGrid 只给了排序和基本选择筛选要自己写成熟的第三方控件自带筛选但要买授权、要换控件库老项目根本不敢动。于是自己动手做一套“表头点击弹出筛选面板、多选勾选、支持关键字搜索”的仿 Excel 筛选成了最现实的方案。这个实例的核心就是把 Excel 的筛选交互搬到 WPF DataGrid 上数据层面用集合视图做过滤不破坏原数据同时把列头 UI、弹窗、多选状态、性能边界这些坑一个个填平。适合正在维护 WPF 项目、不想引入商业控件、又需要交付完整筛选体验的桌面开发者。2. 先把需求讲透Excel 筛选到底做了什么我们要砍掉哪些2.1 Excel 筛选的三步交互对应到 WPF 里是什么Excel 的自动筛选看起来简单但拆开看是三个独立能力的组合第一点击表头右侧的下拉箭头会弹出一个面板第二面板里列出当前列的所有唯一值并且支持搜索定位第三勾选或取消勾选后表格行立即隐藏但数据本身还在取消筛选后原样恢复。对应到 WPF 里第一步是一个弹出层Popup 或自定义 Window第二步是从源数据里提取唯一值并绑定到列表控件第三步则是给集合视图挂上 Filter 回调。很多人一开始会走弯路以为筛选就是遍历 DataGrid 的行把不满足的行 Visibility 设为 Collapsed。这个思路在数据量小的时候能用但一旦遇到排序、分组、虚拟化行会被 DataGrid 反复重建显隐状态极其容易丢。正确做法是操作数据源而不是操作视觉元素让 DataGrid 自己根据 ItemsSource 的变化去刷新行。这个理念决定了下面所有设计筛选状态存到数据层UI 只负责触发和呈现。2.2 现成控件为什么不能直接抄WPF 生态里能做仿 Excel 筛选的现成货主要是 DevExpress、Telerik 这类商业控件库它们自带完整的筛选菜单但问题也很现实授权费用按开发者席位算老项目升级控件库可能引发连锁兼容问题而且样式和公司现有的设计系统往往对不上最后还得花时间调主题。开源方案里有一部分项目提供 DataGrid 扩展但多数只做了文本过滤不支持多选、不支持空值、排序后菜单状态还会丢反而不如自己维护一套可控的逻辑。自己写的成本没有想象中高核心代码拢共几百行而且只依赖 WPF 自带的 DataGrid 和 Popup不需要引入额外包。麻烦的地方在于交互细节点击外部要自动关菜单、勾选时菜单不能误关、唯一值要按原始数据类型取、筛选和排序要能共存。把这些问题逐个解决这套代码就能沉淀成通用组件以后任何项目都能复用。2.3 数据层用 ICollectionView 才是正解ICollectionView 是 WPF 里数据集合的统一视图抽象排序、分组、筛选都是它的职责。DataGrid 默认会通过 CollectionViewSource.GetDefaultView 拿到一个视图我们在这个视图上挂 Filter 委托按列条件判断行去留再调用 Refresh 触发一次重过滤行就更新了。源数据始终是完整的那份Filter 只是视图层的“黑匣子”这一点和 Excel 的“隐藏行但数据还在”思路完全一致。用 ICollectionView 还有一个好处它和 DataGrid 的排序天然兼容。用户点击列头排序时DataGrid 内部改的是同一个视图的 SortDescriptions不会清掉我们设置的 Filter。只要不把筛选状态放在列头控件里排序后重新打开筛选菜单勾选状态依然能正确恢复。3. 表头点击与筛选面板从零写出弹窗交互3.1 列头模板把按钮塞进 DataGridColumnHeader仿 Excel 筛选的第一步是让表头可以点击。DataGrid 的列头默认有排序逻辑直接在整个列头区域挂 MouseLeftButtonUp 会和排序冲突所以常规做法是重写 ColumnHeaderStyle在列头内容后面加一个小按钮点击按钮时弹筛选面板点击列头其他区域仍然走排序。DataGrid.ColumnHeaderStyle Style TargetTypeDataGridColumnHeader Setter PropertyTemplate Setter.Value ControlTemplate TargetTypeDataGridColumnHeader Border Background{TemplateBinding Background} Padding8,4 BorderBrush#D0D0D0 BorderThickness0,0,1,1 StackPanel OrientationHorizontal TextBlock Text{TemplateBinding Content} VerticalAlignmentCenter/ Button x:NameFilterButton Content▾ Margin8,0,0,0 Padding2,0 CursorHand Tag{Binding RelativeSource{RelativeSource TemplatedParent}, PathColumn} ClickFilterButton_Click/ /StackPanel /Border /ControlTemplate /Setter.Value /Setter /Style /DataGrid.ColumnHeaderStyle这里有两个关键点。Tag 绑定用的是 TemplatedParent 的 Column这样才能在 Click 事件里拿到当前列对象进而知道要对哪个字段做筛选。Button 的 Click 不会冒泡到列头的排序逻辑因为 Button 本身就是输入命中元素事件在手柄被处理后就不会继续冒泡到 DataGridColumnHeader 的排序行为上了。3.2 筛选面板Popup 加 CheckBox 列表面板用一个 Popup 承载里面放三样东西搜索框、选项列表、全选/清空按钮。选项列表用 ItemsControl 而不是 ListBox因为 ItemsControl 没有选中态管理正好让每个选项自己是一个 CheckBox状态完全由数据驱动不需要处理 ListBox 的 SelectionChanged 带来的额外干扰。Popup x:NameFilterPopup StaysOpenFalse AllowsTransparencyTrue PlacementBottom VerticalOffset2 Border Width240 MaxHeight320 BackgroundWhite BorderBrush#C0C0C0 BorderThickness1 CornerRadius4 StackPanel TextBox x:NameFilterSearchBox Padding4 Margin8,8,8,4 TextChangedFilterSearchBox_TextChanged/ ScrollViewer MaxHeight240 VerticalScrollBarVisibilityAuto ItemsControl x:NameFilterItemsControl ItemsControl.ItemTemplate DataTemplate CheckBox Content{Binding Display} IsChecked{Binding IsChecked, ModeTwoWay} Margin4,2 ClickFilterItemCheckBox_Click/ /DataTemplate /ItemsControl.ItemTemplate /ItemsControl /ScrollViewer UniformGrid Columns2 Margin8 Button Content全选 ClickSelectAll_Click/ Button Content清空 ClickClearFilter_Click/ /UniformGrid /StackPanel /Border /Popup注意 Popup 的 StaysOpen 属性。设成 False 意味着点击 Popup 外部任意位置都会自动关闭体验上非常接近 Excel。但代价是 Popup 内部任何控件接收鼠标点击时如果事件处理不当也可能让 Popup 关闭这就是第 5 章要专门处理的坑。Placement 设成 Bottom让面板出现在表头正下方视觉上和 Excel 一致。3.3 点击表头时加载唯一值点击筛选按钮后要做两件事记录“当前在筛哪一列”以及把这一列的所有唯一值加载到 ItemsControl 里。唯一值必须从源数据取而不是从 DataGrid 的行取。从行取会拿到格式化后的显示文本比如日期列可能已经是“2024-05-01”这种字符串筛回去还要再做一次类型转换得不偿失。private string _currentColumnName; private void FilterButton_Click(object sender, RoutedEventArgs e) { var button sender as Button; var column button.Tag as DataGridColumn; if (column is DataGridBoundColumn boundColumn) { _currentColumnName (boundColumn.Binding as Binding)?.Path.Path; } else { // DataGridTemplateColumn 需要用辅助字典映射后面讲到 _currentColumnName _templateColumnMap[column]; } LoadDistinctValues(_currentColumnName); ShowFilterPopup(button); } private void LoadDistinctValues(string propertyName) { var distinctValues _sourceData .Select(item item.GetType().GetProperty(propertyName)?.GetValue(item)) .Distinct() .OrderBy(v v?.ToString()) .Select(v new FilterItem { Display v?.ToString() ?? (空), Value v, IsChecked true }) .ToList(); FilterItemsControl.ItemsSource distinctValues; }这个取法有几个细节。GetProperty 拿属性用的是反射如果这列在界面上绑定的路径和类的属性名不一致取出来是空所以界面上列的绑定字段最好和实体属性名保持一致少做一层映射。OrderBy 用 ToString 排序数字列会按字符串排要精确排序的话应该对 Value 类型做专门比较但大多数业务场景字符串排序已经够用。空值单独显示成“(空)”方便用户筛掉空行。3.4 光标悬停提示与交互反馈热词里有一条是“光标移到表格标题上提示文字”这个在仿 Excel 筛选里值得做。表头区域的筛选按钮很小用户不一定知道它能点悬停时给出提示能明显降低学习成本。我用 ToolTip 解决同时给按钮加了手型光标。Button Content▾ CursorHand ToolTip点击筛选此列 Tag{Binding RelativeSource{RelativeSource TemplatedParent}, PathColumn} ClickFilterButton_Click/还可以在筛选生效时给列头一个视觉标识。比如被筛选的列按钮内容改成“▾●”或者给列头背景加一个浅色高亮。这个状态可以在加载唯一值的时候同步维护如果当前列的选中集合不等于全量集合就认为筛选已生效。高亮状态建议存到一个 ObservableCollection 或字典里不要直接改 Button 的样式因为排序后列头会被重建直接改样式容易丢。4. 从勾选到数据刷新字段映射、多选状态与 Filter 回调4.1 字段映射DataGrid 列和实体属性怎么对上第 3 章的代码里DataGridBoundColumn 可以通过 Binding.Path 拿属性名但实际项目里总有几个列是模板列比如“操作”列、带图片的列、多字段拼一列的。模板列不能筛这没问题问题在于筛选面板打开时遇到模板列会拿不到属性名代码直接崩。我会在建列时同时维护一个字典把 DataGridColumn 对象映射到属性名。模板列如果业务上确实要筛就在字典里手动指定不筛的列映射为 null点击时直接忽略。private Dictionarystring, string _columnFilterMap new Dictionarystring, string(); private void BuildColumnFilterMap() { _columnFilterMap[NameColumn] Name; _columnFilterMap[AmountColumn] Amount; _columnFilterMap[StatusColumn] Status; // 模板列映射到它绑定的实体属性 _columnFilterMap[LevelColumn] Level; }用列头的 x:Name 做 key 比用列索引靠谱因为列可以拖动排序索引会变。映射建立好之后筛选逻辑只认属性名不认列对象DataGrid 怎么摆列都不影响筛选。4.2 多选状态存哪里字典加 HashSet筛选菜单的核心状态是“每一列目前勾选了哪些值”。我用一个 Dictionarystring, HashSetCheckBox 勾选变化时更新当前列的 HashSet然后调用视图刷新。这里最容易犯的错是把勾选状态存在 FilterItem 对象里每次重新打开菜单时还得去同步一旦某一处漏了同步界面显示和实际过滤结果就不一致。正确做法是 FilterItem 只是一个临时表现对象它的 IsChecked 初始化时对应 HashSet 的状态点击后立即把选择写回 HashSet。private void FilterItemCheckBox_Click(object sender, RoutedEventArgs e) { var checkBox sender as CheckBox; var item checkBox.DataContext as FilterItem; if (!_columnFilters.TryGetValue(_currentColumnName, out var selectedSet)) { selectedSet new HashSetobject(); _columnFilters[_currentColumnName] selectedSet; } if (checkBox.IsChecked true) selectedSet.Add(item.Value); else selectedSet.Remove(item.Value); // 不改变该列筛选状态时不触发刷新 if (selectedSet.Count 0) _columnFilters.Remove(_currentColumnName); ApplyFilter(); }注意判断条件里有一个重要分支如果选中集合为空要直接从字典移除这一列表示“不筛”。因为空集合如果留在字典里Filter 回调里很容易把它当成“没有任何值被选中所以所有行都排除”这就翻车了。Excel 的行为是取消所有勾选等于不过滤我们要对齐这个语义而不是把空集合当成过滤条件。4.3 Filter 回调怎么写才不容易出问题ICollectionView 的 Filter 是一个 Predicate对集合里每个对象调用一次返回 true 表示保留。筛选条件可能同时存在多列所以要遍历字典里所有非空集合当前行对象只要有一列不满足就整体排除。private ICollectionView _view; private void ApplyFilter() { if (_view null) { _view CollectionViewSource.GetDefaultView(DataGrid.ItemsSource); _view.Filter FilterPredicate; } _view.Refresh(); } private bool FilterPredicate(object obj) { foreach (var kvp in _columnFilters) { if (kvp.Value null || kvp.Value.Count 0) continue; var value obj.GetType().GetProperty(kvp.Key)?.GetValue(obj); if (!kvp.Value.Contains(value)) return false; } return true; }这里有一个可能影响性能的点FilterPredicate 里每次都对 obj 做反射取属性。如果数据量不大比如几千行体感没问题但如果是几万行甚至更多每次 Refresh 都要对全量数据做反射就会明显卡顿。第 6 章我会讲怎么用 PropertyDescriptor 加速以及怎么把唯一值构建放到后台线程。现在就先用反射保证逻辑正确性能优化放在功能稳定之后。4.4 全选、清空与恢复勾选状态全选和清空是两个常用按钮。全选就是把当前列的 HashSet 清掉并刷新清空则是把字典里当前列的所有值加入 HashSet等同于恢复未筛选状态。注意清空的语义要重新设计如果当前列没有任何筛选清空按钮应该直接关闭菜单不做多余操作。private void SelectAll_Click(object sender, RoutedEventArgs e) { if (_columnFilters.ContainsKey(_currentColumnName)) { _columnFilters.Remove(_currentColumnName); foreach (var item in FilterItemsControl.ItemsSource as ListFilterItem) { item.IsChecked true; } ApplyFilter(); } } private void ClearFilter_Click(object sender, RoutedEventArgs e) { var items FilterItemsControl.ItemsSource as ListFilterItem; foreach (var item in items) { item.IsChecked false; } if (!_columnFilters.TryGetValue(_currentColumnName, out var selectedSet)) { selectedSet new HashSetobject(); _columnFilters[_currentColumnName] selectedSet; } selectedSet.Clear(); foreach (var item in items) { if (item.Value ! null) selectedSet.Add(item.Value); } ApplyFilter(); }清空逻辑里有一个细节把“未勾选”转成“选中全部”需要遍历原列表把值都加入 HashSet。这里不能直接靠 CheckBox 的 Click 事件一个个触发因为那时候列表已经被改成全部未勾选数据源里原来的值列表已经丢了。所以我才会在清空按钮里先临时缓存 items 再做处理保证清空之后菜单关闭、数据恢复全量。5. 避坑手册仿 Excel 筛选的 5 个常见翻车点5.1 Popup 刚打开就被关闭现象点击表头按钮后 Popup 闪一下立刻消失或者勾选第一个 CheckBox 时整个面板关闭。原因Popup 的 StaysOpenFalse 时只要鼠标点击发生在 Popup 之外的区域就关闭。而 CheckBox 的 Click 事件在部分情况下会被识别为“外部点击”尤其是 Popup 内部没有设置背景色或者事件路由到底层窗口时。另一个常见原因是 Popup 的 PlacementTarget 设置错了比如设成了 DataGrid 而不是按钮导致 Popup 定位在表格中间部分区域落在 Popup 外。解决给 Popup 内的根 Border 设置明确的 Background比如 White这样 Popup 整个区域都是命中区域CheckBox 的 Click 事件里不要修改会影响 Popup 打开状态的东西。另外一个稳定做法是把 Popup 的 StaysOpen 改成 True自己处理外部点击关闭但那样需要监听窗口鼠标事件代码量更多。我一般先用 False配合 Background 设置九成场景都能解决。5.2 唯一值取错显示文本和原始值对不上现象日期列筛选时菜单里出现“2024/1/1 0:00:00”这种带时间格式的值或者枚举列显示成数字而不是中文。原因唯一值从 DataGrid 的行单元格取取到的是绑定转换器处理后的显示值。日期列的 CellTemplate 可能绑定了 ToString(yyyy-MM-dd)枚举列可能绑定了 Description 特性这些显示值再拿来和实体属性值做 Contains 比较永远匹配不上。解决统一切到源数据取原始值按实体属性反射。这是第 3 章代码已经做对的地方但在实际开发中很多人因为偷懒从单元格取结果在日期和枚举上反复踩坑。反思下来的经验是凡是涉及筛选的比较逻辑数据的“原始形态”必须全程一致显示格式化只留在 DataGrid 的列模板里筛选层永远不碰显示层。5.3 排序后筛选状态丢光现象点击某一列筛选后再点表头排序重新打开筛选菜单发现所有勾选都是全勾状态。原因排序会触发 DataGrid 重新应用列头模板筛选按钮和 Popup 的 PlacementTarget 被重建之前在列头控件里绑定的任何状态全部丢失。更隐蔽的是Popup 的 IsOpen 状态也可能因为 PlacementTarget 被移除而重置。解决把筛选状态全部放到窗体的字典里列头只是展示入口不持有任何筛选数据。重新打开菜单时从字典初始化 CheckBox 的 IsChecked。曾经踩过把当前列名存在按钮 Tag 里的坑排序后 Tag 虽然还在但已经不是当前列了后来改成用列 x:Name 做映射排序不影响。5.4 数据量大时打开菜单卡死现象列表有一万多行点击筛选按钮界面卡住两三秒期间窗口标题显示“未响应”。原因LoadDistinctValues 是在 UI 线程同步遍历全量数据加上每行都走反射一万行可能产生几十万次属性访问耗时直接拉满。更糟的是 ApplyFilter 里的 FilterPredicate 又遍历一次双重叠加。解决唯一值提取改成两步走。第一步在数据源变更时比如查询接口返回后就预计算各个列的哈希集合存到字典里第二步打开菜单时直接读缓存集合不再遍历。如果数据源非常动态就要用 Task.Run 把 Distinct 逻辑放到后台线程构建完再 Invoke 回 UI 线程绑定 ItemsControl。两种做法我在第 6 章详细展开。5.5 设置 ItemsSource 后 Filter 不生效现象代码里查了 _view.Filter 赋值了也调用了 Refresh但 DataGrid 行没有任何变化。原因DataGrid.ItemsSource 在 XAML 或构造函数里先被赋值之后又在某个地方重新设置了一个新集合。而我们拿 _view 的时机是在第一次 ApplyFilter 时那时 DataGrid.ItemsSource 绑定的还是旧集合的视图之后新集合被设置后DataGrid 用的是新集合的默认视图旧视图上的 Filter 自然不影响显示。解决不要在 DataGrid.ItemsSource 赋值之后才初始化 _view而应该在赋值后的同一处初始化或者监听 ItemsSource 变化时重新获取。更稳妥的做法是数据层直接维护一个 ICollectionView把这个视图赋给 DataGrid.ItemsSource后续所有筛选操作都作用在这同一个视图上规避了“切换集合导致筛选丢失”的问题。6. 进阶数据量上来了怎么保住帧率6.1 用 PropertyDescriptor 替代反射FilterPredicate 里每次都调用 GetProperty GetValue这个开销在几万行数据时非常可观。PropertyDescriptor 是 WPF 绑定时内置的元数据机制我们可以为每个实体类型缓存一份属性描述符字典后续取值走描述符的 GetValue比全名反射快一个数量级。private Dictionarystring, PropertyDescriptor _propertyCache new Dictionarystring, PropertyDescriptor(); private PropertyDescriptor GetPropertyDescriptor(string propertyName) { if (_propertyCache.TryGetValue(propertyName, out var descriptor)) return descriptor; var props TypeDescriptor.GetProperties(typeof(OrderEntity)); descriptor props[propertyName]; _propertyCache[propertyName] descriptor; return descriptor; }在 FilterPredicate 里用 GetPropertyDescriptor(propertyName).GetValue(obj) 替换掉 obj.GetType().GetProperty改动量很小收益在数据量大的场景下立竿见影。这算是我优化过的最有效的一行替换比后面加各种异步方案都值得先做。6.2 唯一值缓存与后台构建如果数据量的主要瓶颈在打开筛选菜单那一刻可以考虑在实体列表加载完成后用 Task.Run 后台预计算每列的唯一值。注意这里不需要提前把每列都算出来只算用户在界面上实际可能会点开的那几列可以在 BuildColumnFilterMap 时声明一个“可筛列清单”只在清单内的列做预计算省掉没必要的开销。private Dictionarystring, Listobject _cachedDistinctValues new Dictionarystring, Listobject(); private void RebuildDistinctValueCache() { foreach (var kvp in _columnFilterMap) { var propertyName kvp.Value; var values Task.Run(() { return _sourceData .Select(item GetPropertyDescriptor(propertyName).GetValue(item)) .Distinct() .OrderBy(v v?.ToString()) .ToList(); }).Result; // 注意这里简化演示实际不要 .Result 在 UI 线程 } }这段代码里的 .Result 只是为了演示思路实战里不能直接这么写否则 UI 线程还是被阻塞。我一般会把这个逻辑封装成一个 async 方法构造完成后再 Invoke 到 UI 线程绑定。如果你用的实体类型是固定的还可以在后台线程里先生成一遍全量过滤结果内存换时间打开菜单时直接读取完全无感知。6.3 验证性能不要靠感觉做过一次优化后我会用 Stopwatch 在操作前后记录耗时至少跑三次取平均值。打开菜单的耗时目标控制在 200 毫秒以内Filter 刷新的耗时控制在 100 毫秒以内。超过这个标准就要继续优化不要觉得“还行吧”。有一次我以为缓存优化到位了结果 Stopwatch 一测还有 1.2 秒排查发现是 DataGrid 的虚拟化在 ItemsSource 变更时重新生成所有列头模板引起的后来把 DataGrid 的 EnableColumnVirtualization 打开才算真正解决。性能问题最怕没有验证手段全靠肉眼感觉Optimization 前先搭好测量工具这才是一个仿 Excel 筛选功能能稳定交付的底气。从这套实例里我学到的最重要一课不是怎么写弹出菜单而是所有交互状态都要和数据层对齐视觉表现可以花哨底层逻辑必须朴素、可预测。以后再做类似功能我会先把筛选状态的数据结构设计好再谈 UI 怎么做顺序反了返工是必然的。希望这些经验能帮你省下几天调试时间让你把精力留给真正有价值的功能。本文还有配套的精品资源点击获取
返回列表