Confessions of a ListBox groupie [Using IValueConverter to create a grouped list of items simply and flexibly]
A customer recently asked how to implement a simple "grouped ListBox" experience in Silverlight (now available in desktop, mobile, and extra crispy flavor!), so I dashed off this sample to show one way that's pretty easy to work with.
Well, actually, the first thing I did was ask if DataGrid (which supports grouping natively) was an option - but the customer felt strongly about using a ListBox
, so here we are...
The core of my solution is a custom IValueConverter implementation. If you've read my blog much, you were probably expecting that because I tend to be a pretty big fan. As usual, IValueConverter
is convenient because it allows us to easily transform the source data into something that looks how we want without needing to modify the actual data source or values. In fact, the rest of the application doesn't really need to know what's going on - this is a (mostly) UI-only solution.
Okay, not needing to modify the original data is a nice advantage. What else would we like to see in a good solution? Well, it would be nice if it were easy to customize the appearance of items and their group headers without writing any code. And it would be nice if the grouping logic were flexible enough to allow grouping on any criteria (ex: value of a property, value ranges, first letter of name, etc.). And of course we want the designer to have the flexibility to hook everything up in XAML.
That seems like a pretty reasonable list of requirements - and we can handle them all without a problem. But first, let's see it in action:
On the left is the original data in a simple ItemsControl, in the center is a grouped version of that same data (just like we wanted!), and on the right is the same grouping of that data - this time a little more fancy and in a ListBox
so the items are selectable! The XAML for the middle example looks like this:
<Grid.Resources> <delay:GroupingItemsControlConverter x:Key="GroupingItemsControlConverter"/> <delay:GroupingItemsControlConverterParameters x:Key="SimpleGroupingItemsControlConverterParameter"> <delay:GroupingItemsControlConverterParameters.GroupSelector> <local:AnimalSpeciesGroupSelector/> </delay:GroupingItemsControlConverterParameters.GroupSelector> <delay:GroupingItemsControlConverterParameters.GroupHeaderTemplate> <DataTemplate> <ContentControl Content="{Binding}" FontWeight="Bold"/> </DataTemplate> </delay:GroupingItemsControlConverterParameters.GroupHeaderTemplate> <delay:GroupingItemsControlConverterParameters.ItemTemplate> <DataTemplate> <ContentControl Content="{Binding Name}" Padding="8 0 0 0"/> </DataTemplate> </delay:GroupingItemsControlConverterParameters.ItemTemplate> </delay:GroupingItemsControlConverterParameters> </Grid.Resources> <!-- ... --> <ItemsControl ItemsSource="{Binding Converter={StaticResource GroupingItemsControlConverter}, ConverterParameter={StaticResource SimpleGroupingItemsControlConverterParameter}}"/>
How does it work? Fairly simply, actually! Once parameters have been validated, the GroupingItemsControlConverter
class makes a call to Linq's GroupBy extension method using the custom grouping method specified and follows with a call to the OrderBy extension method. The results are then output as a sequence of ContentControl instances with a custom DataTemplate applied according to whether each thing is a group header or an item. This pattern should seem pretty familiar; it's the standard ItemsControl
model mixed together with something kind of like implicit DataTemplate
s. The GroupingItemsControlConverterParameters
class lets you specify the GroupHeaderTemplate
, the ItemTemplate
, and an class implementing the IGroupingItemsControlConverterSelector
interface. And don't worry, the custom implementation of that interface is quite trivial - here's what the sample application uses:
// Simple IGroupingItemsControlConverterSelector implementation for grouping by an Animal's species public class AnimalSpeciesGroupSelector : IGroupingItemsControlConverterSelector { public Func<object, IComparable> GetGroupSelector() { return (o) => ((Animal)o).Species; } }
As you can see, the simple example really is pretty simple. IValueConverter
to disable the headers so they can't be clicked on or selected.
Aside: Yes, I know that the very top group header is selectable when using the keyboard on current Silverlight bits. No, it's not my bug. Yes, I already reported it to the relevant people.:)
Those of you familiar with my blog may be wondering why I haven't mentioned that everything here works on WPF, too... Okay, fine, I fully expect that what I've done here will work exactly the same on WPF as it does on Silverlight. DataTemplate
s means that I'd probably implement this solution a little differently on WPF. If you're itching to use this code as-is on WPF, go right ahead; I don't anticipate any problems with that. But if you do, maybe spend just a bit of time thinking about how you would do things differently on WPF...
Here's the complete implementation of GroupingItemsControlConverter
and its helper classes for those who are interested:
/// <summary> /// Class that implements simple grouping for ItemsControl and its subclasses (ex: ListBox) /// </summary> public class GroupingItemsControlConverter : IValueConverter { /// <summary> /// Modifies the source data before passing it to the target for display in the UI. /// </summary> /// <param name="value">The source data being passed to the target.</param> /// <param name="targetType">The Type of data expected by the target dependency property.</param> /// <param name="parameter">An optional parameter to be used in the converter logic.</param> /// <param name="culture">The culture of the conversion.</param> /// <returns>The value to be passed to the target dependency property.</returns> public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { // Validate parameters var valueAsIEnumerable = value as IEnumerable; if(null == valueAsIEnumerable) { throw new ArgumentException("GroupingItemsControlConverter works for only IEnumerable inputs.", "value"); } var parameterAsGroupingItemsControlConverterParameter = parameter as GroupingItemsControlConverterParameters; if (null == parameterAsGroupingItemsControlConverterParameter) { throw new ArgumentException("Missing required GroupingItemsControlConverterParameter.", "parameter"); } var groupSelectorAsIGroupingItemsControlConverterSelector = parameterAsGroupingItemsControlConverterParameter.GroupSelector as IGroupingItemsControlConverterSelector; if (null == groupSelectorAsIGroupingItemsControlConverterSelector) { throw new ArgumentException( "GroupingItemsControlConverterParameter.GroupSelector must be non-null and implement IGroupingItemsControlConverterSelector.", "parameter"); } // Return the grouped results return ConvertAndGroupSequence(valueAsIEnumerable.Cast<object>(), parameterAsGroupingItemsControlConverterParameter); } /// <summary> /// Converts and groups the values of the specified sequence according to the settings of the specified parameters. /// </summary> /// <param name="sequence">Sequence of items.</param> /// <param name="parameters">Parameters for the grouping operation.</param> /// <returns>Converted and grouped sequence.</returns> private IEnumerable<object> ConvertAndGroupSequence(IEnumerable<object> sequence, GroupingItemsControlConverterParameters parameters) { // Validate parameters var groupSelector = ((IGroupingItemsControlConverterSelector)(parameters.GroupSelector)).GetGroupSelector(); if (null == groupSelector) { throw new NotSupportedException("IGroupingItemsControlConverterSelector.GetGroupSelector must return a non-null value."); } // Do the grouping and ordering var groupedOrderedSequence = sequence.GroupBy(groupSelector).OrderBy(g => g.Key); // Return the wrapped results foreach (var group in groupedOrderedSequence) { yield return new ContentControl { Content = group.Key, ContentTemplate = parameters.GroupHeaderTemplate }; foreach (var item in group) { yield return new ContentControl { Content = item, ContentTemplate = parameters.ItemTemplate }; } } } /// <summary> /// Modifies the target data before passing it to the source object. This method is called only in TwoWay bindings. /// </summary> /// <param name="value">The target data being passed to the source.</param> /// <param name="targetType">The Type of data expected by the source object.</param> /// <param name="parameter">An optional parameter to be used in the converter logic.</param> /// <param name="culture">The culture of the conversion.</param> /// <returns>The value to be passed to the source object.</returns> public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotImplementedException("GroupingItemsControlConverter does not support ConvertBack."); } } /// <summary> /// Class that represents the input parameters to the GroupingItemsControlConverter class. /// </summary> public class GroupingItemsControlConverterParameters { /// <summary> /// Template to use for the header for a group. /// </summary> public DataTemplate GroupHeaderTemplate { get; set; } /// <summary> /// Template to use for the items of a group. /// </summary> public DataTemplate ItemTemplate { get; set; } /// <summary> /// Selector to use for determining the grouping of the sequence. /// </summary> public IGroupingItemsControlConverterSelector GroupSelector { get; set; } } /// <summary> /// Interface for classes to be used as a selector for the GroupingItemsControlConverterParameters class. /// </summary> public interface IGroupingItemsControlConverterSelector { /// <summary> /// Function that returns the group selector. /// </summary> /// <returns>Key to use for grouping.</returns> Func<object, IComparable> GetGroupSelector(); }