Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Implement a Firefox-like search in WPF applications using M-V-VM

0.00/5 (No votes)
1 Feb 2009 1  
How to add a Firefox-like incremental search to WPF applications using the M-V-VM pattern. The search is performed directly on a CollectionView, so it can be used with any WPF items control.

Introduction

In order to build the code, you'll need Visual Studio 2008 SP1. To run the sample, .NET Framework 3.5 SP1 is required.

Recently, my team had a customer request for searching items in a WPF DataGrid control. Search should be done automatically as the user types in letters. We decided to create a more generic implementation that works similar to Firefox's search. Besides the incremental search, there are Next and Previous buttons. The result can be seen below.

Sample application

Background

Before describing the solution, let's first provide some information about the sample infrastructure. The user interface for the application we are working on (and also for this sample) is based on the Model-View-ViewModel pattern. You can find more about this pattern by following the links at the bottom of this article.

ViewModel

All ViewModel classes, including the SearchViewModel, inherit from the base ViewModel class. This class implements only the INotifyPropertyChanged interface.

/// <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

Another important ingredient of the user interface is DelegateCommand. DelegateCommand is a class implementing the WPF ICommand interface. It doesn't encapsulate any command code but uses a delegate (an Action<T> instance) to run some external code. The second, optional delegate (of type Predicate<T>) can be used to enable or disable a command, providing a nice feedback to the user.

/// <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);
    }
}

Search "component"

SearchViewModel

OK, now that we have covered some basic infrastructure, it's time to move to the actual search implementation. The search logic is implemented in SearchViewModel. Let's first see the code, and then we'll comment it:

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);
        }
    }
}

The first thing you'll note is that this class is generic, which allows us to avoid type casting when matching items. Also, since we have several null checks, it's constrained as a 'class'.

The search logic is in the FindItem methods. The main FindItem method accepts a SearchType enumeration. There are three types of search. When a search is performed by typing characters to a TextBox, the current item is also matched. This search type is SearchType.Forward. When a search is performed by clicking on the Next and Previous buttons, the current item is skipped and we use SearchType.ForwardSkipCurrent and SearchType.Backward, respectively.

One of the most important classes in the WPF data binding model is the CollectionView class / ICollectionView interface. All item controls use an object of this type to store their ItemsSource property, so it is the best way to provide a search upon them. The problem with the ICollectionView is that it doesn't have a Count property or an indexer. Actually, we could use the CollectionView class instead, because it has a Count property, but the missing indexer prevents us from efficiently iterating backwards or forwards from a previous position. To allow iterating with the for loop, we added the SearchIndex property of type IList<T>. The SearchIndex is created in the constructor and at any time the CollectionView changes (items added or removed, sorting, etc.). It holds all the items from the CollectionView, is strongly typed and in the current sort order. This means that the search will work correctly even if you change the sort order.

The FindItem method itself doesn't know if a single item matches the search term. It loops through all the items and calls the itemMatch delegate (of type Func<T, string, bool>) for every item. This delegate is the second parameter of the SearchViewModel constructor. Having the match as a delegate allows us to have custom match logic whenever we use this component and for any custom item type.

SearchUserControl

SearchViewModel also has several properties that are data bound to 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>

The SearchTerm is bound to a TextBox. Whenever the user types in a character, a search is performed. The NoResults property is bound to a No results TextBlock. The TextBlock is displayed when NoResults is true. PreviousCommand and NextCommand are bound to Previous and Next buttons. Commands are instances of DelegateCommand. Whenever the user clicks on one of these buttons, a forward or backward search is performed. When SearchTerm is empty or there are no results, both buttons are disabled. The disabled state is controlled by the second Predicate<T> parameter of the DelegateCommand constructor.

Using the search component

ProductsViewModel

Now that we have a component ready, it's time to see how it can be used. The following is the ProductsViewModel class. It's used to data bind a list of Product objects (you can find the Product class in the accompanied source code) to 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 is also exposed as a property of ProductsViewModel. It's initialized in the ProductsViewModel constructor with a list of Products and an ItemMatch method delegate. ItemMatch matches Products whose Code or Barcode starts with the search term, or whose Name contains the search term. You could write any custom logic here, i.e., to match Products whose quantity is greater than the search term, to match the Name with multiple words in the search term, etc.

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 has only a DataGrid and a SearchUserControl. Both are bound to ProductsViewModel. In order for the search to work, the IsSynchronizedWithCurrentItem property of the DataGrid (or any other items control) needs to be set to true, so that the control picks up whenever the CurrentItem is changed.

You'll also notice that the DataGrid is scrolled to display the current item which is not the default behavior of DataGrid. The Infrastructure.DataGridExtenders class is in charge of auto scrolling. It's a variation of the solution found here: Autoscroll ListBox in WPF. It could be made more generic to support all WPF item controls.

PersonsViewModel and PersonsPage from the sample application are similar. PersonsPage uses a ListBox as an item control to reflect the fact that the search is independent of the controls.

Points of Interest

The SearchUserControl can be further improved with shortcut keys, restyled to a floating transparent window, hidden until the user tries to type in something in the grid, etc.

I hope this article will be helpful to you, to understand the power and elegance behind the M-V-VM pattern.

You can find more about this pattern here:

Other useful links:

And, of course, all WPF articles from CodeProject gurus:

History

02-01-2009

  • Original article.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here