Skip to content

Instantly share code, notes, and snippets.

@alextd
Forked from shanecelis/DragManipulator.cs
Last active April 16, 2025 04:31
Show Gist options
  • Save alextd/4c5e4119dad02690fd3785fca1d234d5 to your computer and use it in GitHub Desktop.
Save alextd/4c5e4119dad02690fd3785fca1d234d5 to your computer and use it in GitHub Desktop.
This manipulator makes a visual element draggable at runtime in Unity's UIToolkit.
/* Original code[1] Copyright (c) 2022 Shane Celis[2]
Licensed under the MIT License[3]
(edits from alextd)
[1]: https://gist.github.com/shanecelis/b6fb3fe8ed5356be1a3aeeb9e7d2c145
[2]: https://twitter.com/shanecelis
[3]: https://opensource.org/licenses/MIT
*/
using System;
using UnityEngine;
using UnityEngine.UIElements;
using System.Collections.Generic;
/** This manipulator makes a visual element draggable at runtime. Unity's
UIToolkit also has a [drag-and-drop system][1] but it is only appropriate
for use within its editor.
## Usage
```
element.AddManipulator(new DragManipulator());
element.RegisterCallback<DropEvent>(evt =>
Debug.Log($"{evt.target} dropped on {evt.dropTarget}");
```
OR
```
foreach (var element in root.Query(className: "draggable").Build()) {
element.AddManipulator(new DragManipulator());
}
root.RegisterCallback<DropEvent>(evt =>
Debug.Log($"{evt.target} dropped on {evt.dropTarget}");
```
### Styling
When dragging, one should be able to style the participating elements.
Coupled with Unity Style Sheet (USS) transitions, one can provide automatic
tweens.
| USS Selectors | Description |
|-----------------------+-----------------------------------------------|
| .draggable | Present on any element with a DragManipulator |
| .draggable--dragging | Present while dragging |
| .draggable--can-drop | Present while dragging over a dropTarget |
| .dropTarget | Identifies a dropTarget element (editable) |
| .dropTarget--can-take | Present while a draggable is hovering |
When reordering = true:
| --can-take--left | Pointer is over the left or right half |
| --can-take--right | of the dropTarget element |
TODO: handle vertical, with --top and --bottom.
A custom property also allows one to disable dragging via the style sheet.
| USS Properties | Description |
|---------------------+------------------------------------------------|
| --draggable-enabled | When set to false, dragging is disabled |
## Requirements
- Unity 2020.3 or later
## Dragging
Clicking and dragging on the draggable element will cause it to move. The
USS class "draggable--dragging" will be present during
the duration.
### Remove USS Class on Drag
One can remove a USS class while dragging by setting the following
parameter at initialization:
```
var dragger = new DragManipulator { removeClassOnDrag = "transitions" };
```
Usage: If one has translation USS transitions set, dragging may look wrong
and may not be smooth. Placing transitions into a special class and removing
that class during the drag fixed that problem.
## Dropping
Elements that have a "dropTarget" USS class will be considered dropTarget.
When dragging and hovering over a dropTarget element, the USS class
"dropTarget--can-take" will be added; the draggable element will have
"draggable--can-drop" added to it.
If the draggable element is dropped on a non-dropTarget element, the
draggable element's position is reset. It is suggested that one turn on USS
transitions if one wants the draggable to tween back into its original
place.
### Distinct Droppables
If one has distinct dropTarget objects, one set the `droppableId` on the
`DragManipulator` to something other than "dropTarget".
```
var dragger = new DragManipulator { droppableId = "discard-pile" };
```
## Handling Events
When a draggable element is released on a dropTarget element or its child, a
`DropEvent` is emitted. The position of the element is not reset
automatically in that case. If the dropped object is supposed to return to
its original position, one ought to do that in the callback code.
```
void OnDrag(DropEvent evt) {
evt.target.transform.position = Vector3.zero;
// OR
// evt.dragger.ResetPosition();
}
```
## Limitations
This manipulator changes the `transform.position` of the target element
while dragging. If one's styling is making use of that, the behavior is
undefined.
## Notes
The drop event bubbles up, so the callback can be placed on the parent or
root element.
Acknowledgments to Crayz[2] and Stacey[3] for their inspiring code.
[1]: https://forum.unity.com/threads/visualelement-drag-and-drop-during-runtime.930000/#post-6373881
[2]: https://forum.unity.com/threads/creating-draggable-visualelement-and-clamping-it-to-screen.1017715/
[3]: https://gamedev-resources.com/create-an-in-game-inventory-ui-with-ui-toolkit/
*/
public class DragManipulator : IManipulator
{
private VisualElement _target;
public VisualElement target
{
get => _target;
set
{
if (_target != null)
{
if (_target == value)
return;
_target.UnregisterCallback<PointerDownEvent>(DragBegin);
_target.UnregisterCallback<PointerUpEvent>(DragEnd);
_target.UnregisterCallback<PointerMoveEvent>(PointerMove);
_target.UnregisterCallback<CustomStyleResolvedEvent>(OnCustomStyleResolved);
_target.RemoveFromClassList("draggable");
lastDroppable?.RemoveFromClassList("dropTarget--can-take");
lastDroppable = null;
}
_target = value;
_target.RegisterCallback<PointerDownEvent>(DragBegin);
_target.RegisterCallback<PointerUpEvent>(DragEnd);
_target.RegisterCallback<PointerMoveEvent>(PointerMove);
_target.RegisterCallback<CustomStyleResolvedEvent>(OnCustomStyleResolved);
_target.AddToClassList("draggable");
}
}
protected static readonly CustomStyleProperty<bool> draggableEnabledProperty
= new CustomStyleProperty<bool>("--draggable-enabled");
protected Vector3 offset;
private bool mightBeDragging = false, isDragging = false;
private VisualElement lastDroppable = null;
private string _droppableId = "dropTarget";
/** This is the USS class that is determines whether the target can be dropped
on it. It is "dropTarget" by default. */
public string droppableId
{
get => _droppableId;
init => _droppableId = value;
}
/** This manipulator can be disabled. */
public bool enabled { get; set; } = true;
/** Enables setting dropTarget classes based on dropping on left or right side of the element.
The DropEvent handler should handle separately like so:
if (evt.dropPos.x > evt.dropTarget.worldBound.center.x)
rightSide = true;
*/
public bool reordering { get; set; } = false;
/** Don't do anything until dragged at least a few pixels
* This also allows ClickEvent on the same element to work.
* */
public int dragMinimum { get; set; } = 5;
private Vector3 startLocalPosition;
/** Supply a VisualElement topLayer that is in front of other elements, with fullscreen size,
so that the dragged element draws over other elements */
public VisualElement topLayer { get; set; } = null;
private VisualElement origParent = null, placeholder = null;
private int origChildIndex;
/** Anchor the top-left of the dragged element to the bottom-right of the cursor position */
public bool attachToCursor { get; set; } = true;
public Vector3 cursorAnchor { get; set; } = new Vector3(8,8);
private PickingMode lastPickingMode;
private string _removeClassOnDrag;
/** Optional. Remove the given class from the target element during the drag.
If removed, replace when drag ends. */
public string removeClassOnDrag
{
get => _removeClassOnDrag;
init => _removeClassOnDrag = value;
}
private bool removedClass = false;
/** Optionally supply Callbacks to trigger when the drag begins and ends */
public Action<VisualElement> onDragBegin;
public Action<VisualElement> onDragEnd;
private void OnCustomStyleResolved(CustomStyleResolvedEvent e)
{
if (e.customStyle.TryGetValue(draggableEnabledProperty, out bool got))
enabled = got;
}
private void DragBegin(PointerDownEvent ev)
{
if (ev.button != 0) // left mouse only
return;
if (!enabled)
return;
mightBeDragging = true;
if (dragMinimum == 0)
{
ActuallyStartDrag(ev.localPosition, ev.pointerId);
}
else
startLocalPosition = ev.localPosition;
}
private void ActuallyStartDrag(Vector3 localPosition, int pointerID)
{
onDragBegin?.Invoke(target);
target.AddToClassList("draggable--dragging");
if (removeClassOnDrag != null)
{
removedClass = target.ClassListContains(removeClassOnDrag);
if (removedClass)
target.RemoveFromClassList(removeClassOnDrag);
}
lastPickingMode = target.pickingMode;
target.pickingMode = PickingMode.Ignore;
isDragging = true;
offset = attachToCursor ? -cursorAnchor : localPosition;
target.CapturePointer(pointerID);
if (topLayer != null)
{
// Find where to place this element in topLayer
origParent = target.parent;
Vector3 targetAnchor = target.worldBound.position;
Vector3 parentAnchor = origParent.worldBound.position;
bool relative = target.resolvedStyle.position == Position.Relative;
origChildIndex = origParent.IndexOf(target);
if (relative)
{
// If the dragged element is part of a flex-formatted block,
// Removing it would move around elements.
// Put in a placeholder to keep the same size.
placeholder = new();
placeholder.style.width = target.resolvedStyle.width;
placeholder.style.height = target.resolvedStyle.height;
origParent.Insert(origChildIndex, placeholder);
}
topLayer.Add(target);
if(relative) // draggingParent should be 0,0
target.transform.position += targetAnchor;
else // our world pos would include the absolute positioning
target.transform.position += parentAnchor;
}
}
private void DragEnd(IPointerEvent ev)
{
if (!mightBeDragging)
return;
mightBeDragging = false;
if (!isDragging)
return;
VisualElement dropTarget;
bool canDrop = CanDrop(ev.position, out dropTarget);
//Debug.Log($"dropTarget {dropTarget}");
if (canDrop)
{
dropTarget.RemoveFromClassList("dropTarget--can-take");
dropTarget.RemoveFromClassList("dropTarget--can-take--left");
dropTarget.RemoveFromClassList("dropTarget--can-take--right");
}
target.RemoveFromClassList("draggable--dragging");
target.RemoveFromClassList("draggable--can-drop");
lastDroppable?.RemoveFromClassList("dropTarget--can-take");
lastDroppable?.RemoveFromClassList("dropTarget--can-take--left");
lastDroppable?.RemoveFromClassList("dropTarget--can-take--right");
lastDroppable = null;
target.ReleasePointer(ev.pointerId);
target.pickingMode = lastPickingMode;
isDragging = false;
if(topLayer != null)
{
if (placeholder != null)
{
origParent.Remove(placeholder);
placeholder = null;
}
origParent.Insert(origChildIndex, target);
origParent = null;
}
if (canDrop)
Drop(dropTarget, ev.position);
else
ResetPosition();
if (removeClassOnDrag != null && removedClass)
target.AddToClassList(removeClassOnDrag);
onDragEnd?.Invoke(target);
}
protected virtual void Drop(VisualElement dropTarget, Vector3 dropPos)
{
var e = DropEvent.GetPooled(this, dropTarget, dropPos);
e.target = this.target;
// We send the event one tick later so that our changes to the class list
// will take effect.
this.target.schedule.Execute(() => e.target.SendEvent(e));
}
/** Reset the target's position to zero.
Note: Schedules the change so that the USS classes will be restored when
run. (Helps when a "transitions" USS class is used.)
*/
public virtual void ResetPosition()
{
target.transform.position = Vector3.zero;
}
private readonly List<VisualElement> pickedElems = new();
protected virtual bool CanDrop(Vector3 position, out VisualElement dropTarget)
{
target.panel.PickAll(position, pickedElems);
foreach (var pickedElem in pickedElems)
{
var elem = pickedElem;
// Walk up parent elements to see if any are dropTarget.
while (elem != null && !elem.ClassListContains(droppableId))
elem = elem.parent;
if (elem != null)
{
dropTarget = elem;
pickedElems.Clear();
return true;
}
}
dropTarget = null;
pickedElems.Clear();
return false;
}
private void PointerMove(PointerMoveEvent ev)
{
if (!mightBeDragging)
return;
if (!isDragging)
{
Vector3 localPosition = ev.localPosition;
int pointerID = ev.pointerId;
if ((startLocalPosition - localPosition).sqrMagnitude > dragMinimum * dragMinimum)
ActuallyStartDrag(startLocalPosition, pointerID);
else
return;
}
if (!enabled)
{
DragEnd(ev);
return;
}
Vector3 delta = ev.localPosition - (Vector3)offset;
target.transform.position += delta;
if (CanDrop(ev.position, out var dropTarget))
{
target.AddToClassList("draggable--can-drop");
dropTarget.AddToClassList("dropTarget--can-take");
// TODO: vertical option.
if (reordering)
{
bool after = ev.position.x > dropTarget.worldBound.center.x;
dropTarget.EnableInClassList("dropTarget--can-take--left", !after);
dropTarget.EnableInClassList("dropTarget--can-take--right", after);
}
if (lastDroppable != dropTarget)
{
lastDroppable?.RemoveFromClassList("dropTarget--can-take");
lastDroppable?.RemoveFromClassList("dropTarget--can-take--left");
lastDroppable?.RemoveFromClassList("dropTarget--can-take--right");
}
lastDroppable = dropTarget;
}
else
{
target.RemoveFromClassList("draggable--can-drop");
lastDroppable?.RemoveFromClassList("dropTarget--can-take");
lastDroppable?.RemoveFromClassList("dropTarget--can-take--left");
lastDroppable?.RemoveFromClassList("dropTarget--can-take--right");
lastDroppable = null;
}
}
}
/** This event represents a runtime drag and drop event. */
public class DropEvent : EventBase<DropEvent>
{
public DragManipulator dragger { get; protected set; }
public VisualElement dropTarget { get; protected set; }
public Vector3 dropPos { get; protected set; }
protected override void Init()
{
base.Init();
this.LocalInit();
}
private void LocalInit()
{
this.bubbles = true;
this.tricklesDown = false;
}
public static DropEvent GetPooled(DragManipulator dragger, VisualElement dropTarget, Vector3 dropPos)
{
DropEvent pooled = EventBase<DropEvent>.GetPooled();
pooled.dragger = dragger;
pooled.dropTarget = dropTarget;
pooled.dropPos = dropPos;
return pooled;
}
public DropEvent() => this.LocalInit();
}
// This hack allows us to use init properties in earlier versions of Unity.
#if UNITY_5_3_OR_NEWER && !UNITY_2021_OR_NEWER
// https://stackoverflow.com/a/62656145
namespace System.Runtime.CompilerServices
{
using System.ComponentModel;
[EditorBrowsable(EditorBrowsableState.Never)]
internal class IsExternalInit { }
}
#endif
@alextd
Copy link
Author

alextd commented Apr 15, 2025

My additions include some support for reordering elements among others:

  • Renamed droppable to dropTarget because I found that confusing ( it meant drop-onto-able )
  • Fix to enable ClickEvent on same element: an option to not start the drag until a few pixels have moved
  • bool reorderable option, that adds classes for --can-take--left/right, so you know if it's dropping on the left or right side of an element, to reorder before or after that element (this is to enable styles like border-right:1px, though DropEvent code needs to handle this all separately)
  • dropPos added to the DropEvent ( so that the DropEvent handler can tell which side it was dropped on)
  • An option to provide a screen-sized VisualElement "topLayer" to hold the dragged element, so that it will draw over other elements (needed to support "reorderables" since other elements in the same parent would draw over the dragged element)
  • An option to anchor the element to the cursor instead of sticking to wherever you clicked it
  • Optionally supply Action callbacks onDragBegin/End to trigger

TODO: for better reordering:

  • Dropping the element outside the bounds of any droppable element would find the closest droppable and drop there.
  • Instead of --can-take--left/right styles, draw a simple line between two elements where the reorderable item would be dropped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment