如何在WPF中动态绘制时间轴

7
我正在尝试在WPF中绘制时间轴。它应该基本上由3个矩形组成。
它应该看起来像这样(使用XAML硬编码): Timeline 大白色矩形应填满所有可用空间,绿色矩形表示发生在时间轴上的事件的开始和持续时间。
表示此模型的是TimeLineEvent类,它具有TimeSpan开始和时间跨度持续时间以表示事件何时开始以及持续多长时间(以滴答声、秒或其他方式)。还有一个TimeLine类,它具有一个ObservableCollection,其中包含时间轴上的所有事件。它还具有一个TimeSpan持续时间,表示时间轴本身的持续时间。
我需要做的是能够根据它们的持续时间和开始时间以及它们之间的比例动态地在时间轴上绘制事件(绿色矩形),以便相应地绘制事件发生的时间和持续时间。一个时间轴上可能有多个事件。
到目前为止,我的方法是创建一个TimeLine.xaml文件,其中仅包含一个Canvas元素。在代码后台文件中,我已经重写了OnRender方法来绘制这些矩形,它可以使用硬编码的值工作。
在MainWindow.xaml中,我创建了一个数据模板,并将数据类型设置为TimeLine:
<DataTemplate x:Key="TimeLineEventsTemplate" DataType="{x:Type local:TimeLine}">
        <Border>
            <local:TimeLine Background="Transparent"/>
        </Border>
    </DataTemplate>

我尝试过不同的设置,但说实话不确定自己在做什么。然后我有一个堆栈面板,其中包含一个列表框,它使用我的数据模板并绑定 TimeLines。TimeLines 是 MainWindow 代码后台中持有 TimeLine 对象的 ObservableCollection。

<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>

当我创建新的时间轴对象时,它会绘制新的时间轴,如下所示:Timelines。但是问题在于它无法正确地呈现绿色矩形,因为我需要知道白色矩形的宽度,以便使用不同持续时间的比例来转换为位置。问题似乎在于当调用OnRender方法时,宽度属性为0。我已经尝试重写OnRenderSizeChanged,如此处所示:In WPF how can I get the rendered size of a control before it actually renders? 我在调试打印中看到OnRender首先被调用,然后是OnRenderSizeChanged,然后通过在覆盖中调用this.InvalidateVisual(); 来再次运行OnRender。然而,我可以得到的所有宽度属性仍然始终为0,这很奇怪,因为我可以看到它被渲染并具有大小。我也尝试了其他帖子中显示的Measure和Arrange覆盖,但迄今为止还没有能够获得除0以外的值。那么,如何动态绘制时间轴上的矩形并使其位置和大小正确呢?抱歉如果我漏掉了一些明显的东西,我只是在使用WPF工作了一个星期,没有人可以问。如果您想看更多的代码示例,请告诉我。感谢任何帮助 :)。

我向下面的回答者@plast1k询问了,但希望有另一个机会。您能否友好地让我拥有缺失的部分“Locator”?这将不胜感激。谢谢! - Kay Lee
1个回答

16

首先,对于一个新手来说,你似乎已经掌握了WPF的一些要领。

无论如何,这可能是个人偏好,但我通常尽可能地利用WPF布局引擎,然后只有在绝对需要时才开始处理绘图,特别是因为你遇到的困难,比如确定什么被渲染,什么没有被渲染,什么有宽度等等。

我将提出一个方案,主要使用XAML并利用多值转换器。与其他方法相比,这种方法有优点和缺点,我将解释一下,但至少对于努力而言,这是最佳选择之一。

代码

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>

当有两个时间轴,分别有两个和三个事件时,我看到的是这样的情况。 enter image description here

解释

在这里,我们使用了嵌套的ItemsControls,一个用于顶层TimeLine属性,另一个用于每个时间轴的事件。我们覆盖了TimeLine ItemControl的ItemsPanel,将其设置为简单的网格 - 我们这样做是为了确保我们的所有矩形都使用相同的原点(以匹配我们的数据),而不是StackPanel等其他控件。

接下来,每个事件都有它自己的矩形,我们使用EventLengthConverter计算Margin(实际上是偏移量)和宽度。我们给多值转换器提供了它所需的一切,包括时间轴的持续时间、事件的开始或持续时间以及容器的宽度。当其中任何一个值发生变化时,都会调用转换器。理想情况下,每个矩形都应该在网格中占据一列,并且你可以将所有这些宽度设置为百分比,但由于数据的动态性,我们失去了这种便利。

优缺点

事件是元素树中的独立对象。现在您可以完全控制如何显示这些事件。它们不仅需要成为矩形,还可以是具有更多行为的复杂对象。至于这种方法的缺点 - 我不确定。有人可能会对性能提出异议,但我无法想象这会是一个实际的问题。

提示

您可以像以前一样将这些数据模板拆分,我只是将它们全部包含在一起,以便更轻松地查看答案中的层次结构。此外,如果您希望转换器的意图更清晰,可以创建两个转换器,例如“EventStartConverter”和“EventWidthConverter”,并放弃对targetType的检查。

编辑:

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);
        }
    }
}

2
好的,我已经通过将代码添加到现有项目中来解决了这个问题。该项目正在使用MVVM light工具包实现MVVM模式。我找到了如何将其连接到ViewModelLocator,并且现在它可以正常工作了! - Magnus Brantheim
1
抱歉,有时候我在设置项目时会进入自动驾驶模式,有时候会忘记一些东西。很高兴你解决了问题! - plast1k
@plast1k,你好!你能否请好心提供一下像“定位器”这样的缺失部分?整个代码将不胜感激。我想实现与你的截图类似的功能,但我的是以日期为主,例如9年5月2017日,为期30天的抗高血压药物处方。在此感谢您的好点子! - Kay Lee
1
@KayLee 'Locator'是由MVVM Light Toolkit提供的,但这并不是本问题所必需的。我使用它来快速设置遵循MVVM设计模式的投影仪。如果您计划走这条路,请深入了解一下。我已经找到了旧的MainViewModel.cs文件,它为XAML提供了数据和绑定上下文,并将其包含在我的答案的最近编辑中。 - plast1k
@intrixius,这是我附上的代码,我想请教一些问题。你需要自己设置事件的时间点和持续时间。我在网上搜索了一些好的代码,但失败了,只能自己编写。我的代码非常简单。https://dev59.com/a67la4cB1Zd3GeqPk-mG - Kay Lee
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接