drag and drop¶
goo ships a drag-and-drop system built out of four pieces: a shared context, a draggable source, a drop target, and a ghost layer. you wire them together once and they handle the ghost following the cursor, hover highlighting, and safe payload delivery across the source-fires-before-target ordering that the engine uses internally.
this article covers the full surface. if you want the raw engine events on Container before reaching for the higher-level pieces, they are described at the end.
the four pieces¶
the system is generic over a payload type T. the same type flows through all four pieces, so the compiler catches mismatches.
DragContext<T> is the hub. it holds the active payload, the current cursor position, and the ghost factory. create one and keep it in a field on your panel. pass the same instance to every source, zone, and the layer.
DragSource<T> is a cell that wraps a draggable tile. you give it a payload, a content builder, and optionally a ghost builder. it wires the engine drag events for you.
DropZone<T> is a cell that wraps a drop target. you give it a content builder that receives a bool for whether the cursor is over it right now. when a drop lands, it hands you the payload and a DropLocation.
DragLayer<T> is a cell that renders the ghost at the cursor. mount it once at the root of your tree, absolutely positioned so it floats above everything else.
setting up the context¶
hold one DragContext<T> per interaction space. a panel that has a drag-and-drop inventory is one interaction space. declare a single context for it.
readonly DragContext<InvNode> _dnd = new();
nothing else is needed at construction. IsDragging, HasPayload, Payload, Pos, and Ghost all start cleared.
the drag layer¶
mount the DragLayer<T> at the root of your tree, as a sibling of your content. mark the root PointerEvents.None and the content PointerEvents.All so the ghost never swallows clicks.
protected override Container Build() => new Container
{
Position = PositionMode.Absolute,
Left = 0f, Top = 0f,
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
PointerEvents = PointerEvents.None,
Children =
{
PanelFrame(),
Cell.Mount<DragLayer<InvNode>>( key: "drag-layer", configure: l => l.Context = _dnd ),
},
};
the layer renders an empty container when no drag is in flight, so it costs nothing at rest. when a drag is active it renders the ghost at Context.Pos using PositionMode.Absolute with PointerEvents.None, so the ghost follows the cursor but never intercepts events.
the drag source¶
DragSource<T> is a cell, so you mount it with Cell.Mount. pass a configure action that sets Context, Payload, and Content. Content is a Func<bool, Container>: the bool is true while the tile is being dragged, letting you dim or fade the original.
CellElement ItemSlot( InvNode item ) =>
Cell.Mount<DragSource<InvNode>>( key: $"item-{item.Id}", configure: s =>
{
s.Context = _dnd;
s.Payload = item;
s.Content = dragging => ItemTile( item, dragging );
s.Ghost = () => GhostTile( item );
} );
Ghost is optional. when set it is a Func<Container> that builds the ghost separately from the tile. when omitted the layer uses Content(false) as the ghost. the ghost renders with its top-left at the cursor. offset your container inside Ghost if you want it centred.
Container ItemTile( InvNode item, bool dragging ) => new Container
{
Width = SlotSize, Height = SlotSize,
BackgroundColor = SlotBg,
Opacity = dragging ? 0.4f : 1f,
BorderRadius = 5f,
JustifyContent = Justify.Center,
AlignItems = Align.Center,
Children = { /* icon and badge */ },
};
Container GhostTile( InvNode item ) => new Container
{
Width = SlotSize, Height = SlotSize,
BackgroundColor = new Color( 0.4f, 0.7f, 1f, 0.25f ),
BorderRadius = 5f,
Opacity = 0.85f,
JustifyContent = Justify.Center,
AlignItems = Align.Center,
Children = { /* icon only */ },
};
the drop zone¶
DropZone<T> works the same way: mount with Cell.Mount, set Context and Content, then assign OnDropPayload. Content receives a bool for the hover state, so you can change the zone's background while a drag is over it.
CellElement BuildBag( InvNode bag, int depth ) =>
Cell.Mount<DropZone<InvNode>>( key: $"bag-{bag.Id}", configure: z =>
{
z.Context = _dnd;
z.OnDropPayload = ( payload, _ ) => DropInto( payload, bag.Id );
z.Content = hovered => BagBox( bag, depth, hovered );
} );
OnDropPayload is an Action<T, DropLocation>. DropLocation carries Local (cursor position relative to the zone's top-left, in rendered pixels) and ZoneSize (the zone's full rendered size). for a simple bag you can ignore the location. for an ordered grid you map Local to a cell index.
void DropInto( InvNode payload, string bagId )
{
InventoryTree.Move( Root(), payload.Id, bagId, 0 );
Rebuild();
}
DropZone stops propagation internally, so the innermost zone under the cursor wins. you never write StopPropagation yourself.
why the context exists¶
the engine fires OnDragEnd on the source and OnDrop on the target in panel-tree-walk order, not dispatch order. neither can reliably hand state to the other. DragContext sidesteps this: the payload lives in the context and is cleared only when a new drag starts (via Complete()), not on OnDragEnd. the drop zone always reads a valid payload regardless of which event ran first.
DragContext exposes these members you may read in your own code:
| member | type | what it is |
|---|---|---|
IsDragging |
bool |
a drag is currently in flight |
HasPayload |
bool |
the payload is still valid (cleared by Complete, not Release) |
Payload |
T |
the current payload |
Pos |
Vector2 |
current cursor position in CSS pixels |
Ghost |
Func<Container>? |
the ghost factory the layer calls each frame |
you rarely call the context methods directly. DragSource calls Begin, Move, and Release. DropZone calls Complete. the distinction matters: Release sets IsDragging = false (the ghost vanishes) but leaves HasPayload true so OnDrop can still read the payload. Complete clears both.
dead-zone cancel¶
dropping over nothing is a no-op: OnDragEnd fires on the source, which calls Context.Release(). IsDragging goes false, the layer renders nothing, and HasPayload is left true until the next drag clears it via Begin. no OnDrop fires, so OnDropPayload is never invoked. the payload is not lost. it is just silently discarded on the next drag start.
the OnHover callback¶
DropZone also exposes OnHover, an Action<DropLocation?>. it fires with a DropLocation on every drag-enter event and with null on leave. use it to preview where an item will land before the user releases: for an ordered list you can compute the insertion index from the cursor's position inside the zone and highlight the gap.
z.OnHover = loc =>
{
_previewIndex = loc is { } l ? (int)(l.Local.y / (l.ZoneSize.y / SlotCount)) : -1;
Rebuild();
};
one rule decides how often the preview updates: drag-enter is edge-triggered per hittable panel, so OnHover re-fires only when the cursor crosses onto a different hittable panel inside the zone. give each row (or cell) PointerEvents = PointerEvents.All and every row crossing delivers a fresh location. leave the rows inert and the zone is one big hover target: you get exactly one location at the zone boundary and the preview never moves. the zone absorbs the child-to-child crossings internally, so the hover flag does not flicker.
a second rule pins the units: Local and ZoneSize are rendered pixels, already multiplied by the root scale (screen height over 1080, so almost never exactly 1), while style values like a Px.Of( 36 ) row height are layout pixels. always divide Local.y by a pitch derived from ZoneSize (ZoneSize.y / SlotCount): the scale cancels and the index is exact at any resolution. divide by the style constant instead and every index is off by the scale factor, worst at the row boundaries, which is exactly where edge-triggered enters sample; the misread then sticks until the next crossing, so the preview sits one row off while the cursor is plainly inside a row.
a third rule keeps the pitch honest: the rows must tile the zone exactly from its top edge in equal slots. leading padding or inter-row margins shift the grid under the math, and because updates only arrive at crossings, a row entered through its bottom edge bins into the band below and the preview reads one slot off until the next crossing (dragging upward shows it; dragging downward does not). either lay the rows out flush, or subtract the offsets from Local.y before dividing.
one layout caveat: render the preview as an absolutely-positioned overlay rather than inserting it into the row flow. an in-flow gap line shifts the rows the next Local.y is measured against, and the preview can oscillate at row boundaries.
nesting zones¶
zones nest freely. the DropZone for an outer bag and the DropZone for an inner bag can both be in the tree at the same time. the innermost zone under the cursor wins because each zone calls e.StopPropagation() on every drag event it handles. the outer bag does not receive events while the cursor is inside the inner bag.
for a working nested-inventory example with a recursive BuildBag helper, DragSource item slots, and a whole-tree rebuild on drop, see Code.Tests/NestedInventoryStructureTests.cs. that file simulates the demo structure as a reconciler test harness and shows how the pieces compose at multiple depths.
PointerDrag: dragging a value, not a payload¶
the four pieces above move a payload between panels. a different job is dragging a value: moving a window by its title bar, growing a box from a corner grip. for that, Goo.PointerDrag polls the absolute cursor each frame instead of relying on the engine drag events, which starve when the cursor leaves a hittable panel mid-drag.
the owner holds an instance as a field, begins it on the handle's OnMouseDown, ends it on OnMouseUp, and reads the live value in Tick:
readonly PointerDrag _drag = new();
Vector2 _pos = new( 300f, 220f );
// on the draggable surface, in Build():
OnMouseDown = _ => _drag.Begin( _pos ),
OnMouseUp = _ => _drag.End(),
// in Tick():
if ( _drag.Active )
{
_pos = _drag.Current( Panel );
return true; // rebuild so the panel follows
}
Begin captures the value being dragged and the cursor origin. Current returns that start value plus the cursor delta, converted from screen pixels to CSS pixels. pass the owning Panel and the helper looks up ScaleFromScreen itself (falling back to 1 pre-mount), and a Current(float scale) overload exists when you already hold a scale. keep OnDrag* handlers off the panel so the engine never starts a native drag.
two constraints: drive something that follows the cursor (a moving card or a growing grip stays under the cursor, so the OnMouseUp that ends the drag stays deliverable), and call End() on teardown as a backstop so a destroyed handle cannot leave the drag armed.
raw container events¶
Container also exposes six low-level drag event properties directly, without the context or ghost machinery. they are useful for one-off interactions where you do not need a ghost or a shared context.
| property | fires when |
|---|---|
OnDragStart |
the user begins dragging (mouse-down plus movement threshold) |
OnDrag |
every tick while the drag is in progress |
OnDragEnd |
the drag is released, whether or not it landed on a target |
OnDragEnter |
the dragged cursor enters this container's bounds |
OnDragLeave |
the dragged cursor leaves this container's bounds |
OnDrop |
the user releases the drag over this container |
OnDragStart, OnDrag, and OnDragEnd are Action<Sandbox.UI.DragEvent>. DragEvent carries ScreenPosition, ScreenGrabPosition, and MouseDelta.
OnDragEnter, OnDragLeave, and OnDrop are Action<Sandbox.UI.PanelEvent>. on these events, e.This is the panel currently handling the bubbled event, which is the drop zone itself. use e.This.MousePosition to read the cursor position relative to the zone. no opt-in is needed for a container to receive drop events: any visible container with PointerEvents not set to None receives them automatically.
goo internally sets WantsDrag = true on any container that has at least one of OnDragStart, OnDrag, or OnDragEnd assigned.
OnDragEnd fires on the source before OnDrop fires on the target. if OnDragEnd clears shared state that OnDrop needs, capture that state before clearing it.
see also¶
- cells - what
Cell.Mount,key, andconfiguremean on the mounts above - events - mouse handlers,
PanelEvent, ande.Thisduring bubble propagation - your first counter - the mutate-a-field-then-rebuild state pattern
- build method - how
Build()andRebuild()work - arranging children - flex layout and children lists