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.
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.
public abstract class ViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
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.
public class DelegateCommand : ICommand
{
private readonly Action<object> _executeAction;
private readonly Predicate<object> _canExecute;
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
public DelegateCommand(Action<object> executeAction) : this(executeAction, null)
{
}
public DelegateCommand(Action<object> executeAction, Predicate<object> canExecute)
{
_executeAction = executeAction;
_canExecute = canExecute;
}
public bool CanExecute(object parameter)
{
return _canExecute == null ? true : _canExecute.Invoke(parameter);
}
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;
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:
item = FindItem(CollectionView.CurrentPosition, SearchIndex.Count - 1) ??
FindItem(0, CollectionView.CurrentPosition);
break;
case SearchType.ForwardSkipCurrent:
item = FindItem(CollectionView.CurrentPosition + 1, SearchIndex.Count - 1) ??
FindItem(0, CollectionView.CurrentPosition);
break;
case SearchType.Backward:
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 Product
s and an ItemMatch
method delegate. ItemMatch
matches Product
s 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 Product
s 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