WPF 的蜘蛛类型控件树






4.89/5 (66投票s)
WPF 蜘蛛类型控件树。
引言
我最近开始了一份新工作,作为一名 WPF 开发人员。到达后,同事们给了我一个任务,要制作一个酷炫的应用程序,他们非常喜欢 Vertigo 的 FamilyShow 示例的风格。我也很喜欢它,还有 Infragistics 的 Tangerine,它们是我最喜欢的 WPF 演示。
我喜欢这两者的流畅的动画,尤其是 FamilyShow 示例中使用的图表方法。我刚开始工作的那里的同事问我,创建类似 FamilyShow 示例中看到的图表组件有多难。所以,我立即联系了我最喜欢的、在奇怪 WPF 项目方面合作的伙伴——Fredrik Bornander 先生,我喜欢和他一起进行这些更奇特的想法。我们似乎一起做得还不错,至少我是这么认为的。
本文将描述一个我们称之为“SpiderControl”的类似树状的图表控件。
这里有一张截图,仅供参考
本文的其余部分将描述我们是如何构建这个小控件的
它有什么作用
以下是该控件实际功能的列表
- 使用专门的
ScrollViewer
,它允许用户使用鼠标进行启用摩擦的拖动操作(这真的很酷) - 最多只显示树的三层,以保持整洁
- 当前选定的节点会在可用区域内居中显示
- 节点折叠/展开按钮会根据当前节点的子节点数量自动启用
它是如何制作的
现在,我们进入正题,这部分可能才是您最想看的。
所以,首先,让我们快速看一下基本结构。
我将把它分成两个图表,没有其他原因,就是因为我想不出办法让 Word 的 SmartArt 在其标准 SmartArt 图表中添加更多层级,诅咒技术。
从上面的图表可以看出,HostWindow 持有一个 DragViewer
实例。所以窗口的代码很简单,如下所示:
<Window x:Class="SpiderTreeControl.HostWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:diagram="clr-namespace:SpiderTreeControl.Diagram"
WindowStartupLocation="CenterScreen"
Title="HostWindow" Height="400" Width="400">
<Grid>
<diagram:DragViewer x:Name="dragViewer"
Width="auto" Height="auto"
Margin="0"/>
</Grid>
</Window>
然后,我们关注实际的 DragViewer
,我们将 DiagramViewer
包装在 FrictionScrollViewer
中。
FrictionScrollViewer
是一个特殊的 ScrollViewer,它通过摩擦来创建流畅的拖动操作。我曾在我以前的一篇博客文章中对此进行了更详细的介绍,您可以在这里阅读:http://sachabarber.net/?p=225。
<UserControl x:Class="SpiderTreeControl.Diagram.DragViewer"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:diagram="clr-namespace:SpiderTreeControl.Diagram;assembly="
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="auto" Width="auto">
<diagram:FrictionScrollViewer x:Name="sv"
Style="{StaticResource ScrollViewerStyle}">
<diagram:DiagramViewer x:Name="diagramViewer"
Margin="0" Width="2000" Height="2000"/>
</diagram:FrictionScrollViewer>
</UserControl>
滚动条的 Style
是使用位于 AppStyles.xaml ResourceDictionary
中的一些样式实现的。这使得 ScrollViewer 具有如下所示的外观:
但所有这些都是简单的视觉效果,我们需要关注核心内容。
DragViewer 类
回到 DragViewer
,它持有一个 DiagramViewer
实例。在代码隐藏中,DragViewer
负责设置其嵌入的 DiagramViewer
使用的节点集合。
这在代码中如下实现:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Threading;
namespace SpiderTreeControl.Diagram
{
/// <summary>
/// Interaction logic for DragViewer.xaml
/// </summary>
public partial class DragViewer : UserControl
{
public DragViewer()
{
InitializeComponent();
this.Loaded+=delegate
{
LoadDiagramNodes();
};
}
public void LoadDiagramNodes()
{
DiagramNode root = new DiagramNode("Root", null,
"../Images/DiagramRootNode.png", "Dummy1View",
"this is the root node");
DiagramNode a = new DiagramNode("A", root,
"../Images/DiagramNode.png", "Dummy1View",
"this is node A");
DiagramNode b = new DiagramNode("B", root,
"../Images/DiagramNode.png", "Dummy1View", "this is node B");
DiagramNode c = new DiagramNode("C", root,
"../Images/DiagramNode.png", "Dummy1View", "this is node C");
DiagramNode d = new DiagramNode("D", root,
"../Images/DiagramNode.png", "Dummy1View", "this is node D");
DiagramNode e = new DiagramNode("E", root,
"../Images/DiagramNode.png", "Dummy1View", "this is node E");
DiagramNode f = new DiagramNode("F", root,
"../Images/DiagramNode.png", "Dummy1View", "this is node F");
diagramViewer.RootNode = root;
diagramViewer.FrictionScrollViewer = this.sv;
}
}
}
其中创建了单独的 DiagramNode
对象,它们之间的关系是通过将相关的 DiagramNode
作为构造函数参数传递给另一个 DiagramNode
来建立的。显然,对于根 DiagramNode
,此值为 null。最后要做的是设置嵌入的 DiagramViewer
的 RootNode
属性为根 DiagramNode
。还需要设置嵌入的 DiagramViewer
的 FrictionScrollViewer
属性,以便 DiagramViewer
的布局算法可以响应 ScrollViewer
位置的新变化,如果用户移动 ScrollViewer
或拖动图表。
DiagramViewer 类
在这里发生所有包含的 DiagramNode
的布局。这是一个简单的用户控件,包含一个单独的 TreeCanvas
,它包含实际的 DiagramNode
集合,并绘制它们之间的线条,下面将对此进行讨论。DiagramViewer
还监听来自 DiagramNode
的事件,例如 Selected/Collapsed/Expanded,届时它将根据节点选择执行布局。
DiagramViewer
使用一个径向算法来布局子节点集合围绕父节点。这是使用标准的三角函数实现的。基本思想是为每个 DiagramNode
分配一个边界圆,以确保所有节点的大小均匀,然后计算节点之间的角度。
此过程在 NodeExpanded()
中完成,如下所示:
private void NodeExpanded(DiagramNode sender, RoutedEventArgs eventArguments)
{
rootNode.Location = new Point(
(double)GetValue(Canvas.ActualWidthProperty) / 2.0,
(double)GetValue(Canvas.ActualHeightProperty) / 2.0);
MakeChildrenVisible(sender);
if (sender.DiagramParent != null)
{
sender.DiagramParent.Visibility = Visibility.Visible;
foreach (DiagramNode sibling in sender.DiagramParent.DiagramChildren)
{
if (sibling != sender)
sibling.Visibility = Visibility.Collapsed;
}
if (sender.DiagramParent.DiagramParent != null)
sender.DiagramParent.DiagramParent.Visibility = Visibility.Collapsed;
}
if (sender.DiagramChildren.Count > 0)
{
double startAngle = CalculateStartAngle(sender);
double angleBetweenChildren = (sender == rootNode ? Math.PI * 2.0 : Math.PI) /
((double)sender.DiagramChildren.Count - 0);
double legDistance = CalculateLegDistance(sender, angleBetweenChildren);
for (int i = 0; i < sender.DiagramChildren.Count; ++i)
{
DiagramNode child = sender.DiagramChildren[i];
child.Selected += new NodeStateChangedHandler(NodeSelected);
child.Expanded += new NodeStateChangedHandler(NodeExpanded);
child.Collapsed += new NodeStateChangedHandler(NodeCollapsed);
Point parentLocation = sender.Location;
child.Location = new Point(
parentLocation.X + Math.Cos(startAngle +
angleBetweenChildren * (double)i) * legDistance,
parentLocation.Y + Math.Sin(startAngle +
angleBetweenChildren * (double)i) * legDistance);
foreach (DiagramNode childsChild in child.DiagramChildren)
{
childsChild.Visibility = Visibility.Collapsed;
}
}
}
BaseCanvas.InvalidateArrange();
BaseCanvas.UpdateLayout();
BaseCanvas.InvalidateVisual();
}
上述过程还依赖于另一个过程,即确定节点之间连线长度(从一个节点到其子节点的线条长度)的过程。
如下所示:
private static double CalculateLegDistance(DiagramNode sender, double angleBetweenChildren)
{
double legDistance = 1.0;
double childToChildMinDistance = 1.0;
foreach (DiagramNode child in sender.DiagramChildren)
{
legDistance = Math.Max(legDistance,
sender.BoundingCircle + child.BoundingCircle);
foreach (DiagramNode otherChild in sender.DiagramChildren)
{
if (otherChild != child)
{
childToChildMinDistance =
Math.Max(childToChildMinDistance,
child.BoundingCircle + otherChild.BoundingCircle);
}
}
}
legDistance = Math.Max(
legDistance,
(childToChildMinDistance / 2.0) / Math.Sin(angleBetweenChildren / 2.0));
return legDistance;
}
TreeCanvas 类
这是一个特殊的 Canvas
控件,它简单地绘制所有 DiagramNode
之间的线条,这些节点当前显示在 DiagramViewer
中。这是通过覆盖 Canvas
控件的 OnRender()
方法来实现的。如下所示:
protected override void OnRender(System.Windows.Media.DrawingContext dc)
{
base.OnRender(dc);
foreach (UIElement uiElement in Children)
{
if (uiElement is DiagramNode)
{
DiagramNode node = (DiagramNode)uiElement;
if (node.Visibility == Visibility.Visible)
{
if (node.DiagramParent != null &&
node.DiagramParent.Visibility == Visibility.Visible)
{
dc.DrawLine(new Pen(Brushes.Black, 2.0),
node.Location, node.DiagramParent.Location);
}
}
}
}
}
DiagramNode 类
这是一个相当标准的 WPF UserControl,代表 DiagramViewer
中的单个 DiagramNode
。这真的很标准,几个用于展开/折叠和选中状态的按钮。让我们看看它的 XAML,好吗?
<UserControl x:Class="SpiderTreeControl.Diagram."
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:diagram="clr-namespace:SpiderTreeControl.Diagram"
Height="80" Width="90"
Background="Transparent"
BorderBrush="Transparent">
<UserControl.CommandBindings>
<CommandBinding Command="{x:Static diagram:DiagramNode.expandCommand}"
CanExecute="ExpandCommand_CanExecute"
Executed="ExpandCommand_Executed"/>
<CommandBinding Command="{x:Static diagram:DiagramNode.collapseCommand}"
CanExecute="CollapseCommand_CanExecute"
Executed="CollapseCommand_Executed"/>
</UserControl.CommandBindings>
<UserControl.Resources>
<ControlTemplate x:Key="expandCollapseButton" TargetType="{x:Type Button}">
<Grid Width="20" Height="20" Background="Transparent">
<Ellipse Width="20" Height="20" Fill="DarkGray"/>
<Ellipse Width="16" Height="16" Fill="WhiteSmoke"/>
<Label x:Name="lbl" Content="{TemplateBinding Content}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Background="Transparent"
FontFamily="Arial Black" FontSize="10"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="false">
<Setter TargetName="lbl"
Property="Foreground" Value="DarkGray"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="BitmapEffect">
<Setter.Value>
<DropShadowBitmapEffect />
</Setter.Value>
</Setter>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</UserControl.Resources>
<Canvas Background="Transparent"
Width="90" Height="80" >
<Label x:Name="NodeName"
Content="Name" FontFamily="Arial Black"
FontSize="14" Canvas.ZIndex="1"/>
<Button x:Name="btnNavigate"
Template="{StaticResource simpleImageButtonTemplate}"
Click="btnNavigate_Click" Width="60" Height="60"
Canvas.Left="20" Canvas.Top="20"
Canvas.ZIndex="0"/>
<Button x:Name="ExpandButton" Content="+"
Canvas.Top="30" Canvas.Left="0"
Template="{StaticResource expandCollapseButton}"
Command="{x:Static diagram:DiagramNode.expandCommand}" />
<Button x:Name="CollapseButton" Content="-"
Canvas.Top="55" Canvas.Left="0"
Template="{StaticResource expandCollapseButton}"
Command="{x:Static diagram:DiagramNode.collapseCommand}" />
</Canvas>
</UserControl>
每个 DiagramNode
的外观如下:
已知问题
只有一个已知问题,但事实上,这几乎是 WPF 中所有图表解决方案都会遇到的情况,包括 Vertigo 的 FamilyShow 示例,以及我在上一份工作中评估过的许多其他解决方案。基本上,为了动态定位图表节点,您必须使用支持 X/Y 定位的容器面板,这意味着使用 Canvas
。
这没问题,但这意味着您必须确保 Canvas
足够大,能够容纳您的算法所需的最大的布局位置。问题不大,但您需要注意。
为此,您会发现在演示应用程序中,DragViewer
控件内嵌入的 DiagramViewer
的大小固定为 2000 x 2000,声明如下:
<UserControl x:Class="SpiderTreeControl.Diagram.DragViewer"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:diagram="clr-namespace:SpiderTreeControl.Diagram;assembly="
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="auto" Width="auto">
<diagram:FrictionScrollViewer x:Name="sv"
Style="{StaticResource ScrollViewerStyle}">
<diagram:DiagramViewer x:Name="diagramViewer"
Margin="0" Width="2000" Height="2000"/>
</diagram:FrictionScrollViewer>
</UserControl>
除了这个单一问题之外,正如我所说,您可能会在几乎所有 WPF 的图表组件中看到,没有已知的问题。