Day 6 - 搜索与过滤
建议用时:180-210 分钟
你将学会什么
- 搜索和过滤有什么区别
- 为什么要区分全部数据和可见数据
- 空关键字为什么显示全部
- 怎么做忽略大小写搜索
- 怎么做库存过滤
- 没有结果时怎么显示空状态
今天不是写一个孤立的 Where。要把搜索接到主列表页面:全部商品放在 allProducts,界面列表绑定 VisibleProducts,每次搜索或过滤都重新生成可见列表。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“详情与编辑”,今天继续学习“搜索与过滤”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“搜索与过滤”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。搜索过滤的核心是:不要把原始数据改丢,只改变当前显示的数据。
搜索和过滤的区别
| 名词 | 含义 | 本页例子 |
|---|---|---|
| 搜索 | 按用户输入的文字匹配 | 名称包含 key |
| 过滤 | 按固定条件筛选 | 库存大于 0 |
搜索通常来自输入框。
过滤通常来自复选框、下拉框、分类按钮。
为什么分成 allProducts 和 VisibleProducts
本页有两份数据:
private readonly List<ProductItem> allProducts = new();
public ObservableCollection<ProductItem> VisibleProducts { get; } = new();allProducts 是全部商品。
VisibleProducts 是界面当前显示的商品。
这样做的原因是:
- 搜索不会破坏全部数据。
- 清空搜索时能恢复全部。
- 多个过滤条件可以反复重新计算。
- 界面只绑定可见结果。
空关键字为什么显示全部
用户把搜索框清空,通常表示“不搜索”。
所以代码这样写:
if (!string.IsNullOrWhiteSpace(keyword))
{
query = query.Where(product =>
product.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase));
}如果关键字为空,就不加搜索条件,结果就是全部商品。
为什么要 Trim
用户可能输入:
key 前后空格通常不是搜索内容,所以先写:
string keyword = SearchText.Trim();这样输入 key 和 key 的结果一样。
为什么要忽略大小写
商品名可能是 Keyboard,用户可能输入 key。
如果大小写敏感,就搜不到。
所以本页使用:
StringComparison.OrdinalIgnoreCase表示不区分大小写。
为什么用 ApplyFilter
搜索文字变了要过滤。
库存勾选变了也要过滤。
所以把逻辑统一放进:
private void ApplyFilter()然后两个变化都调用它:
partial void OnSearchTextChanged(string value)
{
ApplyFilter();
}
partial void OnOnlyInStockChanged(bool value)
{
ApplyFilter();
}这样不会出现搜索一套逻辑、库存过滤另一套逻辑。
空结果不是错误
没有匹配商品是正常情况,不是程序错误。
所以不要抛异常,也不要崩溃。
只要显示:
没有找到符合条件的商品。并让列表为空即可。
搜索与过滤常用写法速查
| 需求 | 常用写法 | 说明 |
|---|---|---|
| 原始数据 | allProducts | 保存完整列表 |
| 可见数据 | VisibleProducts | 绑定到界面 |
| 搜索文本 | SearchText | 输入框绑定 |
| 忽略大小写 | ToLower() | 两边统一小写 |
| 名称包含 | Name.Contains(keyword) | 关键词搜索 |
| 只看库存 | OnlyInStock | 布尔过滤 |
| 刷新列表 | RefreshVisibleProducts() | 每次条件变化调用 |
| 空结果 | IsEmpty | 显示无结果提示 |
搜索固定流程:
从 allProducts 开始
-> 按 SearchText 过滤
-> 按 OnlyInStock 过滤
-> 清空 VisibleProducts
-> 把结果加入 VisibleProducts第二部分:把知识组合成完整例子
今天做一个搜索过滤页面:
- 顶部有搜索框。
- 可以勾选“仅显示有库存”。
- 列表显示过滤后的商品。
- 没有结果时显示提示。
- 点击“清空过滤”后恢复全部商品。
ViewModel
文件位置:
ViewModels/MainWindowViewModel.csusing System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ProductApp.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
private readonly List<ProductItem> allProducts = new()
{
new ProductItem { Id = Guid.NewGuid(), Name = "Keyboard", Price = 199m, Stock = 10 },
new ProductItem { Id = Guid.NewGuid(), Name = "Mouse", Price = 59m, Stock = 30 },
new ProductItem { Id = Guid.NewGuid(), Name = "Monitor", Price = 899m, Stock = 0 },
new ProductItem { Id = Guid.NewGuid(), Name = "USB Cable", Price = 19m, Stock = 100 },
new ProductItem { Id = Guid.NewGuid(), Name = "Laptop Stand", Price = 129m, Stock = 0 }
};
public ObservableCollection<ProductItem> VisibleProducts { get; } = new();
[ObservableProperty]
private string searchText = "";
[ObservableProperty]
private bool onlyInStock;
[ObservableProperty]
private string message = "";
[ObservableProperty]
private string emptyMessage = "";
[ObservableProperty]
private bool isEmpty;
public MainWindowViewModel()
{
ApplyFilter();
}
partial void OnSearchTextChanged(string value)
{
ApplyFilter();
}
partial void OnOnlyInStockChanged(bool value)
{
ApplyFilter();
}
[RelayCommand]
private void ClearFilter()
{
SearchText = "";
OnlyInStock = false;
ApplyFilter();
}
private void ApplyFilter()
{
string keyword = SearchText.Trim();
IEnumerable<ProductItem> query = allProducts;
if (!string.IsNullOrWhiteSpace(keyword))
{
query = query.Where(product =>
product.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase));
}
if (OnlyInStock)
{
query = query.Where(product => product.Stock > 0);
}
List<ProductItem> result = query.ToList();
VisibleProducts.Clear();
foreach (ProductItem product in result)
{
VisibleProducts.Add(product);
}
IsEmpty = VisibleProducts.Count == 0;
EmptyMessage = IsEmpty ? "没有找到符合条件的商品。" : "";
Message = $"显示 {VisibleProducts.Count} / {allProducts.Count} 条商品";
}
}
public sealed class ProductItem
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
public int Stock { get; set; }
}窗口
文件位置:
Views/MainWindow.axaml<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ProductApp.ViewModels"
x:Class="ProductApp.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Width="840"
Height="560"
Title="Product Search">
<Grid RowDefinitions="Auto,Auto,Auto,*" Margin="20" RowSpacing="12">
<TextBlock Text="商品搜索与过滤"
FontSize="24"
FontWeight="Bold" />
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<TextBox Width="260"
PlaceholderText="输入商品名称"
Text="{Binding SearchText, Mode=TwoWay}" />
<CheckBox Content="仅显示有库存"
IsChecked="{Binding OnlyInStock, Mode=TwoWay}" />
<Button Content="清空过滤"
Command="{Binding ClearFilterCommand}" />
</StackPanel>
<StackPanel Grid.Row="2" Spacing="4">
<TextBlock Text="{Binding Message}" />
<TextBlock Text="{Binding EmptyMessage}"
IsVisible="{Binding IsEmpty}" />
</StackPanel>
<ListBox Grid.Row="3"
ItemsSource="{Binding VisibleProducts}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ProductItem">
<Grid ColumnDefinitions="180,100,80" Margin="4">
<TextBlock Text="{Binding Name}" />
<TextBlock Grid.Column="1"
Text="{Binding Price}" />
<TextBlock Grid.Column="2"
Text="{Binding Stock}" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Window>第三部分:跟着敲代码
从这里开始动手。打开 Avalonia 项目。
第 1 步:准备全部商品
private readonly List<ProductItem> allProducts = new()
{
new ProductItem { Id = Guid.NewGuid(), Name = "Keyboard", Price = 199m, Stock = 10 },
new ProductItem { Id = Guid.NewGuid(), Name = "Mouse", Price = 59m, Stock = 30 },
new ProductItem { Id = Guid.NewGuid(), Name = "Monitor", Price = 899m, Stock = 0 },
new ProductItem { Id = Guid.NewGuid(), Name = "USB Cable", Price = 19m, Stock = 100 },
new ProductItem { Id = Guid.NewGuid(), Name = "Laptop Stand", Price = 129m, Stock = 0 }
};解释:
- 有库存和无库存商品都准备几条。
- 这样才能验证库存过滤。
第 2 步:准备可见商品集合
public ObservableCollection<ProductItem> VisibleProducts { get; } = new();解释:
- 界面列表只绑定
VisibleProducts。 - 不直接绑定
allProducts。
第 3 步:写搜索和过滤状态
[ObservableProperty]
private string searchText = "";
[ObservableProperty]
private bool onlyInStock;
[ObservableProperty]
private string message = "";
[ObservableProperty]
private string emptyMessage = "";
[ObservableProperty]
private bool isEmpty;解释:
SearchText绑定搜索框。OnlyInStock绑定复选框。IsEmpty控制空结果提示是否显示。
第 4 步:启动时先显示全部
public MainWindowViewModel()
{
ApplyFilter();
}解释:
- 启动时没有搜索条件,所以会显示全部商品。
第 5 步:搜索文字变化时过滤
partial void OnSearchTextChanged(string value)
{
ApplyFilter();
}解释:
[ObservableProperty]会生成SearchText。- 同时允许你写
OnSearchTextChanged,在变化时执行逻辑。
第 6 步:库存过滤变化时过滤
partial void OnOnlyInStockChanged(bool value)
{
ApplyFilter();
}解释:
- 勾选或取消勾选时重新计算结果。
第 7 步:写 ApplyFilter
private void ApplyFilter()
{
string keyword = SearchText.Trim();
IEnumerable<ProductItem> query = allProducts;
if (!string.IsNullOrWhiteSpace(keyword))
{
query = query.Where(product =>
product.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase));
}
if (OnlyInStock)
{
query = query.Where(product => product.Stock > 0);
}
List<ProductItem> result = query.ToList();解释:
- 先从全部商品开始。
- 有关键字就加搜索条件。
- 勾选有库存就加库存条件。
- 最后得到结果列表。
第 8 步:刷新 VisibleProducts
继续写在 ApplyFilter 里:
VisibleProducts.Clear();
foreach (ProductItem product in result)
{
VisibleProducts.Add(product);
}
IsEmpty = VisibleProducts.Count == 0;
EmptyMessage = IsEmpty ? "没有找到符合条件的商品。" : "";
Message = $"显示 {VisibleProducts.Count} / {allProducts.Count} 条商品";
}解释:
- 先清空旧结果。
- 再加入新结果。
- 没结果时显示空状态。
第 9 步:写清空过滤命令
[RelayCommand]
private void ClearFilter()
{
SearchText = "";
OnlyInStock = false;
ApplyFilter();
}解释:
- 清空搜索文字。
- 取消库存过滤。
- 重新显示全部商品。
第 10 步:写顶部搜索区
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<TextBox Width="260"
PlaceholderText="输入商品名称"
Text="{Binding SearchText, Mode=TwoWay}" />
<CheckBox Content="仅显示有库存"
IsChecked="{Binding OnlyInStock, Mode=TwoWay}" />
<Button Content="清空过滤"
Command="{Binding ClearFilterCommand}" />
</StackPanel>解释:
- 输入框绑定搜索关键字。
- 复选框绑定库存过滤。
- 按钮绑定清空命令。
第 11 步:写结果提示和列表
<StackPanel Grid.Row="2" Spacing="4">
<TextBlock Text="{Binding Message}" />
<TextBlock Text="{Binding EmptyMessage}"
IsVisible="{Binding IsEmpty}" />
</StackPanel>
<ListBox Grid.Row="3"
ItemsSource="{Binding VisibleProducts}">解释:
Message显示当前结果数量。EmptyMessage只在没有结果时显示。- 列表绑定过滤结果。
第 12 步:运行后检查
运行后按顺序检查:
- 启动后显示 5 条商品。
- 输入
key,只显示Keyboard。 - 输入
USB,只显示USB Cable。 - 输入
xxx,列表为空并显示空结果提示。 - 清空输入后恢复 5 条。
- 勾选“仅显示有库存”,隐藏库存为 0 的商品。
- 输入
stand且勾选有库存,结果为空。
常见错误和修法
| 错误 | 为什么错 | 修法 |
|---|---|---|
| 搜索直接改原始列表 | 清空关键词后数据回不来 | 保留 AllProducts,显示 VisibleProducts |
| 搜索大小写敏感 | 同一商品可能搜不到 | 比较前统一 ToLower() |
| 每输入一个字就读文件 | 性能差,逻辑乱 | 先在内存列表里过滤 |
| 多个条件互相覆盖 | 分类、关键词、状态不能组合 | 用一条 LINQ 查询按顺序叠加条件 |
| 空关键词没有处理 | 清空搜索后仍然过滤 | 关键词为空时显示全部 |
小白重复敲写训练
搜索过滤先在纯 C# 集合上练,再接界面输入框。
训练 1:按名称搜索
string keyword = "key";
var result = products.Where(product =>
product.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase));分别搜索 key、mouse 和不存在的词。
训练 2:组合库存条件
bool onlyInStock = true;
var result = products.Where(product =>
!onlyInStock || product.Stock > 0);改动任务:切换 onlyInStock 为 false。
训练 3:输入变化后刷新
partial void OnKeywordChanged(string value)
{
ApplyFilter();
}
private void ApplyFilter()
{
FilteredProducts.Clear();
foreach (Product product in _allProducts.Where(Matches))
FilteredProducts.Add(product);
}第三遍补上空关键词时显示全部商品。
每日小测
做完本页后,用这 5 题检查是否真的掌握。
1. 判断题
本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。
答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。
2. 填空题
本页主题是:搜索与过滤。今天至少要掌握的 3 个点是:
1. 搜索和过滤有什么区别
2. 为什么要区分全部数据和可见数据
3. 空关键字为什么显示全部答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。
3. 流程题
遇到本页相关功能时,先按什么顺序处理?
答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。
4. 找错误题
如果本页代码运行失败,第一步应该做什么?
答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。
5. 改需求题
在本页完整例子跑通后,至少改一个小需求。
可选改法:
- 改一个字段名称。
- 多加一个校验条件。
- 多输出一行结果。
- 把固定数据改成用户输入。
- 把一次处理改成多条数据处理。
答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:搜索和过滤有什么区别。
上位机专项练习
设备多了以后必须支持搜索、状态筛选和排序,否则操作者找不到目标。
下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。
专项例子 1:按名称搜索
IEnumerable<DeviceItem> result = Devices.Where(device =>
device.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase));运行结果或界面效果:
输入 plc 只保留名称包含 plc 的设备改动任务: 同时搜索 IP 地址。
专项例子 2:按状态筛选
if (SelectedStatus == "在线")
{
result = result.Where(device => device.IsOnline);
}
else if (SelectedStatus == "离线")
{
result = result.Where(device => !device.IsOnline);
}运行结果或界面效果:
下拉框切换在线或离线改动任务: 增加“报警”筛选。
专项例子 3:组合搜索栏
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBox Width="260" Watermark="搜索名称或 IP" Text="{Binding SearchText}" />
<ComboBox ItemsSource="{Binding StatusOptions}" SelectedItem="{Binding SelectedStatus}" />
<Button Content="清空" Command="{Binding ClearFilterCommand}" />
</StackPanel>运行结果或界面效果:
搜索、状态和清空操作放在一起改动任务: 增加按名称排序按钮。
第四部分:作业完整答案
作业要求:完成商品搜索过滤页面,支持关键字搜索、仅显示有库存、清空过滤、空结果提示。
答案文件 1:ViewModels/MainWindowViewModel.cs
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ProductApp.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
private readonly List<ProductItem> allProducts = new()
{
new ProductItem { Id = Guid.NewGuid(), Name = "Keyboard", Price = 199m, Stock = 10 },
new ProductItem { Id = Guid.NewGuid(), Name = "Mouse", Price = 59m, Stock = 30 },
new ProductItem { Id = Guid.NewGuid(), Name = "Monitor", Price = 899m, Stock = 0 },
new ProductItem { Id = Guid.NewGuid(), Name = "USB Cable", Price = 19m, Stock = 100 },
new ProductItem { Id = Guid.NewGuid(), Name = "Laptop Stand", Price = 129m, Stock = 0 }
};
public ObservableCollection<ProductItem> VisibleProducts { get; } = new();
[ObservableProperty]
private string searchText = "";
[ObservableProperty]
private bool onlyInStock;
[ObservableProperty]
private string message = "";
[ObservableProperty]
private string emptyMessage = "";
[ObservableProperty]
private bool isEmpty;
public MainWindowViewModel()
{
ApplyFilter();
}
partial void OnSearchTextChanged(string value)
{
ApplyFilter();
}
partial void OnOnlyInStockChanged(bool value)
{
ApplyFilter();
}
[RelayCommand]
private void ClearFilter()
{
SearchText = "";
OnlyInStock = false;
ApplyFilter();
}
private void ApplyFilter()
{
string keyword = SearchText.Trim();
IEnumerable<ProductItem> query = allProducts;
if (!string.IsNullOrWhiteSpace(keyword))
{
query = query.Where(product =>
product.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase));
}
if (OnlyInStock)
{
query = query.Where(product => product.Stock > 0);
}
List<ProductItem> result = query.ToList();
VisibleProducts.Clear();
foreach (ProductItem product in result)
{
VisibleProducts.Add(product);
}
IsEmpty = VisibleProducts.Count == 0;
EmptyMessage = IsEmpty ? "没有找到符合条件的商品。" : "";
Message = $"显示 {VisibleProducts.Count} / {allProducts.Count} 条商品";
}
}
public sealed class ProductItem
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
public int Stock { get; set; }
}答案文件 2:Views/MainWindow.axaml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ProductApp.ViewModels"
x:Class="ProductApp.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Width="840"
Height="560"
Title="Product Search">
<Grid RowDefinitions="Auto,Auto,Auto,*" Margin="20" RowSpacing="12">
<TextBlock Text="商品搜索与过滤"
FontSize="24"
FontWeight="Bold" />
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<TextBox Width="260"
PlaceholderText="输入商品名称"
Text="{Binding SearchText, Mode=TwoWay}" />
<CheckBox Content="仅显示有库存"
IsChecked="{Binding OnlyInStock, Mode=TwoWay}" />
<Button Content="清空过滤"
Command="{Binding ClearFilterCommand}" />
</StackPanel>
<StackPanel Grid.Row="2" Spacing="4">
<TextBlock Text="{Binding Message}" />
<TextBlock Text="{Binding EmptyMessage}"
IsVisible="{Binding IsEmpty}" />
</StackPanel>
<ListBox Grid.Row="3"
ItemsSource="{Binding VisibleProducts}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ProductItem">
<Grid ColumnDefinitions="180,100,80" Margin="4">
<TextBlock Text="{Binding Name}" />
<TextBlock Grid.Column="1"
Text="{Binding Price}" />
<TextBlock Grid.Column="2"
Text="{Binding Stock}" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Window>验收结果
运行后必须看到:
- 启动后显示 5 条商品。
- 输入
key后只显示Keyboard。 - 输入
usb后只显示USB Cable。 - 输入不存在的关键字后显示空结果提示。
- 清空过滤后恢复全部商品。
- 勾选有库存后不显示库存为 0 的商品。
- 搜索和库存过滤可以组合使用。
为什么这个答案是对的
这个答案把搜索过滤拆成了清楚的数据流:
| 数据 | 作用 |
|---|---|
allProducts | 保存全部商品,不被搜索破坏 |
SearchText | 保存用户输入的关键字 |
OnlyInStock | 保存库存过滤开关 |
ApplyFilter | 统一计算过滤结果 |
VisibleProducts | 给界面显示当前结果 |
IsEmpty | 控制空结果提示 |
搜索过滤的关键是:原始数据不丢,显示结果可变,条件变化时重新计算可见列表。