7

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 :).

  • I asked to the below answerer @plast1k but hope another opportunity. Can you kindly let me have the missing part 'Locator' ? It would be highly appreciated. Thank you ! – Kay Lee Sep 26 '18 at 08:08

1 Answers1

16

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);
        }
    }
}
plast1k
  • 793
  • 8
  • 15
  • Thank you for such a detailed reply, I am trying to get this to run but I get an error on: DataContext="{Binding Source={StaticResource Locator}, Path=Main}" What is this Locator? Do I have to make a Locator class in the project? What is the purpose of that and what should it do? – Magnus Brantheim Jun 22 '16 at 09:04
  • 2
    Alright have fixed this by adding the code to the existing project which is implementing the MVVM pattern using MVVM light toolkit, figured out how to connect this to the viewmodellocator there and now it works! – Magnus Brantheim Jun 22 '16 at 11:59
  • 1
    Oops - sorry I kick into autopilot when setting up projects and forget about that stuff sometimes. Glad you figured it out! – plast1k Jun 22 '16 at 12:11
  • @plast1k, Hello, Could you please kindly let me have the missing part like 'Locator' ? The whole code would be highly appreciated..I want to implement similar like your screenshot but mine is with 'date' like 9, May, 2017 for 30 days as anti-hypertension medicine 30 days prescription. Many thanks for good ideas here ! – Kay Lee Sep 26 '18 at 01:15
  • 1
    @KayLee The 'Locator' is provided by the [MVVM Light Toolkit](http://www.mvvmlight.net/) though it is not required for this question. I use it quickly setup projectors following the MVVM design pattern. Read in to that a bit if you plan to go down this route. I _was_ able to dig up the old MainViewModel.cs which provided the data and binding context for the XAML, and included it in my answer's recent edit. – plast1k Oct 01 '18 at 19:24
  • @plast1k, Oh, many thanks for the kind feedback with precious time. This might be really helpful to people who're looking for their solutions. Because I have to implement a timeline with 100 event x 100 times per day dynamically and little bit special functionality to be added, I decided to write my own codes and now almost finalizing it. I send my cordial appreciation again ! – Kay Lee Oct 03 '18 at 03:03
  • @KayLee , any chance you can share your code? I am also trying to generate a timeline with a lot of events per day... thanks – intrixius Feb 06 '19 at 07:30
  • @intrixius, Here is the code which I enclosed my code to ask some issue. You have to set timepoint of events, durations with your own. I traveled on internet to search some good codes but failed and wrote by myself. My code is just simple. https://stackoverflow.com/questions/52679865/c-sharp-wpf-dropshadoweffect-is-sometimes-missing-when-dynamically-adding-a-con – Kay Lee Feb 06 '19 at 12:46