Drawing (1)
To study how to draw some sort of figures such as lines and circles etc., and how to tap&drag those items, I developed a sample program referring to the official site of SkiaSharp and Inventing Events from Effects.
In this sample program, correlation diagrams of novel characters can be easily created and editted. Inputting characters group names in the 1st (main) page, they are listed in the ListView of the page. If tap one of the name (the list item), move to the page where the correlation diagram of the tapped characters group can be created and editted.
Create the contents of the main page. The Entry of CharaGroupTitle and Characters get text input, then AddPage method in MainPage.xaml.cs is called, and the characters group name is listed in PageListView. If one of the characters group names is tapped, JumpPage method in MainPage.xaml.cs navigates to the page for characters correlation diagram creation.
In the first some lines of this code, two classes and several variables are declared. "Page" class has two fields, characters group name and characters, and this class will be tap-enable items of PageListView in the main page. "Link" class is for directed links connecting nodes of characters explained later, and has five fields, link existence and coordinates of start and end points. To share some sort of data between methods, declare some array data and initialize them. maxpage and maxnode are the variables to set the size of the array data. BTW, in the declaration of the variables, "int maxpage=20;" causes a built error. "static int maxpage=20;" seems to be correct. I seem to need to learn more...
When text input of characters group name occurred in the main page, AddPage method are called. Firstly, the characters group name are added to PageListView, then, create page contents to create and display the characters correlation diagram. The page contains Entries and Buttons to input characters and link titles, Buttons for page navigation, canvas on which links are drawn using SkiaSharp, and AbsoluteLayout on which some Entries displaying characters are placed. The canvas and the AbsoluteLayout are overlapped in a Grid in the same way as the SkiaSharp sample program. Finally, save some variables referenced from other methods. As you have noticed, SkiaSharp and SkiaSharp.Views.Forms of NuGet packages should be added to each projects.
JumePage method detects the list index of the tapped characters group name using IndexOf method, and navigates to the page of the tapped characters group.
AddNodeEntryTextChanged and the some following methods are to add characters node and links. I couldn't find a proper way to get text from Entry control described in not xaml but code behind (C#), so I used TextChanged event to do it this time. Is there any other smart way?
MovePreviousPage and the following two methods are for page navigation.
AddNodeBox method adds and displays characters as nodes of characters correlation diagram. The characters are displayed using Entry instead of Label so that they could be edited in the future, but they don't need to be edited in this version, so made InputTransparent property of the Entry "true." The Entry controls are not directly placed in absoluteLayout. Once they are placed in ContentViews, and the ContentViews are placed in absoluteLayout, so that they could be tapped and dragged by OnTouchEffectAction explained later. TapGestureRecognizer detects tap event when connecting two characters (nodes). After AddLink Button is pressed, a link is created so that the 1st tapped node is start point and the 2nd one is the end point.
OnTouchEffectAction method detects touch action type against characters nodes such as "Pressed", "Moved", and "Released." While drag a node linked with other node, calculate the start and end points of the link optimally according to the relative position of the linked two nodes. And also, any two nodes can be linked in both directions, and the two links are placed with a small gap not to overlapp each other.
Finally, in OnCanvasViewPaintSurface, draw links, end point circles of the links, and link titles at the each positions calculated in OnTouchEffectAction method. I actually wanted to draw arrow heads at the end point of links, but I didn't because the codes becomes complicated. Also, for the positions of link titles, it may be better to calculate the optimum positions, but I didn't do it, since it has nothing to do with this theme, study drawing and tap&drag.
In App.xaml.cs, just changed "MainPage=new MainPage();" to "MainPage=new NavigationPage(new MainPage());"
This code should be added to PCL. This is just a part of SkiaSharp official sample program (demo program tapping and dragging bitmap image of monkey).
And also, some codes for each device platform are necessary. In the case of iOS, TouchEffect.cs and TouchRecognizer.cs should be added to iOS project. You can find these codes in the official sample program.
Ran this sample program on iOS simulator:
Main page. The three characters groups are listed, and the fourth characters group are being inputted in the Entry. If you tap one of the characters group, "Anti Terrorist Task Force" for example, move to the page shown below.
The four characters has been added, and two of them are just linked (They are characters from Nelson Demille's "The Lion's Game"). Input "colleague" into Link Name Entry, and press AddLink Button -> tap "John Corey" node -> tap "George Foster" node, then you can see the display shown above. Every node can be dragged, and the link positions change dynamically according to the node positions.
All characters (nodes) are linked each other.
As mentioned above, I studied drawing using SkiaSharp and how to tap&drag items.
In this sample program, correlation diagrams of novel characters can be easily created and editted. Inputting characters group names in the 1st (main) page, they are listed in the ListView of the page. If tap one of the name (the list item), move to the page where the correlation diagram of the tapped characters group can be created and editted.
MainPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
x:Class="DrawTouch.MainPage">
<StackLayout x:Name="stackLayout" Margin="0, 20, 0, 0">
<Label Text="Drawing and Touch/Tracking Demo:" />
<Label Text=" Book Characters Correlation Diagram" />
<Label Text="----------------------------------------" />
<!-- Add a new Character Group page to PageListView-->
<StackLayout Orientation="Horizontal">
<StackLayout>
<Entry x:Name="CharaGroupTitle" Text="" Placeholder="Characters Group" Keyboard="Text" WidthRequest="250"/>
<Entry x:Name="Characters" Text="" Placeholder="Characters" Keyboard="Text" WidthRequest="250">
<!-- Enable this Entry, if CharaGroupTitle-Entry gets text input -->
<Entry.Triggers>
<DataTrigger TargetType="Entry" Binding="{Binding Source={x:Reference CharaGroupTitle}, Path=Text.Length}" Value="0">
<Setter Property="IsEnabled" Value="False" />
</DataTrigger>
</Entry.Triggers>
</Entry>
</StackLayout>
<Button Text="Add Group" Clicked="AddPage" BorderColor="Blue" BorderWidth="1">
<!-- Enable this Button, if CharaGroupTitle-Entry gets text input -->
<Button.Triggers>
<DataTrigger TargetType="Button" Binding="{Binding Source={x:Reference CharaGroupTitle}, Path=Text.Length}" Value="0">
<Setter Property="IsEnabled" Value="False" />
</DataTrigger>
</Button.Triggers>
</Button>
</StackLayout>
<!-- Create a Character Group page List "PageListView"
Call "JumpPage" method, if one of the items (pages) is tapped -->
<ListView x:Name="PageListView" ItemTapped="JumpPage">
<ListView.ItemTemplate>
<DataTemplate>
<TextCell
Text="{Binding Title, Mode=TwoWay}"
Detail="{Binding Characters, Mode=TwoWay}"
TextColor="Black" DetailColor="DimGray" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackLayout>
<!-- Change color, if Entry is focused -->
<ContentPage.Resources>
<ResourceDictionary>
<Style TargetType="Entry">
<Style.Triggers>
<Trigger TargetType="Entry" Property="IsFocused" Value="True">
<Setter Property="BackgroundColor" Value="Aqua" />
</Trigger>
</Style.Triggers>
</Style>
</ResourceDictionary>
</ContentPage.Resources>
</ContentPage>
(This source code is also here.)<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
x:Class="DrawTouch.MainPage">
<StackLayout x:Name="stackLayout" Margin="0, 20, 0, 0">
<Label Text="Drawing and Touch/Tracking Demo:" />
<Label Text=" Book Characters Correlation Diagram" />
<Label Text="----------------------------------------" />
<!-- Add a new Character Group page to PageListView-->
<StackLayout Orientation="Horizontal">
<StackLayout>
<Entry x:Name="CharaGroupTitle" Text="" Placeholder="Characters Group" Keyboard="Text" WidthRequest="250"/>
<Entry x:Name="Characters" Text="" Placeholder="Characters" Keyboard="Text" WidthRequest="250">
<!-- Enable this Entry, if CharaGroupTitle-Entry gets text input -->
<Entry.Triggers>
<DataTrigger TargetType="Entry" Binding="{Binding Source={x:Reference CharaGroupTitle}, Path=Text.Length}" Value="0">
<Setter Property="IsEnabled" Value="False" />
</DataTrigger>
</Entry.Triggers>
</Entry>
</StackLayout>
<Button Text="Add Group" Clicked="AddPage" BorderColor="Blue" BorderWidth="1">
<!-- Enable this Button, if CharaGroupTitle-Entry gets text input -->
<Button.Triggers>
<DataTrigger TargetType="Button" Binding="{Binding Source={x:Reference CharaGroupTitle}, Path=Text.Length}" Value="0">
<Setter Property="IsEnabled" Value="False" />
</DataTrigger>
</Button.Triggers>
</Button>
</StackLayout>
<!-- Create a Character Group page List "PageListView"
Call "JumpPage" method, if one of the items (pages) is tapped -->
<ListView x:Name="PageListView" ItemTapped="JumpPage">
<ListView.ItemTemplate>
<DataTemplate>
<TextCell
Text="{Binding Title, Mode=TwoWay}"
Detail="{Binding Characters, Mode=TwoWay}"
TextColor="Black" DetailColor="DimGray" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackLayout>
<!-- Change color, if Entry is focused -->
<ContentPage.Resources>
<ResourceDictionary>
<Style TargetType="Entry">
<Style.Triggers>
<Trigger TargetType="Entry" Property="IsFocused" Value="True">
<Setter Property="BackgroundColor" Value="Aqua" />
</Trigger>
</Style.Triggers>
</Style>
</ResourceDictionary>
</ContentPage.Resources>
</ContentPage>
Create the contents of the main page. The Entry of CharaGroupTitle and Characters get text input, then AddPage method in MainPage.xaml.cs is called, and the characters group name is listed in PageListView. If one of the characters group names is tapped, JumpPage method in MainPage.xaml.cs navigates to the page for characters correlation diagram creation.
MainPage.xaml.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Collections.ObjectModel;
using Xamarin.Forms;
using SkiaSharp;
using SkiaSharp.Views.Forms;
using TouchTracking;
namespace DrawTouch
{
public partial class MainPage : ContentPage
{
public class Page
{
public string Title { get; set; }
public string Characters { get; set; }
}
public static ObservableCollection<Page> Pages;
public class Link
{
public bool exist; // true, if link exists
public int x1, y1, x2, y2; // start and end points
}
Dictionary<ContentView, DragInfo> dragDictionary = new Dictionary<ContentView, DragInfo>();
// set some array variables to memory the corresponding variable
static int maxpage = 20;
static int maxnode = 10;
ContentPage[] contentPages = new ContentPage[maxpage];
SKCanvasView[] canvasViews = new SKCanvasView[maxpage];
AbsoluteLayout[] absoluteLayouts = new AbsoluteLayout[maxpage];
ContentView[,] node = new ContentView[maxpage, maxnode];
Link[,,] link = new Link[maxpage, maxnode, maxnode];
string[,,] linkTitle = new string[maxpage, maxnode, maxnode];
int page = 0; // Current Page
int NoP = 0; // The Number of Page
int[] NoN = Enumerable.Repeat<int>(0, maxnode).ToArray(); //The Number of Nodes in each Page
int linkStart, linkEnd = 0; // temporary variable
string entryText = ""; // temporary variable
int nodeID = 0; // temporary variable
int scale = 2; // canvas scale: 2(iOS), 1(UWP)
int space = 7; // space between two arrows with opposite direction
public MainPage()
{
InitializeComponent();
// Initialize PageListView
Pages = new ObservableCollection<Page>()
{
new Page(){Title="No Groups", Characters="" }
};
PageListView.ItemsSource = Pages;
// Initialize link[,,]
for (int i = 0; i < 20; i++)
{
for (int j = 0; j < 10; j++)
{
for (int k = 0; k < 10; k++)
{
link[i, j, k] = new Link { exist = false, x1 = 0, y1 = 0, x2 = 0, y2 = 0 };
}
}
}
}
// Add Character Group Title to PageListView, and
// Create a new Character Group Page to draw the characters correlation diagram
void AddPage(object sender, EventArgs args)
{
// Add Character Group Title to PageListView
Page new_page = new Page()
{
Title = CharaGroupTitle.Text,
Characters = Characters.Text
};
if (NoP == 0) Pages.Clear(); // Delete "No Pages" message, if 1st item was added
Pages.Add(new_page);
// stacklayoutTop1
Entry AddNodeEntry = new Entry
{
Placeholder = "Character Name",
WidthRequest=230
};
AddNodeEntry.TextChanged += AddNodeEntryTextChanged;
Button AddNodeButton = new Button
{
Text = "Add Character",
BorderColor = Color.Blue,
BorderWidth = 1,
HorizontalOptions = LayoutOptions.EndAndExpand,
WidthRequest=120
};
AddNodeButton.Clicked += AddNode;
StackLayout stacklayoutTop1 = new StackLayout
{
Orientation = StackOrientation.Horizontal,
Children =
{
AddNodeEntry,
AddNodeButton
}
};
// stacklayoutTop2
Entry AddLinkEntry = new Entry
{
Placeholder = "Link Name",
WidthRequest = 230
};
AddLinkEntry.TextChanged += AddLinkEntryTextChanged;
Button AddLinkButton = new Button
{
Text = "Add Link",
BorderColor = Color.Blue,
BorderWidth = 1,
HorizontalOptions = LayoutOptions.EndAndExpand,
WidthRequest=120
};
AddLinkButton.Clicked += AddLink;
StackLayout stacklayoutTop2 = new StackLayout
{
Orientation=StackOrientation.Horizontal,
Children =
{
AddLinkEntry,
AddLinkButton
}
};
// Character Group Title
Label charagroupTitle = new Label
{
Text = CharaGroupTitle.Text,
FontSize = 25
};
// create SkiaSharp canvas and AbsoluteLayout to draw CharacterNode
SKCanvasView canvas = new SKCanvasView();
canvas.PaintSurface += OnCanvasViewPaintSurface;
AbsoluteLayout absoluteLayout = new AbsoluteLayout { };
Grid gridCanvas = new Grid
{
HeightRequest = 500,
WidthRequest = 300,
BackgroundColor = Color.Gray,
Children = {
canvas,
absoluteLayout
}
};
//stacklayoutBottom
Button PreviousPage = new Button
{
Text="<=",
FontSize=20,
HorizontalOptions=LayoutOptions.Start
};
PreviousPage.Clicked += MovePreviousPage;
Label LabelPageNo = new Label
{
Text = "Page " + (NoP + 1).ToString(),
FontSize = 20,
HorizontalOptions=LayoutOptions.CenterAndExpand
};
Button TopPage = new Button
{
Text = "Top Page",
};
TopPage.Clicked += PopToRoot;
Button NextPage = new Button
{
Text = "=>",
FontSize=20,
HorizontalOptions = LayoutOptions.EndAndExpand
};
NextPage.Clicked += MoveNextPage;
StackLayout stacklayoutBottom = new StackLayout
{
Orientation = StackOrientation.Horizontal,
Children = {
PreviousPage,
TopPage,
LabelPageNo,
NextPage
}
};
ContentPage contentPage = new ContentPage
{
Content = new StackLayout
{
Children =
{
stacklayoutTop1,
stacklayoutTop2,
charagroupTitle,
gridCanvas,
stacklayoutBottom
}
}
};
// store the value of the current contentPage, absoluteLayout and canvas
contentPages[NoP] = contentPage;
absoluteLayouts[NoP] = absoluteLayout;
canvasViews[NoP] = canvas;
NoP++;
CharaGroupTitle.Text = ""; Characters.Text = ""; // Clear text input in Entry
}
// Get the page number of the tapped item and jump to the page
async void JumpPage(object sender, ItemTappedEventArgs e)
{
var index = Pages.IndexOf((Page)e.Item);
await Navigation.PushAsync(contentPages[index]);
page = (int)index; // set Current Page Number
}
// Add Character Node
Entry entry = new Entry();
void AddNodeEntryTextChanged(object sender, TextChangedEventArgs args)
{
entry = sender as Entry;
entryText = entry.Text;
}
void AddNode(object sender, EventArgs args)
{
AddNodeBox(absoluteLayouts[page]);
entry.Text = "";
NoN[page]++;
}
// set flag to control node selection for linking
void AddLinkEntryTextChanged(object sender, TextChangedEventArgs args)
{
entry = sender as Entry;
entryText = entry.Text;
}
int AddLinkFlag = 0; // Initialize AddArrowFlag
void AddLink(object sender, EventArgs args)
{
AddLinkFlag = 1; // 1st node has been selected
// Initialize Node Boarder Color (Red -> Black)
for (int i = 0; i < NoN[page]; i++)
{
node[page, i].BackgroundColor = Color.Black;
}
}
// Page Control
async void MovePreviousPage(object sender, EventArgs args)
{
if (page == 0)
{
await Navigation.PopToRootAsync();
return;
}
page--;
await Navigation.PopAsync();
}
async void MoveNextPage(object sender, EventArgs args)
{
if (page == (NoP-1))
{
await DisplayAlert("",page.ToString(), "OK");
return;
}
page++;
await Navigation.PushAsync(contentPages[page]);
}
async void PopToRoot(object sender, EventArgs args)
{
await Navigation.PopToRootAsync();
}
// add Character Node
void AddNodeBox(AbsoluteLayout absLayout)
{
Entry CharacterName = new Entry
{
FontSize = 20,
BackgroundColor = Color.White,
InputTransparent = true,
Text=entryText
};
ContentView contentView = new ContentView
{
BackgroundColor = Color.Black,
Padding = new Thickness(3),
Content=new StackLayout {
Children = { CharacterName }
}
};
node[page, NoN[page]] = contentView;
// detect tap event
var tap = new TapGestureRecognizer();
tap.Tapped += (s, e) =>
{
if(AddLinkFlag==1) // 1st node has already been selected
{
AddLinkFlag = 2; // 2nd node has been selected
ContentView view = contentView as ContentView;
Rectangle rect = AbsoluteLayout.GetLayoutBounds(view);
for (int i = 0; i < NoN[page]; i++){
if (view == node[page, i]){
linkStart = i; break;
}
}
view.BackgroundColor = Color.Red;
return;
}
else if(AddLinkFlag==2) // 2nd node has already been selected
{
AddLinkFlag = 0; //reset AddLinkFlag
ContentView view = contentView as ContentView;
Rectangle rect = AbsoluteLayout.GetLayoutBounds(view);
for (int i = 0; i < NoN[page]; i++)
{
if (view == node[page, i]) {
linkEnd = i; break;
}
}
// set initial link position to the center of the each node
link[page, linkStart, linkEnd].exist = true;
link[page, linkStart, linkEnd].x1 = (int)((node[page,linkStart].X + node[page,linkStart].Width/2) * scale);
link[page, linkStart, linkEnd].y1 = (int)((node[page,linkStart].Y + node[page,linkStart].Height/2) * scale);
link[page, linkStart, linkEnd].x2 = (int)((node[page,linkEnd].X + node[page,linkEnd].Width/2) * scale);
link[page, linkStart, linkEnd].y2 = (int)((node[page,linkEnd].Y + node[page,linkEnd].Height/2) * scale);
linkTitle[page, linkStart, linkEnd] = entryText;
view.BackgroundColor = Color.Red;
entry.Text = ""; // clear AddLinkEntry
return;
}
};
contentView.GestureRecognizers.Add(tap);
TouchEffect touchEffect = new TouchEffect();
touchEffect.TouchAction += OnTouchEffectAction;
contentView.Effects.Add(touchEffect);
absLayout.Children.Add(contentView);
}
void OnTouchEffectAction(object sender, TouchActionEventArgs args)
{
ContentView view = sender as ContentView;
SKCanvasView canvas = new SKCanvasView();
canvas = canvasViews[page];
// identify the touched node
for (int i = 0; i < NoN[page]; i++)
{
if (view == node[page, i]) { nodeID = i; break; }
}
switch (args.Type)
{
case TouchActionType.Pressed:
// Don't allow a second touch on an already touched BoxView
if (!dragDictionary.ContainsKey(view))
{
dragDictionary.Add(view, new DragInfo(args.Id, args.Location));
// Set Capture property to true
TouchEffect touchEffect = (TouchEffect)view.Effects.FirstOrDefault(e => e is TouchEffect);
touchEffect.Capture = true;
}
break;
case TouchActionType.Moved:
if (dragDictionary.ContainsKey(view) && dragDictionary[view].Id == args.Id)
{
// update all touched node posisions
Rectangle rect = AbsoluteLayout.GetLayoutBounds(view);
Point initialLocation = dragDictionary[view].PressPoint;
rect.X += args.Location.X - initialLocation.X;
rect.Y += args.Location.Y - initialLocation.Y;
AbsoluteLayout.SetLayoutBounds(view, rect);
// Update all link positions of the touched node
// The positions depend on the positions of the start and end node
for (int i = 0; i < NoN[page]; i++){
if(link[page,nodeID,i].exist){
if (node[page, nodeID].X + node[page, nodeID].Width < node[page, i].X)
{
link[page, nodeID, i].x1 = (int)((node[page, nodeID].X + node[page, nodeID].Width) * scale);
link[page, nodeID, i].y1 = (int)((node[page, nodeID].Y + node[page, nodeID].Height / 2 + space) * scale);
link[page, nodeID, i].x2 = (int)(node[page, i].X * scale);
link[page, nodeID, i].y2 = (int)((node[page, i].Y + node[page, i].Height / 2 + space) * scale);
}
else if (node[page, i].X + node[page, i].Width < node[page, nodeID].X)
{
link[page, nodeID, i].x1 = (int)(node[page, nodeID].X * scale);
link[page, nodeID, i].y1 = (int)((node[page, nodeID].Y + node[page, nodeID].Height / 2 + space) * scale);
link[page, nodeID, i].x2 = (int)((node[page, i].X + node[page, i].Width) * scale);
link[page, nodeID, i].y2 = (int)((node[page, i].Y + node[page, i].Height / 2 +space) * scale);
}
else if (node[page, nodeID].Y < node[page, i].Y + node[page, i].Height)
{
link[page, nodeID, i].x1 = (int)((node[page, nodeID].X + node[page, nodeID].Width / 2 + space) * scale);
link[page, nodeID, i].y1 = (int)((node[page, nodeID].Y + node[page, nodeID].Height) * scale);
link[page, nodeID, i].x2 = (int)((node[page, i].X + node[page, i].Width / 2 + space) * scale);
link[page, nodeID, i].y2 = (int)((node[page, i].Y) * scale);
}
else
{
link[page, nodeID, i].x1 = (int)((node[page, nodeID].X + node[page, nodeID].Width / 2 + space) * scale);
link[page, nodeID, i].y1 = (int)((node[page, nodeID].Y) * scale);
link[page, nodeID, i].x2 = (int)((node[page, i].X + node[page, i].Width / 2 + space) * scale);
link[page, nodeID, i].y2 = (int)((node[page, i].Y + node[page, i].Height) * scale);
}
}
if (link[page, i, nodeID].exist)
{
if (node[page, i].X + node[page, i].Width < node[page, nodeID].X )
{
link[page, i, nodeID].x1 = (int)((node[page, i].X + node[page, i].Width) * scale);
link[page, i, nodeID].y1 = (int)((node[page, i].Y + node[page, nodeID].Height / 2 - space) * scale);
link[page, i, nodeID].x2 = (int)(node[page, nodeID].X * scale);
link[page, i, nodeID].y2 = (int)((node[page, nodeID].Y + node[page, nodeID].Height / 2 -space) * scale);
}
else if(node[page, nodeID].X + node[page, nodeID].Width < node[page, i].X)
{
link[page, i, nodeID].x1 = (int)(node[page, i].X * scale);
link[page, i, nodeID].y1 = (int)((node[page, i].Y + node[page, i].Height / 2 - space) * scale);
link[page, i, nodeID].x2 = (int)((node[page, nodeID].X + node[page, nodeID].Width) * scale);
link[page, i, nodeID].y2 = (int)((node[page, nodeID].Y + node[page, nodeID].Height / 2 - space) * scale);
}
else if(node[page, i].Y < node[page, nodeID].Y+node[page, nodeID].Height)
{
link[page, i, nodeID].x1 = (int)((node[page, i].X + node[page, i].Width / 2 - space) * scale);
link[page, i, nodeID].y1 = (int)((node[page, i].Y + node[page, i].Height ) * scale);
link[page, i, nodeID].x2 = (int)((node[page, nodeID].X + node[page, nodeID].Width / 2 -space) * scale);
link[page, i, nodeID].y2 = (int)((node[page, nodeID].Y ) * scale);
}
else
{
link[page, i, nodeID].x1 = (int)((node[page, i].X + node[page, i].Width / 2 - space) * scale);
link[page, i, nodeID].y1 = (int)((node[page, i].Y) * scale);
link[page, i, nodeID].x2 = (int)((node[page, nodeID].X + node[page, nodeID].Width / 2 - space) * scale);
link[page, i, nodeID].y2 = (int)((node[page, nodeID].Y + node[page, nodeID].Height ) * scale);
}
}
}
canvas.InvalidateSurface();
}
break;
case TouchActionType.Released:
if (dragDictionary.ContainsKey(view) && dragDictionary[view].Id == args.Id)
{
dragDictionary.Remove(view);
canvas.InvalidateSurface();
}
break;
}
}
// draw links using SkiaSharp
void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs e)
{
var surface = e.Surface;
var canvas = surface.Canvas;
canvas.Clear();
// Draw Line
for (int i = 0; i < NoN[page]; i++)
{
for (int j = 0; j < NoN[page]; j++)
{
if (link[page, i, j].exist)
{
canvas.DrawLine(link[page, i, j].x1,
link[page, i, j].y1,
link[page, i, j].x2,
link[page, i, j].y2, linePaint);
// draw circle instead of arrow at the ending point of this link
canvas.DrawCircle(link[page, i, j].x2,
link[page, i, j].y2,
5 * scale,
circlePaint);
// draw link title
canvas.DrawText(linkTitle[page, i, j],
(link[page, i, j].x1 + link[page, i, j].x2) / 2,
(link[page, i, j].y1 + link[page, i, j].y2) / 2,
textPaint);
}
}
}
}
private SKPaint linePaint = new SKPaint
{
StrokeWidth = 2,
IsAntialias = true,
Color = SKColors.Black
};
private SKPaint circlePaint = new SKPaint
{
Color = SKColors.Black
};
private SKPaint textPaint = new SKPaint
{
TextSize = 15,
Color = SKColors.Black
};
}
}
using System.Collections.Generic;
using System.Linq;
using System.Collections.ObjectModel;
using Xamarin.Forms;
using SkiaSharp;
using SkiaSharp.Views.Forms;
using TouchTracking;
namespace DrawTouch
{
public partial class MainPage : ContentPage
{
public class Page
{
public string Title { get; set; }
public string Characters { get; set; }
}
public static ObservableCollection<Page> Pages;
public class Link
{
public bool exist; // true, if link exists
public int x1, y1, x2, y2; // start and end points
}
Dictionary<ContentView, DragInfo> dragDictionary = new Dictionary<ContentView, DragInfo>();
// set some array variables to memory the corresponding variable
static int maxpage = 20;
static int maxnode = 10;
ContentPage[] contentPages = new ContentPage[maxpage];
SKCanvasView[] canvasViews = new SKCanvasView[maxpage];
AbsoluteLayout[] absoluteLayouts = new AbsoluteLayout[maxpage];
ContentView[,] node = new ContentView[maxpage, maxnode];
Link[,,] link = new Link[maxpage, maxnode, maxnode];
string[,,] linkTitle = new string[maxpage, maxnode, maxnode];
int page = 0; // Current Page
int NoP = 0; // The Number of Page
int[] NoN = Enumerable.Repeat<int>(0, maxnode).ToArray(); //The Number of Nodes in each Page
int linkStart, linkEnd = 0; // temporary variable
string entryText = ""; // temporary variable
int nodeID = 0; // temporary variable
int scale = 2; // canvas scale: 2(iOS), 1(UWP)
int space = 7; // space between two arrows with opposite direction
public MainPage()
{
InitializeComponent();
// Initialize PageListView
Pages = new ObservableCollection<Page>()
{
new Page(){Title="No Groups", Characters="" }
};
PageListView.ItemsSource = Pages;
// Initialize link[,,]
for (int i = 0; i < 20; i++)
{
for (int j = 0; j < 10; j++)
{
for (int k = 0; k < 10; k++)
{
link[i, j, k] = new Link { exist = false, x1 = 0, y1 = 0, x2 = 0, y2 = 0 };
}
}
}
}
// Add Character Group Title to PageListView, and
// Create a new Character Group Page to draw the characters correlation diagram
void AddPage(object sender, EventArgs args)
{
// Add Character Group Title to PageListView
Page new_page = new Page()
{
Title = CharaGroupTitle.Text,
Characters = Characters.Text
};
if (NoP == 0) Pages.Clear(); // Delete "No Pages" message, if 1st item was added
Pages.Add(new_page);
// stacklayoutTop1
Entry AddNodeEntry = new Entry
{
Placeholder = "Character Name",
WidthRequest=230
};
AddNodeEntry.TextChanged += AddNodeEntryTextChanged;
Button AddNodeButton = new Button
{
Text = "Add Character",
BorderColor = Color.Blue,
BorderWidth = 1,
HorizontalOptions = LayoutOptions.EndAndExpand,
WidthRequest=120
};
AddNodeButton.Clicked += AddNode;
StackLayout stacklayoutTop1 = new StackLayout
{
Orientation = StackOrientation.Horizontal,
Children =
{
AddNodeEntry,
AddNodeButton
}
};
// stacklayoutTop2
Entry AddLinkEntry = new Entry
{
Placeholder = "Link Name",
WidthRequest = 230
};
AddLinkEntry.TextChanged += AddLinkEntryTextChanged;
Button AddLinkButton = new Button
{
Text = "Add Link",
BorderColor = Color.Blue,
BorderWidth = 1,
HorizontalOptions = LayoutOptions.EndAndExpand,
WidthRequest=120
};
AddLinkButton.Clicked += AddLink;
StackLayout stacklayoutTop2 = new StackLayout
{
Orientation=StackOrientation.Horizontal,
Children =
{
AddLinkEntry,
AddLinkButton
}
};
// Character Group Title
Label charagroupTitle = new Label
{
Text = CharaGroupTitle.Text,
FontSize = 25
};
// create SkiaSharp canvas and AbsoluteLayout to draw CharacterNode
SKCanvasView canvas = new SKCanvasView();
canvas.PaintSurface += OnCanvasViewPaintSurface;
AbsoluteLayout absoluteLayout = new AbsoluteLayout { };
Grid gridCanvas = new Grid
{
HeightRequest = 500,
WidthRequest = 300,
BackgroundColor = Color.Gray,
Children = {
canvas,
absoluteLayout
}
};
//stacklayoutBottom
Button PreviousPage = new Button
{
Text="<=",
FontSize=20,
HorizontalOptions=LayoutOptions.Start
};
PreviousPage.Clicked += MovePreviousPage;
Label LabelPageNo = new Label
{
Text = "Page " + (NoP + 1).ToString(),
FontSize = 20,
HorizontalOptions=LayoutOptions.CenterAndExpand
};
Button TopPage = new Button
{
Text = "Top Page",
};
TopPage.Clicked += PopToRoot;
Button NextPage = new Button
{
Text = "=>",
FontSize=20,
HorizontalOptions = LayoutOptions.EndAndExpand
};
NextPage.Clicked += MoveNextPage;
StackLayout stacklayoutBottom = new StackLayout
{
Orientation = StackOrientation.Horizontal,
Children = {
PreviousPage,
TopPage,
LabelPageNo,
NextPage
}
};
ContentPage contentPage = new ContentPage
{
Content = new StackLayout
{
Children =
{
stacklayoutTop1,
stacklayoutTop2,
charagroupTitle,
gridCanvas,
stacklayoutBottom
}
}
};
// store the value of the current contentPage, absoluteLayout and canvas
contentPages[NoP] = contentPage;
absoluteLayouts[NoP] = absoluteLayout;
canvasViews[NoP] = canvas;
NoP++;
CharaGroupTitle.Text = ""; Characters.Text = ""; // Clear text input in Entry
}
// Get the page number of the tapped item and jump to the page
async void JumpPage(object sender, ItemTappedEventArgs e)
{
var index = Pages.IndexOf((Page)e.Item);
await Navigation.PushAsync(contentPages[index]);
page = (int)index; // set Current Page Number
}
// Add Character Node
Entry entry = new Entry();
void AddNodeEntryTextChanged(object sender, TextChangedEventArgs args)
{
entry = sender as Entry;
entryText = entry.Text;
}
void AddNode(object sender, EventArgs args)
{
AddNodeBox(absoluteLayouts[page]);
entry.Text = "";
NoN[page]++;
}
// set flag to control node selection for linking
void AddLinkEntryTextChanged(object sender, TextChangedEventArgs args)
{
entry = sender as Entry;
entryText = entry.Text;
}
int AddLinkFlag = 0; // Initialize AddArrowFlag
void AddLink(object sender, EventArgs args)
{
AddLinkFlag = 1; // 1st node has been selected
// Initialize Node Boarder Color (Red -> Black)
for (int i = 0; i < NoN[page]; i++)
{
node[page, i].BackgroundColor = Color.Black;
}
}
// Page Control
async void MovePreviousPage(object sender, EventArgs args)
{
if (page == 0)
{
await Navigation.PopToRootAsync();
return;
}
page--;
await Navigation.PopAsync();
}
async void MoveNextPage(object sender, EventArgs args)
{
if (page == (NoP-1))
{
await DisplayAlert("",page.ToString(), "OK");
return;
}
page++;
await Navigation.PushAsync(contentPages[page]);
}
async void PopToRoot(object sender, EventArgs args)
{
await Navigation.PopToRootAsync();
}
// add Character Node
void AddNodeBox(AbsoluteLayout absLayout)
{
Entry CharacterName = new Entry
{
FontSize = 20,
BackgroundColor = Color.White,
InputTransparent = true,
Text=entryText
};
ContentView contentView = new ContentView
{
BackgroundColor = Color.Black,
Padding = new Thickness(3),
Content=new StackLayout {
Children = { CharacterName }
}
};
node[page, NoN[page]] = contentView;
// detect tap event
var tap = new TapGestureRecognizer();
tap.Tapped += (s, e) =>
{
if(AddLinkFlag==1) // 1st node has already been selected
{
AddLinkFlag = 2; // 2nd node has been selected
ContentView view = contentView as ContentView;
Rectangle rect = AbsoluteLayout.GetLayoutBounds(view);
for (int i = 0; i < NoN[page]; i++){
if (view == node[page, i]){
linkStart = i; break;
}
}
view.BackgroundColor = Color.Red;
return;
}
else if(AddLinkFlag==2) // 2nd node has already been selected
{
AddLinkFlag = 0; //reset AddLinkFlag
ContentView view = contentView as ContentView;
Rectangle rect = AbsoluteLayout.GetLayoutBounds(view);
for (int i = 0; i < NoN[page]; i++)
{
if (view == node[page, i]) {
linkEnd = i; break;
}
}
// set initial link position to the center of the each node
link[page, linkStart, linkEnd].exist = true;
link[page, linkStart, linkEnd].x1 = (int)((node[page,linkStart].X + node[page,linkStart].Width/2) * scale);
link[page, linkStart, linkEnd].y1 = (int)((node[page,linkStart].Y + node[page,linkStart].Height/2) * scale);
link[page, linkStart, linkEnd].x2 = (int)((node[page,linkEnd].X + node[page,linkEnd].Width/2) * scale);
link[page, linkStart, linkEnd].y2 = (int)((node[page,linkEnd].Y + node[page,linkEnd].Height/2) * scale);
linkTitle[page, linkStart, linkEnd] = entryText;
view.BackgroundColor = Color.Red;
entry.Text = ""; // clear AddLinkEntry
return;
}
};
contentView.GestureRecognizers.Add(tap);
TouchEffect touchEffect = new TouchEffect();
touchEffect.TouchAction += OnTouchEffectAction;
contentView.Effects.Add(touchEffect);
absLayout.Children.Add(contentView);
}
void OnTouchEffectAction(object sender, TouchActionEventArgs args)
{
ContentView view = sender as ContentView;
SKCanvasView canvas = new SKCanvasView();
canvas = canvasViews[page];
// identify the touched node
for (int i = 0; i < NoN[page]; i++)
{
if (view == node[page, i]) { nodeID = i; break; }
}
switch (args.Type)
{
case TouchActionType.Pressed:
// Don't allow a second touch on an already touched BoxView
if (!dragDictionary.ContainsKey(view))
{
dragDictionary.Add(view, new DragInfo(args.Id, args.Location));
// Set Capture property to true
TouchEffect touchEffect = (TouchEffect)view.Effects.FirstOrDefault(e => e is TouchEffect);
touchEffect.Capture = true;
}
break;
case TouchActionType.Moved:
if (dragDictionary.ContainsKey(view) && dragDictionary[view].Id == args.Id)
{
// update all touched node posisions
Rectangle rect = AbsoluteLayout.GetLayoutBounds(view);
Point initialLocation = dragDictionary[view].PressPoint;
rect.X += args.Location.X - initialLocation.X;
rect.Y += args.Location.Y - initialLocation.Y;
AbsoluteLayout.SetLayoutBounds(view, rect);
// Update all link positions of the touched node
// The positions depend on the positions of the start and end node
for (int i = 0; i < NoN[page]; i++){
if(link[page,nodeID,i].exist){
if (node[page, nodeID].X + node[page, nodeID].Width < node[page, i].X)
{
link[page, nodeID, i].x1 = (int)((node[page, nodeID].X + node[page, nodeID].Width) * scale);
link[page, nodeID, i].y1 = (int)((node[page, nodeID].Y + node[page, nodeID].Height / 2 + space) * scale);
link[page, nodeID, i].x2 = (int)(node[page, i].X * scale);
link[page, nodeID, i].y2 = (int)((node[page, i].Y + node[page, i].Height / 2 + space) * scale);
}
else if (node[page, i].X + node[page, i].Width < node[page, nodeID].X)
{
link[page, nodeID, i].x1 = (int)(node[page, nodeID].X * scale);
link[page, nodeID, i].y1 = (int)((node[page, nodeID].Y + node[page, nodeID].Height / 2 + space) * scale);
link[page, nodeID, i].x2 = (int)((node[page, i].X + node[page, i].Width) * scale);
link[page, nodeID, i].y2 = (int)((node[page, i].Y + node[page, i].Height / 2 +space) * scale);
}
else if (node[page, nodeID].Y < node[page, i].Y + node[page, i].Height)
{
link[page, nodeID, i].x1 = (int)((node[page, nodeID].X + node[page, nodeID].Width / 2 + space) * scale);
link[page, nodeID, i].y1 = (int)((node[page, nodeID].Y + node[page, nodeID].Height) * scale);
link[page, nodeID, i].x2 = (int)((node[page, i].X + node[page, i].Width / 2 + space) * scale);
link[page, nodeID, i].y2 = (int)((node[page, i].Y) * scale);
}
else
{
link[page, nodeID, i].x1 = (int)((node[page, nodeID].X + node[page, nodeID].Width / 2 + space) * scale);
link[page, nodeID, i].y1 = (int)((node[page, nodeID].Y) * scale);
link[page, nodeID, i].x2 = (int)((node[page, i].X + node[page, i].Width / 2 + space) * scale);
link[page, nodeID, i].y2 = (int)((node[page, i].Y + node[page, i].Height) * scale);
}
}
if (link[page, i, nodeID].exist)
{
if (node[page, i].X + node[page, i].Width < node[page, nodeID].X )
{
link[page, i, nodeID].x1 = (int)((node[page, i].X + node[page, i].Width) * scale);
link[page, i, nodeID].y1 = (int)((node[page, i].Y + node[page, nodeID].Height / 2 - space) * scale);
link[page, i, nodeID].x2 = (int)(node[page, nodeID].X * scale);
link[page, i, nodeID].y2 = (int)((node[page, nodeID].Y + node[page, nodeID].Height / 2 -space) * scale);
}
else if(node[page, nodeID].X + node[page, nodeID].Width < node[page, i].X)
{
link[page, i, nodeID].x1 = (int)(node[page, i].X * scale);
link[page, i, nodeID].y1 = (int)((node[page, i].Y + node[page, i].Height / 2 - space) * scale);
link[page, i, nodeID].x2 = (int)((node[page, nodeID].X + node[page, nodeID].Width) * scale);
link[page, i, nodeID].y2 = (int)((node[page, nodeID].Y + node[page, nodeID].Height / 2 - space) * scale);
}
else if(node[page, i].Y < node[page, nodeID].Y+node[page, nodeID].Height)
{
link[page, i, nodeID].x1 = (int)((node[page, i].X + node[page, i].Width / 2 - space) * scale);
link[page, i, nodeID].y1 = (int)((node[page, i].Y + node[page, i].Height ) * scale);
link[page, i, nodeID].x2 = (int)((node[page, nodeID].X + node[page, nodeID].Width / 2 -space) * scale);
link[page, i, nodeID].y2 = (int)((node[page, nodeID].Y ) * scale);
}
else
{
link[page, i, nodeID].x1 = (int)((node[page, i].X + node[page, i].Width / 2 - space) * scale);
link[page, i, nodeID].y1 = (int)((node[page, i].Y) * scale);
link[page, i, nodeID].x2 = (int)((node[page, nodeID].X + node[page, nodeID].Width / 2 - space) * scale);
link[page, i, nodeID].y2 = (int)((node[page, nodeID].Y + node[page, nodeID].Height ) * scale);
}
}
}
canvas.InvalidateSurface();
}
break;
case TouchActionType.Released:
if (dragDictionary.ContainsKey(view) && dragDictionary[view].Id == args.Id)
{
dragDictionary.Remove(view);
canvas.InvalidateSurface();
}
break;
}
}
// draw links using SkiaSharp
void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs e)
{
var surface = e.Surface;
var canvas = surface.Canvas;
canvas.Clear();
// Draw Line
for (int i = 0; i < NoN[page]; i++)
{
for (int j = 0; j < NoN[page]; j++)
{
if (link[page, i, j].exist)
{
canvas.DrawLine(link[page, i, j].x1,
link[page, i, j].y1,
link[page, i, j].x2,
link[page, i, j].y2, linePaint);
// draw circle instead of arrow at the ending point of this link
canvas.DrawCircle(link[page, i, j].x2,
link[page, i, j].y2,
5 * scale,
circlePaint);
// draw link title
canvas.DrawText(linkTitle[page, i, j],
(link[page, i, j].x1 + link[page, i, j].x2) / 2,
(link[page, i, j].y1 + link[page, i, j].y2) / 2,
textPaint);
}
}
}
}
private SKPaint linePaint = new SKPaint
{
StrokeWidth = 2,
IsAntialias = true,
Color = SKColors.Black
};
private SKPaint circlePaint = new SKPaint
{
Color = SKColors.Black
};
private SKPaint textPaint = new SKPaint
{
TextSize = 15,
Color = SKColors.Black
};
}
}
In the first some lines of this code, two classes and several variables are declared. "Page" class has two fields, characters group name and characters, and this class will be tap-enable items of PageListView in the main page. "Link" class is for directed links connecting nodes of characters explained later, and has five fields, link existence and coordinates of start and end points. To share some sort of data between methods, declare some array data and initialize them. maxpage and maxnode are the variables to set the size of the array data. BTW, in the declaration of the variables, "int maxpage=20;" causes a built error. "static int maxpage=20;" seems to be correct. I seem to need to learn more...
When text input of characters group name occurred in the main page, AddPage method are called. Firstly, the characters group name are added to PageListView, then, create page contents to create and display the characters correlation diagram. The page contains Entries and Buttons to input characters and link titles, Buttons for page navigation, canvas on which links are drawn using SkiaSharp, and AbsoluteLayout on which some Entries displaying characters are placed. The canvas and the AbsoluteLayout are overlapped in a Grid in the same way as the SkiaSharp sample program. Finally, save some variables referenced from other methods. As you have noticed, SkiaSharp and SkiaSharp.Views.Forms of NuGet packages should be added to each projects.
JumePage method detects the list index of the tapped characters group name using IndexOf method, and navigates to the page of the tapped characters group.
AddNodeEntryTextChanged and the some following methods are to add characters node and links. I couldn't find a proper way to get text from Entry control described in not xaml but code behind (C#), so I used TextChanged event to do it this time. Is there any other smart way?
MovePreviousPage and the following two methods are for page navigation.
AddNodeBox method adds and displays characters as nodes of characters correlation diagram. The characters are displayed using Entry instead of Label so that they could be edited in the future, but they don't need to be edited in this version, so made InputTransparent property of the Entry "true." The Entry controls are not directly placed in absoluteLayout. Once they are placed in ContentViews, and the ContentViews are placed in absoluteLayout, so that they could be tapped and dragged by OnTouchEffectAction explained later. TapGestureRecognizer detects tap event when connecting two characters (nodes). After AddLink Button is pressed, a link is created so that the 1st tapped node is start point and the 2nd one is the end point.
OnTouchEffectAction method detects touch action type against characters nodes such as "Pressed", "Moved", and "Released." While drag a node linked with other node, calculate the start and end points of the link optimally according to the relative position of the linked two nodes. And also, any two nodes can be linked in both directions, and the two links are placed with a small gap not to overlapp each other.
Finally, in OnCanvasViewPaintSurface, draw links, end point circles of the links, and link titles at the each positions calculated in OnTouchEffectAction method. I actually wanted to draw arrow heads at the end point of links, but I didn't because the codes becomes complicated. Also, for the positions of link titles, it may be better to calculate the optimum positions, but I didn't do it, since it has nothing to do with this theme, study drawing and tap&drag.
In App.xaml.cs, just changed "MainPage=new MainPage();" to "MainPage=new NavigationPage(new MainPage());"
TouchTracking.cs
using System;
using Xamarin.Forms;
namespace TouchTracking
{
class DragInfo
{
public DragInfo(long id, Point pressPoint)
{
Id = id;
PressPoint = pressPoint;
}
public long Id { private set; get; }
public Point PressPoint { private set; get; }
}
public delegate void TouchActionEventHandler(object sender, TouchActionEventArgs args);
public enum TouchActionType
{
Entered,
Pressed,
Moved,
Released,
Exited,
Cancelled
}
public class TouchEffect : RoutingEffect
{
public event TouchActionEventHandler TouchAction;
public TouchEffect() : base("XamarinDocs.TouchEffect")
{
}
public bool Capture { set; get; }
public void OnTouchAction(Element element, TouchActionEventArgs args)
{
TouchAction?.Invoke(element, args);
}
}
public class TouchActionEventArgs : EventArgs
{
public TouchActionEventArgs(long id, TouchActionType type, Point location, bool isInContact)
{
Id = id;
Type = type;
Location = location;
IsInContact = isInContact;
}
public long Id { private set; get; }
public TouchActionType Type { private set; get; }
public Point Location { private set; get; }
public bool IsInContact { private set; get; }
}
}
using Xamarin.Forms;
namespace TouchTracking
{
class DragInfo
{
public DragInfo(long id, Point pressPoint)
{
Id = id;
PressPoint = pressPoint;
}
public long Id { private set; get; }
public Point PressPoint { private set; get; }
}
public delegate void TouchActionEventHandler(object sender, TouchActionEventArgs args);
public enum TouchActionType
{
Entered,
Pressed,
Moved,
Released,
Exited,
Cancelled
}
public class TouchEffect : RoutingEffect
{
public event TouchActionEventHandler TouchAction;
public TouchEffect() : base("XamarinDocs.TouchEffect")
{
}
public bool Capture { set; get; }
public void OnTouchAction(Element element, TouchActionEventArgs args)
{
TouchAction?.Invoke(element, args);
}
}
public class TouchActionEventArgs : EventArgs
{
public TouchActionEventArgs(long id, TouchActionType type, Point location, bool isInContact)
{
Id = id;
Type = type;
Location = location;
IsInContact = isInContact;
}
public long Id { private set; get; }
public TouchActionType Type { private set; get; }
public Point Location { private set; get; }
public bool IsInContact { private set; get; }
}
}
This code should be added to PCL. This is just a part of SkiaSharp official sample program (demo program tapping and dragging bitmap image of monkey).
And also, some codes for each device platform are necessary. In the case of iOS, TouchEffect.cs and TouchRecognizer.cs should be added to iOS project. You can find these codes in the official sample program.
Ran this sample program on iOS simulator:
Main page. The three characters groups are listed, and the fourth characters group are being inputted in the Entry. If you tap one of the characters group, "Anti Terrorist Task Force" for example, move to the page shown below.
The four characters has been added, and two of them are just linked (They are characters from Nelson Demille's "The Lion's Game"). Input "colleague" into Link Name Entry, and press AddLink Button -> tap "John Corey" node -> tap "George Foster" node, then you can see the display shown above. Every node can be dragged, and the link positions change dynamically according to the node positions.
All characters (nodes) are linked each other.
As mentioned above, I studied drawing using SkiaSharp and how to tap&drag items.
コメント
コメントを投稿