Pages

Sunday 1 January 2012

WPF - Custom combo box with grouping

Happy new year to everyone!

I am sure many of us had a time when we require to create grouped items in the combo box - same way I required. I needed to create the combo box with look like the address bar of Internet explorer - where there are three groups of links:
  • Group without any title showing the just visited URL
  • History group showing URL used in past
  • Favourite group showing the favourite URLs

Let's create such combo box step by step - which can be named as Custom Combo box, Combo box with grouped items or Combo box with Internet Explorer Address bar style - which will look like:


I have used Visual Studio 2010 for this example. Create a new WPF Application in Visual Studio and give it any suitable name. I have used 'ComboBoxWithGroupedItems'.

It will add Xaml page with default Grid added to your window. Simply add new combo box to this window:
<stackpanel>
 <combobox height="30" width="300">
 </combobox>
</stackpanel>


Now, its time to create the informative class for our URL information. Add new class to your project - named - 'URLDetails' with following members:
public class URLDetails
 {
  public URLDetails()
  {
  }

  public string Link { get; set; }
  public string GroupName { get; set; }
 }

Link property will hold Link to be opened and GroupName property will hold the name of the group under which the item should be displayed. Let's define one property of type ObservableCollection in the .cs file of our Xaml as shown below. To use the ObservableCollection, you will have to add 'using System.Collections.ObjectModel;' at the top of your xaml.cs file.
private ObservableCollection&lt;URLDetails> _URLs;
public ObservableCollection&lt;URLDetails> URLs
{
 get { return _URLs; }
 set { _URLs = value; }
}

Now, we have property ready to store the details or URLs. We will now fill dummy data to this property. Let's create private method to do this for us, and call this method from the constructor of xaml.cs file as shown below:
const string MRUGroupName = "Most Recently Used";
  const string HistoryGroupName = "History";
  const string FavouritesGroupName = "Favourites";
  public MainWindow()
  {
   InitializeComponent();
   FillDummyData();
  }

  private void FillDummyData()
  {
   _URLs = new ObservableCollection&lt;URLDetails>();

   // Add MRU link
   _URLs.Add(new URLDetails { GroupName = MRUGroupName, Link = "www.msdn.com" });

   // Add History links
   _URLs.Add(new URLDetails { GroupName = HistoryGroupName, Link = "www.google.co.in"  });
   _URLs.Add(new URLDetails { GroupName = HistoryGroupName, Link = "www.yahoo.com" } );
   _URLs.Add(new URLDetails { GroupName = HistoryGroupName, Link = "www.msn.com" } );
   _URLs.Add(new URLDetails { GroupName = HistoryGroupName, Link = "https://mail.google.com" } );
   _URLs.Add(new URLDetails { GroupName = HistoryGroupName, Link = "www.msdn.com" } );
   _URLs.Add(new URLDetails { GroupName = HistoryGroupName, Link = "www.wikipedia.com" } );

   // Add Favorite links
   _URLs.Add(new URLDetails { GroupName = FavouritesGroupName, Link = "www.mymail.com" } );
   _URLs.Add(new URLDetails { GroupName = FavouritesGroupName, Link= "www.codeproject.com" } );
   _URLs.Add(new URLDetails { GroupName = FavouritesGroupName, Link = "www.songs.in" } );
  }

OK, now we have data ready to bind with combo box. But before doing this, we want to have some grouping applied there. So, lets first define the CollectionViewSource with Grouping in Xaml file as shown below. Don't forget to refer to xmlns:scm="clr-namespace:System.ComponentModel;assembly=WindowsBase" as shown in below code.
<window height="400" title="My Grouped Combo" width="350" x:class="ComboBoxWithGroupedItems.MainWindow" xmlns:scm="clr-namespace:System.ComponentModel;assembly=WindowsBase" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
 <window.resources>
  <collectionviewsource source="{Binding URLs}" x:key="GroupedData">
   <collectionviewsource.groupdescriptions>
    <propertygroupdescription propertyname="GroupName">
    </propertygroupdescription>
   </CollectionViewSource.GroupDescriptions>
 </window.resources>
</window>

Now, lets add template for group header and group item description in Window.Resources only. Here, Link template says that value of Link property should be displayed as item.
<DataTemplate x:Key="GroupHeader">
   <TextBlock Text="{Binding Name}" Margin="10,0,0,0" Foreground="#989791" />
  <:/DataTemplate>
  <DataTemplate x:Key="URLTemplate">
   <TextBlock Text="{Binding Link}" Margin="10,0,0,0"/>
  </DataTemplate>

Now, we need to define style for combo box group item, to tell compiler to add Expander for each group item - so grouped items can be expanded and collapsed. Add below style in Window.Resources:

OK, now we are ready to bind this data source with combo-box:
<StackPanel>
  <ComboBox Width="300" Grid.Row="0" Height="30" 
      ItemsSource="{Binding Source={StaticResource GroupedData}}"
      ItemTemplate="{StaticResource URLTemplate}">
   <ComboBox.GroupStyle>
    <GroupStyle ContainerStyle="{StaticResource containerStyle}"
     HeaderTemplate="{StaticResource GroupHeader}">
    </GroupStyle>
   </ComboBox.GroupStyle>
  </ComboBox>
 </StackPanel>
And, here it is - how it will look when you run the application:
Hurray! We have got something similar what we want to achieve. But observe that,
  • The expander button is still having default arrow style, its on the left side of header text (which we want on right most side of group header), 
  • There is no line present in the header text, and 
  • When the group is collapsed, no item is being shown (which we want to show some configured item at least when its collapsed).
For this, we will have to modify the control template of Combo box. It would lead to a lot of Xaml code. The control template of any control can be easily found on Internet or Microsoft Expression Blend can be used to get/modify the template of any control. Any control's template will include number of colour brushed it has used for styling, and lot of Xaml code which should be placed in Windows.Resources. If you want to get rid of hundreds of lines in your Xaml, then you can shift your styles into other Xaml and then can refer those styles using Resource dictionary concept in your Xaml. First, we need to add one property in Xaml.cs for desired number items to be displayed while group is collapsed:
public int NumberOfItems
  {
   get
   {
    return 3;
   }
  }  
Also, for calculating the minimum number of items to be displayed while the group is collapsed, we need to add converter for calculation of height:
public class HeightConverter : IMultiValueConverter
 {
  /// 





There will be two values here - first one will be the collection group bound - one call per group in combo box.
  /// The second will be the desired items to be displayed when the group is not expanded.  /// 





  /// 





  /// 





  /// 
  public object Convert(object[] values, Type targetType, object parameter, System.Globalization.CultureInfo culture)
  {
   double returnValue;
   // Find out the total number of items in the current group i.e. 4 items in Most Recently Used group.
   int itemCount = ((System.Windows.Data.CollectionViewGroup)(((System.Windows.FrameworkElement)(values[0])).DataContext)).ItemCount;
   // This is the number of items to be displayed when the group is not expanded.
   int configuredItems = (int)values[1];
   // Now, if there are less items available in particular group than configured, then we will display all the items available in group
   // else we will display the configured number of items only. This means, if configured item number is 5 but there are only 3 items in 
   // any group, then we will consider 3 items and will return height to display exact 3 items only.
   if (configuredItems >= itemCount)
   {
    // SystemParameters.SmallIconHeight - return the required height for one row - this is required because
    // if you configured it hard coded, then when user will change the resolution, the number of items displayed will not be
    // what you wish i.e. if resolution is incresed, then instead of 3 - it will display 4 items and vice versa.
    returnValue = itemCount * SystemParameters.SmallIconHeight;
   }
   else
   {
    returnValue = configuredItems * SystemParameters.SmallIconHeight;
   }
   // This 2 is added just to ensure some space is left at the end of combo box.
   return returnValue + 2;
  }

  public object[] ConvertBack(object value, Type[] targetTypes, object parameter, System.Globalization.CultureInfo culture)
  {
   throw new NotImplementedException();
  }
 }
The above class will return the desired height to display the defined number of items for particular combo box group. Now, final step to use the modified style for the combo box. The below is the style you need to add to Window.Resources. This is final mark-up for our Xaml:
<Window x:Class="ComboBoxWithGroupedItems.MainWindow"
  xmlns:scm="clr-namespace:System.ComponentModel;assembly=WindowsBase"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:local="clr-namespace:ComboBoxWithGroupedItems"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="My Grouped Combo" Height="400" Width="350" x:Name="MyWindow">
 <Window.Resources>
  <!-- Fill Brushes, which are required by the control template of combo box - so we have copied them. -->
  <local:HeightConverter x:Key="MyHeightConverter" />
  <LinearGradientBrush x:Key="NormalBrush" StartPoint="0,0" EndPoint="0,1">
   <LinearGradientBrush.GradientStops>
    <GradientStopCollection>
     <GradientStop Color="#FFF" Offset="0.0"/>
     <GradientStop Color="#CCC" Offset="1.0"/>
    </GradientStopCollection>
   </LinearGradientBrush.GradientStops>
  </LinearGradientBrush>
  <LinearGradientBrush x:Key="HorizontalNormalBrush" StartPoint="0,0" EndPoint="1,0">
   <LinearGradientBrush.GradientStops>
    <GradientStopCollection>
     <GradientStop Color="#FFF" Offset="0.0"/>
     <GradientStop Color="#CCC" Offset="1.0"/>
    </GradientStopCollection>
   </LinearGradientBrush.GradientStops>
  </LinearGradientBrush>
  <LinearGradientBrush x:Key="LightBrush" StartPoint="0,0" EndPoint="0,1">
   <LinearGradientBrush.GradientStops>
    <GradientStopCollection>
     <GradientStop Color="#FFF" Offset="0.0"/>
     <GradientStop Color="#EEE" Offset="1.0"/>
    </GradientStopCollection>
   </LinearGradientBrush.GradientStops>
  </LinearGradientBrush>
  <LinearGradientBrush x:Key="HorizontalLightBrush" StartPoint="0,0" EndPoint="1,0">
   <LinearGradientBrush.GradientStops>
    <GradientStopCollection>
     <GradientStop Color="#FFF" Offset="0.0"/>
     <GradientStop Color="#EEE" Offset="1.0"/>
    </GradientStopCollection>
   </LinearGradientBrush.GradientStops>
  </LinearGradientBrush>
  <LinearGradientBrush x:Key="DarkBrush" StartPoint="0,0" EndPoint="0,1">
   <LinearGradientBrush.GradientStops>
    <GradientStopCollection>
     <GradientStop Color="#FFF" Offset="0.0"/>
     <GradientStop Color="#AAA" Offset="1.0"/>
    </GradientStopCollection>
   </LinearGradientBrush.GradientStops>
  </LinearGradientBrush>
  <LinearGradientBrush x:Key="PressedBrush" StartPoint="0,0" EndPoint="0,1">
   <LinearGradientBrush.GradientStops>
    <GradientStopCollection>
     <GradientStop Color="#BBB" Offset="0.0"/>
     <GradientStop Color="#EEE" Offset="0.1"/>
     <GradientStop Color="#EEE" Offset="0.9"/>
     <GradientStop Color="#FFF" Offset="1.0"/>
    </GradientStopCollection>
   </LinearGradientBrush.GradientStops>
  </LinearGradientBrush>
  <SolidColorBrush x:Key="DisabledForegroundBrush" Color="#888" />
  <SolidColorBrush x:Key="DisabledBackgroundBrush" Color="#EEE" />
  <SolidColorBrush x:Key="WindowBackgroundBrush" Color="#FFF" />
  <SolidColorBrush x:Key="SelectedBackgroundBrush" Color="#DDD" />
  <!-- Border Brushes -->
  <LinearGradientBrush x:Key="NormalBorderBrush" StartPoint="0,0" EndPoint="0,1">
   <LinearGradientBrush.GradientStops>
    <GradientStopCollection>
     <GradientStop Color="#CCC" Offset="0.0"/>
     <GradientStop Color="#444" Offset="1.0"/>
    </GradientStopCollection>
   </LinearGradientBrush.GradientStops>
  </LinearGradientBrush>
  <LinearGradientBrush x:Key="HorizontalNormalBorderBrush" StartPoint="0,0" EndPoint="1,0">
   <LinearGradientBrush.GradientStops>
    <GradientStopCollection>
     <GradientStop Color="#CCC" Offset="0.0"/>
     <GradientStop Color="#444" Offset="1.0"/>
    </GradientStopCollection>
   </LinearGradientBrush.GradientStops>
  </LinearGradientBrush>
  <LinearGradientBrush x:Key="DefaultedBorderBrush" StartPoint="0,0" EndPoint="0,1">
   <LinearGradientBrush.GradientStops>
    <GradientStopCollection>
     <GradientStop Color="#777" Offset="0.0"/>
     <GradientStop Color="#000" Offset="1.0"/>
    </GradientStopCollection>
   </LinearGradientBrush.GradientStops>
  </LinearGradientBrush>
  <LinearGradientBrush x:Key="PressedBorderBrush" StartPoint="0,0" EndPoint="0,1">
   <LinearGradientBrush.GradientStops>
    <GradientStopCollection>
     <GradientStop Color="#444" Offset="0.0"/>
     <GradientStop Color="#888" Offset="1.0"/>
    </GradientStopCollection>
   </LinearGradientBrush.GradientStops>
  </LinearGradientBrush>
  <SolidColorBrush x:Key="DisabledBorderBrush" Color="#AAA" />
  <SolidColorBrush x:Key="SolidBorderBrush" Color="#888" />
  <SolidColorBrush x:Key="LightBorderBrush" Color="#AAA" />
  <!-- Miscellaneous Brushes -->
  <SolidColorBrush x:Key="GlyphBrush" Color="#444" />
  <SolidColorBrush x:Key="LightColorBrush" Color="#DDD" />
  <!--This is the default toggle button style - we have not played it with it, just leave it as it is.
  If you wish, then you can change the expanded and collapsed image of this button too. -->
  <ControlTemplate x:Key="ExpanderToggleButton" TargetType="{x:Type ToggleButton}">
   <Border x:Name="Border" CornerRadius="2,0,0,0" Background="Transparent" BorderBrush="{StaticResource NormalBorderBrush}"
    BorderThickness="0,0,0,0">
    <Path x:Name="Arrow" Fill="{StaticResource GlyphBrush}" HorizontalAlignment="Center" VerticalAlignment="Center"
      Data="M 0 0 L 4 4 L 8 0 Z"/>
   </Border>
   <ControlTemplate.Triggers>
    <Trigger Property="IsMouseOver" Value="true">
     <Setter TargetName="Border" Property="Background"
              Value="{StaticResource DarkBrush}" />
    </Trigger>
    <Trigger Property="IsPressed" Value="true">
     <Setter TargetName="Border" Property="Background"
              Value="{StaticResource PressedBrush}" />
    </Trigger>
    <Trigger Property="IsChecked" Value="true">
     <Setter TargetName="Arrow" Property="Data"
              Value="M 0 4 L 4 0 L 8 4 Z" />
    </Trigger>
    <Trigger Property="IsEnabled" Value="False">
     <Setter TargetName="Border" Property="Background"
              Value="{StaticResource DisabledBackgroundBrush}" />
     <Setter TargetName="Border" Property="BorderBrush"
              Value="{StaticResource DisabledBorderBrush}" />
     <Setter Property="Foreground"
              Value="{StaticResource DisabledForegroundBrush}"/>
     <Setter TargetName="Arrow" Property="Fill"
              Value="{StaticResource DisabledForegroundBrush}" />
    </Trigger>
   </ControlTemplate.Triggers>
  </ControlTemplate>
  <Style TargetType="{x:Type Expander}">
   <Setter Property="Template">
    <Setter.Value>
     <ControlTemplate TargetType="{x:Type Expander}">
      <Grid>
       <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition x:Name="ContentRow"/>
       </Grid.RowDefinitions>
       <Border 
        x:Name="Border" Grid.Row="0" Background="{StaticResource WindowBackgroundBrush}"
        BorderBrush="{StaticResource NormalBorderBrush}" BorderThickness="0,0,0,0" 
        CornerRadius="2,2,0,0" >
        <Grid>
         <Grid.ColumnDefinitions>
          <ColumnDefinition Width="*" />
          <ColumnDefinition Width="20" />
         </Grid.ColumnDefinitions>
         <!--Here, we have played a bit, to move the toggle button to expand and collapse the group
         to the right corner of the group header, so we placed this button in column 1 instead of column 0.-->
         <ToggleButton
          IsChecked="{Binding IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
          OverridesDefaultStyle="True" Grid.Column="1" Template="{StaticResource ExpanderToggleButton}" 
          Background="{StaticResource NormalBrush}" />
         <Grid Grid.Column="0">
          <!--We have modified this grid to have Group name i.e. ContentPresenter and than 
          a line upto the expand/collapse button.-->
          <Grid.ColumnDefinitions>
           <ColumnDefinition Width="auto"/>
           <ColumnDefinition Width="*"/>
          </Grid.ColumnDefinitions>
          <!--This content presenter will have value of 'Header' which in our case is group name.-->
          <ContentPresenter Margin="2" ContentSource="Header" RecognizesAccessKey="True" Grid.Column="0" />
          <Line X1="0" Y1="0" X2="1" Y2="0" Stretch="Fill" Stroke="DimGray" Grid.Column="1" />
         </Grid>
        </Grid>
       </Border>
       <Border x:Name="Content" Grid.Row="1" Background="{StaticResource WindowBackgroundBrush}"
        BorderBrush="{StaticResource SolidBorderBrush}" BorderThickness="0,0,0,0" 
        CornerRadius="0,0,2,2" >
        <!--This is border, in which the entire content is placed, and we need to play with height of this border
        in order to dispaly only desired number of items when group is collapsed. We have defined height converter class
        in our project. We are passing the Content and NumberOfItems to this converter using multibinding.-->
        <Border.Height>
         <MultiBinding Converter="{StaticResource MyHeightConverter}">
          <Binding ElementName="ComboExpander" Path="Content" />
          <Binding ElementName="MyWindow" Path="NumberOfItems"/>
         </MultiBinding>
        </Border.Height>
        <ContentPresenter Margin="10,2,0,0" />
       </Border>
      </Grid>
      <!--This is regular expand button and we have left it as it was.-->
      <ControlTemplate.Triggers>
       <Trigger Property="IsExpanded" Value="True">
        <Setter TargetName="Content" Property="Height" Value="{Binding DesiredHeight, ElementName=Content}"/>
       </Trigger>
       <Trigger Property="IsEnabled" Value="False">
        <Setter TargetName="Border" Property="Background"
        Value="{StaticResource DisabledBackgroundBrush}" />
        <Setter TargetName="Border" Property="BorderBrush" Value="{StaticResource DisabledBorderBrush}" />
        <Setter Property="Foreground" Value="{StaticResource DisabledForegroundBrush}"/>
       </Trigger>
      </ControlTemplate.Triggers>
     </ControlTemplate>
    </Setter.Value>
   </Setter>
  </Style>
  <CollectionViewSource x:Key="GroupedData" Source="{Binding URLs}">
   <!--Here we are specifying on which field of collection item should be grouped-->
   <CollectionViewSource.GroupDescriptions>
    <PropertyGroupDescription PropertyName="GroupName"/>
   </CollectionViewSource.GroupDescriptions>
  </CollectionViewSource>
  <!--Here we tell that the Header of each group should be displaying the group name-->
  <DataTemplate x:Key="GroupHeader">
   <TextBlock Text="{Binding Name}" Margin="10,0,0,0" Foreground="#989791" />
  </DataTemplate>
  <!--Here, we tell that the URL's description should be displayed as item text.-->
  <DataTemplate x:Key="URLTemplate">
   <TextBlock Text="{Binding Link}" Margin="10,0,0,0"/>
  </DataTemplate>
  <!-- Container Style for 1st level of grouping -->
  <Style x:Key="containerStyle"  TargetType="{x:Type GroupItem}">
   <Setter Property="Template">
    <Setter.Value>
     <ControlTemplate TargetType="{x:Type GroupItem}">
      <!--Here, we tell that each group of item will be placed under Expander control, 
      and this expander control will by default will have style we defined in above code.-->
      <Expander IsExpanded="False" x:Name="ComboExpander" Header="{TemplateBinding Content}" 
       HeaderTemplate="{TemplateBinding ContentTemplate}">
       <ItemsPresenter />
      </Expander>
     </ControlTemplate>
    </Setter.Value>
   </Setter>
  </Style>
 </Window.Resources>
 <StackPanel>
  <ComboBox Width="300" Grid.Row="0" Height="30" 
      ItemsSource="{Binding Source={StaticResource GroupedData}}"
      ItemTemplate="{StaticResource URLTemplate}">
   <ComboBox.GroupStyle>
    <GroupStyle ContainerStyle="{StaticResource containerStyle}"
     HeaderTemplate="{StaticResource GroupHeader}">
    </GroupStyle>
   </ComboBox.GroupStyle>
  </ComboBox>
 </StackPanel>
</Window>
And here we are done. It would be showing the desired look and behaviour. Here is the sample solution uploaded: Download

Enjoy!

4 comments:

  1. This comment has been removed by a blog administrator.

    ReplyDelete
  2. Hi,
    I liked the article and it is very helpful.
    I like to know whether a particular group can be made expanded by default say "MostRecentlyUsed" and other groups collapsed.

    Thanks in advance.

    ReplyDelete
  3. Does not work anymore... SourceCode is not available anymore.

    ReplyDelete
  4. SourceCode is not available anymore. Any one has the src?

    ReplyDelete