-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
My additions include some support for reordering elements among others:
TODO: for better reordering: