支持数据绑定的 WPF 饼图






4.96/5 (83投票s)
本文描述了一个使用数据绑定的交互式饼图的开发过程。
目录
引言
Windows Presentation Foundation 为开发人员提供了一套用于开发视觉丰富且交互式用户界面的工具集。图表是向用户呈现数据的一种非常有效的方式,其中饼图提供了一种比较不同项目相对数量的简单机制。WPF 没有提供图表库,也没有可以作为扩展购买的“官方”库。因此,互联网上有许多文章提供了简单的图表控件。
对于饼图,CodeProject 有一个3D 饼图库,其他地方你可以找到一个Silverlight 饼图。然而,这些控件都没有充分利用数据绑定。在每种情况下,数据都以饼图数据对象的数组形式(以编程方式或通过 XAML)提供给饼图控件。因此,为了可视化你的数据,你必须将你的数据对象的相关属性复制到“饼图数据对象”中。在审查这些库以供项目使用时,我注意到它们的工作方式反映了 UI 框架在绑定之前的运作方式;也就是说,控件有一个模型在其背后,而你的工作是作为开发人员将你的数据复制到这个模型中,以便控件(视图)能够渲染它。当然,你还需要确保“真实”数据中的更改已复制到支持视图的模型中,从而使两者保持同步。有了数据绑定,在不兼容的模型之间转移数据应该成为过去式了!
本文描述了一个用于 WPF 的饼图用户控件的开发过程,该控件使用数据绑定。饼图不依赖于自己的模型,而是直接绑定到你的数据对象。这有一个很大的优点,即 WPF 框架负责处理与绑定数据更改相关的事件,并相应地更新饼图视图。在此过程中,还将涵盖其他一些感兴趣的领域
- 工具提示和数据绑定的特 peculiarities
- 使用
FrameworkElement.Tag
作为传递数据的机制 - 自定义形状的开发
- 依赖属性继承
与本文相关的代码并非旨在成为一个完整、功能齐全的饼图库。任何过去尝试开发图表库的人都知道这是一项巨大的工程!相反,它旨在为任何希望向项目中添加自定义图表的人提供一个有用的起点。
第一步:我们的第一个扇区
WPF 图形 API 的灵活性使得创建饼图扇区成为一项相对简单的任务,饼图扇区不过是一个由几个LineSegment
和一个ArcSegment
组成的Path
。可以通过以编程方式向我们的控件添加合适的路径来渲染饼图;然而,这种方法有点缺乏灵活性。例如,WPF 中的动画依赖于依赖属性的存在。因此,如果我们的对象的属性没有公开为依赖属性,它就不能被动画化。理想情况下,我们希望能够动画化这些饼图扇区,或许可以平滑地增加它们的扇区大小,或者围绕饼图中心旋转它们。
幸运的是,创建我们自己的自定义形状是一项简单的任务,Tomer Shamam 的文章在 CodeProject 上给出了一个很好的介绍。下面的代码片段展示了我们饼图扇区的几何形状是如何定义的
private void DrawGeometry(StreamGeometryContext context)
{
Point startPoint = new Point(CentreX, CentreY);
Point innerArcStartPoint =
Utils.ComputeCartesianCoordinate(RotationAngle, InnerRadius);
innerArcStartPoint.Offset(CentreX, CentreY);
Point innerArcEndPoint =
Utils.ComputeCartesianCoordinate(RotationAngle + WedgeAngle, InnerRadius);
innerArcEndPoint.Offset(CentreX, CentreY);
Point outerArcStartPoint =
Utils.ComputeCartesianCoordinate(RotationAngle, Radius);
outerArcStartPoint.Offset(CentreX, CentreY);
Point outerArcEndPoint =
Utils.ComputeCartesianCoordinate(RotationAngle + WedgeAngle, Radius);
outerArcEndPoint.Offset(CentreX, CentreY);
bool largeArc = WedgeAngle>180.0;
Size outerArcSize = new Size(Radius, Radius);
Size innerArcSize = new Size(InnerRadius, InnerRadius);
context.BeginFigure(innerArcStartPoint, true, true);
context.LineTo(outerArcStartPoint, true, true);
context.ArcTo(outerArcEndPoint, outerArcSize, 0, largeArc,
SweepDirection.Clockwise, true, true);
context.LineTo(innerArcEndPoint, true, true);
context.ArcTo(innerArcStartPoint, innerArcSize, 0, largeArc,
SweepDirection.Counterclockwise, true, true);
}
ComputeCartesianCoordinate
方法是一个静态的实用方法,用于在极坐标和笛卡尔坐标之间进行转换。变量CentreX
、CentreY
、Radius
等都是依赖属性。这几乎是定义自定义形状所需要的一切。WPF 的ArcSegment
和Geometry.ArcTo()
方法的签名可能有点难以理解。幸运的是,Charles Petzold 发表了一篇非常好的文章,描述了ArcSegment
的数学原理,其中包含许多图示示例。
下面的示例展示了饼图扇区在 XAML 中定义的形状;然而,它们当然也可以在代码隐藏中以编程方式添加。
<Window x:Class="WPFPieChart.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:b="clr-namespace:ScottLogic.Shapes"
Title="Pie Pieces" Height="200" Width="200">
<Grid>
<b:PiePiece CentreX="50" CentreY="80" RotationAngle="45" WedgeAngle="45"
Radius="80" InnerRadius="20" Fill="Beige" Stroke="Black"/>
<b:PiePiece CentreX="50" CentreY="80" RotationAngle="95" WedgeAngle="15"
Radius="90" InnerRadius="40" Fill="Chocolate" Stroke="Black"/>
<b:PiePiece CentreX="30" CentreY="70" RotationAngle="125" WedgeAngle="40"
Radius="80" InnerRadius="0" Fill="DodgerBlue" Stroke="Black"/>
</Grid>
</Window>
第二步:制作一个饼图
一旦我们有了饼图扇区形状,接下来的任务就是在一个用户控件中将这些扇区组装成一个饼图。我考虑过使用ItemsControl
或其子类作为该控件的基础,为其提供合适的控件布局和项目模板。ItemsControl
已被证明非常灵活,其中最著名的例子之一是 Beatriz Costa 的太阳系样式。然而,在尝试引入极坐标变换时,这种方法遇到了死胡同。不幸的是,由于存在内部方法,无法对抽象的Transform
基类进行子类化。如果有人能证明如何修改ItemsControl
,使其包含的项目围绕一个圆渲染以生成饼图,我很乐意听到他们的意见!
在我的解决方案中,饼图扇区被组装成一个用户控件PiePlotter
中的饼图。因为这个控件使用数据绑定,我们可以直接使用DataContext
属性来查找正在绘制的数据。饼图不像网格(ListView
)那样,只能绘制我们数据的一个属性。例如,我们可能想将股票投资组合的属性绘制成饼图。我们投资组合中的项目(即,条目)可能包含数量、市值、价值等属性。使用饼图,我们一次只能绘制这些属性中的一个。出于这个原因,PiePlotter
有一个依赖属性PlottedProperty
,用于指示应显示哪个数据属性。这允许我们从每个项目中提取与此属性相关的数据,如下所示
private double GetPlottedPropertyValue(object item)
{
PropertyDescriptorCollection filterPropDesc = TypeDescriptor.GetProperties(item);
object itemValue = filterPropDesc[PlottedProperty].GetValue(item);
return (double)itemValue;
}
为了将数据渲染为饼图,我们首先从DataContext
获取CollectionView
。下一步是汇总绘制属性的值,以便我们可以将它们缩放到 360° 的饼图中。最后,我们遍历集合,确定代表每个饼图扇区的角度,并在顺时针方向组装饼图时累积所有饼图扇区的总和。
private void ConstructPiePieces()
{
CollectionView myCollectionView = (CollectionView)
CollectionViewSource.GetDefaultView(this.DataContext);
if (myCollectionView == null)
return;
double halfWidth = this.Width / 2;
double innerRadius = halfWidth * HoleSize;
// compute the total for the property which is being plotted
double total = 0;
foreach (Object item in myCollectionView)
{
total += GetPlottedPropertyValue(item);
}
// add the pie pieces
canvas.Children.Clear();
double accumulativeAngle=0;
foreach (Object item in myCollectionView)
{
double wedgeAngle = GetPlottedPropertyValue(item) * 360 / total;
PiePiece piece = new PiePiece()
{
Radius = halfWidth,
InnerRadius = innerRadius,
CentreX = halfWidth,
CentreY = halfWidth,
WedgeAngle = wedgeAngle,
RotationAngle = accumulativeAngle,
Fill = Brushes.Green
};
canvas.Children.Insert(0, piece);
accumulativeAngle += wedgeAngle;
}
}
现在可以将PiePlotter
控件放入具有适当DataContext
的窗口中,并将PlottedProperty
指示要显示的数据属性,如下所示
<Window x:Class="WPFPieChart.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:c="clr-namespace:ScottLogic.Controls.PieChart"
Title="Pie Chart Databinding" Height="300" Width="300">
<Grid>
<c:PiePlotter PlottedProperty="Benchmark" Width="250" Height="250"/>
</Grid>
</Window>
第三步:绑定饼图
上面显示的 P 代码提供了一种直接的方法来将数据的一个属性渲染为饼图。但是,如果绑定数据发生更改,无论是由于项目中某个属性值的更改,还是由于向集合中添加/删除新项目,饼图都不会更新。
这是两个独立的问题:绑定项的更改和绑定集合的更改。我们将逐一考虑。
绑定对象必须实现INotifyPropertyChanged
接口,以通知视图已发生更改,并且应在 UI 中反映出来。因为我们的控件绑定到一个集合,所以我们必须遍历集合,为每个绑定项添加一个事件监听器。这在FrameworkElement.DataContextChanged
事件的处理程序中完成,如下所示
void DataContextChangedHandler(object sender,
DependencyPropertyChangedEventArgs e)
{
CollectionView myCollectionView = (CollectionView)
CollectionViewSource.GetDefaultView(this.DataContext);
foreach (object item in myCollectionView)
{
if (item is INotifyPropertyChanged)
{
INotifyPropertyChanged observable = (INotifyPropertyChanged)item;
observable.PropertyChanged +=
new PropertyChangedEventHandler(ItemPropertyChanged);
}
}
}
private void ItemPropertyChanged(object sender, PropertyChangedEventArgs e)
{
// if the property which this pie chart
// represents has changed, re-construct the pie
if (e.PropertyName.Equals(PlottedProperty))
{
ConstructPiePieces();
}
}
每当更改与此饼图的PlottedProperty
匹配的属性时,都会重新构造图表。请注意,这可以稍作改进。与其销毁所有饼图扇区并从头开始重新构造图表,不如相应地调整现有饼图扇区的依赖属性,甚至可能使用动画。
上面的代码处理了绑定项的属性值更改,但当项被添加到绑定集合或从绑定集合中移除时会怎样?为了支持这一点,WPF 框架还有另一个接口,INotifyCollectionChanged
,它有一个单独的CollectionChanged
事件,当项被添加或删除,或者集合被清空时会引发该事件。为了适应绑定集合中的更改,我们需要像下面这样处理这个事件,处理程序只需重新构造饼图
// handle the events that occur when the bound collection changes
if (this.DataContext is INotifyCollectionChanged)
{
INotifyCollectionChanged observable =
(INotifyCollectionChanged)this.DataContext;
observable.CollectionChanged +=
new NotifyCollectionChangedEventHandler(BoundCollectionChanged);
}
第四步:添加交互性
到目前为止,饼图在功能上是灵活的,但它有点普通,缺乏交互性。一个有用的交互功能是反映绑定集合中当前选定的项,并用动画将当前选定的扇区拉出。
这可以通过处理CollectionView.CurrentChanged
事件,并为相应的饼图扇区设置动画来实现。
void CollectionViewCurrentChanged(object sender, EventArgs e)
{
CollectionView collectionView = (CollectionView)sender;
PiePiece piece = piePieces[collectionView.CurrentPosition];
DoubleAnimation a = new DoubleAnimation();
a.To = 10;
a.Duration = new Duration(TimeSpan.FromMilliseconds(200));
piece.BeginAnimation(PiePiece.PushOutProperty, a);
}
请注意,为了允许从集合视图中的索引导航到饼图扇区,在构造图表期间会填充一个List
“piePieces
”。此外,为了方便从饼图扇区导航回集合中的项,项的索引存储在FrameworkElement
的Tag
属性中。这允许我们为每个饼图扇区添加一个事件处理程序,以便当它被单击时,该项会在绑定集合中被选中
void PiePieceMouseUp(object sender, MouseButtonEventArgs e)
{
CollectionView collectionView = (CollectionView)
CollectionViewSource.GetDefaultView(this.DataContext);
PiePiece piece = sender as PiePiece;
// select the item which this pie piece represents
int index = (int)piece.Tag;
collectionView.MoveCurrentToPosition(index);
}
第五步:工具提示的困惑
工具提示可用于提供进一步的上下文信息。然而,简单地将工具提示添加到每个饼图扇区并不能达到预期的效果。饼图扇区的DataContext
将从PiePlotter
继承,并且是一个集合。绑定到同一DataContext
的ToolTip
将显示与当前选定项相关的信息,而这并不总是与用户鼠标悬停以显示工具提示的扇区相同。为了解决这个问题,需要处理FrameworkElement.ToolTipOpening
事件,允许我们在渲染Tooltip
之前修改DataContext
void PiePieceToolTipOpening(object sender, ToolTipEventArgs e)
{
PiePiece piece = (PiePiece)sender;
CollectionView collectionView = (CollectionView)
CollectionViewSource.GetDefaultView(this.DataContext);
// select the item which this pie piece represents
int index = (int)piece.Tag;
ToolTip tip = (ToolTip)piece.ToolTip;
tip.DataContext = collectionView.GetItemAt(index);
}
同样,饼图扇区的Tag
属性也被有效地利用了。
有了这些,Tooltip
的ContentTemplate
就可以被修改以提供饼图扇区所代表数据的摘要。不幸的是,Tooltip
中的数据绑定不像其他控件那样直接。Tooltip
显示在一个新窗口中;因此,它们不出现在父窗口的逻辑树中,并且由于这个原因,它们不会继承属性。这已经成为许多博客文章的主题,这些文章描述了如何在Tooltip
中执行数据绑定,以及如何让ElementName 绑定
工作。其结果是,你必须手动获取Tooltip
的DataContext
。幸运的是,使用RelativeSource
绑定将这两个DataContext
连接起来,可以很容易地实现这一点。RelativeSource
是一个强大的概念,可以用于许多有趣且令人惊讶的方式,稍后将进一步讨论。下面的Tooltip
DataTemplate
说明了如何通过将TextBlock
的DataContext
设置为Tooltip
的PlacementTarget
(即Tooltip
“附加”到的饼图扇区)来找到饼图扇区所占百分比(这是饼图扇区的一个依赖属性)。然后,TextBlock
的Text
属性将绑定到Percentage
属性,并使用适当的值转换器。
<DataTemplate>
<!-- bind the stack panel datacontext to the tooltip data context -->
<StackPanel Orientation="Horizontal"
DataContext="{Binding Path=DataContext, RelativeSource={RelativeSource
AncestorType={x:Type ToolTip}}}">
<!-- navigate to the pie piece (which is the placement
target of the tooltip) and obtain the percentage -->
<TextBlock FontSize="30" FontWeight="Bold" Margin="0,0,5,0"
DataContext="{Binding Path=PlacementTarget,
RelativeSource={RelativeSource AncestorType={x:Type ToolTip}}}"
Text="{Binding Path=Percentage, Converter={StaticResource
formatter}, ConverterParameter='\{0:0%\}'}"/>
<StackPanel Orientation="Vertical">
<TextBlock FontWeight="Bold" Text="{Binding Path=Class}"/>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Fund"/>
<TextBlock Text=": "/>
<TextBlock Text="{Binding Path=Fund}"/>
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Benchmark"/>
<TextBlock Text=": "/>
<TextBlock Text="{Binding Path=Benchmark}"/>
</StackPanel>
</StackPanel>
</StackPanel>
</DataTemplate>
通过控件模板应用的少量透明度和阴影效果如下
第六步:我是传奇
没有图例指示每个饼图扇区的含义,饼图就没有多大用处
Legend
类是另一个用户控件,即它是一个独立的、可重用的单元,不与PiePlotter
控件紧密耦合。这允许更高的灵活性。饼图的布局(包括饼图本身和图例)可以通过 XAML 控制,从而实现具有不同视觉配置的图表。Legend
和PiePlotter
将共享一些依赖属性,例如PlottedProperty
。将这两个控件包装在另一个定义布局的用户控件中是有意义的
<UserControl x:Class="ScottLogic.Controls.PieChart.PieChartLayout" ...>
<Grid>
<StackPanel Orientation="Horizontal">
<c:PiePlotter Margin="10" Height="200" Width="200" HoleSize="0.3"/>
<c:Legend Margin="10" Height="200" Width="200" />
</StackPanel>
</Grid>
</UserControl>
通用的依赖属性可以在PieChartLayout
控件上定义,该控件可以通过依赖属性继承将其传递给Legend
和PiePlotter
。这里值得注意的是,依赖属性只有在它们是附加属性时才能参与继承。这一点并不显而易见,并且已经给其他人带来了一些困惑。
Legend
控件本身不过是一个带有DataTemplate
的ListBox
,以提供所需的视觉外观。Legend
的标题通过RelativeSource
绑定从PlottedProperty
依赖属性中获取
<TextBlock TextAlignment="Center" Grid.Column="1" FontSize="20" FontWeight="Bold"
Text="{Binding Path=(c:PieChartLayout.PlottedProperty),
RelativeSource={RelativeSource AncestorType={x:Type c:Legend}}}"/>
由于依赖属性是附加属性,因此绑定路径的语法略有不同,这又是引起混淆的另一个领域!
图例列表框的数据模板如下所示
<DataTemplate>
<Grid HorizontalAlignment="Stretch" Margin="3">
<Grid.Background>
<SolidColorBrush Color="#EBEBEB"/>
</Grid.Background>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="20"/>
<ColumnDefinition Width="120"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition/>
</Grid.RowDefinitions>
<Rectangle Grid.Column="0" Width="13"
Height="13" Tag="{Binding}"
Fill="{Binding RelativeSource={RelativeSource Self},
Converter={StaticResource colourConverter}}"/>
<TextBlock Grid.Column="1" Margin="3" Text="{Binding Path=Class}"/>
<TextBlock Grid.Column="2" Margin="3" Tag="{Binding}"
Text="{Binding RelativeSource={RelativeSource Self},
Converter={StaticResource legendConverter}}"/>
</Grid>
</DataTemplate>
矩形的Fill
和第二个TextBlock
的Text
来自一个不寻常但非常规的绑定。TextBlock
显示饼图正在显示的属性的值,该属性由PlottedProperty
定义。换句话说,被绑定到数据对象的属性是可变的。
对于TextBlock
,最好能够将绑定路径值指定为由PlottedProperty
依赖属性(当然是通过RelativeSource
绑定!)派生。然而,这是不可能的,只有依赖属性可以绑定。Binding.Path
是普通 CLR 属性。为了规避这个问题,我使用了一个技巧,该技巧部分受到 Mike Hillberg 关于参数化模板的博客文章的启发,他在其中使用Button
的Tag
属性将图像 URI 传递给数据模板。
为了从项中提取绘制的属性值,我们需要两个信息:首先,PlottedProperty
依赖属性;其次,项本身。值转换器不属于视觉树;因此,我们无法导航到Legend
控件来发现PlottedProperty
的值。这里的技巧是通过类型为Self
的RelativeSource
绑定将TextBlock
传递给值转换器,从而允许值转换器导航视觉树。绑定到ListBoxItem
的项被绑定到Tag
属性。同样,这可以在值转换器中获取。执行此操作的代码如下所示
public class LegendConverter : IValueConverter
{
public object Convert(object value, Type targetType,
object parameter, CultureInfo culture)
{
// the item which we are displaying is bound to the Tag property
TextBlock label = (TextBlock)value;
object item = label.Tag;
// find the item container
DependencyObject container = (DependencyObject)
Helpers.FindElementOfTypeUp((Visual)value, typeof(ListBoxItem));
// locate the items control which it belongs to
ItemsControl owner = ItemsControl.ItemsControlFromItemContainer(container);
// locate the legend
Legend legend = (Legend)Helpers.FindElementOfTypeUp(owner, typeof(Legend));
// extract the ‘plottedproperty’ value from the item
PropertyDescriptorCollection filterPropDesc = TypeDescriptor.GetProperties(item);
object itemValue = filterPropDesc[legend.PlottedProperty].GetValue(item);
return itemValue;
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
Helpers 是由Andrew Whiddett编写的一组视觉树导航实用程序。另外请注意,导航是一个两步过程,首先定位ListBoxItem
父级,然后通过ItemsControlFromItemContainer
定位ItemsControl
。这是因为ItemsControl
中的容器元素不是ItemsControl
的子级。
Josh Smith 在他关于向逻辑树添加“虚拟分支”的文章中描述了一种解决类似问题的方法,解决了在验证规则中获取依赖属性值的问题。
第七步:一抹色彩
为饼图添加颜色是一个有趣的问题。一个简单的解决方案是强制控件绑定到的项具有指定其颜色的属性。然而,强制数据对象公开特定属性正是我们试图避免的。实际上,项目的颜色可以取决于以下两种情况之一:要么是项目本身(也许是从其某个属性派生的),要么是项目在集合中的索引。为此,我们定义了一个带有单个方法的简单接口
public interface IColorSelector
{
Brush SelectBrush(object item, int index);
}
有了IColorSelector
实例,Legend
和PiePlotter
就可以获得正确的Brush
来绘制饼图扇区或与之相关的图例中的颜色面板。一个非常简单的实现,如下所示,它有一个画笔数组,通过循环来选择颜色
public class IndexedColourSelector : DependencyObject, IColorSelector
{
/// <summary>
/// An array of brushes
/// </summary>
public Brush[] Brushes
{... }
public Brush SelectBrush(object item, int index)
{
if (Brushes == null || Brushes.Length == 0)
{
return System.Windows.Media.Brushes.Black;
}
return Brushes[index % Brushes.Length];
}
}
上面实例的实例可以通过 XAML 提供给饼图
<Window >
<Window.Resources>
<x:ArrayExtension Type="{x:Type Brush}" x:Key="brushes">
<SolidColorBrush Color="#9F15C3"/>
<SolidColorBrush Color="#FF8E01"/>
<SolidColorBrush Color="#339933"/>
<SolidColorBrush Color="#00AAFF"/>
<SolidColorBrush Color="#818183"/>
<SolidColorBrush Color="#000033"/>
</x:ArrayExtension>
</Window.Resources>
<Grid>
<c:PieChartLayout PlottedProperty="Fund" Margin="10">
<c:PieChartLayout.ColorSelector>
<c:IndexedColourSelector Brushes="{StaticResource brushes}"/>
</c:PieChartLayout.ColorSelector>
</c:PieChartLayout>
</Grid>
</Window>
结论
本文附带的示例代码显示了下面的饼图。图例、饼图、列表视图和项详细信息都通过数据绑定进行同步。单击列表视图中的列标题将导致饼图绘制数据集的选定属性。
虽然创建支持数据绑定的图表需要更多的努力,但最终结果是一个更具交互性的控件,并且更容易重用。
历史
- 2008/07/25 - 初始文章上传。