Introduction
Recently in our project, we wanted to allow the user to select multiple values in a list. But the list should be
populated inside a grid row. So we didn’t want to use a listbox and also we were not interested in third party tools. Instead of that, we wanted to use a multiselect combobox. When
I browsed through various blogs, forums, etc., I got a few good codes, but none of them worked with MVVM pattern. In those
articles, most of the datasource bindings were done at code behind. So I have changes to those existing code to support MVVM. In this article,
I am going to
explain how we can create a multi select combobox user control step by step. This article will also help people who have recently started learning WPF, since
I have explained how we can create styles and dependency properties.
Using the Code
Create a new WPF User Control library. Rename the User Control to MultiSelectComboBox
.
In order to create a multi-select combobox, we have to analyze what is required to construct such a control. We need a combobox and for each item in the combobox
dropdown, we need to add a checkbox. Since we are going to write our custom data template for combobox items, we can’t just directly use
a combobox. What we can
do is design the template of the combo box well apart from the defining item template.
Our combobox should look like the one shown below:
In order to achieve this, as I mentioned in the above picture, we need a toggle button to
determine, open/close the dropdown, and also to display the selected values. We need a popup control inside which we will be displaying all our items with a checkbox.
All these together form our custom multi-select combobox control.
I am going to split the article into three parts now:
- Defining the
styles and creating the XAML file
- Adding dependency
properties and other objects in the code-behind of the user control
- Use this multiselect combobox DLL in some other XAML application
Defining
the Styles and Templates
Remove the
grid tags and add a combobox in the user control.
<ComboBox
x:Name="MultiSelectCombo"
SnapsToDevicePixels="True"
OverridesDefaultStyle="True"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.CanContentScroll="True"
IsSynchronizedWithCurrentItem="True"
>
Add an item template
for the above combobox. The item template should be a checkbox. Bind the content
property of your checkbox to some property Title
. Remember we didn’t
start with the code-behind. We have to set this property in the code-behind. Bind the
Ischecked
property of the checkbox with the IsSelected
property of the combobox.
<ComboBox.ItemTemplate>
<DataTemplate>
<CheckBox Content="{Binding Title}"
IsChecked="{Binding Path=IsSelected, Mode=TwoWay}"
Tag="{RelativeSource FindAncestor,
AncestorType={x:Type ComboBox}}"
/>
</DataTemplate>
</ComboBox.ItemTemplate>
Add a grid in the control template for the combobox and include a toggle button and a popup as below:
<ComboBox.Template>
<ControlTemplate TargetType="ComboBox">
<Grid >
<ToggleButton
x:Name="ToggleButton"
Grid.Column="2" IsChecked="
{Binding Path=IsDropDownOpen,Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}"
Focusable="false"
ClickMode="Press" HorizontalContentAlignment="Left" >
<ToggleButton.Template>
<ControlTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="18"/>
</Grid.ColumnDefinitions>
<Border
x:Name="Border"
Grid.ColumnSpan="2"
CornerRadius="2"
Background="White"
BorderBrush="Black"
BorderThickness="1,1,1,1" />
<Border
x:Name="BorderComp"
Grid.Column="0"
CornerRadius="2"
Margin="1"
Background="White"
BorderBrush="Black"
BorderThickness="0,0,0,0" >
<TextBlock Text="
{Binding Path=Text,RelativeSource=
{RelativeSource Mode=FindAncestor,
AncestorType=UserControl}}"
Background="White"
Padding="3" />
</Border>
<Path
x:Name="Arrow"
Grid.Column="1"
Fill="Black"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Data="M 0 0 L 4 4 L 8 0 Z"/>
</Grid>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<Popup
Name="Popup"
Placement="Bottom"
AllowsTransparency="True"
Focusable="False" >
<Grid
Name="DropDown"
SnapsToDevicePixels="True"
<Border
x:Name="DropDownBorder"
BorderThickness="1" Background="White"
BorderBrush="Black"/>
<ScrollViewer Margin="4,6,4,6"
SnapsToDevicePixels="True"
DataContext="{Binding}">
<StackPanel IsItemsHost="True"
KeyboardNavigation.DirectionalNavigation=
"Contained" />
</ScrollViewer>
</Grid>
</Popup>
</Grid>
</ControlTemplate>
</ComboBox.Template>
We have added the combobox template, but still haven’t set the relationship between
the popup and the toggle button. We are going to use the template binding concept here.
TemplateBinding
is similar to normal data binding but it can be used only for templates. Here the toggle button acts as
a templated parent and we are
going to bind the IsChecked
property of the toggle button to the popup by specifying a template binding for
the IsOpen
property of the popup. Once you see the below code,
you can understand a little further about it.
Add the below property in the toggle button:
IsChecked="{Binding Path=IsDropDownOpen,
Mode=TwoWay,RelativeSource={RelativeSource TemplatedParent}}"
Add the below code in the popup:
IsOpen="{TemplateBinding IsDropDownOpen}"
PopupAnimation="Slide"
We used a common property IsDropDownOpen
to set the binding for both the objects and we set it as two way in
the toggle button so that whenever the popup is closed, the toggle
button IsChecked
property will be set as false
. Also, we have set
PopupAnimation
as Slide
.
We have to do one more thing now. The popup width should match the combobox width and
the dropdown height should also be set.
Add the below properties as well inside the grid in the popup.
MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="{TemplateBinding MaxDropDownHeight}">
Let us add a few triggers to the combobox now before the control template closing tag.
- Whenever there are no items in the combobox, we have to set some minimum height for the popup.
<ControlTemplate.Triggers>
<Trigger Property="HasItems" Value="false">
<Setter TargetName="DropDownBorder"
Property="MinHeight" Value="95"/>
</Trigger>
</ControlTemplate.Triggers>
- Set some corner radius for the dropdown popup:
<Trigger SourceName="Popup"
Property="Popup.AllowsTransparency" Value="true">
<Setter TargetName="DropDownBorder"
Property="CornerRadius" Value="4"/>
<Setter TargetName="DropDownBorder"
Property="Margin" Value="0,2,0,0"/>
</Trigger>
Now we are almost done with the XAML page. Let us come back to the XAML if there are any style changes required.
The second step is adding
the dependency property in the code behind. Most WPF developers already know
about dependency properties. But for beginners, Dependency Property
is a special kind of property where it gets value from the dependency object
dynamically when we call the getvalue()
method. When we set the value for a dependency
property, it is not stored in field in an object, but in a dictionary
of keys provided by the base class dependency object.
I am going to add four dependency properties:
ItemSource
SelectedItems
DefaultText
Text
These four properties are enough, but if you need any other dependency properties, then you can add your own.
public static readonly DependencyProperty ItemsSourceProperty =
DependencyProperty.Register("ItemsSource", typeof(Dictionary<string, object>),
typeof(MultiSelectComboBox), new UIPropertyMetadata(string.Empty));
public static readonly DependencyProperty SelectedItemsProperty =
DependencyProperty.Register("SelectedItems",
typeof(Dictionary<string, object>),
typeof(MultiSelectComboBox), new UIPropertyMetadata(string.Empty));
public static readonly DependencyProperty TextProperty =
DependencyProperty.Register("Text",
typeof(string), typeof(MultiSelectComboBox),
new UIPropertyMetadata(string.Empty));
public static readonly DependencyProperty DefaultTextProperty =
DependencyProperty.Register("DefaultText", typeof(string),
typeof(MultiSelectComboBox), new UIPropertyMetadata(string.Empty));
public Dictionary<string, object> ItemsSource
{
get { return (Dictionary<string,
object>)GetValue(ItemsSourceProperty); }
set
{
SetValue(ItemsSourceProperty, value);
}
}
public Dictionary<string, object> SelectedItems
{
get { return (Dictionary<string,
object>)GetValue(SelectedItemsProperty); }
set
{
SetValue(SelectedItemsProperty, value);
}
}
public string Text
{
get { return (string)GetValue(TextProperty); }
set { SetValue(TextProperty, value); }
}
public string DefaultText
{
get { return (string)GetValue(DefaultTextProperty); }
set { SetValue(DefaultTextProperty, value); }
}
Note: I have set both ItemSource
and SelectedItems
properties as
a dictionary
object because for binding to the combobox, a simple key value pair is
enough. But you can use a list of objects instead of a dictionary
.
Create a class as “Node
” with two properties in the same namespace:
Remember I already
told you that we are binding the content property of the checkbox to Title
.
public class Node
{
public Node(string title)
{
Title = title;
}
public string Title { get; set; }
public bool IsSelected { get; set; }
}
Now add an Observablecollection
of class as _nodelist
.
private ObservableCollection<Node> _nodeList;
Set the value inside the constructor for the above field.
_nodeList = new ObservableCollection<Node>();
Add a method DisplayInControl
. This method will display the items in the dependency property
ItemsSource
.
private void DisplayInControl()
{
_nodeList.Clear();
if (this.ItemsSource.Count > 0)
_nodeList.Add(new Node("All"));
foreach (KeyValuePair<string, object> keyValue in this.ItemsSource)
{
Node node = new Node(keyValue.Key);
_nodeList.Add(node);
}
MultiSelectCombo.ItemsSource = _nodeList;
}
Whenever there is more than one item, we add an extra value All
. For each item inside
the ItemsSource
, we are adding the key to the nodelist. Finally, we set this nodelist as
the ItemsSource
for the combobox we named as MultiSelectCombo
. But how are we going to use this method?? So in
the dependency property, we have to set the
valuechanged
property. Rewrite the dependency property for the
ItemSource
like below:
public static readonly DependencyProperty ItemsSourceProperty =
DependencyProperty.Register("ItemsSource", typeof(Dictionary<string,
object>), typeof(MultiSelectComboBox), new FrameworkPropertyMetadata(null,
new PropertyChangedCallback(MultiSelectComboBox.OnItemsSourceChanged)));
In the itemsSourceChanged
event, call the method DisplayInControl
.
private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
MultiSelectComboBox control = (MultiSelectComboBox)d;
control.DisplayInControl();
}
We have added a PropertyChanged
event. The next step will be whenever an item is checked in the dropdown.
We have to set it as SelectedItems
and also we have to display it in the combobox. If there
is more than one value, we have to display the item in a comma separated format and if all the values are selected, we have to set
All
as the text in the dropdown as well as check all items.
Add a Click
event in the checkbox:
<CheckBox Content="{Binding Title}"
IsChecked="{Binding Path=IsSelected, Mode=TwoWay}"
Tag="{RelativeSource FindAncestor, AncestorType={x:Type ComboBox}}"
Click="CheckBox_Click" />
In the code behind,
add logic to set the IsSelected
property of each node in the nodelist to
true
if they are checked.
private void CheckBox_Click(object sender, RoutedEventArgs e)
{
CheckBox clickedBox = (CheckBox)sender;
if (clickedBox.Content == "All")
{
if (clickedBox.IsChecked.Value)
{
foreach (Node node in _nodeList)
{
node.IsSelected = true;
}
}
else
{
foreach (Node node in _nodeList)
{
node.IsSelected = false;
}
}
}
else
{
int _selectedCount = 0;
foreach (Node s in _nodeList)
{
if (s.IsSelected && s.Title != "All")
_selectedCount++;
}
if (_selectedCount == _nodeList.Count - 1)
_nodeList.FirstOrDefault(i => i.Title == "All").IsSelected = true;
else
_nodeList.FirstOrDefault(i => i.Title == "All").IsSelected = false;
}
SetSelectedItems();
}
Add the following method to set the selected items:
private void SetSelectedItems()
{
if (SelectedItems == null)
SelectedItems = new Dictionary<string, object>();
SelectedItems.Clear();
foreach (Node node in _nodeList)
{
if (node.IsSelected && node.Title != "All")
{
if (this.ItemsSource.Count > 0)
SelectedItems.Add(node.Title, this.ItemsSource[node.Title]);
}
}
}
Even though we added set IsSelected =true
for each of the nodes, still in the UI, when you check
the “All” option, the other checkboxes are not getting checked. This is because we
didn’t set the notify property changed event for the properties in the node class. Now we are revisiting
Node
class and we are implementing the INotifyPropertyChanged
Interface.
public class Node : INotifyPropertyChanged
{
private string _title;
private bool _isSelected;
#region ctor
public Node(string title)
{
Title = title;
}
#endregion
#region Properties
public string Title
{
get
{
return _title;
}
set
{
_title = value;
NotifyPropertyChanged("Title");
}
}
public bool IsSelected
{
get
{
return _isSelected;
}
set
{
_isSelected = value;
NotifyPropertyChanged("IsSelected");
}
}
#endregion
public event PropertyChangedEventHandler PropertyChanged;
protected void NotifyPropertyChanged(string propertyName)
{
if (PropertyChanged != null)
{
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
}
We are left with two tasks in our control now. Display the selected items in the combobox and when
we load a page and there are some selected values, by default we have to select them to notify the user.
The below method will set the text in the toggle button content:
private void SetText()
{
if (this.SelectedItems != null)
{
StringBuilder displayText = new StringBuilder();
foreach (Node s in _nodeList)
{
if (s.IsSelected == true && s.Title == "All")
{
displayText = new StringBuilder();
displayText.Append("All");
break;
}
else if (s.IsSelected == true && s.Title != "All")
{
displayText.Append(s.Title);
displayText.Append(',');
}
}
this.Text = displayText.ToString().TrimEnd(new char[] { ',' });
}
if (string.IsNullOrEmpty(this.Text))
{
this.Text = this.DefaultText;
}
}
We have to call the above method (SetText
) in the end of the checkbox click event so that whenever
SelectedItems
change, this method will be called.
Now add a method to set the IsSelected
property of each node
based on SelectedItems
. We need this method to pre-populate the selected items on page load.
private void SelectNodes()
{
foreach (KeyValuePair<string, object> keyValue in SelectedItems)
{
Node node = _nodeList.FirstOrDefault(i => i.Title == keyValue.Key);
if (node != null)
node.IsSelected = true;
}
}
We have to modify the SelectedItemsProperty
to include a property changed event and inside the event, call the
selectNodes
method and the SetText
method.
So our dependency property will change to:
public static readonly DependencyProperty SelectedItemsProperty =
DependencyProperty.Register
("SelectedItems", typeof(Dictionary<string, object>),
typeof(MultiSelectComboBox), new FrameworkPropertyMetadata(null,
new PropertyChangedCallback
(MultiSelectComboBox.OnSelectedItemsChanged)));
private static void OnSelectedItemsChanged
(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
MultiSelectComboBox control = (MultiSelectComboBox)d;
control.SelectNodes();
control.SetText();
}
Now we have added most of the essential things and it is time to use the control in the application.
Add a new WPF Application project and name it MultiSelectDemo
. I am not going
to explain the MVVM pattern here. You can find various articles about creating a simple
WPF application using MVVM. Let me skip that part and directly take you to our
control implementation. Add a viewmodelbase
which has an InotifyProperyChanged
Interface implementation. Add a viewmodel
which inherits from ViewModelBase
and
it should have an Items
and a SelectedItems
dictionary property. To test our
control, I have added the below code in my constructor:
Items = new Dictionary<string, object>();
Items.Add("Chennai", "MAS");
Items.Add("Trichy", "TPJ");
Items.Add("Bangalore", "SBC");
Items.Add("Coimbatore", "CBE");
SelectedItems = new Dictionary<string, object>();
SelectedItems.Add("Chennai", "MAS");
SelectedItems.Add("Trichy", "TPJ");
Now add the WPF User control library project as reference to this application
so that you will be able to see the user control library project namespace here. In the view,
I included a reference for the user control library.
xmlns:control="clr-namespace:
MultiSelectComboBox;assembly=MultiSelectComboBox"
Inside the grid, I added our control with the following properties:
ItemsSource
SelectedItems
Width
Height
Remember we didn't
set any Width
and Height
property in the control. This
is because at different places
we need different width and height. So I always suggest you add that in the view.
<control:MultiSelectComboBox
Width="200" Height="30"
ItemsSource="{Binding Items}"
SelectedItems="{Binding SelectedItems}"
x:Name="MC" />
Now we can see our MultiSelectCombobox
is working fine and we will be able to select different items. Also we can use this control in the code-behind bypassing MVVM. Let me also show you a sample
of how to use this in the code-behind of the XAML file. We have to remove the ItemsSource
and SelectedItems
properties from the XAML. Add the same test data in the code-behind constructor.
Finally, set the ItemsSource
and SelectedItems
properties like below:
public MainWindow()
{
InitializeComponent();
Items = new Dictionary<string, object>();
Items.Add("Chennai", "MAS");
Items.Add("Trichy", "TPJ");
Items.Add("Bangalore", "SBC");
Items.Add("Coimbatore", "CBE");
SelectedItems = new Dictionary<string, object>();
SelectedItems.Add("Chennai", "MAS");
SelectedItems.Add("Trichy", "TPJ");
MC.ItemsSource = Items;
MC.SelectedItems = SelectedItems;
}
Extension-1 (ToolTip)
In case if the width of the combobox is small and you have selected more items, you cannot see all the items in the text. So we have to set tooltip so that whenever user hovers the mouse over the combobox, he/she should be able to see all the selected items. In the below code, I have bind the text of the combobox to the tooltip.
ToolTip="{Binding Path=Text, RelativeSource={RelativeSource Self}}"
The combobox control in the XAML will look like this now:
<control:MultiSelectComboBox Width="100" Height="30"
ItemsSource="{Binding Items}" SelectedItems="{Binding SelectedItems}"
x:Name="MC" ToolTip="{Binding Path=Text, RelativeSource={RelativeSource Self}}"/>
Please check the attached demo files. Hope this document and the attachments will help you. Please share this document with your friends and provide your valuable feedback.
Extension -2 Unchecking "All" Item in the combobox
When you click on "All" option checkbox, all the items in that combobox should be checked. But the reverse scenario i.e unchecking "All" should uncheck all the selected items and it didn't happen. I have fixed this now using the below code in checkbox_click method.
We have to select all nodes if the clickedbox is checked and unselect all if the clickedbox is unchecked.
private void CheckBox_Click(object sender, RoutedEventArgs e)
{
CheckBox clickedBox = (CheckBox)sender;
if (clickedBox.Content == "All" )
{
if (clickedBox.IsChecked.Value)
{
foreach (Node node in _nodeList)
{
node.IsSelected = true;
}
}
else
{
foreach (Node node in _nodeList)
{
node.IsSelected = false;
}
}
}
else
{
int _selectedCount = 0;
foreach (Node s in _nodeList)
{
if (s.IsSelected && s.Title != "All")
_selectedCount++;
}
if (_selectedCount == _nodeList.Count - 1)
_nodeList.FirstOrDefault(i => i.Title == "All").IsSelected = true;
else
_nodeList.FirstOrDefault(i => i.Title == "All").IsSelected = false;
}
SetSelectedItems();
SetText();
}