How to dynamically draw a timeline in WPF

Magnus Brantheim picture Magnus Brantheim · Jun 21, 2016 · Viewed 6.9k times · Source

I am trying to draw timelines in WPF. It should basically consist of 3 rectangles.

It should look something like this (hardcoded using XAML): Timeline

The large white rectangle should fill all of the available space, the green rectangles represent the start and duration of events which happen on the timeline.

The models representing this is a TimeLineEvent class which has a TimeSpan start and a timespan duration to represent when the event starts and how long it lasts (in ticks or seconds or whatever). There is also a TimeLine class which has an ObservableCollection which holds all of the events on the timeline. It also has a TimeSpan duration which represents how long the timeline itself is.

What I need to do is to be able to dynamically draw the events (green rectangles) on the timeline based on their duration and start, and the ratios between these so that an event is drawn corresponding to when it occurs and for how long. There can be more than one event on a timeline.

My approach so far has been to make a TimeLine.xaml file which just holds a canvas element. In the code-behind file I have overriden the OnRender method to draw these rectangles, which works with hardcoded values.

In the MainWindow.xaml I have created a datatemplate and set the datatype to TimeLine:

<DataTemplate x:Key="TimeLineEventsTemplate" DataType="{x:Type local:TimeLine}">
        <Border>
            <local:TimeLine Background="Transparent"/>
        </Border>
    </DataTemplate>

Have tried different settings for this, but not sure what I am doing to be honest. I then have a stackpanel which contains a listbox that is using my datatemplate and binding TimeLines, which is an ObservableCollection holding TimeLine objects, in my MainWindow code-behind.

<StackPanel Grid.Column="1" Grid.Row="0">
        <ListBox x:Name="listBox"
                 Margin="20 20 20 0"
                 Background="Transparent"
                 ItemTemplate="{StaticResource TimeLineEventsTemplate}"
                 ItemsSource="{Binding TimeLines}"/>
    </StackPanel>

This draws new timelines when I create new Timeline objects, looking like this: Timelines

The problem with this is that it does not render the green rectangles properly, to do this I need to know the width of the white rectangle, so that I can use the ratios of the different duration to translate to a position. The problem seems to be that the width property is 0 when the OnRender method is called. I have tried overriding OnRenderSizeChanged, as shown here: In WPF how can I get the rendered size of a control before it actually renders? I have seen in my debug printing that OnRender first gets called, then OnRenderSizeChanged and then I get the OnRender to run again by calling this.InvalidateVisual(); in the override. All the width properties I can get out are still always 0 though which is strange because I can see that it gets rendered and has a size. Have also tried the Measure and Arrange overrides as shown in other posts but have not been able to get out a value other than 0 so far.

So how can I dynamically draw rectangles on the timeline with correct position and size?

Sorry if I am missing something obvious here, I have just been working with WPF for a week now and I don't have anyone to ask. Let me know if you would like to see some more code samples. Any help is appreciated :).

Answer

plast1k picture plast1k · Jun 21, 2016

Let me just say that for someone who is new to WPF you seem to have a good handle on things.

Anyway, this may be a personal preference, but I usually try to leverage the WPF layout engine as much as possible first, then if absolutely required start poking around with drawing things, specifically because of the difficulties you ran into when determining what is rendered and what isn't, what has a width yet and what doesn't, etc.

I'm going to propose a solution sticking mostly to XAML and making use of a multi value converter. There are pros and cons to this compared to other methods which I'll explain, but this was the path of least resistance (for effort anyway ;))

Code

EventLengthConverter.cs:

public class EventLengthConverter : IMultiValueConverter
{

    public object Convert(object[] values, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        TimeSpan timelineDuration = (TimeSpan)values[0];
        TimeSpan relativeTime = (TimeSpan)values[1];
        double containerWidth = (double)values[2];
        double factor = relativeTime.TotalSeconds / timelineDuration.TotalSeconds;
        double rval = factor * containerWidth;

        if (targetType == typeof(Thickness))
        {
            return new Thickness(rval, 0, 0, 0);
        }
        else
        {
            return rval;
        }
    }

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

MainWindow.xaml:

<Window x:Class="timelines.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:timelines"
    DataContext="{Binding Source={StaticResource Locator}, Path=Main}"
    Title="MainWindow" Height="350" Width="525">
<Window.Resources>
    <local:EventLengthConverter x:Key="mEventLengthConverter"/>
</Window.Resources>
<Grid>
    <ItemsControl ItemsSource="{Binding Path=TimeLines}">
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <ItemsControl x:Name="TimeLine" ItemsSource="{Binding Path=Events}">
                    <ItemsControl.ItemsPanel>
                        <ItemsPanelTemplate>
                            <Grid x:Name="EventContainer" Height="20" Margin="5" Background="Gainsboro"/>
                        </ItemsPanelTemplate>
                    </ItemsControl.ItemsPanel>
                    <ItemsControl.ItemTemplate>
                        <DataTemplate>
                            <Rectangle Grid.Column="1" Fill="Green" VerticalAlignment="Stretch" HorizontalAlignment="Left">
                                <Rectangle.Margin>
                                    <MultiBinding Converter="{StaticResource mEventLengthConverter}">
                                        <Binding ElementName="TimeLine" Path="DataContext.Duration"/>
                                        <Binding Path="Start"/>
                                        <Binding ElementName="EventContainer" Path="ActualWidth"/>
                                    </MultiBinding>
                                </Rectangle.Margin>
                                <Rectangle.Width>
                                    <MultiBinding Converter="{StaticResource mEventLengthConverter}">
                                        <Binding ElementName="TimeLine" Path="DataContext.Duration"/>
                                        <Binding Path="Duration"/>
                                        <Binding ElementName="EventContainer" Path="ActualWidth"/>
                                    </MultiBinding>
                                </Rectangle.Width>
                            </Rectangle>
                        </DataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>
</Grid>

Here is what I see when there are two Timelines with two and three events, respectively. enter image description here

Explanation

What you end up with here is nested ItemsControls, one for the top level TimeLine property and one for each timeline's Events. We override the TimeLine ItemControl's ItemsPanel to a simple Grid - we do this to make sure that all of our rectangles use the same origin (to match our data), rather than say a StackPanel.

Next, each event gets its own rectangle, which we use the EventLengthConverter to calculate the Margin (effectively the offset) and the width. We give the multivalue converter everything it needs, the Timelines Duration, the events Start or Duration, and the container width. The converter will get called anytime one of these values changes. Ideally each rectangle would get a column in the grid and you could just set all of these widths to percentages, but we lose that luxury with the dynamic nature of the data.

Pros and Cons

Events are their own objects in the element tree. You have a ton of control now over how you display events. They don't need to just be rectangles, they can be complex objects with more behavior. As far as reasons against this method - I'm not sure. Someone might argue with performance but I can't imagine this being a practical concern.

Tips

You can break these data templates out like you had before, I just included them all together to see the hierarchy more easily in the answer. Also, if you'd like the intent of the converter to be clearer you could create two, something like "EventStartConverter" and "EventWidthConverter", and ditch the check against targetType.

EDIT:

MainViewModel.cs

public class MainViewModel : ViewModelBase
{
    /// <summary>
    /// Initializes a new instance of the MainViewModel class.
    /// </summary>
    public MainViewModel()
    {

        TimeLine first = new TimeLine();
        first.Duration = new TimeSpan(1, 0, 0);
        first.Events.Add(new TimeLineEvent() { Start = new TimeSpan(0, 15, 0), Duration = new TimeSpan(0, 15, 0) });
        first.Events.Add(new TimeLineEvent() { Start = new TimeSpan(0, 40, 0), Duration = new TimeSpan(0, 10, 0) });
        this.TimeLines.Add(first);

        TimeLine second = new TimeLine();
        second.Duration = new TimeSpan(1, 0, 0);
        second.Events.Add(new TimeLineEvent() { Start = new TimeSpan(0, 0, 0), Duration = new TimeSpan(0, 25, 0) });
        second.Events.Add(new TimeLineEvent() { Start = new TimeSpan(0, 30, 0), Duration = new TimeSpan(0, 15, 0) });
        second.Events.Add(new TimeLineEvent() { Start = new TimeSpan(0, 50, 0), Duration = new TimeSpan(0, 10, 0) });
        this.TimeLines.Add(second);
    }


    private ObservableCollection<TimeLine> _timeLines = new ObservableCollection<TimeLine>();
    public ObservableCollection<TimeLine> TimeLines
    {
        get
        {
            return _timeLines;
        }
        set
        {
            Set(() => TimeLines, ref _timeLines, value);
        }
    }

}

public class TimeLineEvent : ObservableObject
{
    private TimeSpan _start;
    public TimeSpan Start
    {
        get
        {
            return _start;
        }
        set
        {
            Set(() => Start, ref _start, value);
        }
    }


    private TimeSpan _duration;
    public TimeSpan Duration
    {
        get
        {
            return _duration;
        }
        set
        {
            Set(() => Duration, ref _duration, value);
        }
    }

}


public class TimeLine : ObservableObject
{
    private TimeSpan _duration;
    public TimeSpan Duration
    {
        get
        {
            return _duration;
        }
        set
        {
            Set(() => Duration, ref _duration, value);
        }
    }


    private ObservableCollection<TimeLineEvent> _events = new ObservableCollection<TimeLineEvent>();
    public ObservableCollection<TimeLineEvent> Events
    {
        get
        {
            return _events;
        }
        set
        {
            Set(() => Events, ref _events, value);
        }
    }
}