Thursday 21 January 2010

WPF Header/Multi-Column ListBox Control



The WPF ListBox control can be used to create a wide variety of user interface elements thanks to templates and styling. However, recently it left me out in the cold...for a while at least.

We had an existing control, based on a heavily styled ListBox, that contained a bunch or items that could be considered as rows in a table. It all worked fine until some bright spark had the idea of adding column headers.

Now, at this point you may be thinking that ListBox is not the ideal candidate here. ListView, with its GridView layout, is the control to use if you want to layout column based data with column headers. Right?

Well, not always. GridView is fine if you want to apply styles to individual cells or columns. The problem with our particular style was that each conceptual row could be expanded in various ways, making the GridView unsuitable. There were two options as far as i could see:

  1. Write a custom View (based on ViewBase) for ListView
  2. Add a header to the ListBox control

We went with option two. The pain began.

The basic problem is that the header needs to be part of the ListBox so that when the ListBox is scrolled horizontally, the header scrolls too. However, when you scroll it Vertically you want the header to remain visible and not scroll with the rest of the list.

I started by looking at the style of the ListView control which does something similar to what i wanted to achieve. This gave me a big clue. The ListView uses a custom ScrollViewer that has a child scrollviewer nested inside. This child ScrollViewer contains the header; The scrollbars on the child ScrollViewer are always hidden. Magic.

I also had a look at this very helpful article: Building a Multi-Column ListBox in Avalon

This came close to what i wanted to achieve, but there were a number of problems:

  • It was written before the first release of WPF and would no longer compile
  • Having updated it so that it did compile, there were several problems with the scrolling resulting in unsatisfactory placement of the vertical scrollbar (not consistent with ListView) and errors causing the content to slide past the header at the extreme right.

Nevertheless, it gave me a lot of ideas, In-fact the example is based on a lot of the same XAML.

Next, I need to work out how to make the header scroll horizontally when the list box is scrolled horizontally. Unfortunately there is no easy way to do this. Ideally you would bind the HorizontalOffset of the header ScrollViewer to that of the main one. However, this is not possible thanks to its read only nature. To overcome this I used an attached behaviour to achieve the same goal by making use of the ScrollToHorizontalOffset method.

The resulting XAML for the ScrollViewer looks like this:

 <Style x:Key="HeaderedScrollViewer" TargetType="{x:Type ScrollViewer}"> 
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ScrollViewer}">
<Grid Background="{TemplateBinding Background}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<DockPanel Margin="{TemplateBinding Padding}">
<ScrollViewer DockPanel.Dock="Top"
local:SetHorizontalOffset.Offset="{Binding
RelativeSource={RelativeSource TemplatedParent},
Path=HorizontalOffset}"
HorizontalScrollBarVisibility="Hidden"
VerticalScrollBarVisibility="Hidden"
Focusable="false"
Content="{StaticResource ListHeader}">
</ScrollViewer>
<ScrollContentPresenter Name="PART_ScrollContentPresenter"
KeyboardNavigation.DirectionalNavigation="Local"/>
</DockPanel>
<ScrollBar Name="PART_HorizontalScrollBar"
Orientation="Horizontal"
Grid.Row="1"
Maximum="{TemplateBinding ScrollableWidth}"
ViewportSize="{TemplateBinding ViewportWidth}"
Value="{TemplateBinding HorizontalOffset}"
Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}"/>
<ScrollBar Name="PART_VerticalScrollBar"
Grid.Column="1"
Maximum="{TemplateBinding ScrollableHeight}"
ViewportSize="{TemplateBinding ViewportHeight}"
Value="{TemplateBinding VerticalOffset}"
Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}"/>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>

And it is applied to the ListBox with this style:

 <Style x:Key="{x:Type ListBox}" TargetType="{x:Type ListBox}"> 
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<ScrollViewer Style="{StaticResource HeaderedScrollViewer}"
Grid.IsSharedSizeScope="True">
<StackPanel IsItemsHost="true"/>
</ScrollViewer>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>

You can download the full source here: MultiColumnList.zip

2 comments:

  1. Really good! I wanted to report a problem that I noticed in the sample. Make the window small, scroll horizontally about half way across so that the column headers for Blog Site and Status are both visible. Click on the status header, now click the block site header. The blog site header becomes aligned with the left edge of the window and now all the headers are skewed.

    ReplyDelete
  2. please upload the source code again, thanks!

    ReplyDelete