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!