5.3 列表、表格与筛选
学习目标
你将做出一个能编辑标题、勾选完成状态并按标题筛选的表格,区分集合通知与行属性通知。前提:WpfBasics 项目已安装 CommunityToolkit.Mvvm 8.4.0,理解 属性通知与集合。
数据集合与显示视图
后端分页常把筛选条件送到数据库;WPF 的 ICollectionView 为已有内存集合提供显示视图,可以筛选、排序、分组和维护当前项。过滤只决定哪些项可见,不删除源数据。数据量较大时应让 API 分页和筛选,而非把所有记录下载后全部交给客户端。
ObservableCollection 只报告增加、删除、移动和重置;它不会让每一行自动报告标题变化。可编辑行还需要实现属性通知,例如继承 ObservableObject。两套通知分别回答“有哪些行”和“这一行变成了什么”。此外,属性通知本身不保证筛选重新执行,修改参与条件的行后需要刷新视图,或配置且确认视图支持实时筛选。
ListBox 适合简单选择列表;ListView 配合 GridView 适合多列呈现;DataGrid 提供表格编辑、列、行选择等能力。选控件之前先决定是否需要编辑、选择、多列与批量操作,避免只因“看起来像表格”就采用最复杂的控件。
完整的可筛选 ViewModel
新增完整文件。构造函数及后续集合操作都在 UI 线程执行,筛选逻辑只读取内存状态。
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows.Data;
using CommunityToolkit.Mvvm.ComponentModel;
namespace WpfBasics;
public partial class TodoRow : ObservableObject
{
public TodoRow(int id, string title)
{
Id = id;
this.title = title;
}
public int Id { get; }
[ObservableProperty]
private string title;
[ObservableProperty]
private bool isCompleted;
}
public partial class CollectionViewModel : ObservableObject
{
public ObservableCollection<TodoRow> Items { get; } =
[
new(1, "学习绑定"),
new(2, "学习命令"),
new(3, "实现筛选")
];
public ICollectionView View { get; }
[ObservableProperty]
private string query = "";
public CollectionViewModel()
{
View = CollectionViewSource.GetDefaultView(Items);
View.Filter = item => item is TodoRow row &&
row.Title.Contains(Query, StringComparison.OrdinalIgnoreCase);
View.SortDescriptions.Add(
new SortDescription(nameof(TodoRow.Title), ListSortDirection.Ascending));
}
partial void OnQueryChanged(string value) => View.Refresh();
}
把窗口内容替换成下列片段,保留根元素的命名空间声明及 x:Class="WpfBasics.MainWindow"。显式定义列让排序字段、标题和可编辑状态更清楚。搜索框获得键盘焦点前,先通过 View 的事件提交当前单元格和行的编辑,避免筛选刷新与编辑事务冲突。
<DockPanel Margin="24">
<TextBox DockPanel.Dock="Top" Margin="0,0,0,12"
PreviewGotKeyboardFocus="OnQueryPreviewGotKeyboardFocus"
Text="{Binding Query, UpdateSourceTrigger=PropertyChanged}" />
<DataGrid x:Name="TasksGrid" ItemsSource="{Binding View}" AutoGenerateColumns="False"
CanUserAddRows="False" SelectionMode="Single"
EnableRowVirtualization="True">
<DataGrid.Columns>
<DataGridTextColumn Header="编号" Binding="{Binding Id}" IsReadOnly="True" />
<DataGridTextColumn Header="标题" Width="*"
Binding="{Binding Title, UpdateSourceTrigger=PropertyChanged}" />
<DataGridCheckBoxColumn Header="完成" Binding="{Binding IsCompleted}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
将代码后置替换为下面的完整文件。CommitEdit 返回 false 时,校验或事件可能阻止提交;阻止焦点转入搜索框,让用户先修复当前行。不能只假定点击表格之外的控件就一定结束了行事务。
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace WpfBasics;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new CollectionViewModel();
}
private void OnQueryPreviewGotKeyboardFocus(
object sender, KeyboardFocusChangedEventArgs e)
{
if (!TasksGrid.CommitEdit(DataGridEditingUnit.Cell, true)
|| !TasksGrid.CommitEdit(DataGridEditingUnit.Row, true))
{
e.Handled = true;
}
}
}
这个事件属于 View 的编辑与焦点协调,不把 DataGrid 放进 ViewModel。本例的即时搜索从该输入框发起;如果以后通过其他入口修改 Query,也要先结束编辑事务,再触发 View.Refresh()。
默认视图属于集合,多个控件引用同一个集合默认视图时会共享筛选、排序和当前项。若两个区域需要独立条件,可分别创建 CollectionViewSource。组合修改筛选与排序时可用 DeferRefresh() 减少重复刷新,但它不是线程锁,也不能替代 UI 线程要求。
列表与网络结果的边界
API DTO 可保持简单的数据契约;需要逐行编辑时,把 DTO 映射成行 ViewModel,保存成功后再同步服务端确认的数据。用户修改本地勾选不等于 API 已保存,失败时需要回滚、保留待重试状态或明确提示。网络请求直接 await,收到结果后在 UI 线程更新绑定集合,见 异步与 UI 线程。
虚拟化减少当前屏幕外的控件创建,不会减少已下载的数据量。把 DataGrid 放进无限高度的 StackPanel 或外层 ScrollViewer,可能妨碍合理测量与虚拟化;让表格位于有边界的 Grid 或本例 DockPanel 中。
常见错误
过滤后用“可见行数”为索引删除源集合,可能删错对象,操作应使用实体编号或行对象。后台线程直接 Items.Add 可能触发线程访问异常。标题编辑后排序或筛选没有即时重排,也不一定是通知丢失,应检查视图刷新规则与编辑提交时机。
练习与验收
增加“只看未完成”的筛选条件,在 DataGrid 的行编辑已经提交后刷新视图。不要在行对象的 setter、PropertyChanged 或 OnIsCompletedChanged 中直接调用 View.Refresh():此时可能仍处于 IEditableCollectionView.EditItem 事务,刷新会抛出异常。可以在 View 的行提交事件之后通过 Dispatcher 排队刷新,并确认视图的 IsEditingItem、IsAddingNew 均为 false;校验阻止提交时,应先修复输入。也可研究视图支持的实时筛选,但需要显式配置并确认支持。
验收:提交已勾选行的编辑后,该行从未完成视图消失,关闭筛选后仍存在;搜索为空时显示所有源项;删除时按编号操作;编辑和筛选期间没有事务异常。最后比较“源集合数量”和“可见项数量”,说明筛选没有删除数据。