Friday, February 11, 2011

Custom Control with Connecting Lines using WPF / Silverlight

Recently I was developing a migration tool that incorporates two TreeView controls and allows a user to draw lines between controls. After a bit of a struggle, I finally came up with a solution that works pretty well. The control inherits from the Grid control and uses drag and drop event handlers to draw connecting lines between controls of the same type. Because it is a Grid control, it also gives you the ability to add multiple controls and style it as you wish. In this example, I simply embedded a border control with some rounded corners and line gradients. Within the Border control, I simply added a TextBlock with the necessary text. A random color algorithm (courtesy of Philosophil Blog) determines what color is used.

Custom Properties
ConnectedControl – When two controls are connected, they are able to reference each other through this property.
ConnectedLine – This contains a reference to the line that is added.
AllowMultipleConnections – This determines if the control to connect to multiple items.
ConnectionType – This is an enum that determines whether a control can connect to other controls based on their connection type. The values Both, Source, and Target. Both is the default and says that the control can connect to any other control. Source controls can only connect to Target controls and vice versa.
LineThickness – This determines how thick the connecting line is.

Steps
In order to use this control, you simply need to create a class called ConnectingControl.cs and copy and paste the code below your WPF project. (Of course, you will need to update your namespace.) In order to add the control to your page, simply add a reference to your local assembly and then add the control. At the very least, you will also need to nest another control like a TextBlock or Image. I have included the source code for my MainWindow.xaml below.

Screenshot

ConnectingLines

MainWindow.xaml

<Window x:Class="ConnectingLines.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ConnectingLines"
Title="Connecting Lines" Height="350" Width="525">
<
Window.Resources>
<
Style x:Key="ConnectingGridBorderSource" TargetType="Border">
<
Setter Property="Background" Value="{DynamicResource BoxBrushSource}" />
<
Setter Property="CornerRadius" Value="0,35,0,35" />
<
Setter Property="Margin" Value="0" />
</
Style>
<
Style x:Key="ConnectingGridBorderTarget" TargetType="Border">
<
Setter Property="Background" Value="{DynamicResource BoxBrushTarget}" />
<
Setter Property="CornerRadius" Value="0,35,0,35" />
<
Setter Property="Margin" Value="0" />
</
Style>
<
Style x:Key="ConnectingGridSource" TargetType="local:ConnectingControl">
<
Setter Property="Margin" Value="10" />
<
Setter Property="AllowMultipleConnections" Value="False" />
<
Setter Property="ConnectionType" Value="Source" />
<
Setter Property="LineThickness" Value="3" />
</
Style>
<
Style x:Key="ConnectingGridTarget" TargetType="local:ConnectingControl">
<
Setter Property="Margin" Value="10" />
<
Setter Property="AllowMultipleConnections" Value="False" />
<
Setter Property="ConnectionType" Value="Target" />
<
Setter Property="LineThickness" Value="3" />
</
Style>
<
Style x:Key="ConnectingGridTextBlock" TargetType="TextBlock">
<
Setter Property="Foreground" Value="AliceBlue" />
<
Setter Property="FontSize" Value="24" />
<
Setter Property="FontWeight" Value="Bold" />
<
Setter Property="VerticalAlignment" Value="Center" />
<
Setter Property="HorizontalAlignment" Value="Center" />
</
Style>
<
LinearGradientBrush x:Key="BoxBrushSource" StartPoint="0,0" EndPoint="0,1">
<
LinearGradientBrush.GradientStops>
<
GradientStop Color="AliceBlue" Offset="0.0" />
<
GradientStop Color="Green" Offset="0.4" />
</
LinearGradientBrush.GradientStops>
</
LinearGradientBrush>
<
LinearGradientBrush x:Key="BoxBrushTarget" StartPoint="0,0" EndPoint="0,1">
<
LinearGradientBrush.GradientStops>
<
GradientStop Color="AliceBlue" Offset="0" />
<
GradientStop Color="DarkBlue" Offset=".4" />
</
LinearGradientBrush.GradientStops>
</
LinearGradientBrush>
</
Window.Resources>
<
Grid>
<
Grid.ColumnDefinitions>
<
ColumnDefinition />
<
ColumnDefinition />
<
ColumnDefinition />
</
Grid.ColumnDefinitions>
<
Grid.RowDefinitions>
<
RowDefinition />
<
RowDefinition />
<
RowDefinition />
</
Grid.RowDefinitions>
<
local:ConnectingControl Style="{StaticResource ConnectingGridSource}" Grid.Row="0" Grid.Column="0">
<
Border Style="{StaticResource ConnectingGridBorderSource}">
<
TextBlock Text="Banana" Style="{StaticResource ConnectingGridTextBlock}" />
</
Border>
</
local:ConnectingControl>
<
local:ConnectingControl Style="{StaticResource ConnectingGridSource}" Grid.Row="1" Grid.Column="0">
<
Border Style="{StaticResource ConnectingGridBorderSource}">
<
TextBlock Text="Spinach" Style="{StaticResource ConnectingGridTextBlock}" />
</
Border>
</
local:ConnectingControl>
<
local:ConnectingControl Style="{StaticResource ConnectingGridSource}" Grid.Row="2" Grid.Column="0">
<
Border Style="{StaticResource ConnectingGridBorderSource}">
<
TextBlock Text="Feta" Style="{StaticResource ConnectingGridTextBlock}" />
</
Border>
</
local:ConnectingControl>
<
local:ConnectingControl Style="{StaticResource ConnectingGridTarget}" Grid.Row="0" Grid.Column="2">
<
Border Style="{StaticResource ConnectingGridBorderTarget}">
<
TextBlock Text="Cheese" Style="{StaticResource ConnectingGridTextBlock}" />
</
Border>
</
local:ConnectingControl>
<
local:ConnectingControl Style="{StaticResource ConnectingGridTarget}" Grid.Row="1" Grid.Column="2">
<
Border Style="{StaticResource ConnectingGridBorderTarget}">
<
TextBlock Text="Fruit" Style="{StaticResource ConnectingGridTextBlock}" />
</
Border>
</
local:ConnectingControl>
<
local:ConnectingControl Style="{StaticResource ConnectingGridTarget}" Grid.Row="2" Grid.Column="2">
<
Border Style="{StaticResource ConnectingGridBorderTarget}">
<
TextBlock Text="Vegetable" Style="{StaticResource ConnectingGridTextBlock}" />
</
Border>
</
local:ConnectingControl>
</
Grid>
</
Window>


ConnectingControl.cs

using System;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;

namespace ConnectingLines
{
public class ConnectingControl : Grid
{
#region PROPERTIES
public enum Connection { Both, Source, Target };
private ConnectingControl ConnectedControl { get; set; }
private Line ConnectedLine { get; set; }

new private bool AllowDrop { get; set; }

#region DEPENDENCY PROPERTIES
public event PropertyChangedEventHandler PropertyChanged;

private void NotifyPropertyChanged(String info)
{
if (PropertyChanged != null)
{
PropertyChanged(this, new PropertyChangedEventArgs(info));
}
}

public static readonly DependencyProperty AllowMultipleConnectionsProperty = DependencyProperty.Register("AllowMultipleConnections", typeof(bool), typeof(ConnectingControl));
public static readonly DependencyProperty ConnectionTypeProperty = DependencyProperty.Register("ConnectionType", typeof(Connection), typeof(ConnectingControl));
public static readonly DependencyProperty LineThicknessProperty = DependencyProperty.Register("LineThickness", typeof(double), typeof(ConnectingControl));

public bool AllowMultipleConnections
{
get
{
return (bool)GetValue(AllowMultipleConnectionsProperty); }
set
{
SetValue(AllowMultipleConnectionsProperty, value);
NotifyPropertyChanged("AllowMultipleConnections");
}
}

public Connection ConnectionType
{
get { return (Connection)GetValue(ConnectionTypeProperty); }
set
{
SetValue(ConnectionTypeProperty, value);
NotifyPropertyChanged("ConnectionType");
}
}

public double LineThickness
{
get
{
double thickness = (double)GetValue(LineThicknessProperty);
if (thickness == 0)
{
thickness = 3;
}
return thickness;
}
set
{
SetValue(LineThicknessProperty, value);
NotifyPropertyChanged("LineThickness");
}
}
#endregion
#endregion

#region
CONSTRUCTOR AND INITIALIZER
public ConnectingControl()
{
this.DragEnter += new DragEventHandler(CustomButton_DragEnter);
this.Drop += new DragEventHandler(CustomButton_Drop);
this.PreviewMouseLeftButtonDown += new MouseButtonEventHandler(CustomButton_PreviewMouseLeftButtonDown);
}

protected override void OnInitialized(System.EventArgs e)
{
base.AllowDrop = true;
base.OnInitialized(e);
PreviewMouseLeftButtonDown += new MouseButtonEventHandler(CustomButton_PreviewMouseLeftButtonDown);
Drop += new System.Windows.DragEventHandler(CustomButton_Drop);
DragEnter += new System.Windows.DragEventHandler(CustomButton_DragEnter);
}
#endregion

#region
EVENT HANDLERS AND METHODS
protected void CustomButton_PreviewMouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
ConnectingControl item = (ConnectingControl)sender;
DataObject dragData = new DataObject("source", (ConnectingControl)sender);
DragDrop.DoDragDrop((ConnectingControl)sender, dragData, DragDropEffects.Move);
}

protected void CustomButton_DragEnter(object sender, System.Windows.DragEventArgs e)
{
if (!e.Data.GetDataPresent("source") || sender == e.Source)
{
e.Effects = DragDropEffects.None;
}
}

protected void CustomButton_Drop(object sender, System.Windows.DragEventArgs e)
{
if (e.Data.GetDataPresent("source"))
{
ConnectingControl source = e.Data.GetData("source") as ConnectingControl;
ConnectedControl = source;
ConnectControls();
}
}

private void ConnectControls()
{
bool connect = false;
if (this.ConnectionType == Connection.Both)
{
connect = true;
}
else
{
if (this.ConnectionType == ConnectedControl.ConnectionType)
{
connect = false;
}
else
{
connect = true;
}
}
if (connect)
{
Panel parent = GetRoot(this);
Line line = new Line() { StrokeThickness = LineThickness, Stroke = GetRandomBrush() };
GeneralTransform sourceTransform = ConnectedControl.TransformToVisual(parent);
Point sourcePoint = sourceTransform.Transform(new Point(0, 0));
GeneralTransform targetTransform = this.TransformToVisual(parent);
Point targetPoint = targetTransform.Transform(new Point(0, 0));
if (sourcePoint.X < targetPoint.X)
{
line.X1 = sourcePoint.X + ConnectedControl.ActualWidth;
line.Y1 = sourcePoint.Y + ConnectedControl.ActualHeight / 2;
line.X2 = targetPoint.X;
line.Y2 = targetPoint.Y + this.ActualHeight / 2;
}
else
{
line.X1 = targetPoint.X + this.ActualWidth;
line.Y1 = targetPoint.Y + this.ActualHeight / 2;
line.X2 = sourcePoint.X;
line.Y2 = sourcePoint.Y + ConnectedControl.ActualHeight / 2;
}
if (parent.GetType() == typeof(Grid))
{
line.SetValue(Grid.ColumnSpanProperty, ((Grid)parent).ColumnDefinitions.Count);
line.SetValue(Grid.RowSpanProperty, ((Grid)parent).RowDefinitions.Count);
}
if (!AllowMultipleConnections)
{
if (ConnectedLine != null)
{
parent.Children.Remove(ConnectedLine);
}
if (ConnectedControl != null)
{
if (ConnectedControl.ConnectedLine != null)
{
GetRoot(this).Children.Remove(ConnectedControl.ConnectedLine);
}
}
}
ConnectedLine = line;
ConnectedControl.ConnectedLine = line;
parent.Children.Add(line);
}
}
#endregion

#region
MISC METHODS
private Panel GetRoot(FrameworkElement child)
{
var parent = child.Parent as FrameworkElement;
if (parent == null)
{
if (child is Window)
{
if (((Window)child).Content.GetType().BaseType == typeof(Panel))
{
return ((Window)child).Content as Panel;
}
else
{
throw new Exception("The root content element is an unexpected type. It should be a Panel instead of a " +
((Window)child).Content.GetType().BaseType.ToString() + ".");
}
}
else
{
throw new Exception("The root element is an unexpected type. It should be a Window instead of a" +
child.GetType().ToString() + ".");
}
}
return GetRoot(parent);
}

//COLOR GENERATOR CODE Courtesy of: http://philosophil.spaces.live.com/blog/cns!7E55D8EFA2AEE5D6!201.entry
private Brush GetRandomBrush()
{
Random randomColor = new Random();
SolidColorBrush brush = new SolidColorBrush();
double r, g, b;
double lightness = randomColor.NextDouble() * 0.5 + 0.4; // not too dark nor too light
double hue = randomColor.NextDouble() * 360.0; // full hue spectrum
double saturation = randomColor.NextDouble() * 0.8 + 0.2; // not too grayish
HSLtoRGB(hue, saturation, lightness, out r, out g, out b);
brush.Color = System.Windows.Media.Color.FromRgb((byte)(r * 255.0), (byte)(g * 255.0), (byte)(b * 255.0));
return brush;
}

public static void HSLtoRGB(double hue, double saturation, double luminance, out double red, out double green, out double blue)
{
double q;
double p;
if (luminance < 0.5)
{
q = luminance * (1.0 + saturation);
}
else
{
q = luminance + saturation - (luminance * saturation);
}
p = 2 * luminance - q;
double hk = hue / 360.0;
double tr = hk + 1.0 / 3.0;
double tg = hk;
double tb = hk - 1.0 / 3.0;
tr = Normalize(tr);
tg = Normalize(tg);
tb = Normalize(tb);
red = ComputeColor(q, p, tr);
green = ComputeColor(q, p, tg);
blue = ComputeColor(q, p, tb);
}

private static double ComputeColor(double q, double p, double tc)
{
if (tc < 1.0 / 6.0)
{
return p + ((q - p) * 6.0 * tc);
}
if (tc < 0.5)
{
return q;
}
if (tc < 2.0 / 3.0)
{
return p + ((q - p) * 6.0 * (2.0 / 3.0 - tc));
}
return p;
}

private static double Normalize(double tr)
{
if (tr < 0)
{
return tr + 1.0;
}
if (tr > 1.0)
{
return tr - 1.0;
}
return tr;
}
#endregion
}
}

5 comments:

  1. Great demo. Would you mind sharing your demo project?

    ReplyDelete
  2. Here you go:
    https://skydrive.live.com/?cid=a2379f7b2ea6f936&sc=documents&uc=1&id=A2379F7B2EA6F936%21350#

    ReplyDelete
  3. cool project, I intend to do something similar. Is the project still available somewhere (the link does not seem to work anymore)? That would be great :)

    ReplyDelete
    Replies
    1. The link should be working. I just tried in Chrome Incognito mode.

      https://onedrive.live.com/?cid=a2379f7b2ea6f936&sc=documents&uc=1&id=A2379F7B2EA6F936%21350

      Delete
  4. I already know the source and the target and i want to display it when upload the program(without drag and drop). Do you have an idea how to do it ?

    ReplyDelete