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

Deferring ListCollectionView filter updates for a responsive UI

0.00/5 (No votes)
11 Jan 2009 1  
Explains how to update search results as one types in a text box while keeping the UI responsive.

SnappyFiltering.png

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.

Dictionary.png

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.

DeferredAction.png

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.

/// <summary>
/// Creates a new DeferredAction.
/// </summary>
/// <param name="action">
/// The action that will be deferred. It is not performed until 
/// after <see cref="Defer"/> is called.
/// </param>
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.

/// <summary>
/// Defers performing the action until after time elapses. 
/// Repeated calls will reschedule the action
/// if it has not already been performed.
/// </summary>
/// <param name="delay">
/// The amount of time to wait before performing the action.
/// </param>
public void Defer(TimeSpan delay)
{
    // Fire action when time elapses (with no subsequent calls).
    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());
        }

        // Defer applying search criteria until time has elapsed.
        this.deferredAction.Defer(searchDelay);
    }
    else
    {
        // Apply search criteria immediately. This makes the UI less responsive
        // since the list is updated on every character input.
        ApplySearchCriteria();
    }
}

History

  • 11-Jan-2009 - Initial version.

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