MVVM 图形设计器






4.98/5 (124投票s)
一个 WPF 图形设计器,采用 MVVM 设计。
目录
简介
前段时间,一位名叫“sucram(真名 Marcus)”的用户在这里发布了一系列关于如何使用 WPF 创建图表设计器的文章。Sucram 的原始链接如下:
- https://codeproject.org.cn/Articles/22952/WPF-Diagram-Designer-Part-1
- https://codeproject.org.cn/Articles/23265/WPF-Diagram-Designer-Part-2
- https://codeproject.org.cn/Articles/23871/WPF-Diagram-Designer-Part-3
- https://codeproject.org.cn/Articles/24681/WPF-Diagram-Designer-Part-4
我记得当时我被这一系列文章深深震撼,因为它们展示了如何做以下事情:
- 工具箱
- 拖放
- 使用装饰器进行橡皮筋选择
- 使用装饰器调整项目大小
- 使用装饰器旋转项目
- 连接项目
- 可滚动设计器表面,带有缩放框
哇,听起来很棒,听起来正像你创建功能齐全的图表设计器所需的那种东西。是的,它曾经是,现在仍然是,但是……问题是,我使用 WPF 很多,并且尝试在 WPF 中使用 sucram 系列文章附带的代码并不是那么好。他采取了一种非常以控件为中心的方法,一切都围绕着添加新控件和为这些控件提供静态样式。
实际上,它更像是使用 WinForms 应用程序。这并没有什么错,我真的不想听起来不感激,因为这离事实很远,如果没有那一系列原始文章,我将需要更长的时间才能想出我满意的可用的图表设计器。所以为此我真的非常感谢,谢谢 sucram,你真棒。
无论如何,正如我所说,sucram 的原始代码库采取了非常以控件为中心的观点,并使用代码隐藏添加了控件,并将项目集合直接保存在图表表面控件中。正如我所说,如果那是你想要的,那就太棒了,但是,那不是我想要的。我想要的是:
- sucram 原始代码的所有功能(实际上我不需要任何项目旋转或项目调整大小的功能)。
- 一种更 MVVM 驱动的方法,你知道,允许项目的数据绑定,通过
ICommand
删除项目等等。 - 允许我从单个 ViewModel 中控制整个图表的创建
- 允许将复杂对象添加到图表中,即我可以使用
DataTemplate
(s) 设置样式的 ViewModel。sucram 的原始代码只允许使用简单的字符串作为DataContext
,它将控制Image
使用哪个ImageSource
来显示图表项。我需要我的项目非常丰富,并允许显示和关联弹出窗口与图表项,以便可以操作与图表项相关的数据。 - 允许我将图表保存到某个后端存储。
- 允许我从某个后端存储加载以前保存的图表。
为此,我几乎完全重写了 sucram 的原始代码,我想可能只有大约两个类保持不变,现在代码更多了,很多,但是从最终用户体验来看,我认为现在从一个集中的 ViewModel 控制图表的创建非常容易,它允许通过众所周知的 WPF 范式(如 Binding
/DataTemplating
)创建图表。
例如,这是随附的 DemoApp 代码如何创建一个简单的图表,该图表在您首次运行 DemoApp 时显示:
public partial class Window1 : Window
{
private Window1ViewModel window1ViewModel;
public Window1()
{
InitializeComponent();
window1ViewModel = new Window1ViewModel();
this.DataContext = window1ViewModel;
this.Loaded += new RoutedEventHandler(Window1_Loaded);
}
/// <summary>
/// This shows you how you can create diagram items in code
/// </summary>
void Window1_Loaded(object sender, RoutedEventArgs e)
{
SettingsDesignerItemViewModel item1 = new SettingsDesignerItemViewModel();
item1.Parent = window1ViewModel.DiagramViewModel;
item1.Left = 100;
item1.Top = 100;
window1ViewModel.DiagramViewModel.Items.Add(item1);
PersistDesignerItemViewModel item2 = new PersistDesignerItemViewModel();
item2.Parent = window1ViewModel.DiagramViewModel;
item2.Left = 300;
item2.Top = 300;
window1ViewModel.DiagramViewModel.Items.Add(item2);
ConnectorViewModel con1 = new ConnectorViewModel(item1.RightConnector, item2.TopConnector);
con1.Parent = window1ViewModel.DiagramViewModel;
window1ViewModel.DiagramViewModel.Items.Add(con1);
}
}
随着文章的进展,我将向您展示如何在您自己的应用程序中使用新的 MVVM 驱动的图表设计器类,如果您愿意,您可以就此打住,但如果您想知道它如何工作,那将在文章的其余部分解释。
外观
这很有趣,因为如果你看下面的截图并将其与 sucram 生成的最终文章进行比较,你可能看不到任何区别,正如我之前所说,这是故意的。我认为 sucram 真的做到了,我只是想要一个更 WPF 风格的代码库,一个支持 Binding
等等。所以,是的,我必须承认,你很容易看这个截图并想“哼……这完全一样”,嗯,是的,视觉上来说我猜是这样,但是代码非常非常不同,而且你使用图表的方式也非常不同。好了,废话不多说,这里是截图。
点击图片查看大图
就是这样,正如我所说,视觉上没有太大变化,哦,管理图表项数据的弹出窗口是一个新想法。我们稍后将讨论我为什么需要这个,以及您如何利用这个机制。
附件代码库结构
随附的演示代码分为四个项目,如下所示:
DemoApp | 这个项目是一个演示项目,是创建您自己的图表设计器的一个很好的例子。它是一个功能齐全的演示,还演示了使用 RavenDB(一个 NoSQL 文档数据库,因为我不想编写大量的 SQL)进行持久化/填充。 |
DemoApp.Persistence.Common | 持久化通用类,由 DemoApp 使用。 |
DemoApp.Persistence.RavenDB | 我决定使用 RavenDB 进行持久化,它是一个 NoSQL 数据库,允许存储原始 C# 对象。我之所以这样做,是因为我实在不想创建**所有**的 SQL 来保存/填充图表,我只是想尽快让它运行起来。 但是,如果您使用 SQL Server/MySQL 等等,应该很容易创建与您首选的 SQL 数据库通信的存储过程/数据访问层。 |
DiagramDesigner | 这个项目包含在 WPF 中创建图表所需的核心类。 |
如何在我的应用程序中使用它
本节将引导您如何在自己的应用程序中创建图表。它假定以下内容:
- 您想使用 WPF 的功能,如绑定/数据模板/MVVM
- 您确实希望将图表持久化/填充到某个后端存储(如我所说,我选择使用 RavenDB,这是一个无 SQL 文档数据库,但如果这不适合您,您应该可以很容易地创建自己的数据访问层,与您首选的 SQL 后端通信)
如果您想创建自己的 MVVM 风格的图表设计器,我已将其分解为七个简单的步骤,只要您严格遵循这七个步骤,您应该会做得很好。还有一份随附的 DemoApp 项目代码的工作示例,因此您可以在阅读本文时查看它,希望您能理解。
使用步骤1:创建原始 XAML
这是我推荐的骨架 XAML,您应该使用它(如果您遵循我对 Main ViewModel 的建议)。如果您遵守此推荐的 XAML/ViewModel,您将获得以下功能:
- 自动工具箱创建
- 新图表按钮
- 保存图表按钮
- 加载图表按钮
- 保存/加载图表时显示的进度条
总之,这是推荐的 XAML
<Window x:Class="DemoApp.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:s="clr-namespace:DiagramDesigner;assembly=DiagramDesigner"
xmlns:local="clr-namespace:DemoApp"
WindowState="Maximized"
SnapsToDevicePixels="True"
Title="Diagram Designer"
Height="850" Width="1100">
<Window.InputBindings>
<KeyBinding Key="Del"
Command="{Binding DeleteSelectedItemsCommand}" />
</Window.InputBindings>
<DockPanel Margin="0">
<ToolBar Height="35" DockPanel.Dock="Top">
<Button ToolTip="New"
Content="New"
Margin="8,0,3,0"
Command="{Binding CreateNewDiagramCommand}"/>
<Button ToolTip="Save"
Content="Save"
Margin="8,0,3,0"
Command="{Binding SaveDiagramCommand}" />
<Label Margin="30,0,3,0"
VerticalAlignment="Center"
Content="Saved Diagrams" />
<ComboBox Margin="8,0,3,0"
Width="200"
ToolTip="Saved Diagrams"
IsSynchronizedWithCurrentItem="True"
ItemsSource="{Binding SavedDiagramsCV}"/>
<Button ToolTip="Load Selected Diagram"
Content="Load"
Margin="8,0,3,0"
Command="{Binding LoadDiagramCommand}" />
<ProgressBar Margin="8,0,3,0"
Visibility="{Binding Path=IsBusy,
Converter={x:Static s:BoolToVisibilityConverter.Instance}}"
IsIndeterminate="True"
Width="150"
Height="20"
VerticalAlignment="Center" />
</ToolBar>
<Grid Margin="0,5,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="230" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<!-- ToolBox Control -->
<local:ToolBoxControl Grid.Column="0"
DataContext="{Binding ToolBoxViewModel}" />
<GridSplitter Grid.Column="1"
HorizontalAlignment="Left"
VerticalAlignment="Stretch"
Background="Transparent"
Width="3" />
<!-- Diagram Control -->
<s:DiagramControl Grid.Column="1"
DataContext="{Binding DiagramViewModel}"
Margin="3,1,0,0" />
</Grid>
</DockPanel>
</Window>
使用步骤2:创建主 ViewModel
我已为您创建了一个演示 ViewModel,我认为它基本展示了您想做的一切,所以如果您遵循这个示例,应该不会出现太大问题。
public class Window1ViewModel : INPCBase
{
private ObservableCollection<int> savedDiagrams = new ObservableCollection<int>();
private List<SelectableDesignerItemViewModelBase> itemsToRemove;
private IMessageBoxService messageBoxService;
private IDatabaseAccessService databaseAccessService;
private DiagramViewModel diagramViewModel = new DiagramViewModel();
private bool isBusy = false;
public Window1ViewModel()
{
messageBoxService = ApplicationServicesProvider.Instance.Provider.MessageBoxService;
databaseAccessService = ApplicationServicesProvider.Instance.Provider.DatabaseAccessService;
foreach (var savedDiagram in databaseAccessService.FetchAllDiagram())
{
savedDiagrams.Add(savedDiagram.Id);
}
ToolBoxViewModel = new ToolBoxViewModel();
DiagramViewModel = new DiagramViewModel();
SavedDiagramsCV = CollectionViewSource.GetDefaultView(savedDiagrams);
DeleteSelectedItemsCommand = new SimpleCommand(ExecuteDeleteSelectedItemsCommand);
CreateNewDiagramCommand = new SimpleCommand(ExecuteCreateNewDiagramCommand);
SaveDiagramCommand = new SimpleCommand(ExecuteSaveDiagramCommand);
LoadDiagramCommand = new SimpleCommand(ExecuteLoadDiagramCommand);
}
public SimpleCommand DeleteSelectedItemsCommand { get; private set; }
public SimpleCommand CreateNewDiagramCommand { get; private set; }
public SimpleCommand SaveDiagramCommand { get; private set; }
public SimpleCommand LoadDiagramCommand { get; private set; }
public ToolBoxViewModel ToolBoxViewModel { get; private set; }
public ICollectionView SavedDiagramsCV { get; private set; }
public DiagramViewModel DiagramViewModel
{
get
{
return diagramViewModel;
}
set
{
if (diagramViewModel != value)
{
diagramViewModel = value;
NotifyChanged("DiagramViewModel");
}
}
}
public bool IsBusy
{
get
{
return isBusy;
}
set
{
if (isBusy != value)
{
isBusy = value;
NotifyChanged("IsBusy");
}
}
}
private void ExecuteDeleteSelectedItemsCommand(object parameter)
{
itemsToRemove = DiagramViewModel.SelectedItems;
List<SelectableDesignerItemViewModelBase> connectionsToAlsoRemove = new List<SelectableDesignerItemViewModelBase>();
foreach (var connector in DiagramViewModel.Items.OfType<ConnectorViewModel>())
{
if (ItemsToDeleteHasConnector(itemsToRemove, connector.SourceConnectorInfo))
{
connectionsToAlsoRemove.Add(connector);
}
if (ItemsToDeleteHasConnector(itemsToRemove, (FullyCreatedConnectorInfo)connector.SinkConnectorInfo))
{
connectionsToAlsoRemove.Add(connector);
}
}
itemsToRemove.AddRange(connectionsToAlsoRemove);
foreach (var selectedItem in itemsToRemove)
{
DiagramViewModel.RemoveItemCommand.Execute(selectedItem);
}
}
private void ExecuteCreateNewDiagramCommand(object parameter)
{
//ensure that itemsToRemove is cleared ready for any new changes within a session
itemsToRemove = new List<SelectableDesignerItemViewModelBase>();
SavedDiagramsCV.MoveCurrentToPosition(-1);
DiagramViewModel.CreateNewDiagramCommand.Execute(null);
}
private void ExecuteSaveDiagramCommand(object parameter)
{
....
....
....
}
private void ExecuteLoadDiagramCommand(object parameter)
{
....
....
....
}
}
这个示例 ViewModel 展示了如何:
- 创建一个可绑定的图表项/连接列表
- 当视图发出删除请求时如何删除
- 如何从数据库保存/填充图表
唯一可能需要更改(如果您使用标准 SQL 数据库)的是持久化方法。我们稍后会详细讨论这些,所以请稍等,我们会讲到。
使用步骤3:创建工具箱项 DataTemplates
一个重要的方面是工具箱的构建方式。那么什么是工具箱呢?我听你问。
工具箱包含您可以添加到图表中的允许项。这可以从下图中清楚地看到(请注意,为简洁起见,我只允许创建两个项)。
它可能看起来很简单,但这个控件**非常**重要,因为它决定了允许添加到图表中的项目**类型**。
这个 ToolBoxControl
看起来像这样:
<UserControl x:Class="DemoApp.ToolBoxControl"
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:s="clr-namespace:DiagramDesigner;assembly=DiagramDesigner"
mc:Ignorable="d"
d:DesignHeight="300" d:DesignWidth="300">
<Border BorderBrush="LightGray"
BorderThickness="1">
<StackPanel>
<Expander Header="Symbols"
IsExpanded="True">
<ItemsControl ItemsSource="{Binding ToolBoxItems}">
<ItemsControl.Template>
<ControlTemplate TargetType="{x:Type ItemsControl}">
<Border BorderThickness="{TemplateBinding Border.BorderThickness}"
Padding="{TemplateBinding Control.Padding}"
BorderBrush="{TemplateBinding Border.BorderBrush}"
Background="{TemplateBinding Panel.Background}"
SnapsToDevicePixels="True">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsPresenter SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}" />
</ScrollViewer>
</Border>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Margin="0,5,0,5"
ItemHeight="50"
ItemWidth="50" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="{x:Type ContentPresenter}">
<Setter Property="Control.Padding"
Value="10" />
<Setter Property="ContentControl.HorizontalContentAlignment"
Value="Stretch" />
<Setter Property="ContentControl.VerticalContentAlignment"
Value="Stretch" />
<Setter Property="ToolTip"
Value="{Binding ToolTip}" />
<Setter Property="s:DragAndDropProps.EnabledForDrag"
Value="True" />
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Image IsHitTestVisible="True"
Stretch="Fill"
Width="50"
Height="50"
Source="{Binding ImageUrl, Converter={x:Static s:ImageUrlConverter.Instance}}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Expander>
</StackPanel>
</Border>
</UserControl>
迄今为止,其中**最**重要的部分是绑定 ItemsControl
到名为 ToolBoxItems
的属性的行,该属性可从演示 ViewModel 中获取;以下是相关代码:
首先,我们从主 DemoApp.Window1ViewModel
暴露一个 ToolBoxViewModel
属性,然后我们深入研究 ToolBoxViewModel
的具体细节,看看 ToolBoxItems
属性如何提供工具箱项。
public class Window1ViewModel : INPCBase
{
ToolBoxViewModel = new ToolBoxViewModel();
public Window1ViewModel()
{
....
....
ToolBoxViewModel = new ToolBoxViewModel();
....
....
}
public ToolBoxViewModel ToolBoxViewModel { get; private set; }
}
public class ToolBoxViewModel
{
private List<ToolBoxData> toolBoxItems = new List<ToolBoxData>();
public ToolBoxViewModel()
{
toolBoxItems.Add(new ToolBoxData("../Images/Setting.png", typeof(SettingsDesignerItemViewModel)));
toolBoxItems.Add(new ToolBoxData("../Images/Persist.png", typeof(PersistDesignerItemViewModel)));
}
public List<ToolBoxData> ToolBoxItems
{
get { return toolBoxItems; }
}
}
ToolBoxViewModel
的重要部分是它存储一个 Image
URL 和一个 Type
。
- 图像 URL 显然用于创建所需工具箱项
Type
的图像。 - 当您将工具箱项拖放到设计表面上时,会使用
Type
,工具箱项Type
将被实例化并显示,这得益于DataTemplate
。Type
应该是派生自DesignerItemViewModelBase
的 ViewModelType
,并且可能应该与您数据库中的某个内容匹配。
使用步骤4:创建图表项 ViewModel
对于随附的演示应用程序,我**只**允许两种不同**类型**的 ViewModel,因此您将看到恰好两个不同的**工具箱**项出现。
toolBoxItems.Add(new ToolBoxData("../Images/Setting.png", typeof(SettingsDesignerItemViewModel)));
toolBoxItems.Add(new ToolBoxData("../Images/Persist.png", typeof(PersistDesignerItemViewModel)));
这两种 ViewModel 类型是:
- 我想在图表设计器上以某种方式表示的类型
- 我想捕获额外信息的类型,这些信息可以持久化到数据库
这是一个这样的 ViewModel(另一个遵循相同的规则):
public class SettingsDesignerItemViewModel : DesignerItemViewModelBase, ISupportDataChanges
{
private IUIVisualizerService visualiserService;
public SettingsDesignerItemViewModel(int id, DiagramViewModel parent,
double left, double top, string setting1)
: base(id, parent, left, top)
{
this.Setting1 = setting1;
Init();
}
public SettingsDesignerItemViewModel()
{
Init();
}
public String Setting1 { get; set; }
public ICommand ShowDataChangeWindowCommand { get; private set; }
public void ExecuteShowDataChangeWindowCommand(object parameter)
{
SettingsDesignerItemData data = new SettingsDesignerItemData(Setting1);
if (visualiserService.ShowDialog(data) == true)
{
this.Setting1 = data.Setting1;
}
}
private void Init()
{
visualiserService = ApplicationServicesProvider.Instance.Provider.VisualizerService;
ShowDataChangeWindowCommand = new SimpleCommand(ExecuteShowDataChangeWindowCommand);
this.ShowConnectors = false;
}
}
这里有几点需要注意,让我们一一讲解:
ApplicationServicesProvider.Instance.SetNewServiceProvider(...);
- 这些图表项 ViewModel **必须**继承自
DesignerItemViewModelBase
,它是核心图表设计器代码中的一个类,需要知道以下内容:- Id:用于持久化和维护持久化连接关系
DiagramViewModel
:这是父级DiagramViewModel
,此 ViewModel 在拖到设计器表面后将自身添加到其中(稍后会详细介绍)- Left:这是项目在设计器表面上的左侧位置
- Top:这是项目在设计器表面上的顶部位置
- Setting1:这是与此
Type
的 ViewModel 相关联的特定数据位。此数据和任何其他特定数据显然会根据您的实际 ViewModel 的数据要求而变化
- 可以看出,此类别还实现了
ISupportDataChanges
,这是一个简单的接口,它公开了一个ICommand
,可用于显示一个弹出式Window
,以允许编辑此 ViewModelType
的特定数据。 - 您可以在
Init()
方法中看到我们使用 ServiceLocation 来查找能够显示弹出Window
的服务,该服务会传入一个新的 ViewModel,该 ViewModel 表示我们希望在弹出Window
中编辑的一些数据。对于这个项目,ServiceLocation 比一个完全成熟的 IOC 更有意义。由于这些 ViewModel 的创建方式以及它们需要了解其父DiagramViewModel
的需求,因此将 IOC 用于这些图表项 ViewModel 并不方便,它根本行不通。如果您愿意,您应该能够将ApplicationServicesProvider
上的IServiceProvider
实例替换为测试版本,只需使用以下方法:
使用步骤5:创建图表项设计器表面 DataTemplates
我们已经讨论了作为 ToolBoxControl
项基础的 ViewModel,并且我们已经看到了其中一个 ViewModel 的示例,但是如何将项从 ToolBoxControl
拖到设计器表面来创建正确的图表项 UI 组件呢?
答案在于 DataTemplate
(s)。对于每个图表项,**必须**有一个匹配的 DataTemplate
,这可以从下面的 DataTemplate
中看到(它与我们刚刚看到的示例 ViewModel 密切相关)。
这个 XAML 位于名为“_SettingsDesignerItemDataTemplate.xaml_”的文件中,其中包含描述图表项 ViewModel 在 UI 控件方面应该是什么样子的 XAML,它还描述了当您更改所选 ViewModel 的数据值时弹出 Window
将是什么样子(假设这是您想要做的事情,在我看来这是理所当然的,您必须创建带有相关数据的项目图表)。
这是 SettingsDesignerItemViewModel
的 XAML:
<!-- DataTemplate for DesignerCanvas look and feel -->
<DataTemplate DataType="{x:Type local:SettingsDesignerItemViewModel}">
<Grid>
<Image IsHitTestVisible="False"
Stretch="Fill"
Source="../../Images/Setting.png"
Tag="setting" />
<Button HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="5"
Template="{StaticResource infoButtonTemplate}"
Command="{Binding ShowDataChangeWindowCommand}" />
</Grid>
</DataTemplate>
当应用为 DataTemplate
时,它将显示以下 UI 控件:
您会在 _DemoApp\Resources\DesignerItems_ 文件夹中找到我选择支持的**所有**图表项 ViewModel 的 DataTemplate。请注意,其中**只有**两个,那是因为我**只**选择支持两种可能的项**类型**。
您**应该**在此处提供自己的资源以匹配您自己的 ViewModel 类型。
使用步骤6:创建图表项弹出窗口 DataTemplates
正如我多次声明的那样,除非您要更改与项目关联的一些数据,否则我看不出图表项的意义。如果这听起来不像您需要的要求,您可能可以略过这部分,尽管随附的 DemoApp 代码完全围绕着您**能够**更改与给定图表项 ViewModel 关联的数据这一事实而设计。
我们刚刚看到我们为图表项 ViewModel 创建了一个特定的 DataTemplate
,并且我们看到该 DataTemplate
的视觉 UI 控件包含一个 Button
,如果我们检查项 ViewModel 代码的相关部分,我们可以看到当 Button
被点击时,我们创建了一个新的精简版 ViewModel。这个精简版 ViewModel 显示在一个通用弹出 Window
(DemoApp\Popups\PopupWindow.xaml) 中,该窗口使用更多的 DataTemplate
(s) 来决定根据通用弹出 Window
的当前 DataContext
应该显示哪些视觉元素。
public void ExecuteShowDataChangeWindowCommand(object parameter)
{
SettingsDesignerItemData data = new SettingsDesignerItemData(Setting1);
if (visualiserService.ShowDialog(data) == true)
{
this.Setting1 = data.Setting1;
}
}
这个精简版 ViewModel 只是充当一个属性包,用于允许编辑当前图表项 ViewModel,只要用户点击通用弹出窗口上的 OK Button
,精简版 ViewModel 的属性值就会应用于启动弹出 Window
的 ViewModel,否则任何编辑都将被忽略。
这个 XAML 位于名为“_SettingsDesignerItemDataTemplate.xaml_”的文件中,如前所述,它包含一个用于图表项 ViewModel 的 DataTemplate
,还包含一个 DataTemplate
来描述精简版 ViewModel 在通用弹出窗口中应该是什么样子。
这是 SettingsDesignerItemData
的 XAML:
<!-- DataTemplate for Popup look and feel -->
<DataTemplate DataType="{x:Type local:SettingsDesignerItemData}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Label Grid.Row="0"
Content="Setting1"
Margin="5" />
<TextBox Grid.Row="1"
HorizontalAlignment="Left"
Text="{Binding Setting1}"
Width="150"
Margin="5" />
</Grid>
</DataTemplate>
当应用为 DataTemplate
时,它将显示以下 UI 控件:
使用步骤7:持久化
为了充分利用这个修改后的图表设计器,我决定要添加的核心功能之一是能够将图表持久化/填充到数据库中。为了简洁起见(以及我个人的理智),我选择了最快的路径,并选择使用嵌入式 RavenDB 形式的 NoSQL 方法。
要保存的重要内容
我们正在尝试保存什么? | 需要保存什么? |
持久化设计器项 这是 DemoApp 特有的,您的要求肯定会有所不同 |
此 ViewModel 显然是一个演示 ViewModel,但它确实向您展示了如何保存图表项类型,而**最**重要的内容实际上在名为 DemoApp.Persistence.Common.DesignerItemBase 的基类上。您应该确保您自己的持久化 ViewModel 继承自该类。无论如何,以下是此 Type 持久化的所有值:
我在一个名为 public class PersistDesignerItem : DesignerItemBase
{
public PersistDesignerItem(int id, double left, double top, string hostUrl) : base(id, left, top)
{
this.HostUrl = hostUrl;
}
public string HostUrl { get; set; }
}
|
SettingsDesignerItem 这是 DemoApp 特有的,您的要求肯定会有所不同 |
此 ViewModel 显然是一个演示 ViewModel,但它确实向您展示了如何保存图表项类型,而**最**重要的内容实际上在名为 DemoApp.Persistence.Common.DesignerItemBase 的基类上。您应该确保您自己的持久化 ViewModel 继承自该类。无论如何,以下是此 Type 持久化的所有值:
我在一个名为 public class SettingsDesignerItem : DesignerItemBase
{
public SettingsDesignerItem(int id, double left, double top, string setting1)
: base(id, left, top)
{
this.Setting1 = setting1;
}
public string Setting1 { get; set; }
}
|
Connection |
我在一个名为 public class Connection : PersistableItemBase
{
public Connection(int id, int sourceId, Orientation sourceOrientation,
Type sourceType, int sinkId, Orientation sinkOrientation, Type sinkType) : base(id)
{
this.SourceId = sourceId;
this.SourceOrientation = sourceOrientation;
this.SourceType = sourceType;
this.SinkId = sinkId;
this.SinkOrientation = sinkOrientation;
this.SinkType = sinkType;
}
public int SourceId { get; private set; }
public Orientation SourceOrientation { get; private set; }
public Type SourceType { get; private set; }
public int SinkId { get; private set; }
public Orientation SinkOrientation { get; private set; }
public Type SinkType { get; private set; }
}
|
图表 |
List<DiagramItemData> DesignerItems
List<int> ConnectionIds
我在一个名为 public class DiagramItem : PersistableItemBase
{
public DiagramItem()
{
this.DesignerItems = new List<DiagramItemData>();
this.ConnectionIds = new List<int>();
}
public List<DiagramItemData> DesignerItems { get; set; }
public List<int> ConnectionIds { get; set; }
}
public class DiagramItemData
{
public DiagramItemData(int itemId, Type itemType)
{
this.ItemId = itemId;
this.ItemType = itemType;
}
public int ItemId { get; set; }
public Type ItemType { get; set; }
}
|
对于那些可能对 RavenDB 代码感兴趣的人,这是整个类(是的,就是全部),当然比不得不创建 N 个表,N 个存储过程,以及编写大量 ADO.NET 代码要好得多。
/// <summary>
/// I decided to use RavenDB instead of SQL, to save people having to have SQL Server, and also
/// it just takes less time to do with Raven. This is ALL the CRUD code. Simple no?
///
/// Thing is the IDatabaseAccessService and the items it persists could easily be applied to helper methods that
/// use StoredProcedures or ADO code, the data being stored would be exactly the same. You would just need to store
/// the individual property values in tables rather than store objects.
/// </summary>
public class DatabaseAccessService : IDatabaseAccessService
{
EmbeddableDocumentStore documentStore = null;
public DatabaseAccessService()
{
documentStore = new EmbeddableDocumentStore
{
DataDirectory = "Data"
};
documentStore.Initialize();
}
public void DeleteConnection(int connectionId)
{
using (IDocumentSession session = documentStore.OpenSession())
{
IEnumerable<Connection> conns = session.Query<Connection>().Where(x => x.Id == connectionId);
foreach (var conn in conns)
{
session.Delete<Connection>(conn);
}
session.SaveChanges();
}
}
public void DeletePersistDesignerItem(int persistDesignerId)
{
using (IDocumentSession session = documentStore.OpenSession())
{
IEnumerable<PersistDesignerItem> persistItems = session.Query<PersistDesignerItem>()
.Where(x => x.Id == persistDesignerId);
foreach (var persistItem in persistItems)
{
session.Delete<PersistDesignerItem>(persistItem);
}
session.SaveChanges();
}
}
public void DeleteSettingDesignerItem(int settingsDesignerItemId)
{
using (IDocumentSession session = documentStore.OpenSession())
{
IEnumerable<SettingsDesignerItem> settingItems = session.Query<SettingsDesignerItem>()
.Where(x => x.Id == settingsDesignerItemId);
foreach (var settingItem in settingItems)
{
session.Delete<SettingsDesignerItem>(settingItem);
}
session.SaveChanges();
}
}
public int SaveDiagram(DiagramItem diagram)
{
return SaveItem(diagram);
}
public int SavePersistDesignerItem(PersistDesignerItem persistDesignerItemToSave)
{
return SaveItem(persistDesignerItemToSave);
}
public int SaveSettingDesignerItem(SettingsDesignerItem settingsDesignerItemToSave)
{
return SaveItem(settingsDesignerItemToSave);
}
public int SaveConnection(Connection connectionToSave)
{
return SaveItem(connectionToSave);
}
public IEnumerable<DiagramItem> FetchAllDiagram()
{
using (IDocumentSession session = documentStore.OpenSession())
{
return session.Query<DiagramItem>().ToList();
}
}
public DiagramItem FetchDiagram(int diagramId)
{
using (IDocumentSession session = documentStore.OpenSession())
{
return session.Query<DiagramItem>().Single(x => x.Id == diagramId);
}
}
public PersistDesignerItem FetchPersistDesignerItem(int settingsDesignerItemId)
{
using (IDocumentSession session = documentStore.OpenSession())
{
return session.Query<PersistDesignerItem>().Single(x => x.Id == settingsDesignerItemId);
}
}
public SettingsDesignerItem FetchSettingsDesignerItem(int settingsDesignerItemId)
{
using (IDocumentSession session = documentStore.OpenSession())
{
return session.Query<SettingsDesignerItem>().Single(x => x.Id == settingsDesignerItemId);
}
}
public Connection FetchConnection(int connectionId)
{
using (IDocumentSession session = documentStore.OpenSession())
{
return session.Query<Connection>().Single(x => x.Id == connectionId);
}
}
private int SaveItem(PersistableItemBase item)
{
using (IDocumentSession session = documentStore.OpenSession())
{
session.Store(item);
session.SaveChanges();
}
return item.Id;
}
}
如果您想使用更传统的东西,例如 SQL/MySQL/Oracle 等等,您只需实现此接口(或提出您自己的持久化逻辑)
public interface IDatabaseAccessService
{
//delete methods
void DeleteConnection(int connectionId);
void DeletePersistDesignerItem(int persistDesignerId);
void DeleteSettingDesignerItem(int settingsDesignerItemId);
//save methods
int SaveDiagram(DiagramItem diagram);
//PersistDesignerItem is pecific to the DemoApp example
int SavePersistDesignerItem(PersistDesignerItem persistDesignerItemToSave);
//SettingsDesignerItem is pecific to the DemoApp example
int SaveSettingDesignerItem(SettingsDesignerItem settingsDesignerItemToSave);
int SaveConnection(Connection connectionToSave);
//Fetch methods
IEnumerable<DiagramItem> FetchAllDiagram();
DiagramItem FetchDiagram(int diagramId);
//PersistDesignerItem is pecific to the DemoApp example
PersistDesignerItem FetchPersistDesignerItem(int settingsDesignerItemId);
//SettingsDesignerItem is pecific to the DemoApp example
SettingsDesignerItem FetchSettingsDesignerItem(int settingsDesignerItemId);
Connection FetchConnection(int connectionId);
}
警告
如果您确实决定走传统的 SQL 路线,您几乎肯定需要存储 Type
的名称而不是实际的 Type
(这就是 NoSQL 的强大之处,存储一个 Type
,当然没问题)在上面显示的 DiagramItem
代码中。您需要保存一个 string
,它是您尝试持久化的图表项 Type
的 AssemblyQualifiedName
。然后要填充,您将必须使用该 Type
信息来帮助您再次创建正确的 Type
。
保存/填充图表
这是演示应用程序的 Window1ViewModel
中相关的持久化代码(您应该以此作为自己代码库的基础)。下面显然有一些我只显示了存根的代码,但希望您能根据方法的名称理解正在发生的事情:
public class Window1ViewModel : INPCBase
{
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// SAVE DIAGRAM
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
private void ExecuteSaveDiagramCommand(object parameter)
{
if (!DiagramViewModel.Items.Any())
{
messageBoxService.ShowError("There must be at least one item in order save a diagram");
return;
}
IsBusy = true;
DiagramItem wholeDiagramToSave = null;
Task<int> task = Task.Factory.StartNew<int>(() =>
{
if (SavedDiagramId != null)
{
int currentSavedDiagramId = (int)SavedDiagramId.Value;
wholeDiagramToSave = databaseAccessService.FetchDiagram(currentSavedDiagramId);
//If we have a saved diagram, we need to make sure we clear out all the removed items that
//the user deleted as part of this work sesssion
foreach (var itemToRemove in itemsToRemove)
{
DeleteFromDatabase(wholeDiagramToSave, itemToRemove);
}
//start with empty collections of connections and items, which will be populated based on current diagram
wholeDiagramToSave.ConnectionIds = new List<int>();
wholeDiagramToSave.DesignerItems = new List<DiagramItemData>();
}
else
{
wholeDiagramToSave = new DiagramItem();
}
//ensure that itemsToRemove is cleared ready for any new changes within a session
itemsToRemove = new List<SelectableDesignerItemViewModelBase>();
//Save all PersistDesignerItemViewModel
foreach (var persistItemVM in DiagramViewModel.Items.OfType<PersistDesignerItemViewModel>())
{
PersistDesignerItem persistDesignerItem = new PersistDesignerItem(persistItemVM.Id,
persistItemVM.Left, persistItemVM.Top, persistItemVM.HostUrl);
persistItemVM.Id = databaseAccessService.SavePersistDesignerItem(persistDesignerItem);
wholeDiagramToSave.DesignerItems.Add(new DiagramItemData(persistDesignerItem.Id, typeof(PersistDesignerItem)));
}
//Save all PersistDesignerItemViewModel
foreach (var settingsItemVM in DiagramViewModel.Items.OfType<SettingsDesignerItemViewModel>())
{
SettingsDesignerItem settingsDesignerItem = new SettingsDesignerItem(settingsItemVM.Id,
settingsItemVM.Left, settingsItemVM.Top, settingsItemVM.Setting1);
settingsItemVM.Id = databaseAccessService.SaveSettingDesignerItem(settingsDesignerItem);
wholeDiagramToSave.DesignerItems.Add(new DiagramItemData(settingsDesignerItem.Id, typeof(SettingsDesignerItem)));
}
//Save all connections which should now have their Connection.DataItems filled in with correct Ids
foreach (var connectionVM in DiagramViewModel.Items.OfType<ConnectorViewModel>())
{
FullyCreatedConnectorInfo sinkConnector = connectionVM.SinkConnectorInfo as FullyCreatedConnectorInfo;
Connection connection = new Connection(
connectionVM.Id,
connectionVM.SourceConnectorInfo.DataItem.Id,
GetOrientationFromConnector(connectionVM.SourceConnectorInfo.Orientation),
GetTypeOfDiagramItem(connectionVM.SourceConnectorInfo.DataItem),
sinkConnector.DataItem.Id,
GetOrientationFromConnector(sinkConnector.Orientation),
GetTypeOfDiagramItem(sinkConnector.DataItem));
connectionVM.Id = databaseAccessService.SaveConnection(connection);
wholeDiagramToSave.ConnectionIds.Add(connectionVM.Id);
}
wholeDiagramToSave.Id = databaseAccessService.SaveDiagram(wholeDiagramToSave);
return wholeDiagramToSave.Id;
});
task.ContinueWith((ant) =>
{
int wholeDiagramToSaveId = ant.Result;
if (!savedDiagrams.Contains(wholeDiagramToSaveId))
{
List<int> newDiagrams = new List<int>(savedDiagrams);
newDiagrams.Add(wholeDiagramToSaveId);
SavedDiagrams = newDiagrams;
}
IsBusy = false;
messageBoxService.ShowInformation(string.Format("Finished saving Diagram Id : {0}", wholeDiagramToSaveId));
}, TaskContinuationOptions.OnlyOnRanToCompletion);
}
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// LOAD DIAGRAM
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
private void ExecuteLoadDiagramCommand(object parameter)
{
IsBusy = true;
DiagramItem wholeDiagramToLoad = null;
if (SavedDiagramId == null)
{
messageBoxService.ShowError("You need to select a diagram to load");
return;
}
Task<DiagramViewModel> task = Task.Factory.StartNew<DiagramViewModel>(() =>
{
//ensure that itemsToRemove is cleared ready for any new changes within a session
itemsToRemove = new List<SelectableDesignerItemViewModelBase>();
DiagramViewModel diagramViewModel = new DiagramViewModel();
wholeDiagramToLoad = databaseAccessService.FetchDiagram((int)SavedDiagramId.Value);
//load diagram items
foreach (DiagramItemData diagramItemData in wholeDiagramToLoad.DesignerItems)
{
if (diagramItemData.ItemType == typeof(PersistDesignerItem))
{
PersistDesignerItem persistedDesignerItem = databaseAccessService.FetchPersistDesignerItem(diagramItemData.ItemId);
PersistDesignerItemViewModel persistDesignerItemViewModel =
new PersistDesignerItemViewModel(persistedDesignerItem.Id, diagramViewModel,
persistedDesignerItem.Left, persistedDesignerItem.Top, persistedDesignerItem.HostUrl);
diagramViewModel.Items.Add(persistDesignerItemViewModel);
}
if (diagramItemData.ItemType == typeof(SettingsDesignerItem))
{
SettingsDesignerItem settingsDesignerItem = databaseAccessService.FetchSettingsDesignerItem(diagramItemData.ItemId);
SettingsDesignerItemViewModel settingsDesignerItemViewModel =
new SettingsDesignerItemViewModel(settingsDesignerItem.Id, diagramViewModel,
settingsDesignerItem.Left, settingsDesignerItem.Top, settingsDesignerItem.Setting1);
diagramViewModel.Items.Add(settingsDesignerItemViewModel);
}
}
//load connection items
foreach (int connectionId in wholeDiagramToLoad.ConnectionIds)
{
Connection connection = databaseAccessService.FetchConnection(connectionId);
DesignerItemViewModelBase sourceItem = GetConnectorDataItem(diagramViewModel, connection.SourceId, connection.SourceType);
ConnectorOrientation sourceConnectorOrientation = GetOrientationForConnector(connection.SourceOrientation);
FullyCreatedConnectorInfo sourceConnectorInfo = GetFullConnectorInfo(connection.Id, sourceItem, sourceConnectorOrientation);
DesignerItemViewModelBase sinkItem = GetConnectorDataItem(diagramViewModel, connection.SinkId, connection.SinkType);
ConnectorOrientation sinkConnectorOrientation = GetOrientationForConnector(connection.SinkOrientation);
FullyCreatedConnectorInfo sinkConnectorInfo = GetFullConnectorInfo(connection.Id, sinkItem, sinkConnectorOrientation);
ConnectorViewModel connectionVM = new ConnectorViewModel(connection.Id,
diagramViewModel, sourceConnectorInfo, sinkConnectorInfo);
diagramViewModel.Items.Add(connectionVM);
}
return diagramViewModel;
});
task.ContinueWith((ant) =>
{
this.DiagramViewModel = ant.Result;
IsBusy = false;
messageBoxService.ShowInformation(string.Format("Finished loading Diagram Id : {0}", wholeDiagramToLoad.Id));
},TaskContinuationOptions.OnlyOnRanToCompletion);
}
private FullyCreatedConnectorInfo GetFullConnectorInfo(int connectorId,
DesignerItemViewModelBase dataItem, ConnectorOrientation connectorOrientation)
{
....
....
....
}
private Type GetTypeOfDiagramItem(DesignerItemViewModelBase vmType)
{
....
....
....
}
private DesignerItemViewModelBase GetConnectorDataItem(DiagramViewModel diagramViewModel,
int conectorDataItemId, Type connectorDataItemType)
{
....
....
....
}
private Orientation GetOrientationFromConnector(ConnectorOrientation connectorOrientation)
{
....
....
....
}
private ConnectorOrientation GetOrientationForConnector(Orientation persistedOrientation)
{
....
....
....
}
private bool ItemsToDeleteHasConnector(List<SelectableDesignerItemViewModelBase> itemsToRemove,
FullyCreatedConnectorInfo connector)
{
....
....
....
}
private void DeleteFromDatabase(DiagramItem wholeDiagramToAdjust,
SelectableDesignerItemViewModelBase itemToDelete)
{
....
....
....
}
}
至此,如何在您自己的应用程序中使用它的步骤就结束了,如果您只追求这些,您可以深吸一口气,结束一天的工作。但是,如果您想了解它内部的工作原理,请继续阅读。
图表设计器究竟是如何工作的
本节将讨论图表设计器如何工作的一些细节(即以 MVVM 的方式)。我应该提几点:
- 应用程序中有几个部分**需要**您进一步调查。要解释每个细节,我需要一整年。
- 某些元素(诚然不多)与 sucram 的原始代码没有改变,因此这些区域将不作解释,因为您可以从 sucram 的原始文章中了解这些元素。
- 有些事情是假定的,例如:
- 对关键 WPF 概念的实际了解,例如:
- VisualTree 操作
- 绑定
- 附加属性
- 数据上下文 (DataContext)
- 样式
- DataTemplates
- 对关键 WPF 概念的实际了解,例如:
拖放到设计表面
我们已经看到了这个难题的一部分,当我们查看 ToolBoxViewModel
时,它公开了图表支持的类型。为了提醒自己,让我们看看 ViewModel 代码:
public class ToolBoxViewModel
{
private List<ToolBoxData> toolBoxItems = new List<ToolBoxData>();
public ToolBoxViewModel()
{
toolBoxItems.Add(new ToolBoxData("../Images/Setting.png", typeof(SettingsDesignerItemViewModel)));
toolBoxItems.Add(new ToolBoxData("../Images/Persist.png", typeof(PersistDesignerItemViewModel)));
}
public List<ToolBoxData> ToolBoxItems
{
get { return toolBoxItems; }
}
}
此 ViewModel 代码与 ToolBoxControl
密切相关(我决定将其包含在 _DemoApp_ 项目中,以防您不喜欢它的外观和感觉),该代码如下所示。
<UserControl x:Class="DemoApp.ToolBoxControl"
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:s="clr-namespace:DiagramDesigner;assembly=DiagramDesigner"
mc:Ignorable="d"
d:DesignHeight="300" d:DesignWidth="300">
<Border BorderBrush="LightGray"
BorderThickness="1">
<StackPanel>
<Expander Header="Symbols"
IsExpanded="True">
<ItemsControl ItemsSource="{Binding ToolBoxItems}">
<ItemsControl.Template>
<ControlTemplate TargetType="{x:Type ItemsControl}">
<Border BorderThickness="{TemplateBinding Border.BorderThickness}"
Padding="{TemplateBinding Control.Padding}"
BorderBrush="{TemplateBinding Border.BorderBrush}"
Background="{TemplateBinding Panel.Background}"
SnapsToDevicePixels="True">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsPresenter SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}" />
</ScrollViewer>
</Border>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Margin="0,5,0,5"
ItemHeight="50"
ItemWidth="50" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="{x:Type ContentPresenter}">
<Setter Property="Control.Padding"
Value="10" />
<Setter Property="ContentControl.HorizontalContentAlignment"
Value="Stretch" />
<Setter Property="ContentControl.VerticalContentAlignment"
Value="Stretch" />
<Setter Property="ToolTip"
Value="{Binding ToolTip}" />
<Setter Property="s:DragAndDropProps.EnabledForDrag"
Value="True" />
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Image IsHitTestVisible="True"
Stretch="Fill"
Width="50"
Height="50"
Source="{Binding ImageUrl, Converter={x:Static s:ImageUrlConverter.Instance}}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Expander>
</StackPanel>
</Border>
</UserControl>
_ToolBoxControl.xaml_ 中**最**重要的一行是:
<Setter Property="s:DragAndDropProps.EnabledForDrag" Value="True" />
这一行注册了一个附加属性,它为 ToolBoxControl
提供了拖放功能。我们来看看那个附加属性中的代码,好吗?
public static class DragAndDropProps
{
#region EnabledForDrag
public static readonly DependencyProperty EnabledForDragProperty =
DependencyProperty.RegisterAttached("EnabledForDrag", typeof(bool), typeof(DragAndDropProps),
new FrameworkPropertyMetadata((bool)false,
new PropertyChangedCallback(OnEnabledForDragChanged)));
public static bool GetEnabledForDrag(DependencyObject d)
{
return (bool)d.GetValue(EnabledForDragProperty);
}
public static void SetEnabledForDrag(DependencyObject d, bool value)
{
d.SetValue(EnabledForDragProperty, value);
}
private static void OnEnabledForDragChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
FrameworkElement fe = (FrameworkElement) d;
if((bool)e.NewValue)
{
fe.PreviewMouseDown += Fe_PreviewMouseDown;
fe.MouseMove += Fe_MouseMove;
}
else
{
fe.PreviewMouseDown -= Fe_PreviewMouseDown;
fe.MouseMove -= Fe_MouseMove;
}
}
#endregion
#region DragStartPoint
public static readonly DependencyProperty DragStartPointProperty =
DependencyProperty.RegisterAttached("DragStartPoint", typeof(Point?), typeof(DragAndDropProps));
public static Point? GetDragStartPoint(DependencyObject d)
{
return (Point?)d.GetValue(DragStartPointProperty);
}
public static void SetDragStartPoint(DependencyObject d, Point? value)
{
d.SetValue(DragStartPointProperty, value);
}
#endregion
static void Fe_MouseMove(object sender, System.Windows.Input.MouseEventArgs e)
{
Point? dragStartPoint = GetDragStartPoint((DependencyObject)sender);
if (e.LeftButton != MouseButtonState.Pressed)
dragStartPoint = null;
if (dragStartPoint.HasValue)
{
DragObject dataObject = new DragObject();
dataObject.ContentType = (((FrameworkElement)sender).DataContext as ToolBoxData).Type;
dataObject.DesiredSize = new Size(65, 65);
DragDrop.DoDragDrop((DependencyObject)sender, dataObject, DragDropEffects.Copy);
e.Handled = true;
}
}
static void Fe_PreviewMouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
SetDragStartPoint((DependencyObject)sender, e.GetPosition((IInputElement)sender));
}
}
可以看出,此代码挂钩了被拖动项目的各种鼠标事件,从而允许移动项目,但我们现在更感兴趣的是事物如何实际显示在设计器表面上。那么,这是如何发生的呢?嗯,这一切都归结为这两行,我们从被拖动的项目(通过检查其 DataContext
完成,我们知道它绑定到一个 ToolBoxData
对象)中获取 Type
。
DragObject dataObject = new DragObject();
dataObject.ContentType = (((FrameworkElement)sender).DataContext as ToolBoxData).Type;
dataObject.DesiredSize = new Size(65, 65);
DragDrop.DoDragDrop((DependencyObject)sender, dataObject, DragDropEffects.Copy);
e.Handled = true;
这就是一部分,那是拖动部分,现在是放置部分。要了解放置是如何工作的,我们需要检查 DesignerCanvas
,它是一个作为整个 DiagramDesigner.DiagramControl
样式的一部分使用的控件。这是 DesignerCanvas
的相关部分:
public class DesignerCanvas : Canvas
{
public DesignerCanvas()
{
this.AllowDrop = true;
....
....
....
}
....
....
....
protected override void OnDrop(DragEventArgs e)
{
base.OnDrop(e);
DragObject dragObject = e.Data.GetData(typeof(DragObject)) as DragObject;
if (dragObject != null)
{
(DataContext as IDiagramViewModel).ClearSelectedItemsCommand.Execute(null);
Point position = e.GetPosition(this);
DesignerItemViewModelBase itemBase = (DesignerItemViewModelBase)Activator.CreateInstance(dragObject.ContentType);
itemBase.Left = Math.Max(0, position.X - DesignerItemViewModelBase.ItemWidth / 2);
itemBase.Top = Math.Max(0, position.Y - DesignerItemViewModelBase.ItemHeight / 2);
itemBase.IsSelected = true;
(DataContext as IDiagramViewModel).AddItemCommand.Execute(itemBase);
}
e.Handled = true;
}
}
可以看出,DesignerCanvas
知道其父 ViewModel,是的,没错,那就是 DiagramViewModel
。
DiagramViewModel
是 DiagramControl
用作其 DataContext
的 ViewModel,因此当通过 DiagramCanvas
添加新项目时,由于 WPF 出色的绑定支持,这些项目会自动显示,并且我们之前在 使用步骤5:创建图表项设计器表面 DataTemplates 部分中查看的设计器项 DataTemplate
用于确保为实际添加的图表项 Type
创建正确的 UI 元素。
绑定 Items 集合
这可能是我最引以为豪的地方。让我解释一下原因。在使用这个图表控件时,完全可以有以下情况:
- X 类型图表项
- Y 类型图表项
- 从 X 到 Y 的连接
现在我想要的是有一个名为“Items”的单一集合,DiagramControl
可以绑定到它。所以我猜你可以考虑在这里使用继承,这样所有的图表项都继承自一个公共基类。这可能行得通。事实上,这确实解决了难题的一半,所以每个图表项 Type
,包括 ConnectorViewModel
(我们还没看到),都继承自 SelectableDesignerItemViewModelBase
。通过使用继承,我们能够从 DiagramViewModel
创建一个单一的“Items”列表,如下所示:
public class DiagramViewModel : INPCBase, IDiagramViewModel
{
private ObservableCollection<SelectableDesignerItemViewModelBase> items =
new ObservableCollection<SelectableDesignerItemViewModelBase>();
public DiagramViewModel()
{
.....
.....
.....
.....
}
public ObservableCollection<SelectableDesignerItemViewModelBase> Items
{
get { return items; }
}
}
我们可以愉快地将一个标准的 ItemsControl
绑定到它,这也是我们在 DiagramControl
的 XAML 中所做的。
<UserControl x:Class="DiagramDesigner.DiagramControl"
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:s="clr-namespace:DiagramDesigner"
xmlns:c="clr-namespace:DiagramDesigner.Controls"
mc:Ignorable="d"
d:DesignHeight="300" d:DesignWidth="300">
<Border BorderBrush="LightGray"
BorderThickness="1">
<Grid>
<ScrollViewer Name="DesignerScrollViewer"
Background="Transparent"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Items}"
ItemContainerStyleSelector="{x:Static s:DesignerItemsControlItemStyleSelector.Instance}">
......
......
......
......
......
......
......
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
</UserControl>
太棒了,我们能够将一个 ItemsControl
绑定到一个单一的 ObservableCollection<SelectableDesignerItemViewModelBase>
,但是这如何使我们能够改变这些项目的视觉外观呢?毕竟,ConnectionViewModel
看起来**必须**与图表项不同。嗯,是的,它确实不同,那它是如何工作的呢?巫术?
嗯,这个秘密在于使用一个专门的 StyleSelector
,您可以看到它被设置为 DesignerItemsControlItemStyleSelector
的实例,它的工作原理如下:
public class DesignerItemsControlItemStyleSelector : StyleSelector
{
static DesignerItemsControlItemStyleSelector()
{
Instance = new DesignerItemsControlItemStyleSelector();
}
public static DesignerItemsControlItemStyleSelector Instance
{
get;
private set;
}
public override Style SelectStyle(object item, DependencyObject container)
{
ItemsControl itemsControl = ItemsControl.ItemsControlFromItemContainer(container);
if (itemsControl == null)
throw new InvalidOperationException("DesignerItemsControlItemStyleSelector : Could not find ItemsControl");
if(item is DesignerItemViewModelBase)
{
return (Style)itemsControl.FindResource("designerItemStyle");
}
if (item is ConnectorViewModel)
{
return (Style)itemsControl.FindResource("connectorItemStyle");
}
return null;
}
}
我们使用项目的 Type
来确定要查找并应用于绑定项目的 Style
,我们期望在父 ItemsControl
的 Resources
部分找到这些 Style
。这可以在 _DesignerControl.xaml_ 的更详细的代码片段中看到,其中可以清楚地看到这里有两种样式:
- **designerItemStyle**:应用于
Type DesignerItemViewModelBase
的任何绑定项 - **connectorItemStyle**:应用于
Type ConnectorViewModel
的任何项
<ItemsControl ItemsSource="{Binding Items}"
ItemContainerStyleSelector="{x:Static s:DesignerItemsControlItemStyleSelector.Instance}">
<ItemsControl.Resources>
<Style x:Key="designerItemStyle" TargetType="{x:Type ContentPresenter}">
<Setter Property="Canvas.Top"
Value="{Binding Top}" />
<Setter Property="Canvas.Left"
Value="{Binding Left}" /><
<Setter Property="s:SelectionProps.EnabledForSelection"
Value="True" />
<Setter Property="s:ItemConnectProps.EnabledForConnection"
Value="True" />
<Setter Property="Width"
Value="{x:Static s:DesignerItemViewModelBase.ItemWidth}" />
<Setter Property="Height"
Value="{x:Static s:DesignerItemViewModelBase.ItemHeight}" />
....
....
....
....
</Style>
<Style x:Key="connectorItemStyle"
TargetType="{x:Type ContentPresenter}">
<Setter Property="Width"
Value="{Binding Area.Width}" />
<Setter Property="Height"
Value="{Binding Area.Height}" />
<Setter Property="Canvas.Top"
Value="{Binding Area.Top}" />
<Setter Property="Canvas.Left"
Value="{Binding Area.Left}" />
<Setter Property="s:SelectionProps.EnabledForSelection"
Value="True" />
....
....
....
....
</Style>
</ItemsControl.Resources>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<s:DesignerCanvas Loaded="DesignerCanvas_Loaded"
MinHeight="800"
MinWidth="1000"
Background="White"
AllowDrop="True">
</s:DesignerCanvas>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
添加连接
添加连接首先是通过 Mouse
悬停在一个图表控件上实现的,这会显示四个 Connector
对象。这可以从应用于绑定图表项的 Style
中看出。
<ItemsControl ItemsSource="{Binding Items}"
ItemContainerStyleSelector="{x:Static s:DesignerItemsControlItemStyleSelector.Instance}">
<ItemsControl.Resources>
<Style x:Key="designerItemStyle"
TargetType="{x:Type ContentPresenter}">
....
....
....
....
<Setter Property="ContentTemplate">
<Setter.Value>
<DataTemplate>
<Grid x:Name="selectedGrid">
<c:DragThumb x:Name="PART_DragThumb"
Cursor="SizeAll" />
<ContentPresenter x:Name="PART_ContentPresenter"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Content="{TemplateBinding Content}" />
<Grid Margin="-5"
x:Name="PART_ConnectorDecorator">
<s:Connector DataContext="{Binding LeftConnector}"
Orientation="Left"
VerticalAlignment="Center"
HorizontalAlignment="Left"
Visibility="{Binding Path=ShowConnectors, Converter={x:Static s:BoolToVisibilityConverter.Instance}}" />
<s:Connector DataContext="{Binding TopConnector}"
Orientation="Top"
VerticalAlignment="Top"
HorizontalAlignment="Center"
Visibility="{Binding Path=ShowConnectors, Converter={x:Static s:BoolToVisibilityConverter.Instance}}" />
<s:Connector DataContext="{Binding RightConnector}"
Orientation="Right"
VerticalAlignment="Center"
HorizontalAlignment="Right"
Visibility="{Binding Path=ShowConnectors, Converter={x:Static s:BoolToVisibilityConverter.Instance}}" />
<s:Connector DataContext="{Binding BottomConnector}"
Orientation="Bottom"
VerticalAlignment="Bottom"
HorizontalAlignment="Center"
Visibility="{Binding Path=ShowConnectors, Converter={x:Static s:BoolToVisibilityConverter.Instance}}" />
</Grid>
</Grid>
<DataTemplate.Triggers>
<Trigger Property="IsMouseOver"
Value="true">
<Setter TargetName="PART_ConnectorDecorator"
Property="Visibility"
Value="Visible" />
</Trigger>
<DataTrigger Value="True"
Binding="{Binding RelativeSource={RelativeSource Self},Path=IsDragConnectionOver}">
<Setter TargetName="PART_ConnectorDecorator"
Property="Visibility"
Value="Visible" />
</DataTrigger>
....
....
....
....
....
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</Setter.Value>
</Setter>
</Style>
</ItemsControl.Resources>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<s:DesignerCanvas Loaded="DesignerCanvas_Loaded"
MinHeight="800"
MinWidth="1000"
Background="White"
AllowDrop="True">
</s:DesignerCanvas>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
可以看出,上面显示了四个 Connector
。每个 Connector
的 DataContext
也绑定到父图表项,这样当实际的 ConnectorViewModel
创建时,我们就知道连接介于哪些父项之间。这是四个 Connector
的外观:
那么这就是四个 Connector
的显示方式,但是连接是如何实际建立的呢?
嗯,为了理解这一点,我们需要查看 DesignerCanvas
代码,如下所示。
基本思想很简单,当 MouseUp
时我们需要执行 HitTest
,如果对接收器图表项的 HitTest
为正,那么我们就有足够的信息来创建完整连接。
public class DesignerCanvas : Canvas
{
private ConnectorViewModel partialConnection;
private List<Connector> connectorsHit = new List<Connector>();
private Connector sourceConnector;
public Connector SourceConnector
{
get { return sourceConnector; }
set
{
if (sourceConnector != value)
{
sourceConnector = value;
connectorsHit.Add(sourceConnector);
FullyCreatedConnectorInfo sourceDataItem = sourceConnector.DataContext as FullyCreatedConnectorInfo;
Rect rectangleBounds = sourceConnector.TransformToVisual(this).TransformBounds(new Rect(sourceConnector.RenderSize));
Point point = new Point(rectangleBounds.Left + (rectangleBounds.Width / 2),
rectangleBounds.Bottom + (rectangleBounds.Height / 2));
partialConnection = new ConnectorViewModel(sourceDataItem, new PartCreatedConnectionInfo(point));
sourceDataItem.DataItem.Parent.AddItemCommand.Execute(partialConnection);
}
}
}
protected override void OnMouseUp(MouseButtonEventArgs e)
{
base.OnMouseUp(e);
Mediator.Instance.NotifyColleagues<bool>("DoneDrawingMessage", true);
if (sourceConnector != null)
{
FullyCreatedConnectorInfo sourceDataItem = sourceConnector.DataContext as FullyCreatedConnectorInfo;
if (connectorsHit.Count() == 2)
{
Connector sinkConnector = connectorsHit.Last();
FullyCreatedConnectorInfo sinkDataItem = sinkConnector.DataContext as FullyCreatedConnectorInfo;
int indexOfLastTempConnection = sinkDataItem.DataItem.Parent.Items.Count - 1;
sinkDataItem.DataItem.Parent.RemoveItemCommand.Execute(
sinkDataItem.DataItem.Parent.Items[indexOfLastTempConnection]);
sinkDataItem.DataItem.Parent.AddItemCommand.Execute(new ConnectorViewModel(sourceDataItem, sinkDataItem));
}
else
{
//Need to remove last item as we did not finish drawing the path
int indexOfLastTempConnection = sourceDataItem.DataItem.Parent.Items.Count - 1;
sourceDataItem.DataItem.Parent.RemoveItemCommand.Execute(
sourceDataItem.DataItem.Parent.Items[indexOfLastTempConnection]);
}
}
connectorsHit = new List<Connector>();
sourceConnector = null;
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
if(SourceConnector != null)
{
if (e.LeftButton == MouseButtonState.Pressed)
{
Point currentPoint = e.GetPosition(this);
partialConnection.SinkConnectorInfo = new PartCreatedConnectionInfo(currentPoint);
HitTesting(currentPoint);
}
}
else
{
//rubber band selection
....
....
....
....
}
e.Handled = true;
}
private void HitTesting(Point hitPoint)
{
DependencyObject hitObject = this.InputHitTest(hitPoint) as DependencyObject;
while (hitObject != null &&
hitObject.GetType() != typeof(DesignerCanvas))
{
if (hitObject is Connector)
{
if (!connectorsHit.Contains(hitObject as Connector))
connectorsHit.Add(hitObject as Connector);
}
hitObject = VisualTreeHelper.GetParent(hitObject);
}
}
}
但是当我们试图连接两个图表项 Connector
,但尚未建立完整连接时,我们该怎么办呢?
嗯,我们仍然需要建立连接,只是对于这种类型的连接,我们**只**知道源图表项而不知道接收器图表项,所以我们仍然可以画一条连接线,但不能画终止的接收器箭头。为了支持此操作,会发生以下情况:
- 在
MouseMove
上,我们创建一个新的ConnectorViewModel
,它有一个FullyCreatedConnectorInfo
源,并且还有一个ConnectorInfoBase
接收器。实际上,接收器连接器将是一个PartCreatedConnectorInfo
。- 其中
FullyCreatedConnectorInfo
信息允许我们从实际的图表项Connector
项绘制一条线 - 其中
ConnectorInfoBase (PartCreatedConnectorInfo)
信息**只**允许我们绘制一条线到给定点(当前的Mouse
位置)
- 其中
- 在
MouseUp
上,如果对图表项Connector
的HitTest
为正,则移除最后一个ConnectorViewModel
并替换为一个新的ConnectorViewModel
,其源和接收器都是FullyCreatedConnectorInfo
对象。这允许我们在两个实际图表项之间创建完整连接(应保留在图表上的连接)。 - 在
MouseUp
上,如果对图表项的HitTest
为负,则移除最后一个ConnectorViewModel
,因为我们没有选择有效的接收器图表项Connector
,并且在空白区域抬起了鼠标。因此,我们绘制的部分连接**不应**保留在图表上。
一旦您看到各种 ViewModel 和 ConnectorViewModel
Style
的样子,所有这些可能更有意义。那么,我们现在来看看它们吧?
ConnectorInfoBase/PartCreatedConnectorInfo
这个类是连接一端的基类,并提供表示连接一端所需的最小数据集。我们将其用于一端,在该端我们(目前)还不知道我们实际命中的图表项 Connector
(这可能永远不会发生)。
public enum ConnectorOrientation
{
None = 0,
Left = 1,
Top = 2,
Right = 3,
Bottom = 4
}
public abstract class ConnectorInfoBase : INPCBase
{
private static double connectorWidth = 8;
private static double connectorHeight = 8;
public ConnectorInfoBase(ConnectorOrientation orientation)
{
this.Orientation = orientation;
}
public ConnectorOrientation Orientation { get; private set; }
public static double ConnectorWidth
{
get { return connectorWidth; }
}
public static double ConnectorHeight
{
get { return connectorHeight; }
}
}
public class PartCreatedConnectionInfo : ConnectorInfoBase
{
public Point CurrentLocation { get; private set; }
public PartCreatedConnectionInfo(Point currentLocation) : base(ConnectorOrientation.None)
{
this.CurrentLocation = currentLocation;
}
}
FullyCreatedConnectorInfo
此类是我们知道与 Connector
关联的实际图表项时使用的类,它提供了表示连接到实际图表项 Connector
的一端所需的完整数据集。
public class FullyCreatedConnectorInfo : ConnectorInfoBase
{
private bool showConnectors = false;
public FullyCreatedConnectorInfo(DesignerItemViewModelBase dataItem, ConnectorOrientation orientation)
: base(orientation)
{
this.DataItem = dataItem;
}
public DesignerItemViewModelBase DataItem { get; private set; }
public bool ShowConnectors
{
get
{
return showConnectors;
}
set
{
if (showConnectors != value)
{
showConnectors = value;
NotifyChanged("ShowConnectors");
}
}
}
}
ConnectorViewModel
这个类同时持有两个 ConnectorInfoBase
连接器,可以是:
- 2 个
FullyCreatedConnectorInfo
- 1 个
FullCreatedConnectorInfo
和 1 个PartCreatedConnectorInfo
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Windows;
using DiagramDesigner.Helpers;
namespace DiagramDesigner
{
public class ConnectorViewModel : SelectableDesignerItemViewModelBase
{
private FullyCreatedConnectorInfo sourceConnectorInfo;
private ConnectorInfoBase sinkConnectorInfo;
private Point sourceB;
private Point sourceA;
private List<Point> connectionPoints;
private Point endPoint;
private Rect area;
public ConnectorViewModel(int id, IDiagramViewModel parent,
FullyCreatedConnectorInfo sourceConnectorInfo, FullyCreatedConnectorInfo sinkConnectorInfo) : base(id,parent)
{
Init(sourceConnectorInfo, sinkConnectorInfo);
}
public ConnectorViewModel(FullyCreatedConnectorInfo sourceConnectorInfo, ConnectorInfoBase sinkConnectorInfo)
{
Init(sourceConnectorInfo, sinkConnectorInfo);
}
public static IPathFinder PathFinder { get; set; }
public bool IsFullConnection
{
get { return sinkConnectorInfo is FullyCreatedConnectorInfo; }
}
public Point SourceA
{
get
{
return sourceA;
}
set
{
if (sourceA != value)
{
sourceA = value;
UpdateArea();
NotifyChanged("SourceA");
}
}
}
public Point SourceB
{
get
{
return sourceB;
}
set
{
if (sourceB != value)
{
sourceB = value;
UpdateArea();
NotifyChanged("SourceB");
}
}
}
public List<Point> ConnectionPoints
{
get
{
return connectionPoints;
}
private set
{
if (connectionPoints != value)
{
connectionPoints = value;
NotifyChanged("ConnectionPoints");
}
}
}
public Point EndPoint
{
get
{
return endPoint;
}
private set
{
if (endPoint != value)
{
endPoint = value;
NotifyChanged("EndPoint");
}
}
}
public Rect Area
{
get
{
return area;
}
private set
{
if (area != value)
{
area = value;
UpdateConnectionPoints();
NotifyChanged("Area");
}
}
}
public ConnectorInfo ConnectorInfo(ConnectorOrientation orientation, double left, double top, Point position)
{
return new ConnectorInfo()
{
Orientation = orientation,
DesignerItemSize = new Size(DesignerItemViewModelBase.ItemWidth, DesignerItemViewModelBase.ItemHeight),
DesignerItemLeft = left,
DesignerItemTop = top,
Position = position
};
}
public FullyCreatedConnectorInfo SourceConnectorInfo
{
get
{
return sourceConnectorInfo;
}
set
{
if (sourceConnectorInfo != value)
{
sourceConnectorInfo = value;
SourceA = PointHelper.GetPointForConnector(this.SourceConnectorInfo);
NotifyChanged("SourceConnectorInfo");
(sourceConnectorInfo.DataItem as INotifyPropertyChanged).PropertyChanged
+= new WeakINPCEventHandler(ConnectorViewModel_PropertyChanged).Handler;
}
}
}
public ConnectorInfoBase SinkConnectorInfo
{
get
{
return sinkConnectorInfo;
}
set
{
if (sinkConnectorInfo != value)
{
sinkConnectorInfo = value;
if (SinkConnectorInfo is FullyCreatedConnectorInfo)
{
SourceB = PointHelper.GetPointForConnector((FullyCreatedConnectorInfo)SinkConnectorInfo);
(((FullyCreatedConnectorInfo)sinkConnectorInfo).DataItem as INotifyPropertyChanged).PropertyChanged
+= new WeakINPCEventHandler(ConnectorViewModel_PropertyChanged).Handler;
}
else
{
SourceB = ((PartCreatedConnectionInfo)SinkConnectorInfo).CurrentLocation;
}
NotifyChanged("SinkConnectorInfo");
}
}
}
private void UpdateArea()
{
Area = new Rect(SourceA, SourceB);
}
private void UpdateConnectionPoints()
{
ConnectionPoints = new List<Point>()
{
new Point( SourceA.X < SourceB.X ? 0d : Area.Width, SourceA.Y < SourceB.Y ? 0d : Area.Height ),
new Point(SourceA.X > SourceB.X ? 0d : Area.Width, SourceA.Y > SourceB.Y ? 0d : Area.Height)
};
ConnectorInfo sourceInfo = ConnectorInfo(SourceConnectorInfo.Orientation,
ConnectionPoints[0].X,
ConnectionPoints[0].Y,
ConnectionPoints[0]);
if(IsFullConnection)
{
EndPoint = ConnectionPoints.Last();
ConnectorInfo sinkInfo = ConnectorInfo(SinkConnectorInfo.Orientation,
ConnectionPoints[1].X,
ConnectionPoints[1].Y,
ConnectionPoints[1]);
ConnectionPoints = PathFinder.GetConnectionLine(sourceInfo, sinkInfo, true);
}
else
{
ConnectionPoints = PathFinder.GetConnectionLine(sourceInfo, ConnectionPoints[1], ConnectorOrientation.Left);
EndPoint = new Point();
}
}
private void ConnectorViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case "Left":
case "Top":
SourceA = PointHelper.GetPointForConnector(this.SourceConnectorInfo);
if (this.SinkConnectorInfo is FullyCreatedConnectorInfo)
{
SourceB = PointHelper.GetPointForConnector((FullyCreatedConnectorInfo)this.SinkConnectorInfo);
}
break;
}
}
private void Init(FullyCreatedConnectorInfo sourceConnectorInfo, ConnectorInfoBase sinkConnectorInfo)
{
this.Parent = sourceConnectorInfo.DataItem.Parent;
this.SourceConnectorInfo = sourceConnectorInfo;
this.SinkConnectorInfo = sinkConnectorInfo;
PathFinder = new OrthogonalPathFinder();
}
}
}
难题的最后一部分是 ConnectorViewModel
的 Style
,它决定了连接的外观,如下所示:
<Style x:Key="connectorItemStyle"
TargetType="{x:Type ContentPresenter}">
<Setter Property="Width"
Value="{Binding Area.Width}" />
<Setter Property="Height"
Value="{Binding Area.Height}" />
<Setter Property="Canvas.Top"
Value="{Binding Area.Top}" />
<Setter Property="Canvas.Left"
Value="{Binding Area.Left}" />
<Setter Property="s:SelectionProps.EnabledForSelection"
Value="True" />
<Setter Property="ContentTemplate">
<Setter.Value>
<DataTemplate>
<Canvas Margin="0"
x:Name="selectedGrid"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Polyline x:Name="poly"
Stroke="Gray"
Points="{Binding Path=ConnectionPoints, Converter={x:Static s:ConnectionPathConverter.Instance}}"
StrokeThickness="2" />
<Path x:Name="arrow"
Data="M0,10 L5,0 10,10 z"
Visibility="{Binding Path=IsFullConnection, Converter={x:Static s:BoolToVisibilityConverter.Instance}}"
Fill="Gray"
HorizontalAlignment="Left"
Height="10"
Canvas.Left="{Binding EndPoint.X}"
Canvas.Top="{Binding EndPoint.Y}"
Stretch="Fill"
Stroke="Gray"
VerticalAlignment="Top"
Width="10"
RenderTransformOrigin="0.5,0.5">
<Path.RenderTransform>
<RotateTransform x:Name="rot" />
</Path.RenderTransform>
</Path>
</Canvas>
<DataTemplate.Triggers>
<DataTrigger Value="True"
Binding="{Binding IsSelected}">
<Setter TargetName="poly"
Property="Stroke"
Value="Black" />
<Setter TargetName="arrow"
Property="Stroke"
Value="Black" />
<Setter TargetName="arrow"
Property="Fill"
Value="Black" />
</DataTrigger>
<DataTrigger Binding="{Binding Path=SinkConnectorInfo.Orientation}"
Value="Left">
<Setter TargetName="arrow"
Property="Margin"
Value="-15,-5,0,0" />
<Setter TargetName="arrow"
Property="RenderTransform">
<Setter.Value>
<RotateTransform Angle="90" />
</Setter.Value>
</Setter>
</DataTrigger>
<DataTrigger Binding="{Binding Path=SinkConnectorInfo.Orientation}"
Value="Top">
<Setter TargetName="arrow"
Property="Margin"
Value="-5,-15,0,0" />
<Setter TargetName="arrow"
Property="RenderTransform">
<Setter.Value>
<RotateTransform Angle="180" />
</Setter.Value>
</Setter>
</DataTrigger>
<DataTrigger Binding="{Binding Path=SinkConnectorInfo.Orientation}"
Value="Right">
<Setter TargetName="arrow"
Property="Margin"
Value="5,-5,0,0" />
<Setter TargetName="arrow"
Property="RenderTransform">
<Setter.Value>
<RotateTransform Angle="-90" />
</Setter.Value>
</Setter>
</DataTrigger>
<DataTrigger Binding="{Binding Path=SinkConnectorInfo.Orientation}"
Value="Bottom">
<Setter TargetName="arrow"
Property="Margin"
Value="-5,10,0,0" />
<Setter TargetName="arrow"
Property="RenderTransform">
<Setter.Value>
<RotateTransform Angle="0" />
</Setter.Value>
</Setter>
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</Setter.Value>
</Setter>
</Style>
从这个 Style
可以看出,我们使用 ConnectionPathConverter ValueConverter
将 ConnectorViewModel
中的 List<Point>
转换为 PathSegmentCollection
,用于绘制实际的连接 Path
。
[ValueConversion(typeof(List<Point>), typeof(PathSegmentCollection))]
public class ConnectionPathConverter : IValueConverter
{
static ConnectionPathConverter()
{
Instance = new ConnectionPathConverter();
}
public static ConnectionPathConverter Instance
{
get;
private set;
}
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
List<Point> points = (List<Point>)value;
PointCollection pointCollection = new PointCollection();
foreach (Point point in points)
{
pointCollection.Add(point);
}
return pointCollection;
}
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
throw new NotImplementedException();
}
}
重要提示
如果您不喜欢连接路径 List<Point>
的查找方式(我没有向您展示,因为它包含大量抽象的无意义代码),您可以将其更改为您自己的算法,只需实现 IPathFinder
接口,它看起来像这样:
public interface IPathFinder
{
List<Point> GetConnectionLine(ConnectorInfo source, ConnectorInfo sink, bool showLastLine);
List<Point> GetConnectionLine(ConnectorInfo source, Point sinkPoint, ConnectorOrientation preferredOrientation);
}
我已经为您提供了此功能的默认实现,可在 OrthogonalPathFinder
类中找到。无论如何,您可以使用默认实现,也可以通过 ConnectorViewModel
上的静态方法将其替换为自己的实现,在 DemoApp.Window1ViewModel
中有一个示例,此处使用您将在此文章中找到的默认路径查找器。此路径查找工作主要由 sucram 完成,我不能为此邀功,它在大多数情况下运行良好,但可以做得更好,因此如果您认为合适,请编写自己的并替换它。
public Window1ViewModel()
{
//OrthogonalPathFinder is a pretty bad attempt at finding path points,
//it just shows you, you can swap this out with relative
//ease if you wish just create a new IPathFinder class and pass it in right here
ConnectorViewModel.PathFinder = new OrthogonalPathFinder();
}
简单,不是吗?
选择/取消选择
选择/取消选择有两种类型。
样式1:标准鼠标按下选择
这种形式的选择/取消选择非常简单,主要由收到的 PreviewMouseDown
事件驱动,但我提供的逻辑也考虑了 CTRL + SHIFT 键盘修饰符。这是一个标准的附加属性,可以通过简单地设置此附加属性的值来应用,如下所示:
<Style x:Key="designerItemStyle" TargetType="{x:Type ContentPresenter}">
....
....
....
<Setter Property="s:SelectionProps.EnabledForSelection" Value="True" />
....
....
....
</style>
这是这个附加属性的完整代码,它非常简单,我们只监听 PreviewMouseDown
事件,并考虑 CTRL + SHIFT 修饰符,如果应该选择,则在 DiagramViewModel
中选择该项。
public static class SelectionProps
{
#region EnabledForSelection
public static readonly DependencyProperty EnabledForSelectionProperty =
DependencyProperty.RegisterAttached("EnabledForSelection", typeof(bool), typeof(SelectionProps),
new FrameworkPropertyMetadata((bool)false,
new PropertyChangedCallback(OnEnabledForSelectionChanged)));
public static bool GetEnabledForSelection(DependencyObject d)
{
return (bool)d.GetValue(EnabledForSelectionProperty);
}
public static void SetEnabledForSelection(DependencyObject d, bool value)
{
d.SetValue(EnabledForSelectionProperty, value);
}
private static void OnEnabledForSelectionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
FrameworkElement fe = (FrameworkElement)d;
if ((bool)e.NewValue)
{
fe.PreviewMouseDown += Fe_PreviewMouseDown;
}
else
{
fe.PreviewMouseDown -= Fe_PreviewMouseDown;
}
}
#endregion
static void Fe_PreviewMouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
SelectableDesignerItemViewModelBase selectableDesignerItemViewModelBase =
(SelectableDesignerItemViewModelBase)((FrameworkElement)sender).DataContext;
if(selectableDesignerItemViewModelBase != null)
{
if ((Keyboard.Modifiers & (ModifierKeys.Shift | ModifierKeys.Control)) != ModifierKeys.None)
{
if ((Keyboard.Modifiers & (ModifierKeys.Shift)) != ModifierKeys.None)
{
selectableDesignerItemViewModelBase.IsSelected = !selectableDesignerItemViewModelBase.IsSelected;
}
if ((Keyboard.Modifiers & (ModifierKeys.Control)) != ModifierKeys.None)
{
selectableDesignerItemViewModelBase.IsSelected = !selectableDesignerItemViewModelBase.IsSelected;
}
}
else if (!selectableDesignerItemViewModelBase.IsSelected)
{
foreach (SelectableDesignerItemViewModelBase item in selectableDesignerItemViewModelBase.Parent.SelectedItems)
selectableDesignerItemViewModelBase.IsSelected = false;
selectableDesignerItemViewModelBase.Parent.SelectedItems.Clear();
selectableDesignerItemViewModelBase.IsSelected = true;
}
}
}
}
当选择一个项目时,它会显示一个阴影,如下所示:
当选择连接时,它会显示一个黑色 Brush
,如下所示:
样式2:橡皮筋选择
橡皮筋选择是使用 AdornerLayer
完成的,我们只需检查 SelectableDesignerItemViewModelBase
(即项目和连接,因为它们都继承自基类)的边界是否在当前橡皮筋矩形内,如果是,则进行适当的选择。
这是 RubberbandAdorner
的相关代码:
public class RubberbandAdorner : Adorner
{
private Point? startPoint;
private Point? endPoint;
private Pen rubberbandPen;
private DesignerCanvas designerCanvas;
public RubberbandAdorner(DesignerCanvas designerCanvas, Point? dragStartPoint)
: base(designerCanvas)
{
this.designerCanvas = designerCanvas;
this.startPoint = dragStartPoint;
rubberbandPen = new Pen(Brushes.LightSlateGray, 1);
rubberbandPen.DashStyle = new DashStyle(new double[] { 2 }, 1);
}
protected override void OnMouseMove(System.Windows.Input.MouseEventArgs e)
{
if (e.LeftButton == MouseButtonState.Pressed)
{
if (!this.IsMouseCaptured)
this.CaptureMouse();
endPoint = e.GetPosition(this);
UpdateSelection();
this.InvalidateVisual();
}
else
{
if (this.IsMouseCaptured) this.ReleaseMouseCapture();
}
e.Handled = true;
}
protected override void OnMouseUp(System.Windows.Input.MouseButtonEventArgs e)
{
// release mouse capture
if (this.IsMouseCaptured) this.ReleaseMouseCapture();
// remove this adorner from adorner layer
AdornerLayer adornerLayer = AdornerLayer.GetAdornerLayer(this.designerCanvas);
if (adornerLayer != null)
adornerLayer.Remove(this);
e.Handled = true;
}
protected override void OnRender(DrawingContext dc)
{
base.OnRender(dc);
// without a background the OnMouseMove event would not be fired !
// Alternative: implement a Canvas as a child of this adorner, like
// the ConnectionAdorner does.
dc.DrawRectangle(Brushes.Transparent, null, new Rect(RenderSize));
if (this.startPoint.HasValue && this.endPoint.HasValue)
dc.DrawRectangle(Brushes.Transparent, rubberbandPen, new Rect(this.startPoint.Value, this.endPoint.Value));
}
private T GetParent<T>(Type parentType, DependencyObject dependencyObject) where T : DependencyObject
{
DependencyObject parent = VisualTreeHelper.GetParent(dependencyObject);
if (parent.GetType() == parentType)
return (T)parent;
return GetParent<T>(parentType, parent);
}
private void UpdateSelection()
{
IDiagramViewModel vm = (designerCanvas.DataContext as IDiagramViewModel);
Rect rubberBand = new Rect(startPoint.Value, endPoint.Value);
ItemsControl itemsControl = GetParent<ItemsControl>(typeof (ItemsControl), designerCanvas);
foreach (SelectableDesignerItemViewModelBase item in vm.Items)
{
if (item is SelectableDesignerItemViewModelBase)
{
DependencyObject container = itemsControl.ItemContainerGenerator.ContainerFromItem(item);
Rect itemRect = VisualTreeHelper.GetDescendantBounds((Visual) container);
Rect itemBounds = ((Visual) container).TransformToAncestor(designerCanvas).TransformBounds(itemRect);
if (rubberBand.Contains(itemBounds))
{
item.IsSelected = true;
}
else
{
if (!(Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl)))
{
item.IsSelected = false;
}
}
}
}
}
}
这是 RubberbandAdorner
实际运行时的截图:
眼尖的你可能会想,这个 RubberbandAdorner
最初是如何创建的。嗯,这实际上是在 DesignerCanvas
中的 Mouse
事件重写中完成的(如果你仔细想想,这也是 RubberbandAdorner
矩形起点的地方)。这是相关代码:
public class DesignerCanvas : Canvas
{
....
....
....
....
protected override void OnMouseDown(MouseButtonEventArgs e)
{
base.OnMouseDown(e);
if (e.LeftButton == MouseButtonState.Pressed)
{
//if we are source of event, we are rubberband selecting
if (e.Source == this)
{
// in case that this click is the start for a
// drag operation we cache the start point
rubberbandSelectionStartPoint = e.GetPosition(this);
IDiagramViewModel vm = (this.DataContext as IDiagramViewModel);
if (!(Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl)))
{
vm.ClearSelectedItemsCommand.Execute(null);
}
e.Handled = true;
}
}
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
if(SourceConnector != null)
{
....
....
....
....
}
else
{
// if mouse button is not pressed we have no drag operation, ...
if (e.LeftButton != MouseButtonState.Pressed)
rubberbandSelectionStartPoint = null;
// ... but if mouse button is pressed and start
// point value is set we do have one
if (this.rubberbandSelectionStartPoint.HasValue)
{
// create rubberband adorner
AdornerLayer adornerLayer = AdornerLayer.GetAdornerLayer(this);
if (adornerLayer != null)
{
RubberbandAdorner adorner = new RubberbandAdorner(this, rubberbandSelectionStartPoint);
if (adorner != null)
{
adornerLayer.Add(adorner);
}
}
}
}
e.Handled = true;
}
}
删除项
一旦选中对象,可以通过按 DEL(删除)键删除它们。这只是从 ViewModel 中删除所有选定项,该 ViewModel 公开实际的图表项,由于我们前面看到的专用 StyleSelector
,这实际上意味着设计器项和连接。
删除过程始于 _DemoApp.Window1.xaml_ 演示代码,我们在这里使用一个简单的 KeyBinding
:
<Window x:Class="DemoApp.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:s="clr-namespace:DiagramDesigner;assembly=DiagramDesigner"
xmlns:local="clr-namespace:DemoApp"
WindowState="Maximized"
SnapsToDevicePixels="True"
Title="Diagram Designer"
Height="850" Width="1100">
<Window.InputBindings>
<KeyBinding Key="Del"
Command="{Binding DeleteSelectedItemsCommand}" />
</Window.InputBindings>
....
....
....
....
</Window>
这个 KeyBinding
只是在演示应用程序 ViewModel DemoApp.Window1ViewModel
中触发一个 ICommand
,如果您还记得,这是我推荐的创建自己的工作代码的方式。那么我们现在就来看看 DemoApp.Window1ViewModel
代码,以下是 DemoApp.Window1ViewModel
代码的相关部分:
public class Window1ViewModel : INPCBase
{
.....
.....
.....
.....
private void ExecuteDeleteSelectedItemsCommand(object parameter)
{
itemsToRemove = DiagramViewModel.SelectedItems;
List<SelectableDesignerItemViewModelBase> connectionsToAlsoRemove =
new List<SelectableDesignerItemViewModelBase>();
foreach (var connector in DiagramViewModel.Items.OfType<ConnectorViewModel>())
{
if (ItemsToDeleteHasConnector(itemsToRemove, connector.SourceConnectorInfo))
{
connectionsToAlsoRemove.Add(connector);
}
if (ItemsToDeleteHasConnector(itemsToRemove, (FullyCreatedConnectorInfo)connector.SinkConnectorInfo))
{
connectionsToAlsoRemove.Add(connector);
}
}
itemsToRemove.AddRange(connectionsToAlsoRemove);
foreach (var selectedItem in itemsToRemove)
{
DiagramViewModel.RemoveItemCommand.Execute(selectedItem);
}
}
.....
.....
.....
.....
}
可以看出,DemoApp.Window1ViewModel
主要执行以下操作:
- 循环遍历每个
ConnectorViewModel
并确定它们是否连接到用户要求删除的某些内容- 如果发现连接到正在请求删除的项,则该连接显然是垃圾,也应删除
- 在数据库中执行删除操作,请记住持久化是我添加的功能,您需要按照**您**的需求来处理它,对我来说,这只是意味着从 RavenDB 中删除一些东西,这在
DemoApp.Window1ViewModel
中完成 - 将项的实际删除委托给
DiagramViewModel
(我们现在将查看其代码)
响应按下 DELETE 键运行的最终代码是 DiagramViewModel
中运行以下 ICommand
:
public class DiagramViewModel : INPCBase, IDiagramViewModel
{
private ObservableCollection<SelectableDesignerItemViewModelBase> items =
new ObservableCollection<SelectableDesignerItemViewModelBase>();
.....
.....
.....
public SimpleCommand RemoveItemCommand { get; private set; }
public ObservableCollection<SelectableDesignerItemViewModelBase> Items
{
get { return items; }
}
public List<SelectableDesignerItemViewModelBase> SelectedItems
{
get { return Items.Where(x => x.IsSelected).ToList(); }
}
private void ExecuteRemoveItemCommand(object parameter)
{
if (parameter is SelectableDesignerItemViewModelBase)
{
SelectableDesignerItemViewModelBase item = (SelectableDesignerItemViewModelBase)parameter;
items.Remove(item);
}
}
.....
.....
.....
}
而且,由于我们使用的是引用 ObservableCollection<SelectableDesignerItemViewModelBase>
的 Binding
(好的,我们使用了我们之前看到的特殊 DesignerItemsControlItemStyleSelector StyleSelector
),图表设计器就会自动更新。
很简单,对吧?
暂时就这些
无论如何,各位,暂时就这些了,希望你们喜欢这篇,我很享受用 WPF 把它做好。
我现在要休假了,所以可能要等我回来才能回答任何问题。不过,如果您喜欢这篇,投票或评论总是 appreciated。哦,对了,等我度假回来,我就直接开始写一篇新的 Node.Js/D3.js 文章。愉快的时光。