如何创建 MVVM 兼容的 WPF 折线图控件






4.89/5 (11投票s)
本文将详细介绍如何在 WPF 中创建折线图,以及如何将其转换为可在遵循 MVVM 模式的 WPF 应用程序中重复使用的图表控件。
引言
WPF 提供了一个统一的图形平台,让您可以在 .NET 应用程序中轻松创建各种用户界面和图形对象。在这里,我将逐步介绍如何在 WPF 中创建折线图控件。我们要求此图表控件的行为类似于 WPF 内置元素:它可以在 XAML 中创建;数据绑定到视图模型中定义的属性;并且与 MVVM 兼容。我将首先描述 WPF 中使用的二维图表坐标系;然后向您展示如何在此坐标系中创建简单的折线图;最后向您展示如何创建折线图控件以及如何在遵循 MVVM 模式的 WPF 应用程序中使用该图表控件。
二维图表的坐标系
二维图表应用程序中使用的自定义坐标系必须满足以下条件:它必须独立于实际图形对象的单位,并且其 Y 轴必须从下到上指向,就像大多数图表应用程序中一样。图 1 说明了此自定义坐标系。
图 1. 二维图表应用程序的自定义坐标系。
您可以看到,我们在渲染区域内定义了真实的 X-Y 坐标系。您可以使用自定义面板控件,通过覆盖其 MeasureOverride 和 ArrangeOverride 方法来创建这样的坐标系。每个方法都返回定位和渲染子元素所需的大小数据。这是一种创建自定义坐标系的标准方法。这里我将不创建自定义面板控件,而是采用另一种基于直接编码的方法来构建此坐标系。在下一节中,我将创建一个简单的折线图,以说明如何构建自定义二维图表坐标系。
简单的折线图
X-Y 折线图使用两个值来表示每个数据点。这种类型的图表对于描述数据之间的关系非常有用,并且经常涉及数据统计分析,在科学、数学、工程和金融领域以及日常生活中都有广泛的应用。
打开 Visual Studio 2013,启动一个新的 WPF 项目,并将其命名为 WpfChart。我将使用 Caliburn.Micro 作为我们的 MVVM 框架,并且不会详细介绍如何使用 Caliburn.Micro。您可以访问他们的网站获取更多信息 (https://github.com/Caliburn-Micro)。向项目添加一个 UserControl 并将其命名为 SimpleChartView。以下是用于创建折线图的 XAML 代码片段
<Grid ClipToBounds="True" cal:Message.Attach="[Event SizeChanged]=
[Action AddChart($this.ActualWidth, $this.ActualHeight)];
[Event Loaded]=[Action AddChart($this.ActualWidth, $this.ActualHeight)]">
<Polyline Points="{Binding SolidLinePoints}" Stroke="Black" StrokeThickness="2"/>
<Polyline Points="{Binding DashLinePoints}" Stroke="Black"
StrokeThickness="2" StrokeDashArray="4,3"/>
</Grid>
在这里,我们向 Grid 添加了两个 Polyline,它们分别绑定到两个 PointCollection 对象,SolidLinePoints 和 DashLinePoints。您可能还注意到,我使用了 Caliburn.Micro 的操作机制将视图中的 UI 事件绑定到视图模型中定义的方法。在这里,我使用 Message.Attach 属性将 Grid 的 Loaded 和 SizeChanged 事件绑定到视图模型中的 AddChart 方法,并将 Grid 的 ActualWidth 和 ActualHeight 属性传递给 AddChart 方法。每当 Grid 加载或调整大小时,此操作都会触发,从而重新创建点集合并在屏幕上重新绘制图表。
向项目添加一个新类并将其命名为 SimpleChartViewModel。这是此类的代码
using System;
using Caliburn.Micro;
using System.Collections.ObjectModel;
using System.ComponentModel.Composition;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Media;
using System.Windows.Controls;
namespace WpfChart
{
[Export(typeof(IScreen)), PartCreationPolicy(CreationPolicy.NonShared)]
public class SimpleChartViewModel : Screen
{
[ImportingConstructor]
public SimpleChartViewModel()
{
DisplayName = "01. Simple Line";
}
private double chartWidth = 300;
private double chartHeight = 300;
private double xmin = 0;
private double xmax = 6.5;
private double ymin = -1.1;
private double ymax = 1.1;
private PointCollection solidLinePoints;
public PointCollection SolidLinePoints
{
get { return solidLinePoints; }
set
{
solidLinePoints = value;
NotifyOfPropertyChange(() => SolidLinePoints);
}
}
private PointCollection dashLinePoints;
public PointCollection DashLinePoints
{
get { return dashLinePoints; }
set
{
dashLinePoints = value;
NotifyOfPropertyChange(() => DashLinePoints);
}
}
public void AddChart(double width, double height)
{
chartWidth = width;
chartHeight = height;
SolidLinePoints = new PointCollection();
DashLinePoints = new PointCollection();
double x = 0;
double y = 0;
double z = 0;
for (int i = 0; i < 70; i++)
{
x = i / 5.0;
y = Math.Sin(x);
z = Math.Cos(x);
DashLinePoints.Add(NormalizePoint(new Point(x, z)));
SolidLinePoints.Add(NormalizePoint(new Point(x, y)));
}
}
public Point NormalizePoint(Point pt)
{
var res = new Point();
res.X = (pt.X - xmin) * chartWidth / (xmax - xmin);
res.Y = chartHeight - (pt.Y - ymin) * chartHeight / (ymax - ymin);
return res;
}
}
}
请注意,轴限制 xmin、xmax、ymin 和 ymax 是在真实世界坐标系中定义的。正弦和余弦函数使用两个点集合 SolidLinePoints 和 DashLinePoints 表示,它们绑定到 Polyline 对象。
请特别注意 AddChart 方法。我们首先将 chartWidth 和 chartHeight 设置为 Grid 的 ActualWidth 和 ActualHeight 属性,以确保当 Grid 调整大小时图表大小也会随之改变。我们还会重新创建点集合,因此当您调整屏幕大小时,图表将重新绘制。
创建此折线图的关键步骤是使用 NormalizePoint 方法将世界坐标系中的原始数据点转换为设备独立像素单位的点。NormalizePoint 方法将世界坐标系中任意单位的点转换为设备坐标系中以设备独立像素为单位的点。
图 2 显示了运行此示例的结果。
图 2. 正弦和余弦函数的简单二维图。
带图表样式的折线图
前面的示例演示了使用标准 MVVM 模式在 WPF 中创建简单的二维折线图是多么容易,并且实现了视图和视图模型之间的完美分离。
为了使图表程序更面向对象并易于扩展以添加新功能,我们需要定义两个新类:ChartStyle 和 LineSeries。LineSeries 类包含图表数据和线条样式,包括线条颜色、粗细、虚线样式等。ChartStyle 类定义所有与图表布局相关的信息,包括网格线、标题、刻度线和轴标签。为此,我们需要在视图上动态创建许多控件,这在 XAML 中很难创建这些控件。因此,我们希望视图模型能够访问视图并动态地向视图添加控件。这可能会违反 MVVM 规则,但我稍后会向您展示,当我们将其转换为图表用户控件时,我们仍然可以创建 MVVM 兼容的图表应用程序。
LineSeries 类
向项目添加一个新文件夹并将其命名为 ChartModel。向 ChartModel 文件夹添加一个新类并将其命名为 LineSeries。这是此类的代码
using System;
using System.Windows.Media;
using System.Windows.Shapes;
using Caliburn.Micro;
using System.Windows;
namespace WpfChart.ChartModel
{
public class LineSeries : PropertyChangedBase
{
public LineSeries()
{
LinePoints = new BindableCollection<Point>();
}
public BindableCollection<Point> LinePoints { get; set; }
private Brush lineColor = Brushes.Black;
public Brush LineColor
{
get { return lineColor; }
set { lineColor = value; }
}
private double lineThickness = 1;
public double LineThickness
{
get { return lineThickness; }
set { lineThickness = value; }
}
public LinePatternEnum LinePattern { get; set; }
private string seriesName = "Default";
public string SeriesName
{
get { return seriesName; }
set { seriesName = value; }
}
private DoubleCollection lineDashPattern;
public DoubleCollection LineDashPattern
{
get { return lineDashPattern; }
set
{
lineDashPattern = value;
NotifyOfPropertyChange(() => LineDashPattern);
}
}
public void SetLinePattern()
{
switch (LinePattern)
{
case LinePatternEnum.Dash:
LineDashPattern = new DoubleCollection() { 4, 3 };
break;
case LinePatternEnum.Dot:
LineDashPattern = new DoubleCollection() { 1, 2 };
break;
case LinePatternEnum.DashDot:
LineDashPattern = new DoubleCollection() { 4, 2, 1, 2 };
break;
}
}
}
public enum LinePatternEnum
{
Solid = 1,
Dash = 2,
Dot = 3,
DashDot = 4,
}
}
这个类为给定的 LineSeries 创建一个名为 LinePoints 的点集合对象。然后,它定义了线条对象的线条样式,包括线条颜色、粗细、线条模式和系列名称。SeriesName 属性将在为图表创建图例时使用。线条模式由一个名为 LinePatternEnum 的公共枚举定义,其中定义了四种线条模式,包括 Solid、Dash、Dot 和 DashDot。
我们通过 SetLinePattern 方法创建线条模式。无需创建实线模式,因为它是 Polyline 对象的默认设置。我们还使用 Polyline 的 StrokeDashArray 属性创建虚线或点线模式。
ChartStyle 类
向 ChartModel 文件夹添加另一个新类 ChartStyle。以下是此类的代码列表
using Caliburn.Micro;
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Shapes;
namespace WpfChart.ChartModel
{
public class ChartStyle
{
private double xmin = 0;
private double xmax = 6.5;
private double ymin = -1.1;
private double ymax = 1.1;
private string title = "Title";
private string xLabel = "X Axis";
private string yLabel = "Y Axis";
private bool isXGrid = true;
private bool isYGrid = true;
private Brush gridlineColor = Brushes.LightGray;
private double xTick = 1;
private double yTick = 0.5;
private LinePatternEnum gridlinePattern;
private double leftOffset = 20;
private double bottomOffset = 15;
private double rightOffset = 10;
private Line gridline = new Line();
public Canvas TextCanvas { get; set; }
public Canvas ChartCanvas { get; set; }
public double Xmin
{
get { return xmin; }
set { xmin = value; }
}
... ...
... ...
public void AddChartStyle(TextBlock tbTitle, TextBlock tbXLabel, TextBlock tbYLabel)
{
Point pt = new Point();
Line tick = new Line();
double offset = 0;
double dx, dy;
TextBlock tb = new TextBlock();
// determine right offset:
tb.Text = Xmax.ToString();
tb.Measure(new Size(Double.PositiveInfinity, Double.PositiveInfinity));
Size size = tb.DesiredSize;
rightOffset = 5;
// Determine left offset:
for (dy = Ymin; dy <= Ymax; dy += YTick)
{
pt = NormalizePoint(new Point(Xmin, dy));
tb = new TextBlock();
tb.Text = dy.ToString();
tb.TextAlignment = TextAlignment.Right;
tb.Measure(new Size(Double.PositiveInfinity, Double.PositiveInfinity));
size = tb.DesiredSize;
if (offset < size.Width)
offset = size.Width;
}
leftOffset = offset + 5;
Canvas.SetLeft(ChartCanvas, leftOffset);
Canvas.SetBottom(ChartCanvas, bottomOffset);
ChartCanvas.Width = Math.Abs(TextCanvas.Width - leftOffset - rightOffset);
ChartCanvas.Height = Math.Abs(TextCanvas.Height - bottomOffset - size.Height / 2);
... ...
... ...
}
public Point NormalizePoint(Point pt)
{
if (Double.IsNaN(ChartCanvas.Width) || ChartCanvas.Width <= 0)
ChartCanvas.Width = 270;
if (Double.IsNaN(ChartCanvas.Height) || ChartCanvas.Height <= 0)
ChartCanvas.Height = 250;
Point result = new Point();
result.X = (pt.X - Xmin) * ChartCanvas.Width / (Xmax - Xmin);
result.Y = ChartCanvas.Height - (pt.Y - Ymin) * ChartCanvas.Height / (Ymax - Ymin);
return result;
}
public void SetLines(BindableCollection<LineSeries> dc)
{
if (dc.Count <= 0)
return;
int i = 0;
foreach (var ds in dc)
{
PointCollection pts = new PointCollection();
if (ds.SeriesName == "Default")
ds.SeriesName = "LineSeries" + i.ToString();
ds.SetLinePattern();
for (int j = 0; j < ds.LinePoints.Count; j++)
{
var pt = NormalizePoint(ds.LinePoints[j]);
pts.Add(pt);
}
Polyline line = new Polyline();
line.Points = pts;
line.Stroke = ds.LineColor;
line.StrokeThickness = ds.LineThickness;
line.StrokeDashArray = ds.LineDashPattern;
ChartCanvas.Children.Add(line);
i++;
}
}
}
}
这里,我只列出了此类中的部分代码。您可以在附件的 zip 文件中查看完整的代码列表。请注意,我们将 Canvas 控件 ChartCanvas 定义为公共属性。通常,MVVM 框架不允许 Canvas 控件出现在模型或视图模型中。如前所述,这样做的目的是能够动态添加控件。
在这里,我们添加了更多的成员字段和相应的属性,用于操作图表的布局和外观。您可以根据其名称轻松理解每个字段和属性的含义。请注意,我添加了另一个 Canvas 属性 TextCanvas,用于保存刻度线标签,而 ChartCanvas 保存图表本身。
此外,我添加了以下成员字段来定义图表的网格线
private bool isXGrid = true;
private bool isYGrid = true;
private Brush gridlineColor = Brushes.LightGray;
private LinePatternEnum gridlinePattern;
这些字段及其相应的属性为自定义网格线外观提供了极大的灵活性。GridlinePattern 属性允许您选择各种线条虚线样式,包括实线、虚线、点线和虚点线。您可以使用 GridlineColor 属性更改网格线的颜色。此外,我定义了两个布尔属性 IsXGrid 和 IsYGrid,允许您打开或关闭水平或垂直网格线。
然后我定义了 X 和 Y 标签、标题和刻度线的成员字段和相应的属性,以便您可以根据自己的喜好进行更改。如果您愿意,可以轻松添加更多成员字段来控制图表的外观;例如,您可以更改标签和标题的字体和文本颜色。
在此类中,AddChartStyle 方法看起来相当复杂;但是,它实际上相当容易理解。首先,我花费大量精力定义 ChartCanvas 的大小,同时考虑相对于 TextCanvas 的合适偏移量。
ChartCanvas.Width = Math.Abs(TextCanvas.Width – leftOffset - rightOffset);
ChartCanvas.Height = Math.Abs(TextCanvas.Height – bottomOffset - size.Height / 2);
接下来,我绘制指定颜色和线条模式的网格线。请注意,所有网格线的端点都已使用 NormalizePoint 方法从世界坐标系转换为设备独立像素。
然后,我绘制图表 X 轴和 Y 轴的刻度线。对于每个刻度线,我找到刻度线与坐标轴连接的设备坐标系中的点,并从该点向 ChartCanvas 内部绘制一条 5 像素长的黑线。
X 轴和 Y 轴的标题和标签通过代码附加到相应的 TextBlock 名称。您还可以创建数据绑定,将 Title、XLabel 和 YLabel 属性直接绑定到 XAML 文件中相应的 TextBlock。
从前面的代码中可以清楚地看到,此类的实现涉及动态创建和定位控件,这在视图中使用 XAML 很难实现。这里我们选择一种简单的方法,即在模型或视图模型类中对控件进行所有动态创建和放置。在某些情况下,例如本例中的情况,您不需要强迫自己遵循 MVVM 规则,特别是当规则使简单问题复杂化时。
使用图表样式创建折线图
现在我们可以使用上面两个类 LineSeries 和 ChartStyle 来创建带有网格线、轴标签、标题和刻度线的折线图。向项目添加一个新的 UserControl 并将其命名为 ChartView。以下是此视图的相关 XAML 代码片段
<Grid Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Margin="2" x:Name="tbTitle" Grid.Column="1" Grid.Row="0"
RenderTransformOrigin="0.5,0.5" FontSize="14" FontWeight="Bold"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
TextAlignment="Center" Text="Title"/>
<TextBlock Margin="2" x:Name="tbXLabel" Grid.Column="1" Grid.Row="2"
RenderTransformOrigin="0.5,0.5" TextAlignment="Center" Text="X Axis"/>
<TextBlock Margin="2" Name="tbYLabel" Grid.Column="0" Grid.Row="1"
RenderTransformOrigin="0.5,0.5" TextAlignment="Center" Text="Y Axis">
<TextBlock.LayoutTransform>
<RotateTransform Angle="-90"/>
</TextBlock.LayoutTransform>
</TextBlock>
<Grid Margin="0,0,0,0" x:Name ="chartGrid" Grid.Column="1" Grid.Row="1"
ClipToBounds="False" Background="Transparent"
cal:Message.Attach="[Event SizeChanged]=[Action AddChart];
[Event Loaded]=[Action AddChart]">
<Canvas Margin="2" Name="textCanvas" Grid.Column="1" Grid.Row="1" ClipToBounds="True"
Width="{Binding ElementName=chartGrid,Path=ActualWidth}"
Height="{Binding ElementName=chartGrid,Path=ActualHeight}">
<Canvas Name="chartCanvas" ClipToBounds="True"/>
</Canvas>
</Grid>
</Grid>
在这里,我们将 X 轴和 Y 轴的标题和标签放置在一个 Grid 控件的不同单元格中,并定义了两个 Canvas 控件:textCanvas 和 chartCanvas。textCanvas 成为一个可调整大小的 Canvas 控件,因为它的 Width 和 Height 属性绑定到 chartGrid 的 ActualWidth 和 ActualHeight 属性。我们使用 textCanvas 控件作为 chartCanvas 的父级,用于保存刻度线标签;chartCanvas 控件将保存图表本身。
向项目添加一个新类并将其命名为 ChartViewModel。以下是此视图的代码
using System;
using Caliburn.Micro;
using System.Collections.ObjectModel;
using System.ComponentModel.Composition;
using System.Windows.Documents;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Media;
using System.Windows.Controls;
using WpfChart.ChartModel;
namespace WpfChart
{
[Export(typeof(IScreen)), PartCreationPolicy(CreationPolicy.NonShared)]
public class ChartViewModel :Screen
{
[ImportingConstructor]
public ChartViewModel()
{
DisplayName = "02. Chart";
}
private ChartView view;
private ChartStyle cs;
private void SetChartStyle()
{
view = this.GetView() as ChartView;
view.chartCanvas.Children.Clear();
view.textCanvas.Children.RemoveRange(1, view.textCanvas.Children.Count - 1);
cs = new ChartStyle();
cs.ChartCanvas = view.chartCanvas;
cs.TextCanvas = view.textCanvas;
cs.Title = "Sine and Cosine Chart";
cs.Xmin = 0;
cs.Xmax = 7;
cs.Ymin = -1.5;
cs.Ymax = 1.5;
cs.YTick = 0.5;
cs.GridlinePattern = LinePatternEnum.Dot;
cs.GridlineColor = Brushes.Green;
cs.AddChartStyle(view.tbTitle, view.tbXLabel, view.tbYLabel);
}
public void AddChart()
{
SetChartStyle();
BindableCollection<LineSeries> dc = new BindableCollection<LineSeries>();
var ds = new LineSeries();
ds.LineColor = Brushes.Blue;
ds.LineThickness = 2;
ds.LinePattern = LinePatternEnum.Solid;
for (int i = 0; i < 50; i++)
{
double x = i / 5.0;
double y = Math.Sin(x);
ds.LinePoints.Add(new Point(x, y));
}
dc.Add(ds);
ds = new LineSeries();
ds.LineColor = Brushes.Red;
ds.LineThickness = 2;
ds.LinePattern = LinePatternEnum.Dash;
ds.SetLinePattern();
for (int i = 0; i < 50; i++)
{
double x = i / 5.0;
double y = Math.Cos(x);
ds.LinePoints.Add(new Point(x, y));
}
dc.Add(ds);
cs.SetLines(dc);
}
}
}
请特别注意我们如何从视图模型访问视图。Caliburn.Micro 提供了一个名为 GetView 的方法,它允许我们轻松访问视图。如前所述,ChartStyle 类需要访问在视图中创建的 textCanvas 和 chartCanvas 控件,因此在 SetChartStyle 方法中,我们使用以下代码片段访问视图
view = this.GetView() as ChartView;
view.chartCanvas.Children.Clear();
view.textCanvas.Children.RemoveRange(1, view.textCanvas.Children.Count - 1);
cs = new ChartStyle();
cs.ChartCanvas = view.chartCanvas;
cs.TextCanvas = view.textCanvas;
我们首先通过 GetView 方法获取视图对象。为了在应用程序窗口调整大小时重新绘制图表,我们需要重新创建 textCanvas 的所有子元素(但 chartCanvas 除外),并删除 chartCanvas 的所有子控件,这通过前面代码片段中加粗的代码语句实现。然后,我们创建 ChartStyle 对象并将其 ChartCanvas 和 TextCanvas 属性设置为视图的相应控件。
在 AddChart 方法中,我们首先调用 SetChartStyle 方法,该方法设置网格线、标题、刻度线和轴标签。其余代码与上一个示例中使用的代码类似。图 3 说明了运行此应用程序的结果。您可以看到图表有标题、标签、网格线和刻度线。恭喜!您已成功在 WPF 中创建了折线图。
图 3. 带有网格线和刻度线的折线图。
折线图控件
在前面的章节中,我们直接实现了图表程序中所有类的源代码。对于简单的应用程序,这种方法效果很好。但是,如果您想在多个 .NET 应用程序中重用相同的代码,这种方法就会失效。.NET 框架和 WPF 提供了一种强大的方法——用户控件——来解决这个问题。
WPF 中的自定义用户控件就像 .NET 和 WPF 已经提供的简单按钮或文本框一样。通常,您设计的控件用于多个窗口或模块化您的代码。这些自定义控件可以减少您必须输入的代码量,并使您更容易更改程序的实现。您的应用程序没有理由重复代码,因为这会留下很多错误的余地。因此,在控件的源代码中创建特定于用户控件的功能是一种良好的编程实践,这可以减少代码重复并模块化您的代码。
在本节中,我将向您展示如何将折线图放入自定义用户控件中,以及如何在遵循 MVVM 风格的 WPF 应用程序中使用此类控件。我们将尝试使图表控件成为一流的 WPF 公民,并使其在 XAML 中可用。这意味着我们需要为图表控件定义依赖项属性和路由事件,以便获得对基本 WPF 服务的支持,例如数据绑定、样式和动画。
基于我们之前开发的折线图,创建折线图控件很容易。图表用户控件的开发模型与 WPF 中应用程序开发使用的模型非常相似。
右键单击 WpfChart 解决方案,然后选择“添加 | 新建项目…”以打开“添加新项目”对话框。您需要从模板中选择一个新的 WPF 用户控件库,并将其命名为 ChartControl。完成此操作后,Visual Studio 2013 将创建一个 XAML 标记文件和一个相应的自定义类来保存您的初始化和事件处理代码。这将生成一个名为 UserControl1 的默认用户控件。通过在解决方案资源管理器中右键单击 UserControl1.xaml 并选择“重命名”,将其重命名为 LineChart。您还需要在 XAML 文件和控件的代码隐藏文件中将名称 UserControl1 更改为 LineChart。向控件添加一个新文件夹并将其命名为 ChartModel。将 WpfChart 项目的 ChartModel 文件夹中的两个现有类添加到当前项目的 ChartModel 文件夹中,并将当前项目的 ChartModel 文件夹中所有这些类的命名空间更改为 ChartControl。为避免混淆,您还需要将这两个类的名称更改为 LineSeriesControl 和 ChartStyleControl。
<UserControl x:Class="ChartControl.LineChart"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignHeight="300" d:DesignWidth="300">
<Grid Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Margin="2" x:Name="tbTitle" Grid.Column="1" Grid.Row="0"
RenderTransformOrigin="0.5,0.5" FontSize="14" FontWeight="Bold"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
TextAlignment="Center" Text="Title"/>
<TextBlock Margin="2" x:Name="tbXLabel" Grid.Column="1" Grid.Row="2"
RenderTransformOrigin="0.5,0.5" TextAlignment="Center" Text="X Axis"/>
<TextBlock Margin="2" Name="tbYLabel" Grid.Column="0" Grid.Row="1"
RenderTransformOrigin="0.5,0.5" TextAlignment="Center" Text="Y Axis">
<TextBlock.LayoutTransform>
<RotateTransform Angle="-90"/>
</TextBlock.LayoutTransform>
</TextBlock>
<Grid Margin="0,0,0,0" x:Name ="chartGrid" Grid.Column="1" Grid.Row="1"
ClipToBounds="False" Background="Transparent" SizeChanged="chartGrid_SizeChanged">
<Canvas Margin="2" Name="textCanvas" Grid.Column="1" Grid.Row="1" ClipToBounds="True"
Width="{Binding ElementName=chartGrid,Path=ActualWidth}"
Height="{Binding ElementName=chartGrid,Path=ActualHeight}">
<Canvas Name="chartCanvas" ClipToBounds="True"/>
</Canvas>
</Grid>
</Grid>
</UserControl>
您可能会注意到,我们将使用代码隐藏代码来实现图表控件,因为我们不使用 Caliburn.Micro 的 Message.Attach 方法来处理事件,例如 SizeChanged 事件。实际上,在创建用户控件库时我们有两种选择。如果我们将设计的图表控件仅在当前应用程序中使用,那么我们可以使用 MVVM 方法创建用户控件。在这种情况下,我们需要为控件创建单独的视图模型,并在使用该控件的父应用程序中创建其实例。因此,父视图中将包含此控件,并将控件的视图模型绑定到控件(通过 ParentVM.UserControlVM),而我们的用户控件将处理其他绑定。
另一方面,如果我们的控件将被其他应用程序或开发人员使用,那么我们需要根据基于依赖属性的控件模板实现来创建我们的用户控件。在这里,我应该指出一个重要的点,无论我们是决定使用 MVVM 还是代码隐藏的依赖属性来开发我们的图表控件,它都不会破坏我们用户控件使用者的 MVVM 规则。
在这里,我将向您展示如何基于代码隐藏中实现的依赖属性创建图表控件。当源对象是 WPF 元素且源属性是依赖属性时,依赖属性提供了一种简单的数据绑定方式。这是因为依赖属性内置了对属性更改通知的支持。结果是,更改源对象中依赖属性的值会立即更新目标对象中绑定的属性。这正是我们想要的——而且无需我们构建任何额外的基础设施,例如 INotifyPropertyChanged 接口。
定义依赖属性
接下来,我们将实现折线图控件向外界公开的公共接口。换句话说,是时候创建控件使用者(使用该控件的应用程序)将依赖的属性、方法和事件,以便与我们的图表控件进行交互。
我们可能希望将 ChartStyleControl 类中的大多数属性(例如轴限制、标题和标签)暴露给外界;同时,我们将尽量对原始折线图示例项目进行最少的更改。
创建依赖属性的第一步是为其定义一个静态字段,并在属性名称末尾添加单词 Property。将以下代码添加到代码隐藏文件中
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Shapes;
using Caliburn.Micro;
using System.Collections.Specialized;
namespace ChartControl
{
public partial class LineChart : UserControl
{
private ChartStyleControl cs;
public LineChart()
{
InitializeComponent();
this.cs = new ChartStyleControl();
this.cs.TextCanvas = textCanvas;
this.cs.ChartCanvas = chartCanvas;
}
private void chartGrid_SizeChanged(object sender, SizeChangedEventArgs e)
{
ResizeLineChart();
}
private void SetLineChart()
{
cs.Xmin = this.Xmin;
cs.Xmax = this.Xmax;
cs.Ymin = this.Ymin;
cs.Ymax = this.Ymax;
cs.XTick = this.XTick;
cs.YTick = this.YTick;
cs.XLabel = this.XLabel;
cs.YLabel = this.YLabel;
cs.Title = this.Title;
cs.IsXGrid = this.IsXGrid;
cs.IsYGrid = this.IsYGrid;
cs.GridlineColor = this.GridlineColor;
cs.GridlinePattern = this.GridlinePattern;
ResizeLineChart();
}
private void ResizeLineChart()
{
chartCanvas.Children.Clear();
textCanvas.Children.RemoveRange(1, textCanvas.Children.Count - 1);
cs.AddChartStyle(tbTitle, tbXLabel, tbYLabel);
if (DataCollection != null)
{
if (DataCollection.Count > 0)
{
cs.SetLines(DataCollection);
}
}
}
public static DependencyProperty XminProperty =
DependencyProperty.Register("Xmin", typeof(double), typeof(LineChart),
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public double Xmin
{
get { return (double)GetValue(XminProperty); }
set { SetValue(XminProperty, value); }
}
......
......
public static readonly DependencyProperty DataCollectionProperty =
DependencyProperty.Register("DataCollection",
typeof(BindableCollection<LineSeriesControl>), typeof(LineChart),
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnDataChanged));
public BindableCollection<LineSeriesControl> DataCollection
{
get { return (BindableCollection<LineSeriesControl>)GetValue(DataCollectionProperty); }
set { SetValue(DataCollectionProperty, value); }
}
private static void OnDataChanged(object sender, DependencyPropertyChangedEventArgs e)
{
var lc = sender as LineChart;
var dc = e.NewValue as BindableCollection<LineSeriesControl>;
if (dc != null)
dc.CollectionChanged += lc.dc_CollectionChanged;
}
private void dc_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
if (DataCollection != null)
{
CheckCount = 0;
if (DataCollection.Count > 0)
CheckCount = DataCollection.Count;
}
}
public static DependencyProperty CheckCountProperty =
DependencyProperty.Register("CheckCount", typeof(int), typeof(LineChart),
new FrameworkPropertyMetadata(0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
new PropertyChangedCallback(OnStartChart)));
public int CheckCount
{
get { return (int)GetValue(CheckCountProperty); }
set { SetValue(CheckCountProperty, value); }
}
private static void OnStartChart(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
(sender as LineChart).SetLineChart();
}
}
}
这里我只想向您展示如何创建依赖属性,并省略了大部分重复的依赖属性定义代码。您可以从附加的项目程序中查看完整的代码列表。注意我们如何为 Xmin 定义依赖属性。WPF 提供了一种全新的技术来为控件定义属性。新属性系统的核心是依赖属性和称为 DepedencyObject 的包装类。在这里,我们使用包装类将 Xmin 依赖属性注册到属性系统中,以确保对象中包含该属性,并且我们可以轻松地获取或设置该属性的值。通常,属性包装器不应包含任何逻辑,因为属性可以直接使用基 DependencyObject 类的 SetValue 和 GetValue 方法进行设置和检索。
然而,在某些情况下,我们可能希望在设置依赖属性的值后执行一些逻辑和计算方法。我们可以通过实现一个回调方法来完成这些任务,该方法在属性通过属性包装器或直接 SetValue 调用更改时触发。例如,在创建包含 LineSeriesControl 对象的 DataCollection 后,我们希望图表控件自动为这些 LineSeriesControl 对象创建相应的折线图。前面代码隐藏文件中加粗的代码显示了如何实现这样的回调方法。DataCollectionProperty 包含一个名为 OnDataChanged 的回调方法。在此回调方法内部,我们将一个事件处理程序添加到 CollectionChanged 属性中,当 DataCollection 更改时它将触发。在 CollectionChanged 处理程序中,我们将另一个名为 CheckCount 的私有依赖属性设置为 DataCollection.Count。如果 CheckCount > 0,我们知道 DataCollection 确实包含 LineSeries 对象,然后我们为 CheckCount 属性实现另一个名为 OnStartChart 的回调方法,通过调用 SetLineChart 方法创建折线图。
请注意,在 SetLineChart 方法内部,我们将 ChartStyleControl 类中的公共属性设置为图表控件的相应依赖属性。这样,每当 LineChart 类中的依赖属性更改时,ChartStyle 和 Legend 类的属性也将相应更改。
在这里,我们还为折线图控件添加了一个 chartGrid_SizeChanged 事件处理程序。此处理程序确保每当图表控件调整大小时,图表都会更新。现在我们可以通过右键单击 ChartControl 项目并选择“生成”来生成控件库。
使用图表控件
现在我们已经创建了折线图控件,我们可以轻松地在 WpfChart 项目中使用它。要在 WPF 应用程序中使用该控件,我们需要将 .NET 命名空间和程序集映射到 XML 命名空间,如下所示
xmlns:local="clr-namespace:ChartControl;assembly=ChartControl"
如果图表控件与我们的应用程序位于同一程序集中,我们只需要映射命名空间
xmlns:local="clr-namespace:ChartControl
使用 XML 命名空间和用户控件类名,您可以像在 XAML 文件中添加任何其他类型的对象一样添加用户控件。
创建一个简单的折线图
现在我将展示如何使用图表控件在 WPF 中创建折线图。在 WpfChart 项目中,右键单击“引用”并选择“添加引用…”以打开“引用管理器”窗口。单击此窗口左侧窗格中的“解决方案”,然后单击“项目”,并突出显示 ChartControl。单击“确定”将 ChartControl 添加到当前项目。这样,您就可以像使用内置元素一样在 WPF 应用程序中使用该控件。向 WpfChart 项目添加一个新的 UserControl 并将其命名为 ChartControlView。以下是此视图的 XAML 文件
<UserControl x:Class="WpfChart.ChartControlView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:ChartControl;assembly=ChartControl"
mc:Ignorable="d"
d:DesignHeight="300" d:DesignWidth="500">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="150"/>
</Grid.ColumnDefinitions>
<Button x:Name="AddChart" Content="Add Chart" Width="100" Height="25" Grid.Column="1"/>
<local:LineChart DataCollection="{Binding DataCollection}"
Xmin="0" Xmax="7" XTick="1" Ymin="-1.5" Ymax="1.5" YTick="0.5" XLabel="X" YLabel="Y"
Title="My Chart" GridlinePattern="Dot" GridlineColor="Green"/>
</Grid>
</UserControl>
在这里,您只需像创建任何其他 WPF 元素一样创建一个折线图控件。请注意我们如何指定 GridlinePattern 属性——只需使用 LinePatternEnum 中定义的 Solid、Dash、Dot 或 DashDot。这比使用代码隐藏简单得多,在代码隐藏中,您需要键入完整的路径才能定义网格线的线条模式。您还可以为图表控件指定 WPF 元素的其他标准属性,例如 Width、Height、CanvasLeft、CanvasTop 和 BackGround。这些标准属性允许您定位控件、设置控件的大小或设置控件的背景颜色。
现在,向项目添加一个新类并将其命名为 ChartControlViewModel。这是此类的代码
using System;
using Caliburn.Micro;
using System.Collections.ObjectModel;
using System.ComponentModel.Composition;
using System.Windows;
using System.Windows.Media;
using ChartControl;
namespace WpfChart
{
[Export(typeof(IScreen)), PartCreationPolicy(CreationPolicy.NonShared)]
public class ChartControlViewModel : Screen
{
[ImportingConstructor]
public ChartControlViewModel()
{
DisplayName = "03. Chart Control";
DataCollection = new BindableCollection<LineSeriesControl>();
}
public BindableCollection<LineSeriesControl> DataCollection{get;set;}
public void AddChart()
{
DataCollection.Clear();
LineSeriesControl ds = new LineSeriesControl();
ds.LineColor = Brushes.Blue;
ds.LineThickness = 2;
ds.SeriesName = "Sine";
ds.LinePattern = LinePatternEnum.Solid;
for (int i = 0; i < 50; i++)
{
double x = i / 5.0;
double y = Math.Sin(x);
ds.LinePoints.Add(new Point(x, y));
}
DataCollection.Add(ds);
ds = new LineSeriesControl();
ds.LineColor = Brushes.Red;
ds.LineThickness = 2;
ds.SeriesName = "Cosine";
ds.LinePattern = LinePatternEnum.Dash;
for (int i = 0; i < 50; i++)
{
double x = i / 5.0;
double y = Math.Cos(x);
ds.LinePoints.Add(new Point(x, y));
}
DataCollection.Add(ds);
}
}
}
就像我们之前创建折线图时所做的那样,我们首先需要创建线条系列,然后将它们添加到图表控件的 DataCollection 中。使用图表控件的优点在于,即使我们使用依赖属性和代码隐藏方法创建图表控件,我们仍然可以在应用程序中使用标准 MVVM 模式。在这里,我们只需在视图模型中定义 DataCollection 属性(我们不需要为该集合实现 INotifyCollectionChanged 接口,因为它具有该接口的内置实现),并将其绑定到图表控件。视图模型中的 AddChart 方法也通过 Caliburn.Micro 的命名约定绑定到视图中的 Button。这样,我们实现了视图和视图模型之间的完美分离,满足了 MVVM 模式的要求。
运行此示例并单击“添加图表”按钮会生成图 4 所示的结果。您可以调整图表大小以查看图表如何自动更新。
图 4. 使用图表控件创建的折线图。
创建多个折线图
使用折线图控件,您可以轻松地在一个 WPF 窗口中创建多个图表。让我们向项目添加一个新的 UserControl 并将其命名为 MultiChartView。创建一个 2x2 的 Grid 控件,并向 Grid 的四个单元格中的每一个添加一个图表控件,这可以使用以下 XAML 文件完成
<UserControl x:Class="WpfChart.MultiChartView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:cal="http://www.caliburnproject.org"
xmlns:local="clr-namespace:ChartControl;assembly=ChartControl"
mc:Ignorable="d"
d:DesignHeight="600" d:DesignWidth="600">
<Grid cal:Message.Attach="[Event Loaded]=[Action AddChart]">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<local:LineChart Width="{Binding ElementName=chartGrid,Path=ActualWidth}"
Height="{Binding ElementName=chartGrid,Path=ActualHeight}"
DataCollection="{Binding DataCollection}"
Xmin="0" Xmax="7" XTick="1" Ymin="-1.5" Ymax="1.5"
YTick="0.5" XLabel="X" YLabel="Y"
Title="Chart1" GridlinePattern="Dot" GridlineColor="Black"
Grid.Column="0" Grid.Row="0"/>
<local:LineChart Width="{Binding ElementName=chartGrid,Path=ActualWidth}"
Height="{Binding ElementName=chartGrid,Path=ActualHeight}"
DataCollection="{Binding DataCollection}"
Xmin="0" Xmax="7" XTick="1" Ymin="-1.5" Ymax="1.5"
YTick="0.5" XLabel="X" YLabel="Y"
Title="Chart2" GridlinePattern="Dot" GridlineColor="Red"
Grid.Column="1" Grid.Row="0"/>
<local:LineChart Width="{Binding ElementName=chartGrid,Path=ActualWidth}"
Height="{Binding ElementName=chartGrid,Path=ActualHeight}"
DataCollection="{Binding DataCollection}"
Xmin="0" Xmax="7" XTick="1" Ymin="-1.5" Ymax="1.5"
YTick="0.5" XLabel="X" YLabel="Y"
Title="Chart3" GridlinePattern="Dot" GridlineColor="Green"
Grid.Column="0" Grid.Row="1"/>
<local:LineChart Width="{Binding ElementName=chartGrid,Path=ActualWidth}"
Height="{Binding ElementName=chartGrid,Path=ActualHeight}"
DataCollection="{Binding DataCollection}"
Xmin="0" Xmax="7" XTick="1" Ymin="-1.5" Ymax="1.5"
YTick="0.5" XLabel="X" YLabel="Y"
Title="Chart4" GridlinePattern="Dot" GridlineColor="Blue"
Grid.Column="1" Grid.Row="1"/>
</Grid>
</UserControl>
在这里,我们创建了四个折线图控件,Chart1、Chart2、Chart3 和 Chart4。为简单起见,在此示例中,我们在每个图表上绘制相同的数据函数。但是,我们为每个图表设置了不同的 GridlineColor 属性。在实践中,我们可以根据应用程序的需求在每个图表上绘制不同的数学函数。
向 ViewModels 文件夹添加一个新类并将其命名为 MultiChartViewModel。以下是此类的代码
using System;
using Caliburn.Micro;
using System.Collections.ObjectModel;
using System.ComponentModel.Composition;
using System.Windows;
using System.Windows.Media;
using System.Windows.Controls;
using ChartControl;
namespace WpfChart
{
[Export(typeof(IScreen)), PartCreationPolicy(CreationPolicy.NonShared)]
public class MultiChartViewModel : Screen
{
private readonly IEventAggregator _events;
[ImportingConstructor]
public MultiChartViewModel()
{
this._events = events;
DisplayName = "02. Multiple Charts";
DataCollection = new BindableCollection<LineSeriesControl>();
}
public BindableCollection<LineSeriesControl> DataCollection { get; set; }
public void AddChart()
{
DataCollection.Clear();
LineSeriesControl ds = new LineSeriesControl();
ds.LineColor = Brushes.Blue;
ds.LineThickness = 2;
ds.SeriesName = "Sine";
ds.LinePattern = LinePatternEnum.Solid;
for (int i = 0; i < 50; i++)
{
double x = i / 5.0;
double y = Math.Sin(x);
ds.LinePoints.Add(new Point(x, y));
}
DataCollection.Add(ds);
ds = new LineSeriesControl();
ds.LineColor = Brushes.Red;
ds.LineThickness = 2;
ds.SeriesName = "Cosine";
ds.LinePattern = LinePatternEnum.Dash;
for (int i = 0; i < 50; i++)
{
double x = i / 5.0;
double y = Math.Cos(x);
ds.LinePoints.Add(new Point(x, y));
}
DataCollection.Add(ds);
}
}
}
此视图模型几乎与您在前面的示例中创建单个折线图时使用的视图模型相同。在这里,四个图表控件绑定到同一个 DataCollection 对象。与任何其他 WPF 内置元素一样,您可以在单个 WPF 应用程序中放置任意数量的折线图控件。
图 5 显示了运行此示例的结果。
图 5. 使用图表控件创建的多个图表。
结论
在这里,我详细介绍了如何在 WPF 应用程序中创建可与 MVVM 模式配合使用的折线图控件。您可能会注意到此控件缺少一些功能,例如图例和符号标记。如果您对创建功能齐全的 2D 和 3D 图表控件感兴趣,请访问我的网站 www.DrXuDotNet.com 获取更多信息。
自我的书籍《实用 C# 图表和图形》(2007)、《实用 WPF 图形编程》(2007) 和《实用 WPF 图表和图形》(2009) 出版以来,我收到了许多读者的反馈。他们一直在要求更新版本。我意识到在过去的几年中 .NET 技术取得了长足进步和变化,是时候将这些 .NET 框架中的新发展融入我的书中了。在这本新书《实用 .NET 图表开发和应用》(即将出版)中,我用 .NET 4.5 和 Visual Studio 2013 重写了大部分示例程序,以反映 .NET 的进步以及我在过去几年中作为量化开发人员/分析师所获得的新编程经验。本书的主要新功能包括
- 数据绑定:在 Windows Forms 应用程序中,数据绑定主要用于使用信息填充应用程序中的元素。数据绑定的优点是您可以在编写很少或不编写代码的情况下填充界面。使用 WPF 中的数据绑定,您可以从几乎任何对象的任何属性中获取数据,并将其绑定到另一个对象的几乎任何其他依赖属性。在本书中,我将尽量使用数据绑定来实现代码示例。
- 数据库和 ADO.NET 实体框架。在过去的几年里,我作为量化分析师/开发人员在华尔街的一家金融公司工作。我每天处理的最重要的事情就是市场数据。不同领域的大多数 .NET 应用程序也需要与存储在数据库中的数据进行交互。因此,本书包含一章,专门介绍数据库和 ADO.NET 实体框架。它向您展示了如何创建一个简单的数据库以及如何使用实体数据模型访问数据库数据。
- MVVM 模式:在传统的 UI 开发中,您使用窗口或用户控件创建视图,然后将所有逻辑代码写入代码隐藏中。这种方法在 UI 和数据逻辑之间创建了强依赖关系,难以维护和测试。另一方面,在 MVVM 中,粘合代码是视图模型。如果视图模型中的属性值发生变化,这些新值将通过数据绑定和通知自动传播到视图。在本书中,我将介绍 MVVM 模式并尝试使用视图模型进行数据绑定。
- 强大的图表控件:在这本新书中,我将 2D 折线图、股票图表和 3D 图表转换为强大的图表控件,您可以轻松地在您的 .NET 应用程序中重用。特别是,这些图表控件与 MVVM 兼容,并允许您基于 MVVM 模式开发带有 2D 和 3D 图表的 .NET 应用程序。
- 金融市场:这本新书结合了更多金融市场的主题和示例,主要来自我的工作经验,包括与市场数据的交互、移动平均线计算、线性回归、用于配对交易的主成分分析(PCA)、从股票图表中检索市场数据以及实现可重用的股票图表控件。
- 实时图表:许多领域都需要实时图表功能。例如,如果您设计股票交易系统,您需要开发一个实时数据馈送器和一个图表控件来在屏幕上显示实时股票市场数据。这本新书提供了一些示例,展示了如何使用我们可重用的图表控件创建此类实时图表应用程序。
请访问我的网站 www.DrxuDotNet.com 获取更多信息。