Skip to content

Instantly share code, notes, and snippets.

@shanecelis
Last active April 16, 2025 04:31
Show Gist options
  • Save shanecelis/b6fb3fe8ed5356be1a3aeeb9e7d2c145 to your computer and use it in GitHub Desktop.
Save shanecelis/b6fb3fe8ed5356be1a3aeeb9e7d2c145 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]
[1]: https://gist.github.com/shanecelis/b6fb3fe8ed5356be1a3aeeb9e7d2c145
[2]: https://twitter.com/shanecelis
[3]: https://opensource.org/licenses/MIT
*/
using UnityEngine;
using UnityEngine.UIElements;
/** 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.droppable}");
```
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.droppable}");
```
### 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 droppable |
| .droppable | Identifies a droppable element (editable) |
| .droppable--can-drop | Present while a draggable is hovering |
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 "droppable" USS class will be considered droppable.
When dragging and hovering over a droppable element, the USS class
"droppable--can-drop" will be added; the draggable element will have
"draggable--can-drop" added to it.
If the draggable element is dropped on a non-droppable 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 droppable objects, one set the `droppableId` on the
`DragManipulator` to something other than "droppable".
```
var dragger = new DragManipulator { droppableId = "discard-pile" };
```
## Handling Events
When a draggable element is released on a droppable 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("droppable--can-drop");
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 isDragging = false;
private VisualElement lastDroppable = null;
private string _droppableId = "droppable";
/** This is the USS class that is determines whether the target can be dropped
on it. It is "droppable" by default. */
public string droppableId {
get => _droppableId;
init => _droppableId = value;
}
/** This manipulator can be disabled. */
public bool enabled { get; set; } = true;
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;
private void OnCustomStyleResolved(CustomStyleResolvedEvent e) {
if (e.customStyle.TryGetValue(draggableEnabledProperty, out bool got))
enabled = got;
}
private void DragBegin(PointerDownEvent ev) {
if (! enabled)
return;
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 = ev.localPosition;
target.CapturePointer(ev.pointerId);
}
private void DragEnd(IPointerEvent ev) {
if (! isDragging)
return;
VisualElement droppable;
bool canDrop = CanDrop(ev.position, out droppable);
Debug.Log($"droppable {droppable}");
if (canDrop)
droppable.RemoveFromClassList("droppable--can-drop");
target.RemoveFromClassList("draggable--dragging");
target.RemoveFromClassList("draggable--can-drop");
lastDroppable?.RemoveFromClassList("droppable--can-drop");
lastDroppable = null;
target.ReleasePointer(ev.pointerId);
target.pickingMode = lastPickingMode;
isDragging = false;
if (canDrop)
Drop(droppable);
else
ResetPosition();
if (removeClassOnDrag != null && removedClass)
target.AddToClassList(removeClassOnDrag);
}
protected virtual void Drop(VisualElement droppable) {
var e = DropEvent.GetPooled(this, droppable);
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));
}
/** Change parent while preserving position via `transform.position`.
Usage: While dragging-and-dropping an element, if the dropped element were
to change its parent in the hierarchy, but preserve its position on
screen, which can be done with `transform.position`. Then one can lerp
that position to zero for a nice clean transition.
Notes: The algorithm isn't difficult. It's find position wrt new parent,
zero out the `transform.position`, add it to the parent, find position wrt
new parent, set `transform.position` such that its screen position will be
the same as before.
The tricky part is when you add this element to a newParent, you can't
query for its position (at least not in a way I could find). You have to
wait a beat. Then whatever was necessary to update will update.
*/
public static IVisualElementScheduledItem ChangeParent(VisualElement target,
VisualElement newParent) {
var position_parent = target.ChangeCoordinatesTo(newParent, Vector2.zero);
target.RemoveFromHierarchy();
target.transform.position = Vector3.zero;
newParent.Add(target);
// ChangeCoordinatesTo will not be correct unless you wait a tick. #hardwon
// target.transform.position = position_parent - target.ChangeCoordinatesTo(newParent,
// Vector2.zero);
return target.schedule.Execute(() => {
var newPosition = position_parent - target.ChangeCoordinatesTo(newParent,
Vector2.zero);
target.RemoveFromHierarchy();
target.transform.position = newPosition;
newParent.Add(target);
});
}
/** 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;
}
protected virtual bool CanDrop(Vector3 position, out VisualElement droppable) {
droppable = target.panel.Pick(position);
var element = droppable;
// Walk up parent elements to see if any are droppable.
while (element != null && ! element.ClassListContains(droppableId))
element = element.parent;
if (element != null) {
droppable = element;
return true;
}
return false;
}
private void PointerMove(PointerMoveEvent ev) {
if (! isDragging)
return;
if (! enabled) {
DragEnd(ev);
return;
}
Vector3 delta = ev.localPosition - (Vector3) offset;
target.transform.position += delta;
if (CanDrop(ev.position, out var droppable)) {
target.AddToClassList("draggable--can-drop");
droppable.AddToClassList("droppable--can-drop");
if (lastDroppable != droppable)
lastDroppable?.RemoveFromClassList("droppable--can-drop");
lastDroppable = droppable;
} else {
target.RemoveFromClassList("draggable--can-drop");
lastDroppable?.RemoveFromClassList("droppable--can-drop");
lastDroppable = null;
}
}
}
/** This event represents a runtime drag and drop event. */
public class DropEvent : EventBase<DropEvent> {
public DragManipulator dragger { get; protected set; }
public VisualElement droppable { 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 droppable) {
DropEvent pooled = EventBase<DropEvent>.GetPooled();
pooled.dragger = dragger;
pooled.droppable = droppable;
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
@oboekenobi
Copy link

Thank you Thank you!

@OmiCron07
Copy link

OmiCron07 commented Mar 11, 2024

@shanecelis nice, it works great !

I have a question. If I want to handle both the click and the drag separately, it works, but when releasing the mouse button on the droppable element, the click event is also fired but does not when releasing on anything but the droppable.

How do we prevent the click event when doing a drag & drop operation but still get the click event when only clicking on it without moving the element ?

@shanecelis
Copy link
Author

Hi @OmiCron07, thanks for the kind words.

Unfortunately I've moved away from Unity, and I don't know how to resolve that issue off hand. I suggest setting up a minimal reproduction of the problem and putting it on the forums to solicit a fix.

@OmiCron07
Copy link

OmiCron07 commented Mar 12, 2024

For those interested, I had to change a bit of code to make it work with my UI, probably because my droppable was a sibling and not a parent. There is the modified function:

protected virtual bool CanDrop(Vector3 position, out VisualElement droppable)
{
  var visualElements = new List<VisualElement>();
  target.panel.PickAll(position, visualElements);

  for (int i = 0; i < visualElements.Count; i++)
  {
    if (visualElements[i].ClassListContains(droppableId))
    {
      droppable = visualElements[i];

      return true;
    }
  }
  
  droppable = null;

  return false;
}

@shanecelis
Copy link
Author

Many thanks for posting your solution.

@antoinechampion
Copy link

@shanecelis Thanks for this manipulator!
An NPE gets thrown when calling VisualElement#RemoveManipulator, here are suggested changes to the target setter L135 to accept a null value:

  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("droppable--can-drop");
        lastDroppable = null;
      }
      
      _target = value;

      if (value != null)
      {
        _target.RegisterCallback<PointerDownEvent>(DragBegin);
        _target.RegisterCallback<PointerUpEvent>(DragEnd);
        _target.RegisterCallback<PointerMoveEvent>(PointerMove);
        _target.RegisterCallback<CustomStyleResolvedEvent>(OnCustomStyleResolved);
        _target.AddToClassList("draggable");
      }
    }
  }

@alextd
Copy link

alextd commented Mar 29, 2025

My changes needed to get reorderable elements in a list (because Unity's ListView doesn't support horizontal direction?)

  • add the ev.position from DragEnd() to the DragEvent so the callback knows where exactly it was dropped: so it knows if it dropped on the left or right side, and re-ordering can be done based on that.
  • use PickAll instead of Pick, because the hovering dragged item would sometimes (when its index was higher) be picked instead of the droppable item under it. Well, the children of the dragged item would be picked -- this code already sets to ignore the dragged element, but all its children could still be picked instead of an actual droppable item.
  • Not a change but a needed step: removeClassOnDrag = "droppable" so it wouldn't drop on itself (as it's always under the cursor...)

@alextd
Copy link

alextd commented Apr 4, 2025

I've also hit a hurdle - ClickEvents are not firing when I add the DragManipulator to an element. It is, though, triggering on children of the element. So my labels of a button are clickable but not the entire button.

And when the DragManipulator is on the parent of an element with a ClickEvent, I can't get it to trigger at all. So it's weird.

I'm living with a draggable container with a button inside.

@shanecelis
Copy link
Author

I'm happy this appears to have been useful, but just to let everyone know, I am no longer using Unity and won't be updating this code. If anyone wishes to post this code into a proper repository and link to it from here, I will add a comment to this gist's header suggesting people go there. Then you can have PRs and issues which seems necessary at this point.

@alextd
Copy link

alextd commented Apr 4, 2025

Of course I've figured it out and it's obvious : The DragBegin calls CapturePointer which is going to prevent other events from the mouse.

I'll fork this with my updates once I'm through with this

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