Angled headers for DataGrid columns…not just angled text…

My last post demonstrated one way that you could angle the text in a DataGrid column header. I received a comment asking for how to angle the entire headers, along the lines of what you can do very easily with Excel. Here’s an example from Excel:

image

My friend Margaret on the WPF team saw this comment as well and set about solving this problem for WPF. I worked with her for a bit and we came up with what we thought was a pretty good solution. Here's a link to Margaret's post…I took the WPF XAML and set about porting it to Silverlight. I had a couple of obstacles:

1) Layout works just a bit different in Silverlight than WPF

2) I don't know as much as I should about layout in Silverlight

We created a style for the ColumnHeaderStyle property that contains a new template for the header. The basic summary of our template is that we drew a rectangle, skewed it 45 degrees and moved it to the right, then added a content presenter bound to the content of the column header, which we rotated  45 degrees and also moved to the right, to align with the column the header is associated with.

In our first WPF sample, we used a combination of render and layout transforms. I couldn’t get this code to work properly in Silverlight so I reworked the code to use just render transforms. The resulting code works pretty well, and when I ported it back to WPF, it worked there too. The render transform isn't perfect because as the column header content gets longer, so does the width of the column, but it's not 1 to 1, so I can live with this for now. It's something I'll continue to investigate.

Here’s what the running code looks like when I’ve bound the DataGrid to a collection that contains 3 writers with the same data as in the image above:

image

Not bad..huh?

Here’s the Silverlight version of the XAML:

<UserControl xmlns:my="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls.Data"
x:Class="DataGridAngledHeaders.MainPage"
xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:primitives="clr-namespace:System.Windows.Controls.Primitives;assembly=System.Windows.Controls.Data"
xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="https://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="https://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:DataGridAngledHeaders"
mc:Ignorable="d"
d:DesignHeight="300" d:DesignWidth="400">

<Grid x:Name="LayoutRoot" Background="White">
<Grid.Resources>
<local:WidthToTranslate x:Key="WidthConverter" />
<local:RectangleHeight x:Key="HeightConverter" />
</Grid.Resources>
<my:DataGrid Name="DG" ItemsSource="{Binding}" >
<my:DataGrid.ColumnHeaderStyle>
<Style TargetType="primitives:DataGridColumnHeader">
<Setter Property="Template" >
<Setter.Value>
<ControlTemplate >
<Grid ShowGridLines="True">
<Rectangle Name="Angle" Width="{TemplateBinding Width}"
Height="{Binding Path=[0], Converter={StaticResource HeightConverter}}"
Fill="LightBlue" Stroke="Black"
StrokeThickness="1" >
<Rectangle.RenderTransform>
<SkewTransform CenterX="0"
CenterY="{Binding ElementName=Angle, Path=Height}" AngleX="-45" AngleY="0" />
</Rectangle.RenderTransform>
</Rectangle>

                                        <ContentPresenter VerticalAlignment="Bottom"
HorizontalAlignment="Left" >
<ContentPresenter.RenderTransform>
<TransformGroup>
<RotateTransform Angle="-45"/>
<TranslateTransform X="{Binding RelativeSource={RelativeSource TemplatedParent},
Path=ActualWidth, Mode=OneWay, Converter={StaticResource WidthConverter}}" />
</TransformGroup>
</ContentPresenter.RenderTransform>
</ContentPresenter>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</my:DataGrid.ColumnHeaderStyle>
</my:DataGrid>
</Grid>
</UserControl>

  

Of course you probably want the code behind, as well. The most interesting items in this code are the two converters. WidthToTranslate calculates where the text content should be moved to to align with its column, and RectangleHeight calculates the height of the rectangle that contains the header name.

Originally we set a static height for the rectangle, but we both became sort of obsessed with setting this height dynamically, based on the length of property names for type contained in the collection you’ve bound to. I sort of solved this problem in Silverlight, but it’s still not a perfect solution. I bind to one of the items in the collection displayed by the DataGrid and use the RectangleHeight converter to calculate a value based on the longest property name of the item type. This converter gets called for every column so I put in some code to circumvent all the calculating after the first column so this converter is a bit complex. Also the converter works well until you change the default font size or put in your own header name or something like that…then you won’t get the correct rectangle height. So the RectangleHeight converter feels a bit like using an expensive sledgehammer when you really need a specific wrench. It’ll work, but not all the time, and you’ve wasted a lot of money for the times when it doesn’t work. Margaret can make this work in WPF because of WPF’s multi-binding capability. Check out her post here. There might be some other approaches for Silverlight here, but I haven’t come up with them yet.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shapes;
using System.Collections.ObjectModel;
using System.Windows.Data;
using System.Reflection;

namespace DataGridAngledHeaders
{
public partial class MainPage : UserControl
{
// Create a collection of writer objects.
ObservableCollection<Writer> writers = new ObservableCollection<Writer>() {new Writer("Chris Sells", 114, "Writer II"), 
new Writer("Luka Albrous",225, "Senior Writer"), new Writer("Jim Hance", 140, "Writer I")};
public MainPage()
{
InitializeComponent();
DG.DataContext = writers;
}

}
//Simple class to bind to.
public class Writer
{
public Writer() { }
public Writer(string writerName, int numOfTypes, string writerTitle)
{
Name = writerName;
Types = numOfTypes;
Title = writerTitle;
}

public string Name { get; set; }
public int Types { get; set; }
public string Title { get; set; }
}
// Converter to figure out how much the text needs to be moved.
public class WidthToTranslate : IValueConverter
{
#region IValueConverter Members

public object Convert(object value, Type targetType, object parameter, 
System.Globalization.CultureInfo culture)
{
Double width = (Double)value;
return width / 2 ;
}

public object ConvertBack(object value, Type targetType, object parameter, 
System.Globalization.CultureInfo culture)
{
throw new NotImplementedException();
}

#endregion
}
// Converter to calculate the height of the rectangle that contains the header text.
// This converter should be passed an item in the collection.
public class RectangleHeight : IValueConverter
{
#region IValueConverter Members
// Static to check if header height has been calculated.
static double headerHeight = 0;

public object Convert(object value, Type targetType, object parameter,
System.Globalization.CultureInfo culture)
{
// If header height has not been calculated, go ahead and calculate,
// otherwise just return the previously calculated value.
if (headerHeight == 0)
{
// Get the type of the item passed in.
Type valueType = value.GetType();

// Get the properties of the type.
PropertyInfo[] properties = valueType.GetProperties();

                TextBlock tb = new TextBlock();
int propLength = 0;
// Get the longest property name and set the TextBlock text to the name.
foreach (PropertyInfo p in properties)
if (p.Name.Length > propLength)
{
tb.Text = p.Name;
propLength = p.Name.Length;
}

//Return the width of the textblock plus some padding.
headerHeight = tb.ActualWidth + 5;
return headerHeight;
}
// If the value has already been calulated, then return it.
return headerHeight;
}

public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
throw new NotImplementedException();
}

#endregion
}
}

I also need to mention that in this solution the column header keep their original hit region, so in order to sort or move the columns, you have to click very low in the header. I am sure there are ways to change the hit regions to reflect the actual area of the column header, but at this point, I need to move on to something else. Margaret is working on the hit region problem and I’ll make sure and link to her post if she solves it.

Enjoy!

--Cheryl