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
.
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:
[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.
ItemSource (IEnumerable)
- Source collection should be bound to this property. It supports collection of as simple type as string to complex type/entities. SelectedItems (IList)
- This property will provide collection of items selected by user. 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. 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
. 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
public interface ILookUpContract
{
bool SupportsNewObjectCreation { get; }
bool IsItemMatchingSearchString(object sender, object item, string searchString);
bool IsItemEqualToString(object sender, object item, string seachString);
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
:
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!
.XAML Code
<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.
.XAML Code
<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 SelectedItem
s 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.]
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