Tuesday, 9 September 2008

How to create a Gantt Control in WPF

About two years ago I was in the fortunate position of having a client who was willing to pay to have Gantt charts added to his application, but no commercial Gantt chart available on the market. Thus I was paid to have fun over a couple of weeks creating one. It turns out to be quite straight-forward with WPF, and since at least one person has been drawn to my blog searching for "Gantt Chart WPF" (I mentioned it in an earlier post about What I do at work), I thought a few sporadic posts on how I did it might be of interest.

WPF Layout

We'll start at the bottom of the problem: how to layout individual bars of a Gantt within a row. WPF's layout functionality makes this part an almost-doddle.

When someone starts playing with WPF for the first time, they often start by creating a Window and putting controls on it. And then they hit the first incline of the learning curve: how do you set the position of controls in a Window? Windows Forms Controls have the Top property: where's the equivalent for WPF? Thus the newbie is introduced to the way layout works in WPF, and finds that it's all done by Panels.  Whenever you need to lay out a collection of visual elements, you put them inside a Panel. You pick your Panel according to the model that you want to use for layout: if you want to emulate the way Windows Forms works you use a Canvas and get absolute positioning; if you want to lay things out in rows and columns, use a Grid; want to put controls one on top of the other? then StackPanel is your friend.

That's why there are no positioning properties, no Left, Right, Top or Bottom on any of the WPF control classes: in most cases such properties are never needed. How then do controls say where they want to go in a panel like a Grid? That is all done by Attached Properties, which are defined by one class but have values set on objects of different classes. For example, Grid defines the Row and Column Attached Properties: these properties can be set on any WPF object, but only the Grid takes any notice of them. In this way each type of Panel can make available to the world the type of properties it needs to do its layout without the WPF base classes suffering API bloat.

Gantt charts are all about dates; specifically, start and end dates. When it comes to laying out Gantt bars it makes sense to do the layout in terms of dates, and this we can do thanks to the flexibility of the WPF layout system. The goal is to arrive at the point where we can use StartDate and EndDate properties to position elements in a panel in the same way that Row and Column properties are used to position elements in a Grid.

Gantt Layout

Let's dive straight into some code:

using System;
using System.Windows;
using System.Windows.Controls;

namespace FunctionalFun.UI
{
    public class GanttRowPanel : Panel 
    {
        public static readonly DependencyProperty StartDateProperty =
           DependencyProperty.RegisterAttached("StartDate", typeof(DateTime), typeof(GanttRowPanel), new FrameworkPropertyMetadata(DateTime.MinValue, FrameworkPropertyMetadataOptions.AffectsParentArrange));
        public static readonly DependencyProperty EndDateProperty =
            DependencyProperty.RegisterAttached("EndDate", typeof(DateTime), typeof(GanttRowPanel), new FrameworkPropertyMetadata(DateTime.MaxValue, FrameworkPropertyMetadataOptions.AffectsParentArrange));

        public static readonly DependencyProperty MaxDateProperty =
           DependencyProperty.Register("MaxDate", typeof(DateTime), typeof(GanttRowPanel), new FrameworkPropertyMetadata(DateTime.MaxValue, FrameworkPropertyMetadataOptions.AffectsMeasure));
        public static readonly DependencyProperty MinDateProperty =
            DependencyProperty.Register("MinDate", typeof(DateTime), typeof(GanttRowPanel), new FrameworkPropertyMetadata(DateTime.MaxValue, FrameworkPropertyMetadataOptions.AffectsMeasure));


        public static DateTime GetStartDate(DependencyObject obj)
        {
            return (DateTime)obj.GetValue(StartDateProperty);
        }

        public static void SetStartDate(DependencyObject obj, DateTime value)
        {
            obj.SetValue(StartDateProperty, value);
        }

        public static DateTime GetEndDate(DependencyObject obj)
        {
            return (DateTime)obj.GetValue(EndDateProperty);
        }

        public static void SetEndDate(DependencyObject obj, DateTime value)
        {
            obj.SetValue(EndDateProperty, value);
        }

        public DateTime MaxDate
        {
            get { return (DateTime)GetValue(MaxDateProperty); }
            set { SetValue(MaxDateProperty, value); }
        }

        public DateTime MinDate
        {
            get { return (DateTime)GetValue(MinDateProperty); }
            set { SetValue(MinDateProperty, value); }
        }

        protected override Size MeasureOverride(Size availableSize)
        {
            foreach (UIElement child in Children)
            {
                child.Measure(availableSize);
            }

            return new Size(0,0);
        }

        protected override Size ArrangeOverride(Size finalSize)
        {
            double range = (MaxDate - MinDate).Ticks;
            double pixelsPerTick = finalSize.Width/range;

            foreach (UIElement child in Children)
            {
                ArrangeChild(child, MinDate, pixelsPerTick, finalSize.Height);
            }

            return finalSize;
        }

        private void ArrangeChild(UIElement child, DateTime minDate, double pixelsPerTick, double elementHeight)
        {
            DateTime childStartDate = GetStartDate(child);
            DateTime childEndDate = GetEndDate(child);
            TimeSpan childDuration = childEndDate - childStartDate;

            double offset = (childStartDate - minDate).Ticks * pixelsPerTick;
            double width = childDuration.Ticks * pixelsPerTick;

            child.Arrange(new Rect(offset, 0, width, elementHeight));
        }
    }
}

As you can see in lines 9-12, GanttRowPanel registers two Attached Properties (StartDate and EndDate) that it will look for on child elements when deciding how to lay them out. It also defines (lines 4-17) two straight-forward Dependency Properties (MaxDate and MinDate) which are used to determine the range of dates that the Panel should expect to display. Lines 20 to 50 contain the obligatory getters and setters for each of these properties.

Notice in particular that when I register the MaxDate and MinDate properties I set the FrameworkPropertyMetadataOptions.AffectsMeasure flag. This does something clever: it tells WPF that when either of these properties changes, the  layout of Panel will become invalid, so will WPF please be kind enough to layout the panel again. Something even cleverer happens for the StartDate and EndDate attached properties when I set the FrameworkPropertyMetadataOptions.AffectsParentArrange flag. This asks WPF that, if either of these properties get changed on a UI element, it should find the parent of that element (in this case the GanttRowPanel) and rearrange the elements within it. Oh the wonder of the Dependency Property subsystem!

As Dr WPF explains, to get a Panel to do its stuff, you need to override two virtual methods, MeasureOverride  and ArrangeOverride. These are the points at which your panel hooks into the WPF layout system. There's one method for each of the two passes of the layout system: the Measure pass determines how much space each element in the display would like to use; the Arrange pass tells each element how much space it has been given, and asks it to fall into line.

The MeasureOverride method starting at line 52 is very simple. It first asks each of the child controls to measure themselves -  every Panel must do this for its children, even if, as in this case, the parent is eventually going to impose a size on its children. Then it just returns a size of (0,0) to indicate to the layout system that it'll take all the space it's given.

The ArrangeOverride method is slightly more involved, because we finally need to take the plunge and convert dates into pixels. At this point in the proceedings the Panel has been given its slot on screen and it's passed a finalSize indicating how big that slot is. It's now up to GanttRowPanel to lay out its children as it sees fit. Note that layout within a panel is all done relative to the top-left corner of panel: that is why ArrangeOverride is only supplied with a size and not a position

The first thing our override does is to calculate what range of dates it is expected to display (using its MinDate and MaxDate properties) and from that derive how many pixels are to be used for displaying each Tick (a Tick being the smallest unit in which DateTimes are measured). That calculated, we can then lay out each child. We convert its start date and duration into an offset and width by multiplying by the pixels-per-tick value. Having assumed that the child element will have the same height as the panel, this gives us a bounding box for the child element which we pass to the child's Arrange method - when interpreting this bounding box (passed as a Rect) remember that the Arrange method on a child works relative to the top left position of its parent (the GanttRowPanel in this case).

And so we've taught the WPF layout system to lay things out by date. Here's a very simple example of using the GanttRowPanel:

<Window x:Class="FunctionalFun.UI.GanttRowPanelExample"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:UI="clr-namespace:FunctionalFun.UI"
    Title="GanttRowPanelExample" Height="300" Width="300">
    <UI:GanttRowPanel MinDate="09:00" MaxDate="17:00">
        <TextBlock Text="Work" UI:GanttRowPanel.StartDate="09:00" UI:GanttRowPanel.EndDate="12:00"/>
        <TextBlock Text="Lunch" UI:GanttRowPanel.StartDate="12:00" UI:GanttRowPanel.EndDate="13:00"/>
        <TextBlock Text="Work" UI:GanttRowPanel.StartDate="13:00" UI:GanttRowPanel.EndDate="17:00"/>
    </UI:GanttRowPanel>
</Window>

Coming up

In my next post I'll give an example illustrating how you can use this Panel in an ItemsControl to layout things from any old data source.

Looking further ahead, I might show how to build upon this panel to create a multi-row Gantt control with independent headers and interactive bars. It all depends on how much love you show me!

32 comments:

sirrocco said...

Where's the demo ? :)
Oh, and return Size(0,0) really means that it will take all the given space ? I really doubt that - Dr.WPF that you mentioned says :

"The value returned from MeasureOverride() becomes the value of the element's DesiredSize property"

Sam said...

Sirrocco,
Dr WPF was simplifying: the value returned from MeasureOverride certainly influences the elements DesiredSize, but it isn't used directly. If you Reflector into the source code for FrameworkElement.MeasureCore which calls MeasureOverride you'll see that all kinds of transforms are performed on the Size returned, taking into account the Margins set on the element amongst other things, before it is converted into a DesiredSize.

Whilst you are in Reflector, have a look at the code for Canvas.MeasureOverride: that uses exactly the same technique (of returning a Size(0,0)) to indicate that it wants all the space it is given.

Sam

sirrocco said...

hmm it appears you are correct :). Thanks for the tip.

Erik Vullings said...

Very cool - I've learned a lot from your example! Can't wait to see the rest!

Sam said...

Erik,
Glad you liked it. I'll have to see about writing the next episode.

Sam

Tom said...

Great post! I am very interested in finding how the whole control would be implemented.

Anonymous said...

Currently (since about 6 months), a WPF-based Gantt Chart control library is available on the market, see: http://www.dlhsoft.com/ganttchart

Sam said...

Anonymous,
I know that a couple of Gantt controls have come to the market since I created mine two years ago. In this article I'm not claiming to offer a Gantt control, merely to show the foundations of how one might be made in WPF.

Bob Briggs said...

Sam,
I really like what you've done here. I have read both installments and I'm eager to get into the display of an actual GANTT chart! I think I understand how I can use the GantRowPanel in a DataTemplate for my task list objects to produce a panel in a range in, say, a listbox control. All of that would be great, but I'm anxious to figure out how to provide a timescale header to such a display. I'm thinking that a timescale header will display a complete range output control that has it's size fixed in the MeasureOverride() and will include properties to identify the pixelsize of each date increment. Is this the correct path to take?

Thanks again for a great article!

Bob Briggs said...

Sam,
In a typical GANTT chart, ala MS Project, the 'tasks' are listed on their own lines. I modified the GantRowPanel to migrate the tasks both down and across. Now I have a singular problem, scrolling! I tried wrapping the ItemsControl in a ScrollViewer and that doesn't seem to do the trick for me. Any ideas? I'm sure I'm just missing some configuration for the ScrollViewer.

Thanks,!

Sam said...

Bob,
Paradoxically, you probably need to set ScrollViewer.CanContentScroll=False. This indicates to the ScrollViewer that its content is not capable of managing scrolling itself, and that ScrollViewer needs to do all the work.

Hope that helps

Bob Briggs said...

Silly me. Of course, I have to put it into a ScrollViewer. And I had to set the size of the ListControl so the ScrollViewer would know how much content to scroll. Now it's off to data binding the mouse events with the data template and I should be good... I think...

Thanks!

.NET Developer said...

It is a too simply example of Gantt control. In other hand the example is useful. It would be better if you added the screenshot of the result control.

Thanks

WEB security tester said...

Hi. Can I publish the article on my site www.hackishcode.com? You may publish it yourself here:
http://hackishcode.com/upload.php?kind=1

It brings new visitors to your WEB site.

Anonymous said...

This is great, I've been looking into doing this for some while, is the rest of the project complete yet? Thanks!

Anonymous said...

Hi there, Please tell me about the second part of this article. M unable to find it here.

Regards,
vi

Roman G. said...

look my gantt chart at http://softwaremilestones.blogspot.com/2009/08/blog-post.html

rqtechie said...

Also, if you want to get started right away, here is a Wpf and Silverlight GanttControl out of the box with rich look and great end-user interaction support:

http://www.radiantq.com

rqtechie said...

Also, if you want to get started right away, here is a Wpf and Silverlight GanttControl out of the box with rich look and great end-user interaction support:

http://www.radiantq.com

Roman G. said...

look my gantt chart at http://softwaremilestones.blogspot.com/2009/08/blog-post.html

Bob Briggs said...

Silly me. Of course, I have to put it into a ScrollViewer. And I had to set the size of the ListControl so the ScrollViewer would know how much content to scroll. Now it's off to data binding the mouse events with the data template and I should be good... I think...

Thanks!

Tom said...

Great post! I am very interested in finding how the whole control would be implemented.

Erik Vullings said...

Very cool - I've learned a lot from your example! Can't wait to see the rest!

Sam said...

Sirrocco,
Dr WPF was simplifying: the value returned from MeasureOverride certainly influences the elements DesiredSize, but it isn't used directly. If you Reflector into the source code for FrameworkElement.MeasureCore which calls MeasureOverride you'll see that all kinds of transforms are performed on the Size returned, taking into account the Margins set on the element amongst other things, before it is converted into a DesiredSize.

Whilst you are in Reflector, have a look at the code for Canvas.MeasureOverride: that uses exactly the same technique (of returning a Size(0,0)) to indicate that it wants all the space it is given.

Sam

jyothi keshavan said...

Hello Sam,

you mentioned about showing how to build upon this panel to create a multi-row Gantt control with independent headers and interactive bars. Did you actually blog about it? I would love to have more information on that too.. This post is awesome and I will use this in my application.

Thanks.

Abc said...

This is a test post

G Manuel Muñoz R said...

Showing.MuchLove(); =)

Vinay said...

Thanks for such a wonderful post. I am curious to know whether can we bind MaxDate and MinDate also in xaml? Let me know.

Samuel Jack said...

Vinay, MinDate and MaxDate are both dependency properties so you can bind them just fine.

Vinay said...

Thanks for reply.





I am trying like above, however in GanttRowPanel MinDate and MaxDate set property in not hitting. Can please help me.I am need of this control.
It works like a charm. I stuck in this place Please help me out.

Samuel Jack said...

Vinay,

The Get/Set properties for Dependency properties are never called when using XAML - they're only used if you call them explicitly in code. Take a look at this article: http://wpftutorial.net/DependencyProperties.html. It shows how you can add a value changed callback to dependency propeties.

Vinay said...

Thanks Sam.. You made my day.. Thank you so much.

Post a Comment