在WPF中使用ItemsControl控件来实现线状图控件(一)
在前面的文章里面提到了如何使用ItemsControl编写一个直方图控件(还没有写完,今天因为有急用就先写线状图的编写方法了),因此在阅读这篇文章之前,推荐先阅读下面几篇文章:
4. 使用ListBox控件来实现直方图控件(四)
5. 使用ListBox控件来实现直方图控件(五)
按照在(三)里面介绍过的方法 ,程序是把直方图后面的数据转换成矩形的高度,对于线状图来说,那就是把线状图后面的数据转化成折线上点的高度就可以了。因此我们的Converter可以复用直方图控件里面的Converter:
internal class ChartValueToHeightConverter : IValueConverter
{
#region IValueConverter Members
public object Convert(object value, Type targetType,
object parameter, CultureInfo culture)
{
// 复用直方图控件里面的Converter唯一的问题是,当时我在Converter函数里面
// 硬编码成Histogram类型了,所以这里我只好暂时先将它改成LineChart类型。
// 如果要做一个通过的Converter的话,问题也不是很大,只要把下面用到的TickLabelHeight
// Maximum, Minimum属性都放在ChartBase类里面就好了。
LineChart chart =
((ObjectDataProvider)parameter).ObjectInstance as LineChart;
return (chart.ActualHeight - chart.TickLabelHeight) * 0.9 * (double)value
/ (chart.Maximum - chart.Minimum);
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
#endregion
}
而ItemsControl的ItemTemplate换成下面这个DataTemplate就可以把点画出来了:
<Grid x:Name="ChartArea" Width="20" VerticalAlignment="Bottom">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:LineChart}}, Path=TickLabelHeight}"/>
</Grid.RowDefinitions>
<Canvas Grid.Row="0">
<Rectangle Width="5" Height="5" Fill="{Binding Path=Fill}" Canvas.Left="0" x:Name="ChartPoint"
Cursor="Cross"
local:ConnectLinker.Linker="{Binding ElementName=connectLinker}"
ToolTip="{Binding Path=Value}">
<Canvas.Bottom>
<Binding Path="Value"
Converter="{StaticResource chartValueToHeightConverter}"
ConverterParameter="{StaticResource LineChartReference}">
</Binding>
</Canvas.Bottom>
</Rectangle>
</Canvas>
<Label Content="{Binding Path=Category}" Padding="0" Margin="0" Grid.Row="1" />
</Grid>
下面是效果图:
现在剩下的问题是,如何把这些点连接起来―也就是说如何根据这些点绘制一个折线?WPF提供了Path控件用来绘制折线、曲线等线形,Path控件要求输入一个Geometry实例数组赋值给Data属性,而每一个Geometry(我们只考虑折线的情况)都要求输入一个起始点,和线段的定义组合―Segments。也就是说,Path控件要求一个连续的线段数组定义,不能从多个点直接定义出来,即WPF没有提供方法让我们以类似下面的代码直接创建一个折线:
Path path = new Path();
path.Data = new PathGeometry(new Point[] { point1, point2, point3, ..., point 100 });
当然啦,通过一些小技巧应该还是可以达到类似上面代码的功能的,例如在Xaml里面,我们就可以用下面的形式来创建一个Path对象――但这篇文章不会去探讨这个技巧。
<Path Stroke="Black" StrokeThickness="1"
Data="M 10,50 L 200,70" />
由于我们是通过ItemsControl.ItemTemplate一个一个地把点画出来的,而绘制线状图的折线段只需要获取定义折线的所有点的坐标就可以了,现在的问题是,如何将ItemsControl里面的一个个独立的点通过一种方式联系起来?一种方案是重写ItemsControl的OnRender函数,然后遍历ItemsControl的每一个Item,通过某种复杂的办法查询出Item里面的点,然后在OnRender函数里面自己写代码绘制出折线段。这种办法有一些缺点:
1. OnRender函数重写可能比较困难,因为我们只能获取到每一个点(实际是一个Rectangle实例)相对于Canvas的高度,因此我们在画线的时候需要考虑到线状图里面的X轴坐标的高度。
2. 对于ItemsControl,你要找到里面使用DataBinding技术生成的控件,那是相当的麻烦,要使用ItemsControl.ItemContainerGenerator里面的好几个不同的函数,并且还要和VisualTreeHelper.GetChildren等几个函数组合使用才能找到DataBinding技术生成的Rectangle实例――而且你还不一定能够拿得到(请参见麻烦三)。
3. ItemsControl会有一个优化,实际上VirtualizeStackPanel的一个优化,就是它不会显示不在可见区域(具体一点说是,用户还没有看到过)的数据,就是说如果这个数据用户还没有看到的话――例如这个数据是显示在屏幕外的,那么DataBinding不会为这个数据计算DataTemplate而生成相应的控件,这样就会导致你使用麻烦二里面描述的方法对一些数据会获取到null引用。
看起来通过现有控件或者通过ItemsControl本身暂时很难做到,于是我们只好自己写一个控件来帮LineChart控件将屏幕上的点连起来,这个我们可以通过自定义一个内容控件并且定义一个附加属性(Attached Property)来实现这个功能。至于为什么可以这样做,我会在后面的文章里面讲。下面是解决方案:
<UserControl x:Class="TestLineChart.LineChart"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:TestLineChart">
...
<Grid>
<local:ConnectLinker x:Name="connectLinker"
... >
<ItemsControl ...>
...
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid x:Name="ChartArea" Width="20" VerticalAlignment="Bottom">
...
<Rectangle ...
local:ConnectLinker.Linker="{Binding ElementName=connectLinker}">
...
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</local:ConnectLinker>
</Grid>
</UserControl>
下面是这个控件的源代码:
using System.Collections.Generic;
using System.Windows.Controls;
using System.Windows;
using System.Windows.Media;
using System.Windows.Shapes;
namespace TestLineChart
{
public class ConnectLinker : ContentControl
{
public static ConnectLinker GetLinker(DependencyObject obj)
{
return (ConnectLinker)obj.GetValue(LinkerProperty);
}
public static void SetLinker(DependencyObject obj, ConnectLinker value)
{
obj.SetValue(LinkerProperty, value);
}
public static readonly DependencyProperty LinkerProperty =
DependencyProperty.RegisterAttached("Linker",
typeof(ConnectLinker), typeof(ConnectLinker),
new UIPropertyMetadata(null, new PropertyChangedCallback(LinkChangedCallBack)));
private List<UIElement> Points { get; set; }
public ConnectLinker()
{
Points = new List<UIElement>();
}
private static void LinkChangedCallBack(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
ConnectLinker linker = e.NewValue as ConnectLinker;
linker.Points.Add(sender as UIElement);
linker.InvalidateVisual();
}
protected override void OnRender(DrawingContext drawingContext)
{
PathGeometry geometry = new PathGeometry();
Point? previousPoint = null;
int index = 0;
Brush brush = null;
for (int i = 0; i <= Points.Count - 1; ++i)
{
Rectangle e = Points[i] as Rectangle;
if (brush == null)
{
brush = e.Fill;
}
if (previousPoint == null)
{
previousPoint = new Point(Canvas.GetLeft(e), Canvas.GetBottom(e));
}
else
{
PathFigure figure = new PathFigure();
LineSegment segment = new LineSegment();
// Canvas parent = LogicalTreeHelper.GetParent(e) as Canvas;
Grid parent = e.FindAncestor<Grid>();
Point point = new Point(parent.ActualWidth * index, Canvas.GetBottom(e));
segment.Point = point;
figure.StartPoint = previousPoint.Value;
PathSegmentCollection segments = new PathSegmentCollection();
segments.Add(segment);
figure.Segments = segments;
previousPoint = point;
geometry.Figures.Add(figure);
}
index++;
}
if (brush != null)
{
drawingContext.DrawGeometry(brush, new Pen(brush, 2), geometry);
}
}
}
public static class Extension
{
public static T FindAncestor<T>(this DependencyObject element) where T : DependencyObject
{
DependencyObject parent = VisualTreeHelper.GetParent(element) as DependencyObject;
while (parent != null)
{
if (parent is T)
return parent as T;
else
parent = VisualTreeHelper.GetParent(parent) as DependencyObject;
}
return null;
}
}
}
这样我们就得到类似下图的效果,但是……有一些问题,这个线怎么没有经过点呢?
仔细分析,原来线是画反了,因为默认Window的坐标原点是屏幕的最上角,也就是说在折线的时候,是以屏幕的左上角为原点绘制的,而画点的时候,却是以Canvas的左下角为原点绘制的――因为<Grid x:Name="ChartArea" Width="20" VerticalAlignment="Bottom">这一行已经明确告诉我们这个事实了。没关系,难不倒我们,只要把最终的线段在中心位置反转一下就可以了-即把最后输出线段的代码修改一下下就好了:
if (brush != null)
{
drawingContext.PushTransform(new ScaleTransform(1, -1, 0, ActualHeight / 2));
drawingContext.DrawGeometry(brush, new Pen(brush, 2), geometry);
}
呃,还是有一点小问题,线段的走向是正确了,但是怎么跟点还是有那么一点点距离呢?
这是因为我们在反转线段的时候,没有考虑到X轴还是有一定的高度的,而刚才反转得时候,我们是根据整个线状图高度的一半进行反转的,没有考虑X轴高度的问题,这个问题好解决,只要在ConnectLinker类里面加一个属性TolerranceHeight,并且在Xaml里面把值赋上就可以了:
internal double TranslateTolorrence
{
get { return (double)GetValue(TranslateTolorrenceProperty); }
set { SetValue(TranslateTolorrenceProperty, value); }
}
internal static readonly DependencyProperty TranslateTolorrenceProperty =
DependencyProperty.Register("TranslateTolorrence", typeof(double), typeof(ConnectLinker), new UIPropertyMetadata(0.0d));
...
protected override void OnRender(DrawingContext drawingContext)
{
...
if (brush != null)
{
drawingContext.PushTransform(new ScaleTransform(1, -1, 0, ActualHeight / 2 - TranslateTolorrence / 2));
// 把线段稍微上移一些(根据点的高度),这样使得线段看起来是从点的中心穿过一样。
drawingContext.PushTransform(new TranslateTransform(3, 0));
drawingContext.DrawGeometry(brush, new Pen(brush, 2), geometry);
}
}
}
Xaml里面的修改:
<local:ConnectLinker x:Name="connectLinker"
TranslateTolorrence="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type local:LineChart}}, Path=TickLabelHeight}">
...
</local:ConnectLinker>
最后终于得到想要的线状图效果了:
未完待续……
这里是完整的源代码:/Files/killmyday/TestLineChart.zip