Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WPF

Multiselect Combobox in WPF

5.00/5 (10 votes)
26 May 2021CPOL5 min read 35.8K   841  
Multiselect Combobox - Custom control for WPF
MultiSelectCombobox combines the behavior of ListBox and goodness of Combobox UI to provide functionality of searching/filtering with multiple selection. MultiSelectCombobox tries to mimic UI behavior of ComboBox.

Table of Contents

Overview

WPF has ListBox control which lets user select more than one item. However, ListBox control UI doesn't have in-built support for searching/filtering. Developers have to do work around to provision one. Moreover, lot of mouse interaction is required. Yes, you may be able to do all completely using keyboard. But, this is not the most efficient way of doing it. On the other hand, Combobox has a very good UI which supports searching and filtering. However, it doesn't support multiple selection.

What if we can combine behavior of ListBox and goodness of Combobox UI? MultiSelectCombobox does exactly the same thing. It provides functionality of searching/filtering with multiple selection. MultiSelectCombobox tries to mimic UI behavior of ComboBox.

Image 1

Image 2

Features

  • In built support for searching and filtering
  • Extensible to support custom searching and filtering for Complex data type
  • Ability to create and add new item which is not part of source collection (through LookUpContract for complex types)
  • Easy to use!

Design Overview

MultiSelectCombobox is composed of RichTextBox, Popup and ListBox. Text entered in RichTextBox is monitored and manipulated. On key press, popup box will show up and display items from source collection matching search criteria. If there is no matching item in collection, it won't show up. If it finds suitable item from source collection, it will replace entered text with source collection item. Selected item is shown as TextBlock - Inline UI element.

Individual control placement and behavior can be changed with Control template. Template parts are defined as follows:

C#
[TemplatePart(Name = "rtxt", Type = typeof(RichTextBox))]
[TemplatePart(Name = "popup", Type = typeof(Popup))]
[TemplatePart(Name = "lstSuggestion", Type = typeof(ListBox))]
public sealed partial class MultiSelectCombobox : Control
{

Dependency Properties

Control is designed to expose minimal properties which are required to make it work.

  1. ItemSource (IEnumerable) - Source collection should be bound to this property. It supports collection of as simple type as string to complex type/entities.
  2. SelectedItems (IList) - This property will provide collection of items selected by user.
  3. ItemSeparator (char) - default value is ';'. In control, items are separated with ItemSeparator char. This is important if items contain spaces. Separator should be chosen carefully. Moreover, to indicate end of item while entering or forcing control to create new item based on current entered text, this character it used. Also, if user enters text which does not match any item provided in collection or LookUpContract does not support creation of object from given text, user entered text will be removed from control UI. Support for creation of new item is discussed later in this document.
  4. DisplayMemberPath (string) - If ItemSource collection is of complex type, developer may need to override ToString() method of type or else can define DisplayMemberPath property. Default value is string.Empty.
  5. LookUpContract (ILookUpContract) - This property is used to customize searching/filtering behavior of the control. Control provides default implementation which works for most users. However, in case of Complex type and/or custom searching/filtering behavior, user can provide implementation and change control behavior.

Explaining LookUpContract (ILookUpContract) for Advance Scenarios

Default search/filtering work on string.StartsWith & string.Equals respectively. For any given item, if DisplayMemberPath is not set, item.ToString() value is sent to filtering mechanism. If DisplayMemberPath is provided, path value is fetched through item property reflection and sent to filter mechanism. This works for most of the user.

However, if user needs to customize these setting/filtering mechanism, he/she can provide implementation of this interface and bind to LookUpContract property. Control will respect newly bound implementation.

ILookUpContract.cs
C#
public interface ILookUpContract
{
	// Whether contract supports creation of new object from user entered text
	bool SupportsNewObjectCreation { get; }
	
	// Method to check if item matches searchString
	bool IsItemMatchingSearchString(object sender, object item, string searchString);
	
	// Checks if item matches searchString or not
	bool IsItemEqualToString(object sender, object item, string seachString);
	
	// Creates object from provided string
	// This method need to be implemented only when SupportsNewObjectCreation is set to true
	object CreateObject(object sender, string searchString);
}
  • IsItemMatchingSearchString - This function is called to filter suggestion items in drop-down list. User entered text is passed as parameter to this function. Return true if item should be displayed in suggestion drop-down for given text. Otherwise, return false.

  • IsItemEqualToString - This function is used to find exact item from collection based on user entered text.

  • CreateObject - This function should only be implemented if SupportsNewObjectCreation is set to true. This function is called to create new object based on provided text. For example, in AdvanceLookUpContract implementation, we can create complex object by entering comma separated value in control ending with ItemSeparator (as shown in the above GIF). This is just a sample implementation. You can define your own format/parsing mechanism.

  • SupportsNewObjectCreation - If this property is set to false, control will not allow user to select item other than provided collection (ItemSource). If this property is set to true, control will allow creation of new object. This is useful when control should let user add new object. Also eliminates the need to create separate TextBox(es) and button to add new item in existing SelectedItems/ItemSource.

  • DefaultLookUpContract - If no new implementation is provided to control, this DefaultLookUpContract implementation is used. This contract uses string.StartsWith for searching and string.Equals for comparison. Both comparison is invariant of culture and case.

Explaining Demo Application Code

Defining Person:

C#
public class Person
{
    public string Name { get; set; }
    public string Company { get; internal set; }
    public string City { get; internal set; }
    public string Zip { get; internal set; }
    public string Info
    {
       get => $"{Name} - {Company}({Zip})";
    }
}

1) Simple Scenario (Most Common)

We're setting DisplayMemberPath to 'Name' value to display Name of Person in control. We only need to define ItemSource and SelectedItems bindings. That's it!

Image 3

.XAML Code

XML
<controls:MultiSelectCombobox ItemSource="{Binding Source, Mode=TwoWay, 
                              UpdateSourceTrigger=PropertyChanged}"
                              SelectedItems="{Binding SelectedItems, 
                              Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
                              DisplayMemberPath="Name"
                              ItemSeparator=";"/>

2) Advance Scenario

If we want filtering on more than one property or need different search/filter strategy. And/or also want to support creation of new Person from MultiSelectCombobox itself.

Image 4

.XAML Code

XAML
<controls:MultiSelectCombobox ItemSource="{Binding Source, 
                              Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
                              SelectedItems="{Binding SelectedItems2, 
                              Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
                              DisplayMemberPath="Info"
                              ItemSeparator=";"
                              LookUpContract="{Binding AdvanceLookUpContract}"/>

In XAML, we have set DisplayMemberPath to Info property. Info is set to return Name, Company and ZipCode.

AdvanceLookUpContract.cs: In this implementation, we have modified search to respect three properties on Person. If any of these three properties contain search string, item will be shown in Suggestion drop-down. Item is selected from ItemSource based on Name property. We have also set SupportsNewObjectCreation to true which means we can create new Person object using control. CreateObject is written to parse string in format {Name},{Company},{Zip}. By inputting string in this format ending with ItemSeparator, it will try to create an object out of inputted string. If it fails to create, it will remove User inputted string from UI. If it succeeds to create object, it will add newly created object to UI and SelectedItems after removing User entered text from UI.

[Please note that following implementation is just for demonstration purpose of LookUpContract functionality. This implementation is not efficient and has lot of scope for improvements.]

C#
public class AdvanceLookUpContract : ILookUpContract
{
    public bool SupportsNewObjectCreation => true;

    public object CreateObject(object sender, string searchString)
    {
        if (searchString?.Count(c => c == ',') != 2)
        {
            return null;
        }

        int firstIndex = searchString.IndexOf(',');
        int lastIndex = searchString.LastIndexOf(',');

        return new Person()
        {
            Name = searchString.Substring(0, firstIndex),
            Company = searchString.Substring(firstIndex + 1, lastIndex - firstIndex - 1),
            Zip = searchString.Length >= lastIndex ? 
                  searchString.Substring(lastIndex + 1) : string.Empty
        };
    }

    public bool IsItemEqualToString(object sender, object item, string seachString)
    {
        if (!(item is Person std))
        {
            return false;
        }

        return string.Compare(seachString, std.Name, 
               System.StringComparison.InvariantCultureIgnoreCase) == 0;
    }

    public bool IsItemMatchingSearchString(object sender, object item, string searchString)
    {
        if (!(item is Person person))
        {
            return false;
        }

        if (string.IsNullOrEmpty(searchString))
        {
            return true;
        }

        return person.Name?.ToLower()?.Contains(searchString?.ToLower()) == true
            || person.Company.ToString().ToLower()?.Contains(searchString?.ToLower()) == true
            || person.Zip?.ToLower()?.Contains(searchString?.ToLower()) == true;
    }
}

History

  • 19th April, 2020: Initial version
  • 26th May, 2021: Upgraded control with enhancement and performance fixes

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)