Table of Contents
Introduction
A while back a user called "sucram (real name Marcus)" posted a series of articles here about how to create a diagram designer using WPF. Sucram's original links are as follows:
I remember being truly blown away by this series of articles, as they showed you how to do the following things:
- Toolbox
- Drag and Drop
- Rubber band selection using Adorners
- Resizing items using Adorners
- Rotating items using Adorners
- Connecting items
- Scrollable designer surface, complete with zoombox
Wow, that sounds fantastic, sounds exactly like the sort of things you would need to create a fully functional diagram designer. Well yeah, it was and still is, but........the thing is I have used WPF a lot, and trying to use the code attached to sucram's series of articles in WPF just wasn't that great. He had taken a very control centric view, in that everything was geared around adding new controls and supplying static styles for said controls.
In reality it was more like working with a WinForms application. Not that there is anything wrong with that, and I really truly do not mean to sound ungrateful, as that could not be further from the truth, without that original series of articles it would have taken me a lot longer to come up with a working diagram designer that I was happy with. So for that I am truly grateful, thanks sucram, you rock.
Anyway, as I say, sucram's original codebase took a very control centric point of view, and added controls using code behind, and held collections of items directly in the diagram surface control. As I say, if that is what you want, cool, however, it was not what I wanted. What I wanted was:
- All of the features of sucram's original code (actually I didn't want any rotating of items, or resizing of items).
- A more MVVM driven approach, you know, allow data binding of items, delete items via
ICommand
etc. etc.
- Allow me to control the creation of an entire diagram from within a single ViewModel
- Allow for complex objects to be added to the diagram, i.e., ViewModels that I could style using
DataTemplate
(s). sucram's original code only allowed simply strings to be used as a DataContext
which would control what ImageSource
an Image
would use to show for a diagram item. I needed my items to be quite rich and allow popups to be shown and associated with the diagram item, such that the data related to the diagram item could be manipulated.
- Allow me to save the diagram to some backing store.
- Allow me to load a previously saved diagram from some backing store.
To this end, I have pretty much completely re-written sucram's original code, I think there are probably about two classes that stayed the same, there is now more code, a lot more, however from an end user experience, I think it is now dead easy to control the creation of diagrams from a centralized ViewModel, which allows a diagram to be created via well known WPF paradigms like Binding
/DataTemplating
.
For example, this is how the attached DemoApp code creates a simple diagram that is shown when you first run the DemoApp:
public partial class Window1 : Window
{
private Window1ViewModel window1ViewModel;
public Window1()
{
InitializeComponent();
window1ViewModel = new Window1ViewModel();
this.DataContext = window1ViewModel;
this.Loaded += new RoutedEventHandler(Window1_Loaded);
}
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);
}
}
As the article progresses, I will show you how to use the new MVVM driven diagram designer classes in your own applications, and you could leave it right there if you wanted to, but if you want to know how it all works, that will be explained in the rest of the article.
What Does It Look Like
This is quite interesting, as if you look at the screenshot below and compare that to the final article that sucram produces, you probably won't see any difference, which as I previously stated was intentional. I think sucram really nailed it, I just wanted a more WPF style codebase, one that supported Binding
etc. etc., so yeah I must admit you could easily look at this screenshot and think "Bah humbug......this is exactly the same", well yes, visually speaking I guess it is, however the code is very very different, and the way in which you work with the diagram is very different. Anyway, enough chat, here is a screenshot.
Click the image to see a larger version
So there you go, as I say not much change visually, oh the popup idea to manage a diagram item's data is a new one. We will discuss why I needed this later, and how you too can make use of this mechanism.
Attached Codebase Structure
The attached demo code is split into four projects, which are shown below:
DemoApp |
This project is a demonstration project, and is a good example of how to create your own diagram designer. It is a fully functioning demo, and also demonstrates persisting/hydrating using RavenDB which is a NoSQL document database (as I could not be bothered writing loads of SQL). |
DemoApp.Persistence.Common |
Persistence common classes, used by DemoApp. |
DemoApp.Persistence.RavenDB |
I decided to use RavenDB for persistence which is a NoSQL database that allows raw C# objects to be stored. I decided to do this as I really couldn't be bothered to create all the SQL to save/hydrate diagrams, and I just wanted to get something up and running ASAP.
Though if you use SQL Server/MySQL etc. etc., it should be pretty easy to create the stored procedures/data access layer that talks to your preferred SQL database. |
DiagramDesigner |
This project contains the core classes that are needed to create a diagram in WPF. |
How Do I Use It In My Own Applications
This section will talk you through how to create a diagram in your own application. It assumes the following:
- That you want to use WPF things like Binding/DataTemplating/MVVM
- You actually want to persist / hydrate diagrams to some backing store (like I say I chose to use RavenDB which is a no SQL document database, but if this is not for you, it should be pretty easy for you to craft your own data access layer talking to your preferred SQL backend)
If you want to create your own MVVM style diagram designer, I have broken it down into seven easy steps, as long as you follow these seven steps to the letter, you should be just fine. There is also a working example of these seven steps by way of the attached DemoApp project code, so you can examine that whilst reading this text, so hopefully you will be OK.
Use It Step 1: Creating the Raw XAML
Here is my bare bones recommended XAML that you should use (providing you go with my recommendation for the Main ViewModel). If you adhere to this recommended XAML/ViewModel, you will get the following features:
- Automatic toolbox creation
- New diagram button
- Save diagram button
- Load diagram button
- Progress bar that is shown when you are saving/loaded a diagram
Anyway here is the recommended 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>
-->
<local:ToolBoxControl Grid.Column="0"
DataContext="{Binding ToolBoxViewModel}" />
<GridSplitter Grid.Column="1"
HorizontalAlignment="Left"
VerticalAlignment="Stretch"
Background="Transparent"
Width="3" />
-->
<s:DiagramControl Grid.Column="1"
DataContext="{Binding DiagramViewModel}"
Margin="3,1,0,0" />
</Grid>
</DockPanel>
</Window>
Use It Step 2: Creating the Main ViewModel
I have taken the liberty of creating a demo ViewModel for you, which I think basically shows you how to do everything you want, so if you follow this example, you should not go too badly wrong.
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)
{
itemsToRemove = new List<SelectableDesignerItemViewModelBase>();
SavedDiagramsCV.MoveCurrentToPosition(-1);
DiagramViewModel.CreateNewDiagramCommand.Execute(null);
}
private void ExecuteSaveDiagramCommand(object parameter)
{
....
....
....
}
private void ExecuteLoadDiagramCommand(object parameter)
{
....
....
....
}
}
This example ViewModel shows you how to:
- Create a bindable list of diagram items / connections
- How to delete when delete requests come from the view
- How to save/hydrate a diagram from the database
The only thing you may need to change (if you go for a standard SQL database) is the persistence methods. We will talk more about these later, so hang on we will get to it.
Use It Step 3: Creating Toolbox Item DataTemplates
One important aspect is how the toolbox gets built. So what is the toolbox, I hear you ask?
The toolbox contains the allowable items that you can add to the diagram. This can clearly be seen from the figure below (note that I only allow two items to be created, for brevity).
It may seem straightforward, but this one control is very important, as it dictates the Type
of items that are allowed to be added to the diagram.
This ToolBoxControl
looks like this:
<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>
The most important part of this by far is the line hat binds the ItemsControl
to a property called ToolBoxItems
, which is available from the demo ViewModel(s); here is the relevant code:
First we expose a ToolBoxViewModel
property from the main DemoApp.Window1ViewModel
and then we look into the specifics of the ToolBoxViewModel
to see how the ToolBoxItems
property provides toolbox items.
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; }
}
}
The important part of the ToolBoxViewModel
is that is stores an Image
URL and a Type
.
- The image URL is obviously used to create an image of the required toolbox item
Type
.
- The
Type
is used when you drag and drop a toolbox item onto the design surface, where the toolbox item Type
will be instantiated and displayed thanks to a DataTemplate
. The Type
should be a ViewModel Type
that derives from DesignerItemViewModelBase
and should probably match something you have in your database.
Use It Step 4: Creating the Diagram Item ViewModels
For the demo app attached, I have ony allowed two different Type
s of ViewModels, as such you will see exactly two different ToolBox
items appearing.
toolBoxItems.Add(new ToolBoxData("../Images/Setting.png", typeof(SettingsDesignerItemViewModel)));
toolBoxItems.Add(new ToolBoxData("../Images/Persist.png", typeof(PersistDesignerItemViewModel)));
These two ViewModels types are:
- Types that I want to represent in someway on the diagram designer
- Types that I want to capture extra information about, which can be persisted to the database
Here is what one of these ViewModels looks like (the other one follows the same rules):
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;
}
}
There are quite a few things to note here, so let's go through them one by one:
ApplicationServicesProvider.Instance.SetNewServiceProvider(...);
- These diagram item ViewModels must inherit from
DesignerItemViewModelBase
, which is a class in the core diagram designer code, that needs to know the following things:
- Id: Which is used for persistence and maintaining connection relationship for persistence
DiagramViewModel
: This is the parent DiagramViewModel
into which this ViewModel will add itself, once it is dragged onto the designer surface (more on this later)
- Left: This is the item's Left position on the designer surface
- Top: This is the item's Top position on the designer surface
- Setting1: This is a specific bit of data that goes along with this
Type
of ViewModel. This data and any other specific data would obviously change depending on your actual ViewModel's data requirements
- It can be seen that this class also implements a
ISupportDataChanges
, which is a simple interface that exposes an ICommand
that can be used to display a popup Window
, to allow the edit of the specific data for this ViewModel Type
.
- You can see in the
Init()
method that we use a ServiceLocation to find a service which is able to show a popup Window
which is passed a new ViewModel that represents some data that we wish to edit in a popup Window
. ServiceLocation actually makes more sense for this project than a full blown IOC. Due to how to these ViewModels get created and the need for them to know about their parent DiagramViewModel
, it just isn't that convenient to use IOC for these diagram item ViewModel(s), it just won't work. You should be able to swap out the IServiceProvider
instance on the ApplicationServicesProvider
for a test version should you want to, just use the following method:
Use It Step 5: Creating Diagram Item Designer Surface DataTemplates
So we have talked about the ViewModels that underpin the allowable ToolBoxControl
items, and we have seen an example of one of these ViewModels, but how does dragging an item from the ToolBoxControl
onto the designer surface create the correct diagram item UI components?
The answer to that lies in DataTemplate
(s). For each diagram item there must be a matching DataTemplate
, this can be seen from the DataTemplate
below (which goes hand in hand with the example ViewModel we just saw).
This XAML is located in the file called "SettingsDesignerItemDataTemplate.xaml", which contains the XAML to describe what the diagram item ViewModel should look like in terms of UI controls, and it also describes what the popup Window
will look like when you are changing the values of the data for the selected ViewModel (assuming this is something you want to do, which in my opinion is a given, you must be creating a diagram of items, where the items have some data associated with them).
Here is the XAML for the SettingsDesignerItemViewModel
:
-->
<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>
Which would show the following UI controls, when applied as a DataTemplate
:
You will find DataTemplate(s) for all the diagram item ViewModels that I have chosen to support in the DemoApp\Resources\DesignerItems folder. See how there are only two of them, that is because I only chose to support two possible item Type
s.
You should provide your own resources to match your own ViewModel types here.
Use It Step 6: Creating Diagram Item Popup DataTemplates
As I stated on numerous occasions I do not see the point of a diagram item unless you are going to change some data associated with the item. If this doesn't sound like a requirement you need you can probably gloss over this bit, although the DemoApp code attached is completely geared around the fact that you are able to change the data associated with a given diagram item ViewModel.
We just saw above that we create a specific DataTemplate
for a diagram item ViewModel, and we saw the visual UI controls for that DataTemplate
contained a Button
, and if we examine the relevant part of the item's ViewModel code, we can see that we create a new slim line ViewModel when the Button
is clicked. This slim line ViewModel is shown in a generic popup Window
(DemoApp\Popups\PopupWindow.xaml) that uses more DataTemplate
(s) to decide what visual elements should be shown based on the current DataContext
of the generic popup Window
.
public void ExecuteShowDataChangeWindowCommand(object parameter)
{
SettingsDesignerItemData data = new SettingsDesignerItemData(Setting1);
if (visualiserService.ShowDialog(data) == true)
{
this.Setting1 = data.Setting1;
}
}
This slim line ViewModel simply acts as a property bag that is used to allow edits to the current diagram item ViewModel, providing the user clicks on the OK Button
on the generic popup window, the slim line ViewModel property values are applied to the ViewModel that launched the popup Window
, otherwise any edits are ignored.
This XAML is located in the file called "SettingsDesignerItemDataTemplate.xaml" which as previously stated contains a DataTemplate
for the diagram item ViewModel and also contains a DataTemplate
to describe what the slim line ViewModel should look like in the generic popup window.
Here is the XAML for the SettingsDesignerItemData
:
-->
<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>
which would show the following UI controls, when applied as a DataTemplate
:
Use It Step 7: Persistence
In order to get the most out of this revised diagram designer, I decided one of the core things I wanted to add was the ability to persist/hydrate diagrams to/from a database. For brevity (and my own personal sanity), I have gone down the quickest route possible and chosen to use a No SQL approach in the form of embedded RavenDB.
What Are the Important Things To Save
What are we trying to Save |
What needs to be saved |
PersistDesignerItem
This is specific to the DemoApp, your requirements will be different for sure |
This ViewModel is obviously a demo one, however it does show you how to save a diagram item type, and the most important stuff is actually on a base class called DemoApp.Persistence.Common.DesignerItemBase . You should make sure your own persistence ViewModels inherit from that. Anyway, here are all the values that this Type persists:
int id (* required by all design items for persistence)
double left (* required by all design items for persistence)
double top (* required by all design items for persistence)
string hostUrl (specific to the demo code)
Where I have made this available in a single class called DemoApp.Persistence.Common.PersistDesignerItem which looks like this:
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
This is specific to the DemoApp, your requirements will be different for sure |
This ViewModel is obviously a demo one, however it does show you how to save a diagram item type, and the most important stuff is actually on a base class called DemoApp.Persistence.Common.DesignerItemBase . You should make sure your own persistence ViewModels inherit from that. Anyway, here are all the values that this Type persists:
int id (* required by all design items for persistence)
double left (* required by all design items for persistence)
double top (* required by all design items for persistence)
string setting1 (specific to the demo code)
Where I have made this available in a single class called DemoApp.Persistence.Common.SettingsDesignerItem which looks like this:
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 |
int id
int sourceId
Orientation sourceOrientation
Type sourceType
int sinkId
Orientation sinkOrientation
Type sinkType
Where I have made this available in a single class called DemoApp.Persistence.Common.Connection which looks like this:
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; }
}
|
Diagram |
List<DiagramItemData> DesignerItems
List<int> ConnectionIds
Where I have made this available in a single class called DemoApp.Persistence.Common.DiagramItem which looks like this:
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; }
}
|
For those of you that may have an interest in what the RavenDB code looks like, here is the entire class (and yes, this is all of it), sure beats having to create n-many tables, n-many stored procedures, and writing loads of ADO.NET code.
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;
}
}
If you want to use something more traditional, such as SQL/MySQL/Oracle etc. etc., you simply need to implement this interface (or come up with your own persistence logic):
public interface IDatabaseAccessService
{
void DeleteConnection(int connectionId);
void DeletePersistDesignerItem(int persistDesignerId);
void DeleteSettingDesignerItem(int settingsDesignerItemId);
int SaveDiagram(DiagramItem diagram);
int SavePersistDesignerItem(PersistDesignerItem persistDesignerItemToSave);
int SaveSettingDesignerItem(SettingsDesignerItem settingsDesignerItemToSave);
int SaveConnection(Connection connectionToSave);
IEnumerable<DiagramItem> FetchAllDiagram();
DiagramItem FetchDiagram(int diagramId);
PersistDesignerItem FetchPersistDesignerItem(int settingsDesignerItemId);
SettingsDesignerItem FetchSettingsDesignerItem(int settingsDesignerItemId);
Connection FetchConnection(int connectionId);
}
Word of Warning
If you do decide to go the traditional SQL route, you will almost certainly need to store the name of the Type
instead of the actual Type
(that's the power of NoSQL, store me a Type
, sure no problem) within the DiagramItem
code shown above. You will need to save a string
which is the AssemblyQualifiedName
of the diagram item Type
you are trying to persist. Then to hydrate, you will have to use that Type
information to assist you in creating the correct Type
again.
Saving/Hydrating a Diagram
Here is the relevant persistence code from the demo app's Window1ViewModel
(which is what you should be using as a basis for your own code base). There is obviously some code which I have just shown stubs for below, but hopefully you can get the gist of what is happening based on the names of the methods:
public class Window1ViewModel : INPCBase
{
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);
foreach (var itemToRemove in itemsToRemove)
{
DeleteFromDatabase(wholeDiagramToSave, itemToRemove);
}
wholeDiagramToSave.ConnectionIds = new List<int>();
wholeDiagramToSave.DesignerItems = new List<DiagramItemData>();
}
else
{
wholeDiagramToSave = new DiagramItem();
}
itemsToRemove = new List<SelectableDesignerItemViewModelBase>();
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)));
}
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)));
}
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);
}
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>(() =>
{
itemsToRemove = new List<SelectableDesignerItemViewModelBase>();
DiagramViewModel diagramViewModel = new DiagramViewModel();
wholeDiagramToLoad = databaseAccessService.FetchDiagram((int)SavedDiagramId.Value);
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);
}
}
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)
{
....
....
....
}
}
So that concludes the steps in how to use this in your own application, and if that is all you are after, you can take a deep breath, and call it a day. However should you wish to know more about how it all works under the bonnet, please read on.
How Does The Diagram Designer Stuff Actually Work
This section will discuss some of the nitty gritty about how the diagram designer works (in a MVVM manner, that is). I should just mention a couple of things:
- There are a few parts of the application that will require further investigation by you. To explain every detail would take me all year.
- There are certain elements (granted not many) that have not changed from sucram's original code, as such these areas will not be explained, as you can get an understanding of these elements from sucram's original articles.
- There are some things that are just assumed, such as:
- A working knowledege of key WPF concepts, such as
- VisualTree manipulation
- Bindings
- Attached properties
- DataContext
- Styles
- DataTemplates
Drag And Drop To the Design Surface
We have already seen part of this puzzle when we looked at the ToolBoxViewModel
which exposed the Types that the diagram supports. Just to remind ourselves of that, let's have a look at the ViewModel code:
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; }
}
}
This ViewModel code goes hand in hand with a ToolBoxControl
(which I have decided to include in the DemoApp project just in case you do not like its look and feel), this code is shown below.
<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>
The most important thing in ToolBoxControl.xaml is the the following line:
<Setter Property="s:DragAndDropProps.EnabledForDrag" Value="True" />
This line registers an attached property which provides some drag and drop loveliness for the ToolBoxControl
. Let's have a look at the code in that attached property, shall we?
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));
}
}
It can be seen that this code hooks up various mouse events for the item that is being dragged, which allows items to be moved, but we are more interested in how things actually make it on to the designer surface for now. So how does that occur? Well, it is all down to these couple of lines, where we grab the Type
from the item that is being dragged, which is done by examining its DataContext
, which we know is bound to a ToolBoxData
object.
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;
So that is one part, that is the drag part, so now for the drop. To see how the drop works, we need to examine the DesignerCanvas
which is a control that is used as part of the overall DiagramDesigner.DiagramControl
styling. Here is the relevant portion of the DesinerCanvas
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;
}
}
It can be seen that the DesignerCanvas
knows about its parent ViewModel, yes that's right, that would be the DiagramViewModel.
The DiagramViewModel
is the ViewModel that the DiagramControl
uses as its DataContext
, so when new items are added via the DiagramCanvas
, these are automatically shown thanks to WPF excellent Binding support, and the designer item DataTemplate
s that we looked previously in the section Use It Step 5: Creating Diagram Item Designer Surface DataTemplates are used to make sure the correct UI elements are created for the actual diagram item Type
s that are added.
Binding the Items Collection
This is probably the area I am most proud of. Let me explain why. When working with this diagramming control, it is totally feasible to have the following:
- Type X diagram item
- Type Y diagram item
- Connection from X to Y
Now what I wanted was to have a single collection of "Items" that the DiagramControl
could bind to. So I guess you could think about using inheritance here such that all of the diagram items inherit from a common base class. That could work. In fact that does solve 1/2 the puzzle, so each and every diagram item Type
including ConnectorViewModel
(which we have not seen just yet) inherits from SelectableDesignerItemViewModelBase
. By using inheritance we are able to create a single "Items" list from the DiagramViewModel
, which is as follows:
public class DiagramViewModel : INPCBase, IDiagramViewModel
{
private ObservableCollection<SelectableDesignerItemViewModelBase> items =
new ObservableCollection<SelectableDesignerItemViewModelBase>();
public DiagramViewModel()
{
.....
.....
.....
.....
}
public ObservableCollection<SelectableDesignerItemViewModelBase> Items
{
get { return items; }
}
}
We can happily bind a standard ItemsControl
to this, which is what we do within the XAML for the DiagramControl
.
<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>
Great, so we are able to bind an ItemsControl
to a single ObservableCollection<SelectableDesignerItemViewModelBase>
, but how does that enable us to change the visual appearance of these items? After all a ConnectionViewModel
simply must look different from a diagram item. Well yeah, sure it does, how does that work? Voodoo?
Well, the secret to that lies in the use of a specialized StyleSelector
which you can see being set above to an instance of DesignerItemsControlItemStyleSelector
, which works as follows:
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;
}
}
We use the Type
of the item to determine which Style
to look for and apply it to the item being bound, where we are expecting to find these Style
s in the Resources
section of the parent ItemsControl
. Where this can be seen below in a more detailed snippet of the DesignerControl.xaml, where it can clearly be seen that there are two styles in play here:
- designerItemStyle: Which is applied to any bound item of
Type DesignerItemViewModelBase
- connectorItemStyle: Which is applied to any item of
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>
Adding Connections
Adding connections is achieved by firstly Mouse
over(ing) a diagram control, which will show four Connector
objects. This can be seen from the Style
that is applied to the bound diagram items.
<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>
It can be seen that there are four Connector
s shown above. Each Connector
also has its DataContext
bound to the parent diagram item, such that when an actual ConnectorViewModel
is created we known which parents the connection is between. Here is what the four Connector
s look like:
So that is how the four Connector
s are shown, but how are the connections actually made?
Well, to understand that we need to look at the DesignerCanvas
code, which is shown below.
The basic idea is a simple one, we need to perform a HitTest
when we MouseUp
and if we have a positive HitTest
for a sink diagram item, then we have enough information to create a full connection.
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
{
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
{
....
....
....
....
}
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);
}
}
}
But what about when we are attempting to connect two diagram item Connector
s, but have not (as yet) made a full connection, what should we do in this case?
Well, we still need to make a connection, it's just for this type of connection, we only know the source diagram item and not the sink diagram item, so we can still draw a connection line, but not the terminating sink arrow. To support this operation, the following things happen:
- On
MouseMove
we create a new ConnectorViewModel
which has a FullyCreatedConnectorInfo
source and also has a ConnectorInfoBase
sink. Where in reality the sink connector will be a PartCreatedConnectorInfo
.
- Where the
FullyCreatedConnectorInfo
information allows us to draw a line from an actual diagram item Connector
item
- Where the
ConnectorInfoBase (PartCreatedConnectorInfo)
information only allows us to draw a line to a given point (the current Mouse
position)
- On
MouseUp
if we have a positive HitTest
for a diagram item Connector
the last ConnectorViewModel
is removed and replaced with a new ConnectorViewModel
with both the source and the sink being FullyCreatedConnectorInfo
objects. Which allows us to create a full connection (one that should remain on the diagram) between two actual diagram items.
- On
MouseUp
if we have a negative HitTest
for a diagram item the last ConnectorViewModel
is removed, as we have not selected a valid sink diagram item Connector
, and have moused up in empty space. As such the partial connection we have drawn should not remain on the diagram.
All of this may make a bit more sense once you see what the various ViewModels and ConnectorViewModel
Style
s look like. So let's have a look at those now, shall we?
ConnectorInfoBase/PartCreatedConnectorInfo
This class is the base class for one end of a connection, and provides the minimum set of data required to represent an end of a connection. We use this for an end where we do not (as yet) know the actual diagram item Connector
we have hit (which may never happen).
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
This class is the class we use when we know the actual associated diagram item associated with the Connector
, and provides the full set of data required to represent an end of a connection to an actual diagram item 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
This class holds two ConnectorInfoBase
connectors at any one time, that could be:
- 2 x
FullyCreatedConnectorInfo
- 1 x
FullCreatedConnectorInfo
and 1 x 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();
}
}
}
The last piece of the puzzle is down to the Style
for the ConnectorViewModel
which dictates what the connection looks like, this is shown below:
<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>
It can be seen from this Style
that we use a ConnectionPathConverter ValueConverter
to convert from the List<Point>
in the ConnectorViewModel
to a PathSegmentCollection
which is used to draw the actual connection 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();
}
}
Important note
If you don't like the way that connections path List<Point>
are found (which I have not shown you as it is a lot of abstract nonsensical code), you can change that to your own algorithm, simply implement the IPathFinder
interface which looks like this:
public interface IPathFinder
{
List<Point> GetConnectionLine(ConnectorInfo source, ConnectorInfo sink, bool showLastLine);
List<Point> GetConnectionLine(ConnectorInfo source, Point sinkPoint, ConnectorOrientation preferredOrientation);
}
I have provided a default implementation of this for you, which is available in the OrthogonalPathFinder
class. Anyway you can either use the default or swap it for your own by using the static method on the ConnectorViewModel
where there is an example of in the DemoApp.Window1ViewModel
, which is shown here using the default path finder that you will find attached to this article. This path finding work was mainly done by sucram, I can't take any credit for it, it works well most of the time, but could be better, so if you see fit, write your own and swap it out.
public Window1ViewModel()
{
ConnectorViewModel.PathFinder = new OrthogonalPathFinder();
}
Easy, no?
Selection/Deselection
The selection/deselection comes in two flavours.
Flavour 1: Standard Mouse Down Selection
This form of selection/deselection is pretty simple and is mainly driven from PreviewMouseDown
events received, however the logic I have provided also takes into account CTRL + SHIFT keyboard modifiers. This is a standard attached property that can be applied by simply setting a value for this attached property as such:
<Style x:Key="designerItemStyle" TargetType="{x:Type ContentPresenter}">
....
....
....
<Setter Property="s:SelectionProps.EnabledForSelection" Value="True" />
....
....
....
</style>
Here is the full code for this attached property, it is pretty simple, we just listen to PreviewMouseDown
events and also takes into account CTRL + SHIFT modifiers, and select the item in the DiagramViewModel
if it should be selected.
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;
}
}
}
}
When an item is selected it is shown with a drop shadow as shown below:
When a connection is selected it is shown with a Black Brush
as shown below:
Rubber band selection is done using the AdornerLayer
where we simply check whether a SelectableDesignerItemViewModelBase
(that is items and connections, as they both inherit from the base class) bounds is within the current rubber band rectangle, and if so make the appropriate selection.
Here is the relevant code for the 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)
{
if (this.IsMouseCaptured) this.ReleaseMouseCapture();
AdornerLayer adornerLayer = AdornerLayer.GetAdornerLayer(this.designerCanvas);
if (adornerLayer != null)
adornerLayer.Remove(this);
e.Handled = true;
}
protected override void OnRender(DrawingContext dc)
{
base.OnRender(dc);
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;
}
}
}
}
}
}
Here is a screenshot of the RubberbandAdorner
in action:
The eagle eyed amongst you may be wondering how this RubberbandAdorner
actually gets created in the first place. Well, that is actually done in the Mouse
event overrides in the DesignerCanvas
(which is where the RubberbandAdorner
rectangle start point would originate if you think about it). Here is the relevant code:
public class DesignerCanvas : Canvas
{
....
....
....
....
protected override void OnMouseDown(MouseButtonEventArgs e)
{
base.OnMouseDown(e);
if (e.LeftButton == MouseButtonState.Pressed)
{
if (e.Source == this)
{
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 (e.LeftButton != MouseButtonState.Pressed)
rubberbandSelectionStartPoint = null;
if (this.rubberbandSelectionStartPoint.HasValue)
{
AdornerLayer adornerLayer = AdornerLayer.GetAdornerLayer(this);
if (adornerLayer != null)
{
RubberbandAdorner adorner = new RubberbandAdorner(this, rubberbandSelectionStartPoint);
if (adorner != null)
{
adornerLayer.Add(adorner);
}
}
}
}
e.Handled = true;
}
}
Deleting Items
Once objects are selected, it is possible to delete them by pressing the DEL (delete) key. This simply removes all selected items from the ViewModel that exposes the actual diagram items which, thanks to our specialized StyleSelector
that we saw earlier, actually means both designer items and connections.
The delete journey starts in the DemoApp.Window1.xaml demo code, where we use a simple 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>
This KeyBinding
simply fires an ICommand
in the demo app ViewModel DemoApp.Window1ViewModel
, which if you remember is my recommended way of creating your own working code. So let's look at that DemoApp.Window1ViewModel
code now, here are the relevant parts of the DemoApp.Window1ViewModel
code:
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);
}
}
.....
.....
.....
.....
}
It can be seen that the DemoApp.Window1ViewModel
basically does the following
- Loops through each
ConnectorViewModel
and determines if they are connected to something that the user has asked to delete
- If a connection is found to an item that is being requested to delete, the connection is obviously junk, and should also be deleted
- Does a delete in the database, remember persistence is something I added, you need to handle this how you need it, for me this just meant deleting something from a RavenDB, this is done in the
DemoApp.Window1ViewModel
- Delegates the real deleting of the items to the
DiagramViewModel
(whose code we will now look at)
The final code that is run in response to a DELETE key being pressed is that the following ICommand
is run in the DiagramViewModel
:
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);
}
}
.....
.....
.....
}
And thanks to the fact that we are using a Binding
that refers to a ObservableCollection<SelectableDesignerItemViewModelBase>
(OK, we use our special DesignerItemsControlItemStyleSelector StyleSelector
that we saw earlier) the diagram designer just updates.
Simple, right?
That's It For Now
Anyway that's it for now folks, hope you enjoyed this one, I quite enjoyed making this proper with WPF.
I am off on holiday now, so may not answer any questions until I get back. However if you liked this, a vote or comment is always appreciated. Oh by the way, when I come back from hols, I am straight into a new Node.Js/D3.js article. Fun times.