WPF | Control Library | MultiSelectCombobox

Introduction

 
WPF has a ListBox control that allows users to select more than one item. However, ListBox control UI doesn't have in-built support for searching/filtering. Developers have to create a workaround to provision one. Moreover, a lot of mouse interaction is required. However, it doesn't support multiple selection. It provides the functionality of searching/filtering with multiple selection. MultiSelectCombobox tries to mimic the UI behavior of ComboBox. What if we can combine behavior of ListBox and goodness of Combobox UI MultiSelectCombobox exactly does the same thing. You may be able to do everything using the keyboard. On the other hand, Combobox has a very good UI which supports Searching and filtering.
 

Features

  • Built-in support for searching and filtering
  • Extensible to support custom searching and filtering for Complex type
  • Ability to add an item which is not part of source collection (through LookUpContract for complex types)
  • Easy to use!
 

Design

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

Dependency Properties

 
Control is designed to expose minimal properties that 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 a collection of items selected by the user.
  3. ItemSeparator (char) - default value is ';'. In control, items are separated with ItemSeparator char.

    This is important if items contain spaces. Separators should be chosen carefully. Moreover, this character is used to indicate the end of an item while entering or forcing control to create a new item based on the current entered text. Also, if the user enters text thatdoes not match any item provided in the collection or a LookUpContract does not support the creation of an object from the given text, the user-entered text will be removed from control UI. Support for the creation of a new item is discussed later in this document.

  4. DisplayMemberPath (string) - If ItemSource collection is of complex type, the developer may need to override ToString() method of type or else they can define it.

    DisplayMemberPath property. The default value is an empty string.

  5. SelectedItemTextBlockStyle (Style) - SelectedItems are shown as Inline element of TextBlocks in RichTextBox document. If you want to change the default style of selected item TextBlocks, you can assign a new style to this property.

  6. LookUpContract (ILookUpContract) - This property is used to customize the searching/filtering behavior of the control. Control provides a default implementation which works for most users. However, in the case of Complex type and/or custom behavior, the user can provide implementation and change control behavior.

Explaining LookUpContract (ILookUpContract)

 
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 the filtering mechanism. If DisplayMemberPath is provided, path value is fetched through item reflection and sent to the filter mechanism.
 
This works for most users.
 
However, if the user needs to customize these settings/filtering mechanisms, he/she can provide an implementation of this interface and bind it to the LookUpContract property. The control will respect the newly bound implementation.
 
ILookUpContract.cs
  1. public interface ILookUpContract   
  2. {     
  3.    // Whether contract supports creation of new object from user entered text     
  4.    bool SupportsNewObjectCreation { get; }        
  5.   
  6.    // Method to check if item matches searchString    
  7.    bool IsItemMatchingSearchString(object sender, object item, string searchString);                  
  8.   
  9.    // Checks if item matches searchString or not      
  10.    bool IsItemEqualToString(object sender, object item, string seachString);                  
  11.   
  12.    // Creates object from provided string     
  13.    // This method need to be implemented only when SupportsNewObjectCreation is set to true       
  14.    object CreateObject(object sender, string searchString);   
  15. }   
IsItemMatchingSearchString
 
This function is called to filter suggestion items in the drop-down list.
 
User entered text is passed as a parameter to this function. It returns true if the item should be displayed in a suggestion drop-down for the given text.
Otherwise, it returns false.
 
IsItemEqualToString
 
This function is used to find an item from the 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 an object based on the provided text.
 
Also, we can create a complex object by entering a comma-separated value in the control ending with ItemSeparator.
 
For example, if we have assigned a collection of Complex type Students having two properties, Name and Age, ItemSeparator is set to ';'.
 
The entered text can be - StudentNameHere, ThisIsAgeValue. The function will receive this text as input.
 
The function should separate a string by comma, then set the first value to Name and the second value to the Age property and return the Student object.
 
This is one way of implementation. You can define a parsing mechanism in the way that the user wants.
 
SupportsNewObjectCreation
 
If this property is set to false, control will not allow the user to select an item other than the provided collection (ItemSource).
 
If this property is set to true, control will allow the creation of a new object. This is useful when control should let the user add a new object.
 
Also, it eliminates the need to create separate TextBox(es) and a button to add a new item in existing SelectedItems/ItemSource.
 
A complete example is provided in the Demo application. Sample implementation - AdvanceLookUpContract
 
 
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 comparisons are invariant of culture and case.
 
Usage
 
Definition of Person:
  1. public class Person   
  2. {      
  3.    public string Name { getset; }       
  4.    public string Company { getinternal set; }       
  5.    public string City { getinternal set; }       
  6.    public string Zip { getinternal set; }       
  7.    public string Info       
  8.    {           
  9.       get => $"{Name} - {Company}({Zip})";       
  10.    }   
  11. }   

Simple Scenario

 
We're setting DisplayMemberPath to Name to show Name of Person in control. We want to perform simple filtering on Name property.
 
We only need to provide ItemSource and SelectedItems collection to control to the function.
 
 
XAML code
  1. <controls:MultiSelectCombobox ItemSource="{Binding Source, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"                                             
  2.          SelectedItems="{Binding SelectedItems, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"                                             
  3.          DisplayMemberPath="Name"                                             
  4.          ItemSeparator=";"/>   

Complex Scenario

 
This is if we want to filter on more than one property, or need a different search/filter strategy. It's also useful for when we want to support the creation of a new Person from the UI itself.
 
 
XAML code
  1. <controls:MultiSelectCombobox ItemSource="{Binding Source, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"                                             
  2.          SelectedItems="{Binding SelectedItems2, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"                                             
  3.          DisplayMemberPath="Info"                                             
  4.          ItemSeparator=";"                                             
  5.          LookUpContract="{Binding AdvanceLookUpContract}"/>   
In XAML, we have set the DisplayMemberPath to Info property. The Info is set to return the Name, Company and ZipCode.
 
AdvanceLookUpContract.cs: In this implementation, we have modified the search to respect 3 properties on a Person.
 
If any of these 3 properties contain search string, the item will be shown in the Suggestion drop-down. An Item is selected from the ItemSource based on the Name property.
 
We have also set SupportsNewObjectCreation to true which means we can create a new Person object using control.
 
CreateObject is written to parse the 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.
  1. public class AdvanceLookUpContract: MultiSelectCombobox.ILookUpContract {  
  2.     public bool SupportsNewObjectCreation => true;  
  3.     public object CreateObject(object sender, string searchString) {  
  4.         if (searchString?.Count(c => c == ',') != 2) {  
  5.             return null;  
  6.         }  
  7.         var firstIndex = searchString.IndexOf(',');  
  8.         var lastIndex = searchString.LastIndexOf(',');  
  9.         return new Person() {  
  10.             Name = searchString.Substring(0, firstIndex),  
  11.                 Company = searchString.Substring(firstIndex + 1, lastIndex - firstIndex - 1),  
  12.                 Zip = searchString.Length >= lastIndex ? searchString.Substring(lastIndex + 1) : string.Empty  
  13.         };  
  14.     }  
  15.     public bool IsItemEqualToString(object sender, object item, string seachString) {  
  16.         if (!(item is Person std)) return false;  
  17.         return string.Compare(seachString, std.Name, System.StringComparison.InvariantCultureIgnoreCase) == 0;  
  18.     }  
  19.     public bool IsItemMatchingSearchString(object sender, object item, string searchString) {  
  20.         if (!(item is Person person)) return false;  
  21.         if (string.IsNullOrEmpty(searchString)) return true;  
  22.         return person.Name?.ToLower()?.Contains(searchString?.ToLower()) == true || person.Company.ToString().ToLower()?.Contains(searchString?.ToLower()) == true || person.Zip?.ToLower()?.Contains(searchString?.ToLower()) == true;  
  23.     }  
  24. }  
For the complete solution, please refer to the demo application.
 
Source code