Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Multiple Selection Control

0.00/5 (No votes)
25 May 2009 1  
A custom control for selecting items. Selection is made with lists for available and currently selected items.
MultiSelectControl_src

Introduction

While developing our client side software at Whitebox Security, we came across the need for a control which allows for selecting items from an available list. More specifically, the requirements were:

  1. Display currently selected items
  2. Display available items for selection, i.e. available items that have not been selected. 
  3. Allow for adding items to the selected items. 
  4. Allow for removing items from the selected items. 
  5. The user will be able to conveniently find available items.
  6. It is a logical error if an item is in the current items list but not in the available list. In such a case, a visual indication will be given.
  7. As a result of using the control, the available items list does not change. On the other hand, the current (selected) items list does change.

I was expecting for such a control to exist, and was surprised not to find one.

Background 

To use the control, you should have basic WPF knowledge.

If you want to understand how the control is written or customize it, you should be familiar with: 

Requirements 

The project was written in Visual Studio 2008, and built on .NET 3.5 SP1.  

The Control

The main parts are the available items list and the current items list. Each list has a title. The available items list has a text filter. The arrow buttons are pretty self explanatory, add an item remove an item, add all items and remove all items. 

Using the Control 

Following is an XAML definition of the demo control:

<local:MultiSelectControl
    x:Name="ListControl"
    Style="{StaticResource MultiSelectControlStyle}"
    CurrentTitle="Current test objects"
    AvailableTitle="Available test objects"
    AvailableItems="{Binding MyTestAvailableItems}"
    CurrentItems="{Binding MyTestCurrentItems, Mode=TwoWay}"
    FilterMemberPath="Data"
    >
    <local:MultiSelectControl.ObjectsTemplate>
        <DataTemplate>
        <TextBlock
            Text="{Binding Data}"
            />
        </DataTemplate>
    </local:MultiSelectControl.ObjectsTemplate>

</local:MultiSelectControl>

MyTestAvailableItems and MyTestCurrentItems are observable collections of type <TestObjectModel>. This class has a "Data" property and implements the Equals method.

As you can see, the definition is pretty straightforward. The custom dependency properties that should be set on the control are:

  • CurrentTitle: The title text to be displayed above the current items list 
  • AvailableTitle: Same as above for available items list
  • CurrentItems: The items used as an ItemsSource for the current ListBox. It is recommended to use an ObservableCollection of some [Object], where the [Object] implements INotifyPropertyChanged and Equals. The current items should be a subset of the available items. Notice the binding mode is set to TwoWay. This allows for changes in the controls' current items list to propagate to the user supplied current items list. 
  • AvailableItems: Same as above, for available items. The available items list should be a superset of the current items.
  • FilterMemberPath: Sets a path to a value on the source object to serve as the visual representation of the object for filtering.
  • ObjectsTemplate: A DataTemplate used to display the available and current items in the listboxes. 

The Structure of the Control

  <Style x:Key="MultiSelectControlStyle" TargetType="{x:Type local:MultiSelectControl}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:MultiSelectControl}">
                    <Border 
                        BorderBrush="{TemplateBinding BorderBrush}"
                        BorderThickness="{TemplateBinding BorderThickness}"
                        Background="{TemplateBinding Background}"
                        CornerRadius="3"
                        SnapsToDevicePixels="true">
                        <Grid  
                            Margin="3"
                            Name="TemplateGridPanel"
                            DataContext="{Binding RelativeSource=
				{RelativeSource TemplatedParent}}">
                            <Grid.RowDefinitions>
                                <RowDefinition Height="*"/>
                                <RowDefinition Height="*"/>
                                <RowDefinition Height="136"/>
                            </Grid.RowDefinitions>
                            
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="Auto"/>
                                <ColumnDefinition Width="40"/>
                                <ColumnDefinition Width="Auto"/>
                            </Grid.ColumnDefinitions>
                             <StackPanel 
                                Orientation="Horizontal"
                                Grid.Row="0" 
                                Grid.Column="0"
                               >
                                <Label HorizontalAlignment="Left">
                                    Filter:
                                </Label>
                                 <TextBox
                                    Name="FilterTextBox"
                                    Height="Auto" 
                                    VerticalAlignment="Center"
                                    TextChanged="FilterTextBox_TextChanged"
                                    MinWidth="80"
                                />
                            </StackPanel>
                             <Label
                                Grid.Row="1"
                                Grid.Column="0"
                                HorizontalAlignment="Left"
                                Content="{TemplateBinding AvailableTitle}"
                                />
                            <Label
                                Grid.Row="1"
                                Grid.Column="2"
                                HorizontalAlignment="Left"
                                Content="{TemplateBinding CurrentTitle}"
                                />
                            <ListBox
                                Grid.Row="2"
                                Grid.Column="0"
                                SelectionMode="Extended"
                                x:Name="PART_AvailableListBox"
                                ItemsSource="{Binding AvailableItems}"
                                ItemTemplate="{TemplateBinding ObjectsTemplate}">
                                <ListBox.ItemContainerStyle>
                                    <Style TargetType="{x:Type ListBoxItem}">
                                        <EventSetter Event="MouseDoubleClick" 
				    Handler="AvailableListBoxItem_DoubleClick" />
                                    </Style>
                                 </ListBox.ItemContainerStyle>
                            </ListBox>
                            <ListBox
                                Grid.Row="2"
                                Grid.Column="2"
                                SelectionMode="Extended"
                                x:Name="PART_CurrentListBox"
                                ItemsSource="{Binding CurrentItems, Mode=TwoWay}"
                                ItemTemplate="{TemplateBinding ObjectsTemplate}">
                                <ListBox.ItemContainerStyle>
                                    <Style TargetType="{x:Type ListBoxItem}">
                                        <EventSetter Event="MouseDoubleClick" 
				    Handler="CurrentListBoxItem_DoubleClick" />
                                    </Style>
                                </ListBox.ItemContainerStyle>
                            </ListBox>
                            <StackPanel
                                Orientation="Vertical"
                                VerticalAlignment="Center"
                                Grid.Row="2"
                                Grid.Column="1">
                                
                                <Button 
                                    Style="{DynamicResource Button_General_UI}"
                                    Click="RightArrow_Click"
                                    Margin="5,0,5,3">
                                    <Image HorizontalAlignment="Center" 
				Source="/Graphics/ButtonArrow_Right.png" 
				Stretch="None"/>
                                </Button>
                                
                                <Button 
                                    Style="{DynamicResource Button_General_UI}"
                                    Click="LeftArrow_Click"
                                    Margin="5,0,5,3">
                                    <Image  Margin="-2,0,0,0"  
				HorizontalAlignment="Center" 
				Source="/Graphics/ButtonArrow_Left.png" 
				Stretch="None"/>
                                 </Button>
                                 <Button Style="{DynamicResource Button_General_UI}"
                                    Click="DoubleRightArrow_Click"
                                    Margin="5,0,5,0">
                                     <Grid Margin="-1,0,0,0">
                                        <Grid.ColumnDefinitions>
                                            <ColumnDefinition Width="Auto"/>
                                            <ColumnDefinition Width="Auto"/>
                                        </Grid.ColumnDefinitions>
                                        <Image Grid.Column="0" 
					HorizontalAlignment="Center" 
					Source="/Graphics/ButtonArrow_Right.png" 
					Stretch="None"/>
                                        <Image Grid.Column="1" 
					HorizontalAlignment="Center" 
					Source="/Graphics/ButtonArrow_Right.png" 
					Stretch="None"/>
                                     </Grid>
                                </Button>
                                 <Button 
                                    Style="{DynamicResource Button_General_UI}"
                                    Click="DoubleLeftArrow_Click"
                                    Margin="5,0,5,3">
                                    
                                        <Grid Margin="-3,0,0,0">
                                        <Grid.ColumnDefinitions>
                                            <ColumnDefinition Width="Auto"/>
                                            <ColumnDefinition Width="Auto"/>
                                        </Grid.ColumnDefinitions>
                                          <Image Grid.Column="0" 
					HorizontalAlignment="Center" 
					Source="/Graphics/ButtonArrow_Left.png" 
					Stretch="None"/>
                                          <Image Grid.Column="1" 
					HorizontalAlignment="Center" 
					Source="/Graphics/ButtonArrow_Left.png" 
					Stretch="None"/>
                                       </Grid>
                                </Button>
                                
                            </StackPanel>
                        </Grid>
                    </Border>
               </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>

The layout of the control is pretty basic. The interesting points are the ItemsSource property of the current and available Listboxes which are bound to the CurrentItems and AvailableItems DPs supplied by the user. The ItemsTemplate is bound to the one provided by the user in the ObjectsTemplate DP. An EventSetter is set on the double click event of the Listboxes, so that double clicking on an item would cause it to change to the opposite list.

The Code Behind  

Here is the code for handling the event of left arrow button click:

void LeftArrow_Click(object sender, RoutedEventArgs e){
            //A copy is used, because the collection is changed in the iteration
            IList currentSelectedItems = 
                new List<object>((IEnumerable<object>)this.CurrentListBox.SelectedItems);
            IList currentListItems = this.CurrentItems as IList;
            if (null != currentListItems){
                foreach (object obj in currentSelectedItems) {
                    currentListItems.Remove(obj);
                }
            }
            //updates the available collection
            this.AvailableItemsCollectionView.Refresh();
        }

The left arrow removes the selected items from the current items list.

This is implemented by iterating over the selected items and removing them from the selected items list.

Notice that they will be displayed in the available items list. This is not because they are added to that list, but because of a filter that displays in the available items listbox only items which are not in the current items list.

This is the reason for calling the refresh method on the available items collection view. 

A click on the double arrow does pretty much the same, just for all items in the list.  

In the same manner, the right arrow button adds items to the current items list, and refreshes the available items list. The current items list is automatically refreshed because presumably an observable collection is used.

A double click on any of the items in one of the lists is equivalent to selecting that item and pressing the right/left arrow buttons. Therefore, the event handler simply delegates the handling to the appropriate method:

private void AvailableListBoxItem_DoubleClick(object sender, MouseButtonEventArgs e) {
    this.RightArrow_Click(sender, e);
    e.Handled = true;
}

Two filters are used in the code. The first is for the text filter on the available items list:

 private bool FilterOutText(object item) {
    if (String.IsNullOrEmpty(this.FilterText))
        return true;
    
    if (null == item){
        return false;
    }
     //This str represents the object. It is determined according to the
    //FilterMemberPath DP
    string str = "";
    //if FilterMemberPath DP not defined, use ToString() result.
    if (String.IsNullOrEmpty(this.FilterMemberPath)) {
        str = item.ToString();
    }
    else {
        //use reflection to get the value of the string
        object value = 
          this.getPropertyValue(item, this.FilterMemberPath, BindingFlags.Public);
        if (null != value) {
            str = value.ToString();
        }
    }
    
    if (String.IsNullOrEmpty(str))
        return false;
     int index = str.IndexOf(
        FilterText,
        0,
        StringComparison.InvariantCultureIgnoreCase
    );
    return index > -1;
} 

The text that represents the item is determined according to the FilterMemberPath DP, with reflection. If this DP is not defined, then the object's ToString() method result is used. The filter text is taken from the text of the filter's TextBox.  

The method used for reflection is straightforward:

   private object getPropertyValue(
            object source,
            string propertyName,
            BindingFlags flags
        ) {
            object value = null;
                // Get the specific field info
                PropertyInfo pInfo =
                    source.GetType().GetProperty(
                        propertyName,
                        flags |
                        BindingFlags.Instance
                    );

                // Make sure the property info is not null
                if (pInfo != null) {

                    // Retrieve the value from the field.
                    value =
                        pInfo.GetValue(
                            source,
                            null
                        );
                }
            return value;
        } 

The second filter is also applied on the available items list:

 public bool FilterOutCurrentItems(object item){
    ICollection currentItems = this.CurrentItems as ICollection;
    if (null != currentItems) {
        //check if object is contained in current items
        foreach (object obj in currentItems) {
            if (obj.Equals(item)) {
                return false;
            }
        }
        return true;
    }
    //current Items is null
    else{
        return false;
    }
} 

The filter filters out an item, if it exists in current items collection. Notice that the given object must implement the Equals method in a meaningful way. 

Because using two filters on one list in WPF is not intuitive, I combine the two filters into one filter which is the actual one used:

 private bool FilterOutTextAndCurrentItems(object item) {
    bool ans;
    //if any of the two are false return false. 
    ans = this.FilterOutText(item);
    //if first is true, return true only if the second one is true as well
    return (ans && this.FilterOutCurrentItems(item));
}

Once the control is loaded, I check if there are items in the current items list which are not in the available items list. If so, I give a visual indication - a red background and a tooltip:

 private void CheckCurrentItemsError() {
    IList availableListItems = this.AvailableItems as IList;
    IList currentListItems = this.CurrentItems as IList;
    foreach (object obj in currentListItems) {
        if (!availableListItems.Contains(obj)) {
            ItemContainerGenerator ig = 
                    this.CurrentListBox.ItemContainerGenerator;
            ListBoxItem lbi = ig.ContainerFromItem(obj) as ListBoxItem;
            if (null != lbi) {
                lbi.Background = Brushes.Red;
                lbi.ToolTip = "Item does not appear in Available list";
            }
        }
    }
} 

What Do You Think?

Let me know if this article was useful and what you think.  

History

  • 21st May, 2009: Initial post 
  • 24th May, 2009: Article updated

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here