Introduction
This article discusses how to implement a WPF application with a search text box which displays results as you type. One way to implement this is to use ICollectionView
's Filter
property by updating it with the appropriate filter as each character is entered. Under some circumstances, this can make the application frustrating to use, with each character entered resulting in an expensive refresh. The solution presented here is to defer updates to the Filter
property until the user has likely finished typing. The trick is to find a simple way to guess when this is.
Background of ICollectionView filtering
When we bind a control to list data, what we are actually binding the control to is a collection view. The collection view, represented by the interface ICollectionView
, is literally a view of the data in the sense that it wraps it and controls how the data is presented by the controls. One of the properties belonging to this interface is Filter
, which is of type Predicate<object>
, a delegate that takes an object
and returns a bool
indicating whether the object should be shown by the control. Knowing this, there is at least one obvious way to implement a search text box which comes to mind. We simply bind a ListBox
to the complete list of data, subscribe to the TextBox
's TextChanged
event, and update the ICollectionView
's Filter
property as each character is entered.
To demonstrate this, in the XAML snippet below, I've created a TextBox
where the search criteria can be entered and a ListBox
which shows the results. This snippet assumes the data context has been set to a List<string>
instance which contains the full set of data to be searched over.
<TextBox x:Name="searchTextBox" TextChanged="searchTextBox_TextChanged"/>
<ListBox Grid.Row="1" ItemsSource="{Binding}" />
In the code-behind, I have a handler for the TextChanged
event which is responsible for updating the view's filter. This method is called upon each character being entered by the user so that the results are always up to date. The code uses CollectionViewSource.GetDefaultView
to get the view which wraps the data. The filter is then updated such that it returns true
whenever the data contains the text in the search text box.
private void searchTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
ICollectionView view =
CollectionViewSource.GetDefaultView(this.DataContext);
if (view != null)
{
string text = this.searchTextBox.Text;
view.Filter = (obj) => ((string)obj).Contains(text);
}
}
One common complaint by people about this type of implementation is that the UI can become unresponsive as characters are entered if the data set being searched over is quite large. In this situation, after each character is entered, the whole UI will freeze until the ListBox
has finished refreshing. In general, this issue will manifest itself whenever refreshing is expensive. I originally ran into this issue when implementing a search box applied to the DataGrid
from the WPF Toolkit, which clearly is going to have a more expensive refresh than the ListBox
used here.
Due to this issue, people typically choose to update the filter either when the user presses Enter or a when the Search button is clicked. However, there is a very simple solution to this problem.
Japanese dictionary demo
To demonstrate the idea behind this article, I have created a demo Japanese dictionary application which makes use of the EDICT dictionary files. The application can use either the full EDICT dictionary, or the EDICT subset dictionary, which is much smaller, including only the 22,000 most common Japanese words. The full EDICT dictionary best demonstrates the cost of refreshing since it is so large. The dictionary to use can be selected from a menu; however, I have only packaged the EDICT subset dictionary, so the EDICT dictionary is grayed out. Those who are inclined can download the full EDICT dictionary and use it with the demo.
A checkbox to the right of the search box toggles between updating the filter on each character entered (when unchecked) and the technique I am about to describe (when checked, the default).
The DeferredAction class
Rather than update the search results as each character is entered, what would be more efficient is to update them only after the user has stopped typing for some period of time. This way, they won't be hampered by the costly refresh while they are still typing. Usually, the user is not interested in intermediate results anyways.
To achieve this behavior, I have created the DeferredAction
class.
A DeferredAction
instance is created from an Action
, which is simply a delegate taking no parameters and returning no value. Calling Defer
and passing in a TimeSpan
causes the action to be invoked after that amount of time has elapsed. Calling Defer
again before the action is invoked causes it to be rescheduled.
So, if we consider updating the Filter
property to be our action, then calling Defer
on every character entered gives us exactly the behavior we want.
Let's take a look at the implementation of DeferredAcion
. When DeferredAction
is initialized, it creates a Timer
instance whose callback uses the application's Dispatcher
to invoke the action. The dispatcher must be used because setting the Filter
property essentially updates the UI, and we know that all UI updates must happen on the appropriate thread. The Timer
's callback may not necessarily be invoked on this thread. Note that initially, the action is not scheduled to be invoked.
public static DeferredAction Create(Action action)
{
if (action == null)
{
throw new ArgumentNullException("action");
}
return new DeferredAction(action);
}
private DeferredAction(Action action)
{
this.timer = new Timer(new TimerCallback(delegate
{
Application.Current.Dispatcher.Invoke(action);
}));
}
The Defer
method uses the Timer
's Change
method to schedule the callback to be called after the specified time elapses.
public void Defer(TimeSpan delay)
{
this.timer.Change(delay, TimeSpan.FromMilliseconds(-1));
}
Using DeferredAction
Now that we've implemented DeferredAction
, we just need to define the action to be performed. ApplySearchCriteria
gets the ICollectionView
for the data being displayed, and sets the filter to a delegate which returns true
when either the entry or the definition contains the text in the text box. Therefore, we can search by either Japanese or English.
private void ApplySearchCriteria()
{
ICollectionView view =
(ICollectionView)CollectionViewSource.GetDefaultView(
this.entriesListBox.ItemsSource);
if (view != null)
{
string text = this.searchBox.Text.ToLowerInvariant();
view.Filter = delegate(object obj)
{
DictionaryEntry entry = obj as DictionaryEntry;
if (entry != null)
{
return entry.Entry.ToLowerInvariant().Contains(text)
|| entry.Definition.ToLowerInvariant().Contains(text);
}
return false;
};
}
}
When text is entered in the text box, one of two things will happen. If the checkbox is unchecked, then the search criteria is applied as each character is entered. If the checkbox is checked, then the search criteria is applied only after the time specified by searchDelay
has elapsed without the user entering any text. For the demo, I've used a delay of 0.25 seconds, as this seems to strike the right balance.
private void searchBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (fastCheckBox.IsChecked == true)
{
if (this.deferredAction == null)
{
this.deferredAction = DeferredAction.Create(() => ApplySearchCriteria());
}
this.deferredAction.Defer(searchDelay);
}
else
{
ApplySearchCriteria();
}
}
History
- 11-Jan-2009 - Initial version.