(Edit: This turned out to be a very long post so take a cup of coffee and enjoy!)
Problem statement: We need to customize the Combo Box's template by adding an 'Add' button as one of the items in the list.
Background: Let's say we have a list in our model - a list of supplier. This list of suppliers is bound to a combo box. The user needs to select a supplier to complete a business case. Now the users ask for an 'Add' button, and they want it as one of the items in the combo box.
Approach 1: Let's consider that the list is not in model/view model but contained in XAML.
For e.g.:
<ComboBox>
<ComboBoxItem>Supplier 1</ComboBoxItem>
<ComboBoxItem>Supplier 2</ComboBoxItem>
<ComboBoxItem>Supplier 3</ComboBoxItem>
<ComboBoxItem>Supplier 4</ComboBoxItem>
</ComboBox>
In this case, we can add the Button as a child of ComboBox:
<ComboBox height="25" x:name="cmbSupplier">
<ComboBoxItem>Supplier 1</ComboBoxItem>
<ComboBoxItem>Supplier 2</ComboBoxItem>
<ComboBoxItem>Supplier 3</ComboBoxItem>
<ComboBoxItem>Supplier 4</ComboBoxItem>
<Button click="Button_Click" Content="Add" />
</ComboBox>
The Button_Click event:
cmbSupplier.Items.Insert(cmbSupplier.Items.Count - 1, "New Supplier");
Now comes the saving part. How do we save the new supplier? We have given the 'default' list in XAML so it's kind of hard-coded which is not a good thing, it should ideally come from persistence store like database or an XML file. But say we add the default supplier's in XAML and the user can add more. So we can get the newly added suppliers from Combo Box's items list in code-behind and persist them. On loading the application again we can add these 'new' supplier again.
The downside here is that it uses code-behind and there no data binding. The Combo box is not bound to a list and we are using click event of the button not a Command.
MVVM: The above approach can be used in certain exceptional cases. Normally we use MVVM pattern and we bind the Combo box ItemsSource property to a collection in our view model. So what can we do in this case? Again we have more than one option, but first let me build the MVVM infrastructure before customizing the Combo Box.
In our model we have:
public class Supplier
{
public string Name { get; set; }
public int Code { get; set; }
}
(We should have the notify property change as well in the properties, but I am keeping things simple here as we focus on adding a new item and not updating existing one.)
Note: I am using
MVVMFoundation framework that's why you see the RelayCommand and ObservableObject.
This is our view model:
public class ViewModel : ObservableObject
{
public ICommand AddSupplierCommand { get; set; }
public ObservableCollection Suppliers { get; set; }
public ViewModel()
{
Suppliers = new ObservableCollection();
FillSuppliers(); //method to get supplier list from a store
AddSupplierCommand = new RelayCommand(AddSupplier);
}
private void AddSupplier()
{
Suppliers.Add(new Supplier { Name = "New Supplier", Code = 999 });
}
}
In XAML:
<ComboBox Height="25" ItemsSource="{Binding Suppliers}">
</ComboBox>
In Window's code-behind:
public partial class Window2 : Window
{
ViewModel _vm;
public Window2()
{
InitializeComponent();
_vm = new ViewModel();
this.DataContext = _vm;
}
}
And we get:
So now we add a data template in XAML to show the name of supplier and not the ToString() implementation.
We change the XAML to:
<ComboBox Height="25" ItemsSource="{Binding Suppliers}">
<Combobox.ItemTemplate>
<DataTemplate>
<TextBlock Margin="5,5" Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
And the result is:
Great, so now we have everything to start adding the 'add button.
Approach 2 Using a Composite Collection:
Composite collections is a recent find for me. From MSDN -
"Enables multiple collections and items to be displayed as a single list."
I think that's perfect for us since we want to add different type of objects to a list. Let me show you the code first and then we will discuss it.
Window resources in XAML: ("vm" is namespace of the project, ViewModel is the class name)
<Window.Resources>
<vm:viewmodel x:key="viewmodel"></vm:viewmodel>
</Window.Resources>
We change the XAML to:
<ComboBox height="25">
<ComboBox.ItemSsource>
<CompositeCollection>
<CollectionContainer Collection="{Binding Source={StaticResource viewmodel}, Path=Suppliers}" />
<Button Command="{Binding Source={StaticResource viewmodel}, Path=AddSupplierCommand}" Content="Add" />
</CompositeCollection>
</Combobox.ItemsSource>
</ComboBox>
Window.xaml.cs:
public partial class Window2 : Window
{
public ViewModel ViewModel { get; set; }
public Window2()
{
InitializeComponent();
this.ViewModel = new ViewModel();
this.DataContext = this.ViewModel;
if (this.TryFindResource("viewmodel") != null)
{
this.Resources["viewmodel"] = this.ViewModel;
}
}
}
There are couple of hacks here:
- Using CompositeCollection in XAML has a problem. It has no visual properties and is not found in Visual tree - so it doesn't inherit the data context we set in code-behind. So this
<CollectionContainer Collection="{Binding Suppliers}"></CollectionContainer> doesn't work.
As a workaround we add the ViewModel object to Resources (later we will see why we are adding the entire view model not just the "Suppliers" list), and refer this resource as a StaticResource in CompositeCollection. It is worth mentioning here that you could also use MultiBinding to which you pass in the two collections and get back the full combined collection.
- We have removed the DataTemplate. This is because now we cannot use a DataTemplate since we have two different types of objects (Supplier and Button) in our collection. We can use a DataTemplateSelector but by using that here we will deviate from our topic, so I have just changed the ToString() in Supplier class to return the "Name" property for now.
- For the Button, we are setting its Command property - again from the ViewModel static resource, because we can't access the DataContext here (this why we store the entire view model in resources).
The result is:
And upon clicking 'Add' we get:
Great, here we can use databinding, MVVM pattern and get the added supplier in our Model. Only problem is using DataTemplate - which can be resolved by DataTemplateSelector. Next we will see another approach where we can actually use a DataTemplate. Other problem is adding the view model in resources.
Approach 3 Using Converters: This approach doesn't use the CompositeCollection. Some people may not like the idea of setting the DataContext and also adding ViewModel object in the resources.
So here we take a different approach - we change the Supplier list to show the 'Add' button.
Let's suppose we get this Suppliers data from our Model:
- Name: Supplier 1, Code: 100
- Name: Supplier 2, Code: 200
- Name: Supplier 3, Code: 300
- Name: Supplier 4, Code: 400
Now just after loading this data we add a "dummy" supplier to the list:
- Name: Supplier -1, Code:-1
Our XAML becomes:
In the resources we add two converters:
<Window.Resources>
<vm:SupplierToVisibilityConverter x:Key="supplierToVisibilityConverter" />
<vm:SupplierToVisibilityForAddConverter x:Key="supplierToVisibilityForAddConverter" />
</Window.Resources>
The ComboBox:
<ComboBox Height="25" ItemsSource="{Binding Suppliers}" Style="{StaticResource ResourceKey={x:Type ComboBox}}" >
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Margin="5,5" Text="{Binding Name}" Visibility="{Binding Path=., Converter={StaticResource supplierToVisibilityConverter}}"/>
<Button Content="Add" Margin="5,5"
Command="{Binding RelativeSource={RelativeSource AncestorType={x:Type ComboBox}}, Path=DataContext.AddSupplierCommand}"
Visibility="{Binding Path=.,Converter={StaticResource supplierToVisibilityForAddConverter}}"/>
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
ViewModel:
public class ViewModel : ObservableObject
{
public ICommand AddSupplierCommand { get; set; }
public ObservableCollection Suppliers { get; set; }
public ViewModel()
{
Suppliers = new ObservableCollection();
AddSupplierCommand = new RelayCommand(AddSupplier);
_dummy = new Supplier { Name = "-1", Code = -1 };
FillSuppliers();
}
Supplier _dummy;
private void AddSupplier()
{
Suppliers.Remove(_dummy);
Suppliers.Add(new Supplier { Name = "New Supplier", Code = 999 });
Suppliers.Add(_dummy);
}
void FillSuppliers()
{
Suppliers.Add(new Supplier { Name = "Supplier1", Code = 1 });
Suppliers.Add(new Supplier { Name = "Supplier2", Code = 2 });
Suppliers.Add(new Supplier { Name = "Supplier3", Code = 3 });
Suppliers.Add(new Supplier { Name = "Supplier4", Code = 4 });
Suppliers.Add(_dummy);
}
}
Explanation:
In the ViewModel, you can see that we are adding a dummy supplier with name and code of value -1. In the DataTemplate of ComboBox we have two controls - TextBlock to see the Supplier details and one Button. Now since DataTemplate is called for every item in the list, we need a way to make visible and hide the TextBlock and Button - based on the supplier item.
For this we use two converters (we could have used one but keeping things simple for now). The converters are SupplierToVisibilityConverter and SupplierToVisibilityForAddConverter:
public class SupplierToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
var supplier = value as Supplier;
if (supplier != null)
{
if (supplier.Code == -1)
return Visibility.Collapsed;
return Visibility.Visible;
}
return Binding.DoNothing;
}
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
return Binding.DoNothing;
}
}
public class SupplierToVisibilityForAddConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
var supplier = value as Supplier;
if (supplier != null)
{
if (supplier.Code == -1)
return Visibility.Visible;
return Visibility.Collapsed;
}
return Binding.DoNothing;
}
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
return Binding.DoNothing;
}
}
In SupplierToVisibilityConverter we check whether the current supplier item has Code == -1, if yes then we hide it else keep it visible. As you can see in XAML this converter is used in TextBlock's visibility. Similarly we use the SupplierToVisibilityForAddConverter for finding out whether the 'Add' button should be visible or not.
Also in the Button, we have used RelativeResource to bind the Command. This is because the AddSupplierCommand is available on the ViewModel and in the DataTemplate we have access only to the current Supplier item being bound. So we refer to the DataContext of the parent ComboBox.
The output:
The disadvantages of this approach are:
- Using converters complicates the logic a little. Although they can be reused for other scenarios as well
- Adding a dummy entry in the list is also not a good idea. This is changing the Model data for a UI hack, so the developer persisting the data needs to be aware about this hack. Necessary sould also be done that in ViewModel to not let the dummy data go out.
Approach 4 Using Control Template (with attached property): Control templates are modified when we need to change the UI of the control. It gives full freedom to change the layout, behavior, look and feel of the control.
I am using this tool
http://thematic.codeplex.com/ to create custom styles. If you use this tool, you will see a file named "combobox.xaml" in the list of generated files.
I am showing here the portion that relevant to our discussion:
<!--<SnippetComboBoxStyle>-->
<Style x:Key="{x:Type ComboBox}" TargetType="ComboBox">
<Setter Property="SnapsToDevicePixels" Value="true"/>
<Setter Property="OverridesDefaultStyle" Value="true"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Auto"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
....
<Popup
Name="Popup"
Placement="Bottom"
....
<ScrollViewer Margin="4,6,4,6" SnapsToDevicePixels="True">
<StackPanel Orientation="Vertical">
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained" />
<Button Margin="5,5" Content="Add" Command="{TemplateBinding app:CommandExtensions.Command}"
Background="{StaticResource WindowBackgroundBrush}"/>
</StackPanel> </ScrollViewer>
.....
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
I have added the lines in bold. The ComboBox has a scroll viewer to scroll the items which we find in the ScrollViewer control. A StackPanel with IsItemsHost="true" is used to display the items. So just after this stack panel we add our 'Add' button.
XAML:
<ComboBox Height="25" ItemsSource="{Binding Suppliers}" Style="{StaticResource ResourceKey={x:Type ComboBox}}"
vm:CommandExtensions.Command="{Binding AddSupplierCommand}" />
Result:
Ok so we have the button but how do we hook the Button with a command? ComboBox doesn't have a Command property which we can use here. And if we had created a custom control by inheriting the ComboBox we could have created a new Dependency Property called Command and used it.
Attached properties to the rescue. This is a perfect scenario for using attached properties, so let's create one.
Create a new class for this code:
internal class CommandExtensions : DependencyObject
{
public static ICommand GetCommand(DependencyObject obj)
{
return (ICommand)obj.GetValue(CommandProperty);
}
public static void SetCommand(DependencyObject obj, ICommand value)
{
obj.SetValue(CommandProperty, value);
}
// Using a DependencyProperty as the backing store for Command. This enables animation, styling, binding, etc...
public static readonly DependencyProperty CommandProperty =
DependencyProperty.RegisterAttached("Command", typeof(ICommand), typeof(CommandExtensions), new UIPropertyMetadata(null));
}
In the XAML we use the new attached property:
<ComboBox Height="25" ItemsSource="{Binding Suppliers}" Style="{StaticResource ResourceKey={x:Type ComboBox}}"
vm:CommandExtensions.Command="{Binding AddSupplierCommand}" />
Approach 5 Using Custom Control: Finally we could also create a custom control by inheriting the ComboBox class. (code coming soon...)