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.