Friday, 12 September 2008

How to create a Gantt Control in WPF - Part 2

In the last episode we taught a still-fairly-young dog a new trick: under our tutelage, WPF learnt how to lay out controls using StartDate and EndDate properties to control their position. This is the first step on the road to creating a Gantt control. Along with flexible layout, ubiquitous data-binding is another of WPF's headline features (see also Josh Smith's introduction), and today I'll show how that relates to the the layout work we did last time.

As you would expect from a product styling itself a "Presentation Foundation", WPF makes it a breeze to present arbitrary collections of things in almost any way you want. The magic is performed by the ItemsControl, about which Dr WPF has composed a very useful A-Z guide. ItemsControl takes care of all the presentation work; sub-classes like ComboBox, ListBox and TreeView extend it with additional behaviour, like selection. In basic use though, you point an ItemsControl at a data source, and it enumerates the items and creates a visual representation for each of them using DataTemplates that you give it (DataTemplates, as you've guessed, use Data Binding to pick out useful bits of information from the data objects so that they can influence the display). In keeping with the layout flexibility of WPF, the ItemsControl doesn't care how items get laid out on screen: it delegates that responsibility to a Panel, and you get to choose which Panel it uses.

What this means for us is that we can take an collection of business objects representing tasks of some kind, each with a StartDate and an EndDate property, and, using an ItemsControl combined with the GanttRowPanel we created last time, lay them out on screen as a Gantt chart.

Let's work through this step by step.

Step by Step

First, a simple Task class with the vital Start and End properties:

namespace FunctionalFun.UI
{
    public class Task
    {
        public string Name { get; set; }
        public DateTime Start { get; set; }
        public DateTime End { get; set; }
    }
}

Next, a Window to host the example:

<Window x:Class="FunctionalFun.UI.ItemsControlExample"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:gc="clr-namespace:FunctionalFun.UI"
    Title="Window1" Height="300" Width="600">
    <Border Width="Auto" Height="Auto" Margin="5,5,5,5" Padding="5,5,5,5" BorderBrush="#FF002BA1" BorderThickness="2,2,2,2" VerticalAlignment="Top">
		<Grid Width="Auto" Height="Auto">
			<Grid.Resources>
				<x:Array x:Key="Tasks" Type="{x:Type gc:Task}">
					<gc:Task Name="Wake Up" Start="06:45" End="07:10"/>
					<gc:Task Name="Eat Breakfast" Start="07:45" End="08:10"/>
					<gc:Task Name="Drive to Work" Start="08:10" End="08:30"/>
					<gc:Task Name="Check Blog Stats" Start="08:30" End="08:45"/>
					<gc:Task Name="Work" Start="08:45" End="12:00"/>
					<gc:Task Name="Write Blog" Start="12:00" End="13:00"/>
					<gc:Task Name="More Work" Start="13:00" End="17:45"/>
					<gc:Task Name="Sit in Traffic" Start="17:45" End="18:00"/>
				</x:Array>
			</Grid.Resources>
			<Grid.RowDefinitions>
				<RowDefinition Height="50"/>
				<RowDefinition Height="Auto"/>
			</Grid.RowDefinitions>
			<ItemsControl Grid.Row="0"  ItemsSource="{StaticResource Tasks}" Height="50">
				...
			</ItemsControl>
			<TextBlock Text="06:45" Grid.Row="1" HorizontalAlignment="Left" VerticalAlignment="Top"/>
			<TextBlock Text="18:00" Grid.Row="1" HorizontalAlignment="Right" VerticalAlignment="Top"/>
		</Grid>
	</Border>
</Window>

Inside the Resource dictionary of the Grid that is used for the overall Window layout, I've tucked an Array containing some instances of Tasks to act as a Data Source for my ItemsControl.

Now the ItemsControl needs to know how to present each of the items. Here's a DataTemplate that just draws a rectangle to represent the Task, with the Task's name displayed in a TextBlock and a ToolTip:

<ItemsControl Grid.Row="0"  ItemsSource="{StaticResource Tasks}" Height="50">
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <Border BorderBrush="#FF007F99" BorderThickness="1">
            	<Border.Background>
            		<LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
            			<GradientStop Color="#FF2FD9FD" Offset="0"/>
            			<GradientStop Color="#FFCAF6FF" Offset="0.112"/>
            			<GradientStop Color="#FF47D8F7" Offset="1"/>
            		</LinearGradientBrush>
            	</Border.Background>
                <TextBlock Text="{Binding Name}" HorizontalAlignment="Center" VerticalAlignment="Center" ToolTip="{Binding Name}"/>
            </Border>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

You might wonder how the "Name" reference in the TextBlock Text property resolves to the Name property on the Task? That's because, when no other Data Source is explicitly given in a Binding expression for a property of an element, WPF uses whatever value has been assigned to the DataContext property of the element as the data source. The clever thing about the DataContext property is that (through the magic of Dependency Properties) it is able to inherit its value from DataContext property of the parent element in the control tree, if it hasn't had a value explicitly given. When ItemsControl creates a visual representation of an object using a DataTemplate, it sets the DataContext of the root visual element to point to that object, and thus all the children of the visual inherit that DataContext. Thus in this case, the DataContext property of, say, the TextBlock resolves to an instance of the Task class.

Finally, the interesting part, where we need to tell the ItemsControl how we want the items to be laid out. The first step is to tell it which kind of Panel to use. That's done by specifying a value for the ItemsPanel property. Specifically, we need to give it an ItemsPanelTemplate which is basically a factory that can create the kind of panel you want and set any properties that are needed - in this case the MinDate and MaxDate properties.

<ItemsControl Grid.Row="0"  ItemsSource="{StaticResource Tasks}" Height="50">
     <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
           <gc:GanttRowPanel MinDate="06:45" MaxDate="18:00"/>
        </ItemsPanelTemplate>
      </ItemsControl.ItemsPanel>
</ItemsControl>

But that's not enough: we need to ensure that the visual elements that get created by the ItemsControl have on them the StartDate/EndDate properties they need to get laid out correctly. When I first tried this, I thought that the outermost element in the DataTemplate (Border in this case) would be the place for them, but that didn't work: the GanttRowPanel wasn't seeing the StartDate and EndDate properties. Having read the manual (aka MSDN) a little more carefully, I discovered that ItemsControl puts whatever elements the DataTemplate generates within a container, and it is the container that gets hosted by the Panel. If you want to set properties on this Container, then you need to use the ItemContainerStyle property of ItemsControl. Like this:

<ItemsControl Grid.Row="0"  ItemsSource="{StaticResource Tasks}" Height="50">
    <ItemsControl.ItemContainerStyle>
        <Style>
            <Setter Property="gc:GanttRowPanel.StartDate" Value="{Binding Start}"/>
            <Setter Property="gc:GanttRowPanel.EndDate" Value="{Binding End}"/>
        </Style>
    </ItemsControl.ItemContainerStyle>
</ItemsControl>

See how the Data Bindings here still resolve to properties on the data object? this is because it is actually the container element that gets its DataContext set to the data object.

And that completes the example. What? You want to see a picture? It's not terribly pretty at this point, but here you go:

Gantt Example Sams Day

If you’d like to try this out yourself, get the code on Github.

15 comments:

Anonymous said...

Is this project already dead ?

Sam said...

It's not dead - merely in extended hibernation!

I have good intentions of extending this series, but I can't promise when that will be.

Anonymous said...

I'm eagerly waiting for part 3 :)

Anonymous said...

This is great, I am also eagerly waiting part 3....please!

Anonymous said...

Looking forward for it too. Thanks!

Dave M. said...

This was a very informative post. It's really opened my eyes as far as data binding in WPF goes. Until now, I've been using old-school approaches for reading and setting values.

I'm currently trying to modify this example extensibly, so that it is a little more dynamic for adding new tasks and also offers some scrolling features, but I'm having problems writing the code-behind for things like ItemsPanelTemplate.

Are you planning on augmenting this example in the near future? :)

Sam said...

Dave,
Never say never ...

Maybe at Christmas I might find some time.

Sam

Dave M. said...

Great! So I've been trying to roll a Gantt chart, and I've used your code as the base. I put everything into a DataGrid and so far that has worked really well. I think inexperience is responsible for my inability to figure out one of the last key pieces of the puzzle -- when I add a task to any row in the datagrid (where each row's ItemPanelTemplate is one of your GanttRows), I would like all of the GanttRows' MaxTime dependency property values to be the same. I was thinking about trying to modify them via code-behind, but is there a better way to do this? I was thinking that it involves databinding with some private member variable in my GanttChart object... Any suggestions would be really appreciated! Thanks again for providing such a great basis for learning a lot of this stuff!!!

Sam said...

Dave,
The only better suggestion I have is to perhaps to create an attached property to hold your common Max Time, then set the value of that on the DataGrid. Then in your ItemPanelTemplate you could use a RelativeSource binding on the GanttRow MaxTime property to look up the tree to the attached property on the DataGrid. Let me know if you need more details on this.

Sam

Dave M. said...

Thanks, Sam, I'll try this out!

Do you think this is a good way to also implement zooming and panning? Right now, I have a big limitation where I am statically defining the length of time that the Gantt chart displays, which clearly isn't the right thing to do. But if I can add the binding as you have suggested, I should be able to modify min/max times to effectively handle zooming and panning, don't you think?

The next thing to fix is the backgrounds. I like the idea of setting the Border's background to a linear gradient. I tried to use DataTemplate triggers to use a different background depending on the text in the gantt task. So far, I haven't been able to get it to work for the border background, but I did get it to work for the item text. My variation of your Gantt chart doesn't use an ItemsControl, but instead uses a regular ListBox -- do you have any suggestions for getting the background gradient to be a different color? I was searching around and came up short, so that's why I just changed the item text (it was an easy way to test the triggers)...

Dave M. said...

Hello, it's me again. :) I am trying my best to read up everything so I can follow your suggestions. Just to be clear, are you suggesting that I define an attached property in the GanttRowPanel because I can't add it to the DataGrid (i.e. I don't have the source)?

My DataGrid element looks something like this in XAML:

<Custom:DataGrid ItemsSource="{Binding}"
x:Name="grid_tasks"
AutoGenerateColumns="False"
SelectionMode="Single"
CanUserDeleteRows="False"
CanUserAddRows="False"
CanUserReorderColumns="False"
CanUserSortColumns="False"
HeadersVisibility="None"
local:GanttRowPanel.RowMaxTime="00:01:00" />

The problem I'm having at this point is that I've created a binding in my ItemsPanelTemplate as follows:

<ItemsPanelTemplate>
<local:GanttRowPanel MinTime="00:00:00" MaxTime="{Binding Path=RowMaxTime, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Custom:DataGrid}}}" />
</ItemsPanelTemplate >

but when I enable binding debugging, it just tells me that there isn't a 'RowMaxTime' property on object 'DataGrid'.

Can you offer me any sort of hints here? Thanks!

Sam said...

Try MaxTime="{Binding Path=(local:GanttRowPanel.RowMaxTime), RelativeSource=etc

Sam said...

Dave,
The only better suggestion I have is to perhaps to create an attached property to hold your common Max Time, then set the value of that on the DataGrid. Then in your ItemPanelTemplate you could use a RelativeSource binding on the GanttRow MaxTime property to look up the tree to the attached property on the DataGrid. Let me know if you need more details on this.

Sam

Sam said...

Dave,
Never say never ...

Maybe at Christmas I might find some time.

Sam

G Manuel Muñoz R said...

Part 3?

Post a Comment