Freeze Row Group Header in WPF DataGrid

Grouping items in a WPF DataGrid can be easily achieved by using CollectionViewSource. We have the option to put headers to identify the groups. However, unlike columns, group headers cannot be frozen. This means that some of the contents of the group headers may be moved out of view when the user scrolls the DataGrid horizontally.

Introduction

To give a brief introduction of what the problem is, please see the following figure.


Figure 1. Unfrozen Group Headers

As you can see, the group headers are scrolled along with the other columns that are not frozen. The first column in this example is frozen by using the FrozenColumnCount property of the DataGrid. I am not sure if having this kind of behavior is acceptable in most situations but I like to have group headers not scrollable by default.

Grouping Items

If you are not familiar with grouping items, here is a quick run-through. We can group items in any ItemsControl object, DataGrid included. To group the items, we can create a CollectionViewSource object, set the PropertyGroupDescription, and assign it to the ItemsSource property of the ItemsControl. If we want to show a group header we have to add a GroupStyle to the GroupStyle collection of the ItemsControl. The following code listing shows the XAML code of the window in Figure 1.

<Window x:Class="WPFDataGridGroupingDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="250" Width="300">
<Window.Resources>
<CollectionViewSource
x:Key="EmployeesCvs"
Source="{Binding RelativeSource={RelativeSource AncestorType=Window, Mode=FindAncestor}, Path=Employees}">
<CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription
PropertyName="Department" />
</CollectionViewSource.GroupDescriptions>
</CollectionViewSource>
</Window.Resources>
<Grid>
<DataGrid HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
ItemsSource="{Binding Source={StaticResource ResourceKey=EmployeesCvs}}"
FrozenColumnCount="1" CanUserAddRows="False" RowHeaderWidth="0">
<DataGrid.GroupStyle>
<GroupStyle>
<GroupStyle.ContainerStyle>
<Style TargetType="{x:Type GroupItem}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type GroupItem}">
<Expander IsExpanded="True">
<Expander.Header>
<TextBlock Text="{Binding Path=Name}"/>
</Expander.Header>
<ItemsPresenter />
</Expander>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</GroupStyle.ContainerStyle>
</GroupStyle>
</DataGrid.GroupStyle>
</DataGrid>
</Grid>
</Window>

Listing 1. DataGrid Grouping in XAML

The Source property of the CollectionViewSource is bound to an ObservableCollection of Employee objects. This collection is defined in the Window’s code-behind. The items are grouped by adding a PropertyGroupDescription to the GroupDescriptions collection. The Department property of the Employee class is used as the grouping criteria.
Meanwhile, the ItemsSource property of the DataGrid is bound to the CollectionViewSource. We also added a GroupStyle and set its ContainerStyle property. This will show the DataGrid items under an Expander. If we do not want to wrap the DataGrid items in some container and show only the group header, we can set the HeaderTemplate instead of the ContainerStyle property.

A Simple Workaround

The simplest workaround I thought of is to adjust the left margin of the header every time the DataGrid is scrolled horizontally. To do this, we can subscribe to the ScrollChanged event of the DataGrid’s ScrollViewer, get the group header and set its margin. In our example though, we used an Expander so we need to find the ToggleButton and set its left margin. The following code listing shows the event handler for the ScrollChanged event and a recursive method that searches for the ToggleButton using the VisualTreeHelper utility class.

private void DataGrid_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
var dataGrid = (DataGrid)sender;
if (dataGrid.IsGrouping && e.HorizontalChange != 0.0)
{
TraverseVisualTree(dataGrid, e.HorizontalOffset);
}
}

private void TraverseVisualTree(DependencyObject reference, double offset)
{
var count = VisualTreeHelper.GetChildrenCount(reference);
if (count == 0)
return;

for (int i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(reference, i);
if (child is ToggleButton)
{
var toggle = (ToggleButton)child;
toggle.Margin = new Thickness(offset, 0, 0, 0);
}
else
{
TraverseVisualTree(child, offset);
}
}
}

Listing 2. Simple Workaround for Unfrozen Group Header

We used the VisualTreeHelper to locate the ToggleButton. The VisualTreeHelper provides us methods for traversing the visual tree of a control. In our example, we start to search from the DataGrid down to its descendants. We did some checking first if the DataGrid items are indeed grouped and the user scrolled the horizontal scrollbar. It gets the job done, as you can see in the figure below.



Figure 2. Group Headers Seem Frozen

As mentioned earlier, we can use the HeaderTemplate instead of the ContainerStyle property. Let’s say we want to use the HeaderTemplate and specify a TextBlock as the group header.

<GroupStyle>
<GroupStyle.HeaderTemplate>
<DataTemplate>
<TextBlock Text="{Binding Path=Name}" />
</DataTemplate>
</GroupStyle.HeaderTemplate>
</GroupStyle>

Listing 3. Modified DataGrid GroupStyle

Our current implementation of TraverseVisualTree is not flexible enough. If we search for TextBlocks instead of ToggleButtons, we will get a lot of TextBlocks, even those that are not group headers. This is because the DataGrid template uses TextBlocks as well. Using the current algorithm, this would cause other TextBlocks to move. This would also be the case for ToggleButtons if we used ToggleButtons for other templates (e.g. column template) used within the DataGrid.

A Refined Workaround

To solve the problem, we have to tweak a bit our current implementation. When we specify a GroupStyle for our DataGrid, GroupItems are generated. There is a difference between using the ContainerStyle property and using the HeaderTemplate property for displaying group headers. Using the example with the Expander, the visual tree will look like this.

System.Windows.Controls.DataGrid
System.Windows.Controls.Border
System.Windows.Controls.ScrollViewer
System.Windows.Controls.Grid
System.Windows.Controls.Button
...
System.Windows.Controls.Primitives.DataGridColumnHeadersPresenter
...
System.Windows.Controls.ScrollContentPresenter
System.Windows.Controls.ItemsPresenter
System.Windows.Controls.StackPanel
System.Windows.Controls.GroupItem
System.Windows.Controls.Expander
System.Windows.Controls.Border
System.Windows.Controls.DockPanel
System.Windows.Controls.Primitives.ToggleButton
...
System.Windows.Controls.ContentPresenter
System.Windows.Controls.ItemsPresenter
...

Listing 4. Visual Tree of DataGrid Using ContainerStyle and Expander

I highlighted the parts we’re interested in. Under the GroupItem is the Expander that we specified as the topmost control in the GroupItem’s template. Further down the tree is the ToggleButton that we searched for in our previous code. We also see here the ItemsPresenter, which contains the DataGrid rows. Meanwhile, the visual tree will look like the following if we used the HeaderTemplate property and a TextBlock.

...
System.Windows.Controls.ScrollContentPresenter
System.Windows.Controls.ItemsPresenter
System.Windows.Controls.StackPanel
System.Windows.Controls.GroupItem
System.Windows.Controls.StackPanel
System.Windows.Controls.ContentPresenter
System.Windows.Controls.TextBlock
System.Windows.Controls.ItemsPresenter
...

Listing 5. Visual Tree of DataGrid Using HeaderTemplate and TextBlock

The ContentPresenter is responsible for showing the control we specified in the header template, which is a TextBlock in our example. The ItemsPresenter contains the DataGrid rows. If you are wondering what will happen if both the ContainerStyle and HeaderTemplate have been set, the ContainerStyle will be the one used. The following code listing shows the modified solution.

private void DataGrid_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
var dataGrid = (DataGrid)sender;
if (dataGrid.IsGrouping && dataGrid.GroupStyle.Count > 0 && e.HorizontalChange != 0.0)
{
TraverseVisualTree<GroupItem>(dataGrid, new Action<GroupItem>(
(GroupItem groupItem) =>
{
var topLevelGroupStyle = dataGrid.GroupStyle.First();
if (topLevelGroupStyle.ContainerStyle != null)
{
var groupItemChild = VisualTreeHelper.GetChild(groupItem, 0);
if (groupItemChild is Expander)
{
TraverseVisualTree<ToggleButton, ItemsPresenter>(groupItem, new Action<ToggleButton>(
(ToggleButton toggleButton) =>
{
toggleButton.Margin = new Thickness(e.HorizontalOffset, 0, 0, 0);
}
));
}
}
else if (topLevelGroupStyle.HeaderTemplate != null)
{
TraverseVisualTree<ContentPresenter, ItemsPresenter>(groupItem, new Action<ContentPresenter>(
(ContentPresenter contentPresenter) =>
{
contentPresenter.Margin = new Thickness(e.HorizontalOffset, 0, 0, 0);
}
));
}
}
));
}
}

private void TraverseVisualTree<TSearch>(DependencyObject reference, Action<TSearch> action)
where TSearch : class
{
var count = VisualTreeHelper.GetChildrenCount(reference);
if (count == 0)
return;

for (int i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(reference, i);
if (child is TSearch)
{
action(child as TSearch);
}
else
{
TraverseVisualTree<TSearch>(child, action);
}
}
}

private void TraverseVisualTree<TSearch, TStop>(DependencyObject reference, Action<TSearch> action)
where TSearch : class
where TStop : class
{
var count = VisualTreeHelper.GetChildrenCount(reference);
if (count == 0 || reference is TStop)
return;

for (int i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(reference, i);
if (child is TSearch)
{
action(child as TSearch);
}
else
{
TraverseVisualTree<TSearch, TStop>(child, action);
}
}
}

Listing 6. The Modified Workaround

Here we have two TraverseVisualTree methods. The first one searches for the control of the specified type TSearch and performs the provided action, with the control as a parameter to that action. The second one basically does the same but stops its search when it encountered a control of a specified type TStop. In the DataGrid_ScrollChanged event handler, we used the first kind of TraverseVisualTree to search for the GroupItems. For each GroupItem, we will use the second TraverseVisualTree to search for the control that we need to adjust and stop when an ItemsPresenter is encountered. This will prevent us from traversing the visual tree all the way down to the DataGrid rows and adjust the margins of controls that should not be affected. Notice that we first checked if the ContainerStyle is set. If so, we have to check what control is used (e.g. Expander) and perform the corresponding action. If we used another type for the template for the GroupItem, then we have to add code to support it. Using the HeaderTemplate is much easier, as we only need to set the margins of the ContentPresenter. We do not need to consider what type of control is inside the ContentPresenter.

Using Together with an Attached Property

Of course, we would not want to copy and paste the event handler code for each DataGrid that uses grouping. What we can do is to create an attached property so that when we want our DataGrid group headers to freeze when scrolling, we only have to set the attached property on the DataGrid. Here is the class that defines the attached property.

public class DataGridGrouping
{
public static readonly DependencyProperty ChangeGroupScrollProperty = DependencyProperty.RegisterAttached(
"ChangeGroupScroll",
typeof(Boolean),
typeof(DataGridGrouping),
new PropertyMetadata(new PropertyChangedCallback(ChangeGroupScrollPropertyChanged))
);

public static void SetChangeGroupScroll(DataGrid element, Boolean value)
{
element.SetValue(ChangeGroupScrollProperty, value);
}

public static Boolean GetChangeGroupScroll(DataGrid element)
{
return (Boolean)element.GetValue(ChangeGroupScrollProperty);
}

private static void ChangeGroupScrollPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
var dataGrid = (DataGrid)obj;
if ((bool)e.NewValue == true)
dataGrid.AddHandler(ScrollViewer.ScrollChangedEvent, new ScrollChangedEventHandler(DataGrid_ScrollChanged));
else
dataGrid.RemoveHandler(ScrollViewer.ScrollChangedEvent, new ScrollChangedEventHandler(DataGrid_ScrollChanged));
}

private static void DataGrid_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
...
}

private static void TraverseVisualTree<TSearch>(DependencyObject reference, Action<TSearch> action)
where TSearch : class
{
...
}

private static void TraverseVisualTree<TSearch, TStop>(DependencyObject reference, Action<TSearch> action)
where TSearch : class
where TStop : class
{
...
}
}

Listing 7. Using Together with an Attached Property

The attached property is named ChangeGroupScroll. When it is set to true, the event handler is registered to the ScrollChanged event. Otherwise, the event handler is removed. To use the attached property, we have to import the namespace where the DataGridGrouping class is defined and use the property like this: <DataGrid local:DataGridGrouping.ChangeGroupScroll=”True”>, assuming that we named the namespace local.

Conclusion

This approach is just a workaround and could not possibly solve all kinds of scenarios. For example, if we used a GroupBox instead of an Expander, the left side of the GroupBox’s border will still scroll. You can download the Visual Studio 2010 solution here.

By Michael Detras   Popularity  (6700 Views)
Biography - Michael Detras
.NET developer. Interested in WPF, Silverlight, and XNA.
My blog