使用 M-V-VM 在 WPF 应用程序中实现类似 Firefox 的搜索






4.91/5 (26投票s)
如何使用 M-V-VM 模式向 WPF 应用程序添加类似 Firefox 的增量搜索。搜索直接在 CollectionView 上执行,因此可用于任何 WPF 项控件。
引言
要构建代码,您需要 Visual Studio 2008 SP1。要运行示例,需要 .NET Framework 3.5 SP1。
最近,我的团队收到客户请求,要求在 WPF DataGrid
控件中搜索项目。搜索应在用户键入字母时自动完成。我们决定创建一个更通用的实现,其工作方式类似于 Firefox 的搜索。除了增量搜索之外,还有“下一个”和“上一个”按钮。结果如下所示。
背景
在描述解决方案之前,我们首先提供一些关于示例基础设施的信息。我们正在开发的应用程序(以及此示例)的用户界面基于 Model-View-ViewModel 模式。您可以通过本文底部的链接找到有关此模式的更多信息。
ViewModel
所有 ViewModel 类,包括 SearchViewModel
,都继承自基 ViewModel
类。此类仅实现 INotifyPropertyChanged
接口。
/// <summary>
/// Base class for all view models (from the Model-View-ViewModel pattern).
/// </summary>
public abstract class ViewModel : INotifyPropertyChanged
{
/// <summary>
/// Occurs when a property value changes.
/// </summary>
public event PropertyChangedEventHandler PropertyChanged;
/// <summary>
/// Raises the <see cref="PropertyChanged"/> event.
/// </summary>
/// <param name="propertyName">Name of the property whose value is changed.</param>
protected virtual void OnPropertyChanged(string propertyName)
{
if (PropertyChanged != null)
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
DelegateCommand
用户界面的另一个重要组成部分是 DelegateCommand
。DelegateCommand
是一个实现 WPF ICommand
接口的类。它不封装任何命令代码,而是使用委托(Action<T>
实例)来运行一些外部代码。第二个可选委托(类型为 Predicate<T>
)可用于启用或禁用命令,从而向用户提供良好的反馈。
/// <summary>
/// Represents an <see cref="ICommand"/>
/// which runs an event handler when it is invoked.
/// </summary>
public class DelegateCommand : ICommand
{
private readonly Action<object> _executeAction;
private readonly Predicate<object> _canExecute;
/// <summary>
/// Raised when changes occur that affect whether or not the command should execute.
/// </summary>
/// <remarks>
/// The trick to integrate into WPF command manager found on:
/// http://joshsmithonwpf.wordpress.com/2008/06/17/
/// allowing-commandmanager-to-query-your-icommand-objects/
/// </remarks>
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
/// <summary>
/// Creates a new instance of <see cref="DelegateCommand"/>
/// and assigns the given action to it.
/// </summary>
/// <param name="executeAction">Event handler to assign to the command.</param>
public DelegateCommand(Action<object> executeAction) : this(executeAction, null)
{
}
/// <summary>
/// Creates a new instance of <see cref="DelegateCommand"/>
/// and assigns the given action and predicate to it.
/// </summary>
/// <param name="executeAction">Event handler to assign to the command.</param>
/// <param name="canExecute">Predicate
/// to check whether the command can be executed.</param>
public DelegateCommand(Action<object> executeAction, Predicate<object> canExecute)
{
_executeAction = executeAction;
_canExecute = canExecute;
}
/// <summary>
/// Defines the method that determines whether
/// the command can execute in its current state.
/// </summary>
/// <returns>
/// true if this command can be executed; otherwise, false.
/// </returns>
/// <param name="parameter">Data used by the command.
/// If the command does not require data
/// to be passed, this object can be set to null.</param>
public bool CanExecute(object parameter)
{
return _canExecute == null ? true : _canExecute.Invoke(parameter);
}
/// <summary>
/// Defines the method to be called when
/// the command is invoked. The method will invoke the
/// attached event handler.
/// </summary>
/// <param name="parameter">Data used
/// by the command. If the command does not require data
/// to be passed, this object can be set to null.</param>
public void Execute(object parameter)
{
_executeAction.Invoke(parameter);
}
}
搜索“组件”
SearchViewModel
好的,现在我们已经介绍了一些基本的基础设施,是时候转到实际的搜索实现了。搜索逻辑在 SearchViewModel
中实现。让我们首先看看代码,然后进行注释。
internal class SearchViewModel<T> : ViewModel where T : class
{
private enum SearchType
{
Forward,
ForwardSkipCurrent,
Backward
}
private readonly Func<T, string, bool> _itemMatch;
private bool _noResults;
private string _searchTerm = String.Empty;
/// <summary>
/// Creates a new instance of <see cref="SearchViewModel{T}"/> class.
/// </summary>
/// <param name="collectionView">Collection to search for items.</param>
/// <param name="itemMatch">Delegate to perform item matching.</param>
public SearchViewModel(ICollectionView collectionView,
Func<T, string, bool> itemMatch)
{
CollectionView = collectionView;
CollectionView.CollectionChanged += (sender, e) => RebuildSearchIndex();
RebuildSearchIndex();
_itemMatch = itemMatch;
NextCommand = new DelegateCommand(
p => FindItem(SearchType.ForwardSkipCurrent),
x => !String.IsNullOrEmpty(SearchTerm) && !NoResults);
PreviousCommand = new DelegateCommand(
p => FindItem(SearchType.Backward),
x => !String.IsNullOrEmpty(SearchTerm) && !NoResults);
}
protected ICollectionView CollectionView { get; private set; }
protected IList<T> SearchIndex { get; private set; }
public ICommand NextCommand { get; private set; }
public ICommand PreviousCommand { get; private set; }
public bool NoResults
{
get { return _noResults; }
set
{
if (_noResults == value) return;
_noResults = value;
OnPropertyChanged("NoResults");
}
}
public string SearchTerm
{
get { return _searchTerm; }
set
{
if (_searchTerm == value) return;
_searchTerm = value;
OnPropertyChanged("SearchTerm");
NoResults = false;
FindItem(SearchType.Forward);
}
}
private void FindItem(SearchType type)
{
if (String.IsNullOrEmpty(SearchTerm)) return;
T item;
switch (type)
{
case SearchType.Forward:
// Search from the current position
// to end and loop from start if nothing found
item = FindItem(CollectionView.CurrentPosition, SearchIndex.Count - 1) ??
FindItem(0, CollectionView.CurrentPosition);
break;
case SearchType.ForwardSkipCurrent:
// Search from the next item position
// to end and loop from start if nothing found
item = FindItem(CollectionView.CurrentPosition + 1, SearchIndex.Count - 1) ??
FindItem(0, CollectionView.CurrentPosition);
break;
case SearchType.Backward:
// Search backwards from the current position
// to start and loop from end if nothing found
item = FindItemReverse(CollectionView.CurrentPosition - 1, 0) ??
FindItemReverse(SearchIndex.Count - 1,
CollectionView.CurrentPosition);
break;
default:
throw new ArgumentOutOfRangeException("type");
}
if (item == null)
NoResults = true;
else
CollectionView.MoveCurrentTo(item);
}
private T FindItem(int startIndex, int endIndex)
{
for (var i = startIndex; i <= endIndex; i++)
{
if (_itemMatch(SearchIndex[i], SearchTerm))
return SearchIndex[i];
}
return null;
}
private T FindItemReverse(int startIndex, int endIndex)
{
for (var i = startIndex; i >= endIndex; i--)
{
if (_itemMatch(SearchIndex[i], SearchTerm))
return SearchIndex[i];
}
return null;
}
private void RebuildSearchIndex()
{
SearchIndex = new List<T>();
foreach (var item in CollectionView)
{
SearchIndex.Add((T) item);
}
}
}
您会注意到的第一件事是,这个类是泛型的,这使我们能够在匹配项目时避免类型转换。此外,由于我们有几个空值检查,因此它被限制为“类”。
搜索逻辑在 FindItem
方法中。主 FindItem
方法接受一个 SearchType
枚举。有三种类型的搜索。当通过向 TextBox
键入字符来执行搜索时,也会匹配当前项目。此搜索类型是 SearchType.Forward
。当通过单击“下一个”和“上一个”按钮执行搜索时,当前项目将被跳过,我们分别使用 SearchType.ForwardSkipCurrent
和 SearchType.Backward
。
WPF 数据绑定模型中最重要的类之一是 CollectionView
类 / ICollectionView
接口。所有项控件都使用此类型的对象来存储它们的 ItemsSource
属性,因此它是对其提供搜索的最佳方式。ICollectionView
的问题是它没有 Count
属性或索引器。实际上,我们可以使用 CollectionView
类,因为它有一个 Count
属性,但缺少索引器使我们无法从以前的位置高效地向后或向前迭代。为了允许使用 for
循环进行迭代,我们添加了类型为 IList<T>
的 SearchIndex
属性。SearchIndex
在构造函数中创建,并且在 CollectionView
发生任何更改(添加或删除项、排序等)时都会创建。它包含 CollectionView
中的所有项,是强类型且按当前排序顺序排列。这意味着即使您更改排序顺序,搜索也能正常工作。
FindItem
方法本身并不知道单个项目是否与搜索词匹配。它会遍历所有项目并为每个项目调用 itemMatch
委托(类型为 Func<T, string, bool>
)。此委托是 SearchViewModel
构造函数的第二个参数。将匹配作为委托允许我们在使用此组件时以及对于任何自定义项目类型都具有自定义匹配逻辑。
SearchUserControl
SearchViewModel
还有几个数据绑定到 SearchUserControl
的属性。
<UserControl x:Class="CodeMind.FirefoxLikeSearch.Views.SearchUserControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:ClassModifier="internal">
<StackPanel Orientation="Horizontal">
<TextBox
Text="{Binding Path=SearchTerm, UpdateSourceTrigger=PropertyChanged}"
Width="200" />
<Button
Command="{Binding Path=NextCommand}"
Margin="5,0,0,0"
Width="70">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Next" />
<Image Width="10" Source="/Resources/arrow_down.png" />
</StackPanel>
</Button>
<Button
Command="{Binding Path=PreviousCommand}"
Margin="5,0,0,0"
Width="70">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Previous" />
<Image Width="10" Source="/Resources/arrow_up.png" />
</StackPanel>
</Button>
<TextBlock
FontWeight="Bold"
Foreground="Red"
Margin="5,0,0,0"
Text="No results"
Visibility="{Binding Path=NoResults,
Converter={StaticResource booleanToVisibilityConverter}}"
VerticalAlignment="Center" />
</StackPanel>
</UserControl>
SearchTerm
绑定到一个 TextBox
。每当用户键入一个字符时,就会执行搜索。NoResults
属性绑定到一个“无结果”TextBlock
。当 NoResults
为 true
时,TextBlock
将显示。PreviousCommand
和 NextCommand
绑定到“上一个”和“下一个”按钮。命令是 DelegateCommand
的实例。每当用户点击其中一个按钮时,就会执行向前或向后搜索。当 SearchTerm
为空或没有结果时,这两个按钮都将被禁用。禁用状态由 DelegateCommand
构造函数的第二个 Predicate<T>
参数控制。
使用搜索组件
ProductsViewModel
现在我们已经准备好了一个组件,是时候看看如何使用它了。下面是 ProductsViewModel
类。它用于将 Product
对象列表(您可以在附带的源代码中找到 Product
类)数据绑定到 ProductsPage
。
internal class ProductsViewModel : ViewModel
{
public ProductsViewModel()
{
Products = CollectionViewSource.GetDefaultView(Product.GetTestProducts());
Search = new SearchViewModel<Product>(Products, ItemMatch);
}
public ICollectionView Products { get; private set; }
public SearchViewModel<Product> Search { get; private set; }
private static bool ItemMatch(Product item, string searchTerm)
{
searchTerm = searchTerm.ToLower();
return item.Code.ToLower().StartsWith(searchTerm) ||
item.Barcode.ToLower().StartsWith(searchTerm) ||
item.Name.ToLower().Contains(searchTerm);
}
}
SearchViewModel
也作为 ProductsViewModel
的属性公开。它在 ProductsViewModel
构造函数中用 Product
列表和 ItemMatch
方法委托初始化。ItemMatch
匹配 Code
或 Barcode
以搜索词开头的 Product
,或 Name
包含搜索词的 Product
。您可以在此处编写任何自定义逻辑,例如,匹配数量大于搜索词的 Product
,匹配搜索词中包含多个单词的 Name
等。
ProductsPage
<Page x:Class="CodeMind.FirefoxLikeSearch.Views.ProductsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dg="http://schemas.microsoft.com/wpf/2008/toolkit"
xmlns:Views="clr-namespace:CodeMind.FirefoxLikeSearch.Views"
xmlns:Infrastructure="clr-namespace:CodeMind.FirefoxLikeSearch.Infrastructure"
Title="ProductsPage"
x:ClassModifier="internal">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="30" />
</Grid.RowDefinitions>
<dg:DataGrid
AlternatingRowBackground="#FFF2F5F1"
AutoGenerateColumns="False"
Grid.Row="0"
GridLinesVisibility="None"
Infrastructure:DataGridExtenders.IsAutoScroll="True"
IsReadOnly="True"
IsSynchronizedWithCurrentItem="True"
ItemsSource="{Binding Path=Products}"
Margin="5,5,5,5"
RowHeight="20"
SelectionMode="Single"
VerticalAlignment="Stretch">
<dg:DataGrid.Columns>
<dg:DataGridTextColumn Header="Code" Binding="{Binding Path=Code}"/>
<dg:DataGridTextColumn Header="Barcode" Binding="{Binding Path=Barcode}"/>
<dg:DataGridTextColumn Header="Name" Binding="{Binding Path=Name}"/>
<dg:DataGridTextColumn Header="Price" Binding="{Binding Path=Price}"/>
<dg:DataGridTextColumn Header="Quantity" Binding="{Binding Path=Quantity}"/>
</dg:DataGrid.Columns>
</dg:DataGrid>
<Views:SearchUserControl
DataContext="{Binding Path=Search}"
Grid.Row="1"
Margin="5,0,5,5" />
</Grid>
</Page>
ProductsPage
只有一个 DataGrid
和一个 SearchUserControl
。两者都绑定到 ProductsViewModel
。为了使搜索工作,DataGrid
(或任何其他项目控件)的 IsSynchronizedWithCurrentItem
属性需要设置为 true
,以便当 CurrentItem
更改时控件会感知到。
您还会注意到 DataGrid
会滚动以显示当前项目,这并非 DataGrid
的默认行为。Infrastructure.DataGridExtenders
类负责自动滚动。它是此处找到的解决方案的变体:WPF 中的自动滚动列表框。它可以做得更通用以支持所有 WPF 项目控件。
示例应用程序中的 PersonsViewModel
和 PersonsPage
类似。PersonsPage
使用 ListBox
作为项控件来反映搜索与控件无关的事实。
关注点
SearchUserControl
可以通过快捷键进一步改进,重新设计成浮动透明窗口,直到用户尝试在网格中键入内容时才显示等等。
我希望这篇文章能帮助您理解 M-V-VM 模式背后的强大和优雅。
- John Gossman 的初始文章
- John Gossman 和 Dan Crevier 文章的摘要
- 使用 M-V-VM 的 WPF 应用程序 - Josh Smith 的 MSDN 文章
- Karl Shifflett 的 M-V-VM Channel 9 视频
- Karl Shifflett 的使用 M-V-VM 的业务线应用程序
其他有用链接
当然,还有 CodeProject 大师的所有 WPF 文章
历史
02-01-2009
- 原始文章。