Mathias Brandewinder on .NET, F#, VSTO and Excel development, and quantitative analysis / machine learning.
by Mathias 27. September 2010 18:15

An interesting user interface issue came up in the project I am working on right now.  In a nutshell, the user needs to select an item from a list of existing items. This sounds straightforward, except that the list of items has hundreds of elements. Clearly, a combo box or a list box won’t do the job – we need a convenient way to reduce the choices and help the user find what he needs.

One suggestion I liked was to use something similar to an auto-complete: type in a few letters, and show only “relevant” items to chose from:

auto-complete-me

While digging into the question, I came into a .NET interface I didn’t know, ICollectionView. Essentially, it is a collection that is suitable for WPF databinding, and also has filtering capabilities. It stores a source collection, which contains all the items, but when a filter is applied to it, WPF will only “see” the filtered results. 

Displaying a standard list

Let’s first create a WPF application, and warm up by adding a simple class, Product:

public class Product
{
   public string Name
   {
      get;
      set;
   }
}

Now let’s add a WPF UserControl, SearchAndSelectView.xaml. The control has a TextBlock, where we will display the name of the selected Product, a TextBox, where the user will type in the filtering text, and a ListBox, where we will display a selection of Products.

<UserControl x:Class="QuickSearchAndSelect.SearchAndSelectView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             mc:Ignorable="d">
   <StackPanel Width="150">
      <TextBlock Text="{Binding Path=SelectedProduct.Name}"/>
      <TextBox x:Name="Filter"/>
      <ListBox Height="150" x:Name="Products"
               ItemsSource="{Binding Path=Products}"
               SelectedItem="{Binding Path=SelectedProduct}">
         <ListBox.ItemTemplate>
            <DataTemplate>
               <StackPanel>
                  <TextBlock Text="{Binding Path=Name}"/>
               </StackPanel>
            </DataTemplate>
         </ListBox.ItemTemplate>
      </ListBox>
   </StackPanel>
</UserControl>

Now we will create a ViewModel class to bind to, exposing a ICollectionView of Product, which we will populate with test data, and a SelectedProduct. Note the intriguing syntax to fill in the ICollectionView, using CollectionViewSource.

public class SearchAndSelectViewModel
{
   private ICollectionView products;
   private Product selectedProduct;

   public SearchAndSelectViewModel()
   {
      var myProducts = new List<Product>();

      myProducts.Add(new Product() { Name = "Asparagus" });
      myProducts.Add(new Product() { Name = "Broccoli" });
      myProducts.Add(new Product() { Name = "Chard" });
      myProducts.Add(new Product() { Name = "Dandelion" });
      myProducts.Add(new Product() { Name = "Endive" });
      myProducts.Add(new Product() { Name = "Fennel" });
      myProducts.Add(new Product() { Name = "Jicama" });
      myProducts.Add(new Product() { Name = "Kale" });
      myProducts.Add(new Product() { Name = "Lettuce" });
      myProducts.Add(new Product() { Name = "Potatoes" });
      myProducts.Add(new Product() { Name = "Rhubarb" });

      this.products = CollectionViewSource.GetDefaultView(myProducts);
   }

   public ICollectionView Products
   {
      get
      {
         return this.products;
      }
   }

   public Product SelectedProduct
   {
      get
      {
         return this.selectedProduct;
      }
      set
      {
         if (this.selectedProduct != value)
         {
            this.selectedProduct = value;
         }
      }
   }
}

Add the control to the MainWindow, name it SearchAndSelectView, and bind the control to the view model:

public partial class MainWindow : Window
{
   public MainWindow()
   {
      InitializeComponent();
      this.SearchAndSelectView.DataContext = new SearchAndSelectViewModel();
   }
}

If you run the application at that point, you should see something like this. Nothing we couldn’t have done with a standard ObservableCollection so far, but it’s a start.

SimpleListView 

Creating a simple filter

So how do we go about filtering that list? ICollectionView has a Filter property, which takes a Predicate<object>. Think of a Predicate as a method which given an object, will return true or false; in our specific case, true means “the object should be included in the list”.

Let’s illustrate with a simple example, and filter out any Product that doesn’t contain the letter A in its name.

To do this, let’s add the following method to the View Model:

public bool NameContainsA(object item)
{
   var product = item as Product;
   if (product == null)
   {
      return false;
   }

   return product.Name.ToUpperInvariant().Contains("A");
}

To apply the filter to the list, simply add a line to the constructor, and right after the part where we set the contents of the source collection, simply set the filter:

this.products = CollectionViewSource.GetDefaultView(myProducts);
this.products.Filter = NameContainsA;

Now when you run the code, you should see something along these lines – all the products with no A are gone. Bye-bye, broccoli and endives.

SimpleFilter

Dynamic filtering

This is nice, but it’s still not very helpful. What we would really like is a filter which can be dynamically changed by the user, and automatically updates the visible contents of the list. We will do this by modifying our filter so that instead of checking whether the name contains “A”, it will verify whether the name contains whatever the user has typed in the TextBox.

First, let’s store the current text of the TextBox in the view model, and update it live, as the user types. By default, the Text binding of the TextBox will be updated only when the TextBox loses focus; to update “as we go”, we need to modify our xaml a bit:

<TextBox x:Name="Filter" Text="{Binding Path=Filter, UpdateSourceTrigger=PropertyChanged}"/>

Now we need to add a Filter property to the View Model, to capture the Text of the textbox, modify our filtering method to use the Filter instead of the hard-coded value, and set this.products.Filter to ContainsFilter:

private string filter;

public string Filter
{
   get
   {
      return this.filter.ToUpperInvariant();
   }
   set
   {
      if (this.filter != value)
      {
         this.filter = value;
         this.Products.Refresh();
      }
   }
}

private bool ContainsFilter(object item)
{
   var product = item as Product;
   if (product == null)
   {
      return false;
   }

   if (string.IsNullOrEmpty(this.Filter))
   {
      return true;
   }

   if (product.Name.ToUpperInvariant().Contains(this.Filter))
   {
      return true;
   }

   return false;
}

And that’s it! If you run the application at that point, you should see something like this:

DynamicFilter

As you type in different strings, the list updates and displays only products that have a name containing that specific substring. Endives and Fennel are back.

One thing I couldn’t figure out how to do yet is to automatically select the item in the list, if only one item is left – why not spare the user a click if you can? But I couldn’t find anything like a count of the visible items, so for the moment, there is still that extra click in the way of bliss. Anyone knows how to do that?

Comments

9/27/2010 9:10:39 PM #

ross

Nice example, would it be possible to load all the items then "goto" them in the list as you type, i.e. you have 26k lines, 1000 for each letter, if you first type "P", the list box move the selection down to the first "P", then "Pl", and and so on? That would be more like the Google implantation anyway i think.

Great Stuff, .Net sure has a lot of cool thing in it!

ross United Kingdom | Reply

9/28/2010 3:21:52 PM #

Mathias

Hi Ross,
Thanks for the positive feedback! Indeed, the .NET framework is full of goodies - I don't think I'll ever get to the bottom of the treasure trove.
I have to try out your idea, selecting the first available match sounds good, and would address the issue I was unhappy about. I'll give it a shot!
Mathias

Mathias United States | Reply

10/1/2010 7:27:50 AM #

Paul Keister

Mathais,

Nice example of what can be done with WPF data binding! I don't find the lack of item count on the CollectionViewSource to be a big problem, because you can hack a visible item count into the ViewModel pretty easily:

private string filter = string.Empty; //had to add default value to avoid errors
private int _itemCount = 0; //this is new

public string Filter
{
    get
    {
        return this.filter.ToUpperInvariant();
    }
    set
    {
        if (this.filter != value)
        {
            this.filter = value;
            _itemCount = 0; //reset the count
            this.Products.Refresh();
            if (_itemCount == 1 && TimeToSelect != null)
            {
                TimeToSelect();
            }
        }
    }
}


The main thing that bothers me was that in order to actually do the selection, I had to add a delegate to the model and send an event back to the main window:

public class SearchAndSelectViewModel
{
    private ICollectionView products;
    private Product selectedProduct;

    public delegate void SelectDefaultItem();
    public SelectDefaultItem TimeToSelect {get;set;}

    //...


public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        var ctx = new SearchAndSelectViewModel();
        this.searchAndSelectView1.DataContext = ctx;

        ctx.TimeToSelect = new SearchAndSelectViewModel.SelectDefaultItem(SelectNow);            
    }

    private void SelectNow()
    {
        searchAndSelectView1.Products.SelectedIndex = 0;
    }
}


The item count hack is all in a day's work, but sending a message from the ModelView to the View? I may have transgressed at the conceptual level.

Paul Keister United States | Reply

10/11/2010 7:04:40 AM #

Alcide LaVassar

Nice walkthrough, MVP Mathias. Is it possible to get the sln? Typical of the walkthroughs that I do on these dev blogs, the last step bombed.

Alcide LaVassar United States | Reply

10/14/2010 2:15:05 AM #

Mathias

Hi Alcide,
I know the feeling, it is frustrating when after some painful code copy/paste/reformat, nothing works - so I will post a code sample over the week-end.
And... thanks for the positive feedback!

Mathias

Mathias United States | Reply

10/17/2011 10:47:26 PM #

Ady

Hi

Thanks for the article.
Any chance to see full code? been a while since the week-end Smile

Thanks,
Ady.

Ady United States | Reply

Add comment




  Country flag

biuquote
  • Comment
  • Preview
Loading



Comments

Comment RSS