A couple of weeks ago I started prototyping with a friend a behavior in order to improve the grouping in the ListBox control. Today, I’m finally taking time to write a post about this adventure :-)

My overall goal was to have the group headers always visible and smoothly moving while the content of the control was scrolled. Here is a video showing the demo application running:

Get the Flash Player to see this content.

Download the code (VS2010 required)

Of course I wanted to have the cleanest implementation possible, and as I think you already know, this where the WPF behavior comes to the rescue ! Let’s see how this is possible.

Note:

  • You must be aware that enabling group in your ListBox will disable UI virtualization. This might not be a problem if the databound collections is small (<100/200) but if the collection is very large, you might get into performance problem. However this limitation might be fixed in the next release of WPF (see my post about it here).
  • This solution is not 100% compatible with keyboard selection and should be improved to work properly.

Anatomy of a ListBox when grouping is enabled

In order to create this behavior, I had to carefully look the Visual Tree of a ListBox when grouping is enabled. I used Snoop to do so, and I was able to get the following picture:

As you can see, for each group, we have a GroupItem control. However, we cannot direcly apply a transform to this element because it both contains the header (in a ContentPresenter) and the items in the group (in an ItemsPresenter).

Introducing the OverlayGroupingBehavior

The behavior actually walks in the visual tree to find out the interesting part. In our case, we want to attach a TranslateTransform to the headers of each GroupItem. Then, using simple mathematics, we can figure out the needed Y-translation to move the header at the top position of the ListBox.

Here are some details about the behavior:

  • the class inherits Behavior<ListBox>
  • we override the OnAttached method, and in this method we register an event handler for the LayoutUpdated of the associated ListBox
  • in the LayoutUpdated handler, we update the transforms
?View Code CSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interactivity;
using System.Windows.Media;
using DemoAnimatedGroup.Helpers;
 
namespace DemoAnimatedGroup.Behaviors
{
    public class OverlayGroupingBehavior : Behavior<ListBox>
    {
        protected override void OnAttached()
        {
            this.AssociatedObject.LayoutUpdated += new EventHandler(this.OnLayoutUpdated);
        }
 
        private void OnLayoutUpdated(object sender, EventArgs e)
        {
            ListBoxItem topListBoxItem1;
            GroupItem topGroupItem1, topGroupItem2;
            ContentPresenter topPresenter1, topPresenter2 = null;
            double topOffset1, topOffset2 = -1;
 
            // find the first ListBoxItem which is at the top of the control
            topListBoxItem1 = this.GetItemAtMinimumYOffset<ListBoxItem>();
            if (topListBoxItem1 == null)
                return;
 
            // get all group items order by their distance to the top
            var groupItems = TreeHelper.FindVisualChildren<GroupItem>(this.AssociatedObject)
                                       .OrderBy(this.GetYOffset)
                                       .ToList();
 
            // from the GroupItem, find the ContentPresenter on which we can apply the transform
            topGroupItem1 = TreeHelper.FindVisualAncestor<GroupItem>(topListBoxItem1);
            topPresenter1 = TreeHelper.FindVisualChild<ContentPresenter>(topGroupItem1);
            topOffset1 = this.GetYOffset(topPresenter1);
 
            // try to find the next GroupItem and its presenter
            var index = groupItems.IndexOf(topGroupItem1);
            if (index + 1 < groupItems.Count)
            {
                topGroupItem2 = groupItems.ElementAt(index + 1);
                topPresenter2 = TreeHelper.FindVisualChild<ContentPresenter>(topGroupItem2);
                topOffset2 = this.GetYOffset(topPresenter2);            
            }
 
            // update transforms
            if (topOffset2 < 0 || topOffset2 > topPresenter1.ActualHeight)
                this.SetGroupItemOffset(topPresenter1, topOffset1); 
 
            if(topPresenter2 != null)
                topPresenter2.RenderTransform = null;
        }
 
        private T GetItemAtMinimumYOffset<T>() where T : UIElement
        {
            var minOffset = double.MaxValue;
            T topItem = null;
            foreach (var item in TreeHelper.FindVisualChildren<T>(this.AssociatedObject))
            {
                var offset = this.GetYOffset(item);
                if (Math.Abs(offset) <= Math.Abs(minOffset))
                {
                    minOffset = offset;
                    topItem = item;
                }
            }
 
            return topItem;
        }
 
        private void SetGroupItemOffset(ContentPresenter groupHeader, double offset)
        {
            if (groupHeader.RenderTransform as TranslateTransform == null)
                groupHeader.RenderTransform = new TranslateTransform();
 
            ((TranslateTransform)groupHeader.RenderTransform).Y -= offset;
        }
 
        private double GetYOffset(UIElement uiElement)
        {
            var transform = (MatrixTransform) uiElement.TransformToVisual(this.AssociatedObject);
            return transform.Matrix.OffsetY;
        }
    }
}

Using the behavior

It’s more than easy to enable this functionality in any existing app. In the XAML, all you have to do is to attach the behavior to the targeted ListBox:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<ListBox ItemsSource="{Binding Cities}">
    <i:Interaction.Behaviors>
        <Behaviors:OverlayGroupingBehavior/>
    </i:Interaction.Behaviors>
    <ListBox.ItemTemplate>
        <DataTemplate>
            <!-- data template... -->
        </DataTemplate>
    </ListBox.ItemTemplate>
    <ListBox.GroupStyle>
        <GroupStyle>
        <!-- group style -->
        </GroupStyle>
    </ListBox.GroupStyle>
</ListBox>

If you’re not already grouping items in your ListBox, here is how to do it:

?View Code CSHARP
1
2
var cv = CollectionViewSource.GetDefaultView(viewmodel.Cities);
cv.GroupDescriptions.Add(new PropertyGroupDescription("Country"));

Useful resources

Download the code (VS2010 required)