65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (26投票s)

2009年2月1日

CPOL

5分钟阅读

viewsIcon

101045

downloadIcon

1652

如何使用 M-V-VM 模式向 WPF 应用程序添加类似 Firefox 的增量搜索。搜索直接在 CollectionView 上执行,因此可用于任何 WPF 项控件。

引言

要构建代码,您需要 Visual Studio 2008 SP1。要运行示例,需要 .NET Framework 3.5 SP1。

最近,我的团队收到客户请求,要求在 WPF DataGrid 控件中搜索项目。搜索应在用户键入字母时自动完成。我们决定创建一个更通用的实现,其工作方式类似于 Firefox 的搜索。除了增量搜索之外,还有“下一个”和“上一个”按钮。结果如下所示。

Sample application

背景

在描述解决方案之前,我们首先提供一些关于示例基础设施的信息。我们正在开发的应用程序(以及此示例)的用户界面基于 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

用户界面的另一个重要组成部分是 DelegateCommandDelegateCommand 是一个实现 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.ForwardSkipCurrentSearchType.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。当 NoResultstrue 时,TextBlock 将显示。PreviousCommandNextCommand 绑定到“上一个”和“下一个”按钮。命令是 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 匹配 CodeBarcode 以搜索词开头的 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 项目控件。

示例应用程序中的 PersonsViewModelPersonsPage 类似。PersonsPage 使用 ListBox 作为项控件来反映搜索与控件无关的事实。

关注点

SearchUserControl 可以通过快捷键进一步改进,重新设计成浮动透明窗口,直到用户尝试在网格中键入内容时才显示等等。

我希望这篇文章能帮助您理解 M-V-VM 模式背后的强大和优雅。

您可以在此处找到有关此模式的更多信息

其他有用链接

当然,还有 CodeProject 大师的所有 WPF 文章

历史

02-01-2009

  • 原始文章。
© . All rights reserved.