Drag and drop in Xamarin.Forms (Fixed for Android)
In this article I will be showing you how to add a Drag and Drop interaction in your Xamarin.Forms app. I won't use any native component or Custom Renderer on purpose, many of these solutions are available elsewhere (see Drag and Drop solutions for Xamarin). I'll be using PanGestureRecognizer, TranslateTo and some basic maths.
All the code I'm about to explain here is available on my Developer Playground on Github. Feel free to check it out.
1- PanGestureRecognizer
The Sample 1 page let's you play with the PanGestureRecognizer and the data it sends. A pan gesture is a gesture where the user puts at least one finger down and drags it from a point A to a point B then releases it. This gesture fits perfectly what we want to do.
This sample shows you that:
- PanUpdatedEventArgs.TotalX and PanUpdatedEventArgs.TotalY are delta distances between start and end of the pan gesture
- If you initiate two gestures at the same time the PanUpdatedEventArgs.GestureId might be the same, do not use as ID
- PanUpdatedEventArgs.StatusType has one of the following values: Canceled, Completed, Running or Started
- When two views are overlapping, with each having a gesture recognizer, the top level one captures the gesture and the bottom one doesn't
2- Moving the view around
2.1- View.TranslateTo(X, Y);
Now that we know how to read the information from a PanGesture, we need to move the drag and droppable view accordingly. For this I use TranslateTo() because it only moves the interface, the element itself thinks he doesn't move. No need to "Redraw" or call "InvalidateLayout".
Here you can play with the duration of the animation, in milliseconds. If you want to add a delay or make the ui fade out when the drag and drop ends, this is the place.
2.2- GetScreenCoordinates();
We want to be able to know where our view is in the screen. Since we don't actually move it we need a way to calculate these coordinates. We need many values to calculate that:
- Distance from the Top of the screen to the Top of the view before pan
- Distance from the Left of the screen to the Left of the view before pan
- Pan distance horizontally: e.TotalX
- Pan distance vertically: e.TotalY
- Distance from the Left of the view to the Center of the view: Width / 2
- Distance from the Top of the view to the Center of the view: Height / 2
To get the first two, I found a comment from Dan Meier (@FactoryOptimizr) on the Xamarin's forum:
/// <summary>
/// Gets the screen coordinates from top left corner.
/// </summary>
/// <returns>The screen coordinates.</returns>
/// <param name="view">View.</param>
public static (double X, double Y) GetScreenCoordinates(this VisualElement view)
{
// A view's default X- and Y-coordinates are LOCAL with respect to the boundaries of its parent,
// and NOT with respect to the screen. This method calculates the SCREEN coordinates of a view.
// The coordinates returned refer to the top left corner of the view.
// Initialize with the view's "local" coordinates with respect to its parent
double screenCoordinateX = view.X;
double screenCoordinateY = view.Y;
// Get the view's parent (if it has one...)
if (view.Parent.GetType() != typeof(App))
{
VisualElement parent = (VisualElement) view.Parent;
// Loop through all parents
while (parent != null)
{
// Add in the coordinates of the parent with respect to ITS parent
screenCoordinateX += parent.X;
screenCoordinateY += parent.Y;
// If the parent of this parent isn't the app itself, get the parent's parent.
if (parent.Parent.GetType() == typeof(App))
parent = null;
else
parent = (VisualElement) parent.Parent;
}
}
// Return the final coordinates...which are the global SCREEN coordinates of the view
return (screenCoordinateX, screenCoordinateY);
}
Thank you for that Dan! 😉
[Check out this file on Github]
This allows us to calculate the screen coordinates:
var screenCoordinates = this.GetScreenCoordinates();
var xPosition = screenCoordinates.X + e.TotalX + Width / 2;
var yPosition = screenCoordinates.Y + e.TotalY + Height / 2;
3- Bringing it all together
A full Drag and Drop interaction require events to be sent from the moving views to one or many receiving views.
3.1- Receiving View
The receiving view will be the view reacting to some other view drag and dropping. I made IDragAndDropHoverableView and IDragAndDropReceivingView for that:
public interface IDragAndDropHoverableView
{
void OnHovered(List<IDragAndDropMovingView> views);
}
public interface IDragAndDropReceivingView
{
void OnDropReceived(IDragAndDropMovingView view);
}
Note that it can be hovered by many views at once, hence the List<> of views in the parameters. For Drop on the other hand I want to take care of drops separately.
3.2- Moving View
The moving view is the view that ... is moving! From that view we need to have the position of the centre (X,Y) available:
public interface IDragAndDropMovingView
{
double ScreenX { get; set; }
double ScreenY { get; set; }
}
To initialise that view I decided to make it an extension method, so it can be called from different types of moving views. It is useful if you want to drag and drop an image, a button, a video, a frame ... anything.
public class DragAndDropSample3MovingView : Frame, IDragAndDropMovingView
{
public double ScreenX { get; set; }
public double ScreenY { get; set; }
protected override void OnParentSet()
{
base.OnParentSet();
this.InitializeDragAndDrop();
}
}
3.3- Drag and drop Container
To manage all this, we need a container to know what can interact with what. For this reason I added the function GetContainer() that goes through all parent views looking for an element of type IDragAndDropContainer of, if none is found, a Page.
private static VisualElement GetContainer(this IDragAndDropMovingView view)
{
if (!(view is VisualElement visualElement))
throw new Exception($"{nameof(IDragAndDropMovingView)} can only be an interface on a {nameof(View)}");
var dropContainer = visualElement.GetFirstParentOfType<IDragAndDropContainer>();
if (dropContainer is VisualElement output) return output;
return visualElement.GetFirstParentOfType<Page>();
}
3.4- Get all children of type T in View
We need to retrieve all the subviews, children that match the type given in order to get all the receiving or moving views from the container :
public static List<T> GetAllChildrenOfType<T>(this Element view)
{
if (view is ContentPage page) view = page.Content;
var output = new List<T>();
if (view is T tview)
output.Add(tview);
if (view is Layout layout)
foreach (var child in layout.Children)
if (child is Element elementChild)
output.AddRange(elementChild.GetAllChildrenOfType<T>());
return output;
}
3.5- GetFirstParentOfType<T>();
If we put all our drag and drop components inside a container, we need those components to crawl up the view-tree to reach that container. Here is an extension function that does that :
public static T GetFirstParentOfType<T>(this Element view)
{
Element output = view;
while (output.Parent != null)
{
if (output.Parent is T parent)
return parent;
output = output.Parent;
}
return default;
}
3.6- UpdateHoverStatuses();
When the moving views are moving, you can see (at the bottom of PanGestureRecognizer_PanUpdated) that they call UpdateHoverStatuses() on the container. This makes the container to recalculate which view is on top of which and to send that information to the receiving views :
private static void UpdateHoverStatuses(this VisualElement view)
{
var allReceivers = view.GetAllChildrenOfType<IDragAndDropHoverableView>();
var allSenders = view.GetAllChildrenOfType<IDragAndDropMovingView>();
foreach (var receiver in allReceivers)
{
if (!(receiver is VisualElement veReceiver)) continue;
var x = veReceiver.GetScreenCoordinates().X;
var y = veReceiver.GetScreenCoordinates().Y;
var width = veReceiver.Width;
var height = veReceiver.Height;
receiver.OnHovered(allSenders.Where(sender => sender.ScreenX >= x &&
sender.ScreenX <= x + width &&
sender.ScreenY >= y &&
sender.ScreenY <= y + height
).ToList());
}
}
3.7- DragAndDroppableViewDropped(IDragAndDropMovingView);
We also want the container to forward the information to receiving views when a moving view is dropped. Because there might be multiple views underneath I'm sending the info to every receiving view that matches :
private static void DragAndDroppableViewDropped(this VisualElement view, IDragAndDropMovingView sender)
{
var allReceivers = view.GetAllChildrenOfType<IDragAndDropReceivingView>();
foreach (var receiver in allReceivers)
{
if (!(receiver is VisualElement veReceiver)) continue;
var x = veReceiver.GetScreenCoordinates().X;
var y = veReceiver.GetScreenCoordinates().Y;
var width = veReceiver.Width;
var height = veReceiver.Height;
if (sender.ScreenX >= x &&
sender.ScreenX <= x + width &&
sender.ScreenY >= y &&
sender.ScreenY <= y + height)
receiver.OnDropReceived(sender);
}
}
Result
[The source code is available on my Github]
Let me know if it helped you! 😀