flowbased

Building a Search Box with Instant Results and Element Grouping in WPF

🗓️  

In this tutorial we will implement a search box that displays search results or suggestions as soon as a search term is entered (“instant search”). The solution can easily be modified to search after the return key has been pressed or a search button clicked. The result/suggestion list should support different types of results and grouping of results. The implementation should look similar to the start menu search in Windows 10:

Instant Search Example: Windows 10 start menu

Search Box Basics

First the UI elements have to be defined in WPF and bound to properties of the ViewModel (I assume you use the MVVM-pattern for your application, as recommended for WPF). The search box is a simple TextBox, the results will be displayed underneath it in a Popup. We define the UI elements as follows:

<TextBox
 x:Name="searchBox"
 Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}">
</TextBox>
<Popup
  PlacementTarget="{Binding ElementName=searchBox}"
  Placement="Bottom"
  IsOpen="{Binding ElementName=searchBox, Path=IsFocused, Mode=OneWay}">
</Popup>

“UpdateSourceTrigger=PropertyChanged” in the TextBox.Text Binding ensures, that the property in the ViewModel is updated as soon as a key is pressed. The “IsOpen” Binding on the Popup opens the Popup as soon as the TextBox has the focus.

Now we want to insert the result list into the Popup. The search results will be displayed in a ListView defined in WPF. The ItemsSource will be bound to an ObservableCollection in the ViewModel that contains all the result items. We want to support element grouping and different data types. For that we first have to think about the data structure behind our search results.

Let’s say we have an application that contains customer data and help topics we want to find. These are our result groups that will be identified by their group title. Because we want to display the data differently we implement the two data types “CustomerResultItem” und “HelpResultItem” and a third data type “ErrorResultItem” for displaying a “no results” message. All three derive from “ResultItem” which contains the group designation. We implement the following classes with a few exemplary properties:

public class ResultItem
{
	public string Group { get; set; }

	public ResultItem(string group)
	{
		this.Group = group;
	}
}

public class CustomerResultItem : ResultItem
{
	public string CompanyName { get; set; }
	public string ContactName { get; set; }
	public string ContactMail { get; set; }

	public CustomerResultItem(string group, string companyName, string contactName, string contactMail)
		: base(group)
	{
		this.CompanyName = companyName;
		this.ContactName = contactName;
		this.ContactMail = contactMail;
	}
}

public class HelpResultItem : ResultItem
{
	public string Title { get; set; }
	public string Description { get; set; }

	public HelpResultItem(string group, string title, string description)
		: base(group)
	{
		this.Title = title;
		this.Description = description;
	}
}

public class ErrorResultItem : ResultItem
{
	public string ErrorMessage { get; set; }

	public ErrorResultItem(string group, string errorMessage)
		: base(group)
	{
		this.ErrorMessage = errorMessage;
	}
}

We create the property in the ViewModel to which we will later bind the ItemSource of the ListView:

public ObservableCollection<ResultItem> SearchResults { get; set; }

And fill it with a few items in the constructor of the ViewModel, just for testing (note that the first parameter of each result item instantiation is the group name that will be displayed in the result list):

SearchResults = new ObservableCollection<ResultItem>();

SearchResults.Add(new CustomerResultItem("Customers", "ABC Corp", "Helga Smith", "helga.smith@abccorp.com"));
SearchResults.Add(new CustomerResultItem("Customers", "We Love Plumbing", "Joe Scott", "scott@weloveplumbing.com"));
SearchResults.Add(new CustomerResultItem("Customers", "Boing Search", "Bill G. Ates", "chief@boing.com"));

SearchResults.Add(new ErrorResultItem("Bills", "No bills found."));

SearchResults.Add(new HelpResultItem("Help Topics", "Add customer", "To add a customer ..."));
SearchResults.Add(new HelpResultItem("Help Topics", "Delete customer", "To delete a customer ..."));

Search Result List

It’s time to finally add the result list to the search popup we created in step one. The WPF definition of the TextBox and the Popup now looks like this:

<TextBox x:Name="searchBox" Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}" Width="300"></TextBox>
<Popup PlacementTarget="{Binding ElementName=searchBox}" Placement="Bottom" IsOpen="{Binding ElementName=searchBox, Path=IsFocused, Mode=OneWay}">
	<Popup.Resources>
		<CollectionViewSource x:Key="GroupedSearchResults" Source="{Binding SearchResults}">
			<CollectionViewSource.GroupDescriptions>
				<PropertyGroupDescription PropertyName="Group" />
			</CollectionViewSource.GroupDescriptions>
		</CollectionViewSource>
	</Popup.Resources>
	<ListView ItemsSource="{Binding Source={StaticResource GroupedSearchResults}}" Width="300">
		<ListView.Resources>
			<DataTemplate x:Key="GroupingHeader">
				<TextBlock Text="{Binding Path=Name}" Background="AliceBlue" FontWeight="SemiBold" FontSize="14" />
			</DataTemplate>
			<DataTemplate DataType="{x:Type instantsearch:CustomerResultItem}">
				<StackPanel>
					<TextBlock Text="{Binding CompanyName}" FontWeight="SemiBold" />
					<TextBlock Text="{Binding ContactName}" />
					<TextBlock Text="{Binding ContactMail}" />
				</StackPanel>
			</DataTemplate>
			<DataTemplate DataType="{x:Type instantsearch:HelpResultItem}">
				<StackPanel>
					<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
					<TextBlock Text="{Binding Description}" />
				</StackPanel>
			</DataTemplate>
			<DataTemplate DataType="{x:Type instantsearch:ErrorResultItem}">
				<TextBlock Text="{Binding ErrorMessage}" />
			</DataTemplate>
		</ListView.Resources>
		<ListBox.GroupStyle>
			<GroupStyle HeaderTemplate="{StaticResource ResourceKey=GroupingHeader}" HidesIfEmpty="False" />
		</ListBox.GroupStyle>
	</ListView>
</Popup>

In Popup.Resources we tell .NET to group the items in SearchResults by their group name. We then set the grouped collection as ListView.ItemsSource. In ListView.Resources we define templates for the various ResultItem objects and the group header. The ListView now looks like this:

Instant Search Demo

Update Results

We have to update the result list when a key is pressed in the search TextBox. We have bound the TextBox.Text property to “SearchText” in our ViewModel. We can simply extend the setter in the ViewModel to update the result list. I recommend starting an async task that for example queries the database and returns a List of ResultItems. For the tutorial I just create lists of items when the ViewModel is initialized and add items to the result list in the setter of SearchText. The ViewModel looks like this:

public class MainWindowViewModel
    {

        List<CustomerResultItem> _allCustomerResultItems;
        List<HelpResultItem> _allHelpResultItems;

        string _searchText;
        public string SearchText
        {
            get
            {
                return _searchText;
            }
            set
            {
                _searchText = value;
                SearchResults.Clear();
                foreach(CustomerResultItem item in _allCustomerResultItems) {
                    if(item.CompanyName.ToLower().Contains(_searchText.ToLower())) {
                        SearchResults.Add(item);
                    }
                }
                foreach (HelpResultItem item in _allHelpResultItems)
                {
                    if (item.Title.ToLower().Contains(_searchText.ToLower()))
                    {
                        SearchResults.Add(item);
                    }
                }
            }
        }

        public ObservableCollection<ResultItem> SearchResults { get; set; }

        public MainWindowViewModel()
        {
            SearchResults = new ObservableCollection<ResultItem>();

            _allCustomerResultItems = new List<CustomerResultItem>();
            _allCustomerResultItems.Add(new CustomerResultItem("Customers", "ABC Corp", "Helga Smith", "helga.smith@abccorp.com"));
            _allCustomerResultItems.Add(new CustomerResultItem("Customers", "We Love Plumbing", "Joe Scott", "scott@weloveplumbing.com"));
            _allCustomerResultItems.Add(new CustomerResultItem("Customers", "Boing Search", "Bill G. Ates", "chief@boing.com"));

            _allHelpResultItems = new List<HelpResultItem>();
            _allHelpResultItems.Add(new HelpResultItem("Help Topics", "Add customer", "To add a customer ..."));
            _allHelpResultItems.Add(new HelpResultItem("Help Topics", "Delete customer", "To delete a customer ..."));
        }

    }

And here is the result of the implementation:

Instant Search Demo

Handle Clicks

In order to handle clicks on a result item the SelectedItem property of the ListView has to be bound. The XAML declaration of the ListView now looks like this:

<ListView ItemsSource="{Binding Source={StaticResource GroupedSearchResults}}" SelectedItem="{Binding SelectedResult}" Width="300">

The property in the ViewModel looks like this:

ResultItem _selectedResult;
public ResultItem SelectedResult
{
	get
	{
		return _selectedResult;
	}
	set
	{
		_selectedResult = value;
		// Handle selection here
	}
}

Handle Return Key

If you want to select the first result in the list when the user hits return, you can just bind a command to TextBox.InputBindings:

<TextBox x:Name="searchBox" Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}" Width="300">
	<TextBox.InputBindings>
		<KeyBinding Command="{Binding SearchReturnKeyCommand}" Key="Return" />
	</TextBox.InputBindings>
</TextBox>

Next post → ← Previous post  

Comments