In this post I will show you how you can enhance your datagrid with grouping. It will be a long post, it is assumed you already know how to bind and display data.
Introduction
This is what we are going to achieve:
There are several features:
- The groups are persistent even when the records change or the DataGrid is refreshed.
- Grouping and Ungrouping is done using checkbox.
- The expanders have their text formatted (the model store numbers not strings).
- The expanders are indented, and the headers follow the indentation.
In addition, I would precise that:
- The records are not refreshed, only grouped and ungrouped (no additional query from database).
- Everything is done using Attached Behaviors, Converters and Style, so it is completely reusable.
- The MVVM pattern is not violated.
1. Make it Group-Able
At the beginning we have already created a ViewModel and a DataGrid binding to its property:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#region Capacitors | |
private IEnumerable<capacitors> _Capacitors; | |
public IEnumerable<capacitors> Capacitors | |
{ | |
get | |
{ | |
return this._Capacitors; | |
} | |
set | |
{ | |
this._Capacitors = value; | |
this.OnPropertyChanged("Capacitors"); | |
} | |
} | |
#endregion |
So at this point we are already displaying the records from the database queries.
The first thing we want to do is to make it groupable, in order to do that the ItemSource of the DataGrid must now bind to a groupable collection: ListCollectionView
There are different way to achieve this, the solution I choose is maybe not the best but it is the most flexible, because I can then move it to any file (behavior or code-behind), and I can deactivate it simply by removing one line:
this.PropertyChanged
+= new
PropertyChangedEventHandler(this.PropertyChanged_Hdler);
It is the handler of the PropertyChanged events of our ViewModel, where we will listen for the changes in "Capacitors" in order to update the ListCollectionView.
I do it like that because I already have my code working together with the property Capacitors, I don't want to change the queries to the database neither the filter, the navigation toolbar or the edition section to make them work with the ListCollectionView.
Also, I may move later the grouping to a behavior, or I may need to reuse the IEnumerable<capacitors> Capacitors property elsewhere with others queries, not only to be displayed in a DataGrid.
And I have implemented pagination to limit the number of records so the performances won't be affected.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#region GroupedCapacitors | |
private void PropertyChanged_Hdler(object sender, PropertyChangedEventArgs e) | |
{ | |
if (e.PropertyName == "Capacitors") | |
{ | |
if((_GroupedCapacitors != null) &&(_GroupedCapacitors.GroupDescriptions.Count != 0)) | |
{ | |
// backup current grouping | |
GroupDescription[] groupsbackup = new GroupDescription[_GroupedCapacitors.GroupDescriptions.Count]; | |
_GroupedCapacitors.GroupDescriptions.CopyTo(groupsbackup, 0); | |
// | |
// Load new data | |
_GroupedCapacitors = new ListCollectionView(this.Capacitors.ToList()); | |
// restore grouping | |
foreach (GroupDescription gd in groupsbackup) | |
{ | |
_GroupedCapacitors.GroupDescriptions.Add(gd); | |
} | |
} | |
else | |
{ | |
// Load new data | |
_GroupedCapacitors = new ListCollectionView(this.Capacitors.ToList()); | |
} | |
// | |
// inform view | |
this.OnPropertyChanged("GroupedCapacitors"); | |
} | |
} | |
private ListCollectionView _GroupedCapacitors; | |
public ListCollectionView GroupedCapacitors | |
{ | |
get | |
{ | |
return this._GroupedCapacitors; | |
} | |
} | |
#endregion |
First we create a new ListCollectionView property, and listen to the changes in the initial IEnumerable<capacitors> to fill it with the new records.
If the ListCollectionView has already some GroupDescriptions, we backup them and re-apply them so the grouping will remain persistent when the records are updated (if we apply filtering or switch to a different page by example).
2. Group and Ungroup using checkboxes
In this chapter we will see how to group from the user interface, in the last chapter we will see how to implement the indentation.
We will create a new Attached Behavior class to be used with the checkboxes we want to use for grouping.
What we need is clear:
- Inform the Checkbox is it used for grouping.
- Add a parameter of type Datagrid, so the checkbox knows which Datagrid it will group.
- Add a parameter of type String, so the checkbox knows on which Column it will group.
- Add a Converter to format the grouping in the header.
Inside the Attached Behavior class we will create 3 DependencyProperty:
- "CheckBoxGroupingEnabled": If true, the checkbox is used for grouping so we will listen to its checked and unchecked events. It is where we perform the grouping.
- "CheckBoxGroupingTarget": The Datagrid on which the grouping will apply.
- "CheckBoxGroupingDescription": The Column the checkbox will group on.
- "public class DataGridCapacitorGroupConverter : IValueConverter" : The converter for the header, because some column are percentage, others are decimal, we would see some values like 0.000001000000 instead of 1µ in the header.
Let's have a look a the Converter first:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class DataGridCapacitorGroupConverter : IValueConverter | |
{ | |
private string pgd; | |
public DataGridCapacitorGroupConverter(string _pgd) | |
{ | |
this.pgd = _pgd; | |
} | |
// | |
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) | |
{ | |
if (value == null) { return "None"; } | |
// | |
string result; | |
switch(this.pgd) | |
{ | |
case "Capacity": | |
result = string.Format("{0}{1}", ToEngineeringFormat.ConvertDecimal((decimal)value), "F"); | |
break; | |
case "ESR": | |
result = string.Format("{0}{1}", ToEngineeringFormat.ConvertDecimal((decimal)value), "Ω"); | |
break; | |
case "ESL": | |
result = string.Format("{0}{1}", ToEngineeringFormat.ConvertDecimal((decimal)value), "H"); | |
break; | |
case "Tolerance": | |
result = string.Format("{0} ", ((decimal)value).ToString("0.##%")); | |
break; | |
case "VoltRating": | |
result = string.Format("{0}V ", ((decimal)value).ToString("G4", System.Globalization.CultureInfo.InvariantCulture)); | |
break; | |
default: | |
result = value.ToString(); | |
break; | |
} | |
// | |
return result; | |
} | |
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) | |
{ | |
throw new NotImplementedException(); | |
} | |
} |
As you can see, we have a property in the constructor to inform on which column we will be converting. It is because the PropertyGroupDescription does not have any ConverterParameter property that we can use.
ToEngineeringFormat is a great utility class I found on the net, I have just modified it a very little bit but it is not from me and I was very grateful to find it.
Every-time a checkbox is checked we create a new PropertyGroupDescription with "CheckBoxGroupingDescription" as parameter and a new instance of DataGridCapacitorGroupConverter(CheckBoxGroupingDescription) as Converter:
ToEngineeringFormat is a great utility class I found on the net, I have just modified it a very little bit but it is not from me and I was very grateful to find it.
Every-time a checkbox is checked we create a new PropertyGroupDescription with "CheckBoxGroupingDescription" as parameter and a new instance of DataGridCapacitorGroupConverter(CheckBoxGroupingDescription) as Converter:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
PropertyGroupDescription pgd = new PropertyGroupDescription(GetCheckBoxGroupingDescription(cb)); | |
pgd.Converter = new DataGridCapacitorGroupConverter(GetCheckBoxGroupingDescription(cb)); | |
(GetCheckBoxGroupingTarget(cb).ItemsSource as ListCollectionView).GroupDescriptions.Add(pgd); |
Remember, "CheckBoxGroupingTarget" is the DataGrid.
In the end you will see the whole files, Behavior and Style, but because it is quite huge I prefer to write the explanations now.
In the end you will see the whole files, Behavior and Style, but because it is quite huge I prefer to write the explanations now.
3. Indentation and Styles
3.1 Styles
When you apply grouping to a DataGrid you must inform it how to display the groups. In this case we are using Expanders for all groups, but we could also use Expanders only for the top level groups by example. A good example can be found here.
In any case, it is possible to apply different Styles to the levels of your grouping. If you follow the link above, it is written: "Styles are applied in the order in which they are defined".
In our case, what we want is to indent the sub-levels only. So we create 2 Styles, one for the top level with a left margin of 0 and one for all the sub-levels with a left margin of 10. So the group level number 5 by example will be indented by 4*10=40 pixels.
These are our 2 Styles:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<Style x:Key="dgv_capacitors_GroupItem_top" TargetType="{x:Type GroupItem}"> | |
<Setter Property="Template"> | |
<Setter.Value> | |
<ControlTemplate TargetType="{x:Type GroupItem}"> | |
<Expander IsExpanded="True" Background="LightSteelBlue"> | |
<Expander.Header> | |
<StackPanel Orientation="Horizontal"> | |
<TextBlock Text="{Binding Path=Name}" FontWeight="Bold"/> | |
<TextBlock Text=" ("/> | |
<TextBlock Text="{Binding Path=ItemCount}"/> | |
<TextBlock Text=" "/> | |
<TextBlock Text="Items)"/> | |
</StackPanel> | |
</Expander.Header> | |
<ItemsPresenter /> | |
</Expander> | |
</ControlTemplate> | |
</Setter.Value> | |
</Setter> | |
</Style> | |
<Style x:Key="dgv_capacitors_GroupItem_sub" TargetType="{x:Type GroupItem}"> | |
<Setter Property="Margin"> | |
<Setter.Value> | |
<Thickness Left="10"/> | |
</Setter.Value> | |
</Setter> | |
<Setter Property="Template"> | |
<Setter.Value> | |
<ControlTemplate TargetType="{x:Type GroupItem}"> | |
<Expander IsExpanded="True" Background="LightSteelBlue"> | |
<Expander.Header> | |
<StackPanel Orientation="Horizontal"> | |
<TextBlock Text="{Binding Path=Name}" FontWeight="Bold"/> | |
<TextBlock Text=" ("/> | |
<TextBlock Text="{Binding Path=ItemCount}"/> | |
<TextBlock Text=" "/> | |
<TextBlock Text="Items)"/> | |
</StackPanel> | |
</Expander.Header> | |
<ItemsPresenter /> | |
</Expander> | |
</ControlTemplate> | |
</Setter.Value> | |
</Setter> | |
</Style> |
And here is how we apply them to the DataGrid in the main XAML file:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<DataGrid.GroupStyle> | |
<GroupStyle ContainerStyle="{StaticResource dgv_capacitors_GroupItem_top}"> | |
<GroupStyle.Panel> | |
<ItemsPanelTemplate> | |
<DataGridRowsPresenter/> | |
</ItemsPanelTemplate> | |
</GroupStyle.Panel> | |
</GroupStyle> | |
<GroupStyle ContainerStyle="{StaticResource dgv_capacitors_GroupItem_sub}"> | |
<GroupStyle.Panel> | |
<ItemsPanelTemplate> | |
<DataGridRowsPresenter/> | |
</ItemsPanelTemplate> | |
</GroupStyle.Panel> | |
</GroupStyle> | |
</DataGrid.GroupStyle> |
3.2 Indentation
Now we will see how to indent the ColumnHeaders.
Of course in a clean, good, reusable way: using only Styles and Behaviors.
For that we will apply a "translation" to the headers, equal to the maximum indentation: (nbr of group-1)*10.
We must apply this "translation" when the groups are added or removed, and when the DataGrid is refreshed:
- We need MultiBindings
- One Binding on the ItemSource of the DataGrid.
- One Binding when the number of groups change.
- We need a Converter which returns a number: the value of the translation.
Until here no problems, but how can we trigger when the number of groups change?
We will create a new DependencyProperty, holding the current number of groups in the DataGrid, and we will update this number when we add or remove groups in the Attached Behavior!
We will call it "DataGridGroupingCount" and it will trigger the redrawing of the header when we update it in the Attached Behavior.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// redraw the offset of the headers | |
int count = (GetCheckBoxGroupingTarget(cb).ItemsSource as ListCollectionView).GroupDescriptions.Count; | |
SetDataGridGroupingCount((GetCheckBoxGroupingTarget(cb) as DataGrid), count); |
The Converter is here:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class DataGridHeaderOffsetConverter : IMultiValueConverter | |
{ | |
public object Convert(object[] values, Type targetType, object parameter, System.Globalization.CultureInfo culture) | |
{ | |
if ((values[0] == null) || | |
(values[1] == null) || | |
(parameter == null)) { return (double)0; } | |
if ((values[0].GetType() != typeof(ListCollectionView)) || | |
(values[1].GetType() != typeof(int)) || | |
(parameter.GetType() != typeof(int))) { return (double)0; } | |
// | |
double offset = 0; | |
int nbrofgroups = (int)values[1]; | |
int translateparam = (int)parameter; | |
// | |
if (nbrofgroups != 0) | |
{ | |
offset = (nbrofgroups - 1) * translateparam; | |
} | |
// | |
return (double)offset; | |
} | |
public object[] ConvertBack(object value, Type[] targetType, object parameter, System.Globalization.CultureInfo culture) | |
{ | |
throw new NotImplementedException(); | |
} | |
} |
It is simply returning the right offset according to the number of groups, after checking the correctness of the input parameters.
Here is the Style of the ColumnHeaders, to be applied on the DataGrid in the main XAML file:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<Style x:Key="dgvHeaderColumnAligned" TargetType="DataGridColumnHeader"> | |
<Setter Property="HorizontalContentAlignment" Value="Center" /> | |
<Setter Property="VerticalContentAlignment" Value="Center" /> | |
<Setter Property="Width" Value="Auto"/> | |
<Setter Property="TextBlock.FontWeight" Value="Bold"/> | |
<Setter Property="RenderTransform"> | |
<Setter.Value> | |
<TranslateTransform> | |
<TranslateTransform.X> | |
<MultiBinding Mode="OneWay"> | |
<MultiBinding.Converter> | |
<userctrls:DataGridHeaderOffsetConverter/> | |
</MultiBinding.Converter> | |
<MultiBinding.ConverterParameter> | |
<System:Int32>10</System:Int32> | |
</MultiBinding.ConverterParameter> | |
<MultiBinding.Bindings> | |
<Binding RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType=DataGrid}" Path="ItemsSource" Mode="OneWay"/> | |
<Binding RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType=DataGrid}" Path="(behaviors:CheckBoxGroupingBehavior.DataGridGroupingCount)" Mode="TwoWay"/> | |
</MultiBinding.Bindings> | |
</MultiBinding> | |
</TranslateTransform.X> | |
</TranslateTransform> | |
</Setter.Value> | |
</Setter> | |
</Style> |
It is not completely necessary to bind on ItemSource when the records are refreshed, but if later we want to hide the empty groups we would have to recalculate the translation according to the number of visible groups instead of the number of groups itself.
We are almost done! Now you will see the whole Behaviors file with all the DependencyProperties and how they are used.
4. Behavior Class
This is the complete Behavior Class:
This is the complete Behavior Class:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static class CheckBoxGroupingBehavior | |
{ | |
public class DataGridCapacitorGroupConverter : IValueConverter | |
{ | |
private string pgd; | |
public DataGridCapacitorGroupConverter(string _pgd) | |
{ | |
this.pgd = _pgd; | |
} | |
// | |
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) | |
{ | |
if (value == null) { return "None"; } | |
// | |
string result; | |
switch(this.pgd) | |
{ | |
case "Capacity": | |
result = string.Format("{0}{1}", ToEngineeringFormat.ConvertDecimal((decimal)value), "F"); | |
break; | |
case "ESR": | |
result = string.Format("{0}{1}", ToEngineeringFormat.ConvertDecimal((decimal)value), "Ω"); | |
break; | |
case "ESL": | |
result = string.Format("{0}{1}", ToEngineeringFormat.ConvertDecimal((decimal)value), "H"); | |
break; | |
case "Tolerance": | |
result = string.Format("{0} ", ((decimal)value).ToString("0.##%")); | |
break; | |
case "VoltRating": | |
result = string.Format("{0}V ", ((decimal)value).ToString("G4", System.Globalization.CultureInfo.InvariantCulture)); | |
break; | |
default: | |
result = value.ToString(); | |
break; | |
} | |
// | |
return result; | |
} | |
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) | |
{ | |
throw new NotImplementedException(); | |
} | |
} | |
#region CheckBoxGroupingEnabled | |
public static readonly DependencyProperty | |
CheckBoxGroupingEnabledProperty = DependencyProperty.RegisterAttached | |
( | |
"CheckBoxGroupingEnabled", | |
typeof(bool), | |
typeof(CheckBoxGroupingBehavior), | |
new UIPropertyMetadata(false, OnCheckBoxGroupingEnabledPropertyChanged) | |
); | |
public static bool GetCheckBoxGroupingEnabled(DependencyObject obj) | |
{ | |
return (bool)obj.GetValue(CheckBoxGroupingEnabledProperty); | |
} | |
public static void SetCheckBoxGroupingEnabled(DependencyObject obj, bool value) | |
{ | |
obj.SetValue(CheckBoxGroupingEnabledProperty, value); | |
} | |
private static void OnCheckBoxGroupingEnabledPropertyChanged(DependencyObject dpo, DependencyPropertyChangedEventArgs args) | |
{ | |
CheckBox cb = dpo as CheckBox; | |
// | |
if (cb != null) | |
{ | |
// | |
if ((bool)args.NewValue == true) | |
{ | |
cb.Checked += OnChecked_hdler; | |
cb.Unchecked += OnChecked_hdler; | |
} | |
else | |
{ | |
cb.Checked -= OnChecked_hdler; | |
cb.Unchecked -= OnChecked_hdler; | |
} | |
} | |
} | |
private static void OnChecked_hdler(object sender, RoutedEventArgs e) | |
{ | |
CheckBox cb = (CheckBox)sender; | |
if (cb == null) { return; } | |
if (GetCheckBoxGroupingDescription(cb) == null) { return; } | |
if (GetCheckBoxGroupingTarget(cb) == null) { return; } | |
// | |
if(cb.IsChecked == true) | |
{ | |
foreach (PropertyGroupDescription x in (GetCheckBoxGroupingTarget(cb).ItemsSource as ListCollectionView).GroupDescriptions.ToList()) | |
{ | |
if (x.PropertyName == GetCheckBoxGroupingDescription(cb)) | |
{ // do not group several time on the same description | |
return; | |
} | |
} | |
// | |
PropertyGroupDescription pgd = new PropertyGroupDescription(GetCheckBoxGroupingDescription(cb)); | |
pgd.Converter = new DataGridCapacitorGroupConverter(GetCheckBoxGroupingDescription(cb)); | |
(GetCheckBoxGroupingTarget(cb).ItemsSource as ListCollectionView).GroupDescriptions.Add(pgd); | |
// redraw the offset of the headers | |
int count = (GetCheckBoxGroupingTarget(cb).ItemsSource as ListCollectionView).GroupDescriptions.Count; | |
SetDataGridGroupingCount((GetCheckBoxGroupingTarget(cb) as DataGrid), count); | |
} | |
else | |
{ | |
foreach (PropertyGroupDescription pgd in (GetCheckBoxGroupingTarget(cb).ItemsSource as ListCollectionView).GroupDescriptions.ToList()) | |
{ | |
if(pgd.PropertyName == GetCheckBoxGroupingDescription(cb)) | |
{ | |
(GetCheckBoxGroupingTarget(cb).ItemsSource as ListCollectionView).GroupDescriptions.Remove(pgd); | |
//return; | |
} | |
} | |
// redraw the offset of the headers | |
int count = (GetCheckBoxGroupingTarget(cb).ItemsSource as ListCollectionView).GroupDescriptions.Count; | |
SetDataGridGroupingCount((GetCheckBoxGroupingTarget(cb) as DataGrid), count); | |
} | |
} | |
#endregion | |
#region CheckBoxGroupingDescription | |
// What to group on | |
public static readonly DependencyProperty | |
CheckBoxGroupingDescriptionProperty = DependencyProperty.RegisterAttached | |
( | |
"CheckBoxGroupingDescription", | |
typeof(string), | |
typeof(CheckBoxGroupingBehavior), | |
new UIPropertyMetadata(null) | |
); | |
public static string GetCheckBoxGroupingDescription(DependencyObject obj) | |
{ | |
return (string)obj.GetValue(CheckBoxGroupingDescriptionProperty); | |
} | |
public static void SetCheckBoxGroupingDescription(DependencyObject obj, string value) | |
{ | |
obj.SetValue(CheckBoxGroupingDescriptionProperty, value); | |
} | |
#endregion | |
#region CheckBoxGroupingTarget | |
// What to group on | |
public static readonly DependencyProperty | |
CheckBoxGroupingTargetProperty = DependencyProperty.RegisterAttached | |
( | |
"CheckBoxGroupingTarget", | |
typeof(DataGrid), | |
typeof(CheckBoxGroupingBehavior), | |
new UIPropertyMetadata(null) | |
); | |
public static DataGrid GetCheckBoxGroupingTarget(DependencyObject obj) | |
{ | |
return (DataGrid)obj.GetValue(CheckBoxGroupingTargetProperty); | |
} | |
public static void SetCheckBoxGroupingTarget(DependencyObject obj, DataGrid value) | |
{ | |
obj.SetValue(CheckBoxGroupingTargetProperty, value); | |
} | |
#endregion | |
#region DataGridGroupingCountProperty | |
// What to group on | |
public static readonly DependencyProperty | |
DataGridGroupingCountProperty = DependencyProperty.RegisterAttached | |
( | |
"DataGridGroupingCount", | |
typeof(int), | |
typeof(CheckBoxGroupingBehavior), | |
new UIPropertyMetadata(0) | |
); | |
public static int GetDataGridGroupingCount(DependencyObject obj) | |
{ | |
return (int)obj.GetValue(DataGridGroupingCountProperty); | |
} | |
public static void SetDataGridGroupingCount(DependencyObject obj, int value) | |
{ | |
obj.SetValue(DataGridGroupingCountProperty, value); | |
} | |
#endregion | |
} |
5. Conclusion
Grouping and Ungrouping is now integrated in the existing project and is easily reusable.
Only the Converter would need to be adapted to the type of object we are working with.
By example if we would decide to group on Resistors too, we would create a new Converter and add a new DependencyProperty informing us on which objects the checkbox is currently grouping. According to this DependencyProperty we would then decide which converter to set to the PropertyGroupDescription.
I hope this post was useful to someone and worth to read it.
Do not hesitate to help me to improve it, any new idea or suggestion is welcome.
No comments:
Post a Comment