drag and drop a reorderable list¶
build a vertical list whose rows you can drag to reorder
by the end of this guide you will have a vertical list of rows you can drag up or down to reorder. while a drag is in flight an insertion line tracks where the row will land. releasing drops it there.
this guide builds on the same DragContext, DragSource, DropZone, and DragLayer pieces documented in drag and drop, and reuses the OnHover insertion-index preview technique from Code/Demo/DocsProbes/DocsDndProbeUI.cs. that demo drags tray items into an ordered list; it does not drag existing rows to reorder them, so the row-to-row move below is a different composition of the same pieces.
what you are building¶
the finished panel has three pieces:
- a column of draggable rows, each mounted as a
DragSource<int>carrying its own row id as the payload - a
DropZone<int>that wraps the list, computes an insertion index from the cursor position, and moves the dragged row to that index - a thin yellow gap line rendered as an absolute overlay that previews where the row will land
a DragLayer<int> at the root draws the ghost that follows the cursor while a drag is in flight.
start: the data and context¶
create Code/UI/ReorderList.cs. it extends GooPanel<Container>, the same base your first panel covers, so Build() and Rebuild() behave exactly as that article describes. the list owns its data, a shared drag context, and a preview index field.
using System.Collections.Generic;
using Goo;
using Sandbox;
using Sandbox.UI;
public class ReorderList : GooPanel<Container>
{
const float RowHeight = 36f;
readonly DragContext<int> _dnd = new(); // payload is the dragged row's Id
readonly List<(int Id, string Name)> _rows = new()
{
(0, "alpha"),
(1, "beta"),
(2, "gamma"),
};
int _previewIndex = -1;
}
DragContext<int> is the shared hub. every source, zone, and the layer get the same instance. the payload is the row's Id rather than its Name, because the drop handler needs the id to find the row being moved and remove it from its old slot. _previewIndex starts at -1 (no preview).
the root and drag layer¶
Build() returns a root that holds your content and a DragLayer. mark the root PointerEvents.None and restore events on the content so the ghost never swallows clicks.
protected override Container Build() => new Container
{
Position = PositionMode.Absolute,
Left = 60f,
Top = 60f,
PointerEvents = PointerEvents.None,
Children =
{
ListZone(),
Cell.Mount<DragLayer<int>>( key: "drag-layer", configure: l => l.Context = _dnd ),
},
};
drag sources: the rows¶
each row is a DragSource<int>. the content builder receives a bool that is true while that tile is being dragged; use it to dim the original so the user can see the ghost is a copy. the payload is the row's Id, taken from the row tuple the caller passes in.
CellElement Row( (int Id, string Name) row ) =>
Cell.Mount<DragSource<int>>( key: $"row-{row.Id}", configure: s =>
{
s.Context = _dnd;
s.Payload = row.Id;
s.Content = dragging => new Container
{
Height = Px.Of( RowHeight ),
PaddingLeft = 10,
AlignItems = Align.Center,
PointerEvents = PointerEvents.All,
BackgroundColor = new Color( 0.15f, 0.15f, 0.15f ),
BorderBottomWidth = 1,
BorderBottomColor = new Color( 0.25f, 0.25f, 0.25f ),
Opacity = dragging ? 0.4f : 1f,
Children = { new Text( row.Name ) { FontColor = Color.White } },
};
} );
the drop zone and hover preview¶
wrap the list in a DropZone<int>. the zone's OnHover callback fires with a DropLocation on every row crossing and with null on leave. store the preview index and call Rebuild().
on drop, the payload is the id of whichever row was dragged. find that row in _rows, remove it from its old slot, then insert it at the target slot. compute the target slot before removing anything: SlotAt divides by _rows.Count, so removing first would shift the pitch out from under the cursor position you already have.
CellElement ListZone() =>
Cell.Mount<DropZone<int>>( key: "list-zone", configure: z =>
{
z.Context = _dnd;
z.OnHover = loc =>
{
_previewIndex = loc is { } l ? SlotAt( l ) : -1;
Rebuild();
};
z.OnDropPayload = ( id, loc ) =>
{
int from = _rows.FindIndex( r => r.Id == id );
if ( from < 0 ) return; // payload did not originate from this list
int at = System.Math.Clamp( SlotAt( loc ), 0, _rows.Count );
var row = _rows[from];
_rows.RemoveAt( from );
if ( from < at ) at--; // the removal shifted everything after it up by one
_rows.Insert( at, row );
_previewIndex = -1;
Rebuild();
};
z.Content = hovered => ListBox( hovered );
} );
computing the insertion index¶
SlotAt divides the cursor's y by a pitch derived from ZoneSize.y, not from the RowHeight constant - see drag and drop for why the rendered-pixel denominator matters.
int SlotAt( DropLocation loc )
{
float pitch = loc.ZoneSize.y / (_rows.Count + 1);
return pitch > 0f ? (int)(loc.Local.y / pitch) : -1;
}
the + 1 in the denominator accounts for the "append" sentinel row at the bottom so the last slot is reachable.
drawing the list box¶
ListBox builds the visible column. the row loop is the only dynamic part of an otherwise-literal Children list, so it uses Each.Of directly in the initializer rather than building the container first and appending to it in a loop. the insertion line is rendered as an absolute overlay so it never shifts the rows that the slot math is measured against, and it is only present when a drag is hovering, so it is a nullable ternary alongside the other two children.
Container ListBox( bool hovered ) => new Container
{
Position = PositionMode.Relative,
FlexDirection = FlexDirection.Column,
Width = Px.Of( 200 ),
BorderRadius = 8,
Overflow = OverflowMode.Hidden,
BackgroundColor = hovered ? new Color( 0.22f, 0.22f, 0.22f ) : new Color( 0.14f, 0.14f, 0.14f ),
Children =
{
Each.Of( _rows, ( i, row ) => Row( row ) ),
AppendRow(),
_previewIndex >= 0 ? GapLine( System.Math.Min( _previewIndex, _rows.Count ) ) : null,
},
};
static Container AppendRow() => new Container
{
Key = "append",
Height = Px.Of( RowHeight ),
PaddingLeft = 10,
AlignItems = Align.Center,
PointerEvents = PointerEvents.All,
Children = { new Text( "drop here to move to the end" ) { FontColor = new Color( 0.5f, 0.5f, 0.5f ) } },
};
Overflow = OverflowMode.Hidden clips the rounded corners. Each.Of auto-keys each row the same way Children.AddRange does, but here it does not need a keyOf: Row already keys its own Cell.Mount call with $"row-{row.Id}", and a key set directly on the built blob wins over the auto-key regardless of which loop form wrote it. see dynamic children for both loop forms and the keying rule in full.
the gap line¶
the line is absolutely positioned over the list. top is derived from _previewIndex * RowHeight so it sits between rows rather than inside one.
static Container GapLine( int index ) => new Container
{
Key = "gap",
Position = PositionMode.Absolute,
Top = System.MathF.Max( 0f, index * RowHeight - 1.5f ),
Left = 0,
Width = Px.Of( 200 ),
Height = Px.Of( 3 ),
BackgroundColor = new Color( 1f, 0.85f, 0.1f ),
};
Key = "gap" keeps its identity stable so goo moves rather than replaces it as the preview index changes.
putting it together¶
here is the complete class with all methods in place:
using System.Collections.Generic;
using Goo;
using Sandbox;
using Sandbox.UI;
public class ReorderList : GooPanel<Container>
{
const float RowHeight = 36f;
readonly DragContext<int> _dnd = new(); // payload is the dragged row's Id
readonly List<(int Id, string Name)> _rows = new()
{
(0, "alpha"),
(1, "beta"),
(2, "gamma"),
};
int _previewIndex = -1;
protected override Container Build() => new Container
{
Position = PositionMode.Absolute,
Left = 60f,
Top = 60f,
PointerEvents = PointerEvents.None,
Children =
{
ListZone(),
Cell.Mount<DragLayer<int>>( key: "drag-layer", configure: l => l.Context = _dnd ),
},
};
CellElement ListZone() =>
Cell.Mount<DropZone<int>>( key: "list-zone", configure: z =>
{
z.Context = _dnd;
z.OnHover = loc =>
{
_previewIndex = loc is { } l ? SlotAt( l ) : -1;
Rebuild();
};
z.OnDropPayload = ( id, loc ) =>
{
int from = _rows.FindIndex( r => r.Id == id );
if ( from < 0 ) return; // payload did not originate from this list
int at = System.Math.Clamp( SlotAt( loc ), 0, _rows.Count );
var row = _rows[from];
_rows.RemoveAt( from );
if ( from < at ) at--; // the removal shifted everything after it up by one
_rows.Insert( at, row );
_previewIndex = -1;
Rebuild();
};
z.Content = hovered => ListBox( hovered );
} );
int SlotAt( DropLocation loc )
{
float pitch = loc.ZoneSize.y / (_rows.Count + 1);
return pitch > 0f ? (int)(loc.Local.y / pitch) : -1;
}
Container ListBox( bool hovered ) => new Container
{
Position = PositionMode.Relative,
FlexDirection = FlexDirection.Column,
Width = Px.Of( 200 ),
BorderRadius = 8,
Overflow = OverflowMode.Hidden,
BackgroundColor = hovered ? new Color( 0.22f, 0.22f, 0.22f ) : new Color( 0.14f, 0.14f, 0.14f ),
Children =
{
Each.Of( _rows, ( i, row ) => Row( row ) ),
AppendRow(),
_previewIndex >= 0 ? GapLine( System.Math.Min( _previewIndex, _rows.Count ) ) : null,
},
};
static Container AppendRow() => new Container
{
Key = "append",
Height = Px.Of( RowHeight ),
PaddingLeft = 10,
AlignItems = Align.Center,
PointerEvents = PointerEvents.All,
Children = { new Text( "drop here to move to the end" ) { FontColor = new Color( 0.5f, 0.5f, 0.5f ) } },
};
CellElement Row( (int Id, string Name) row ) =>
Cell.Mount<DragSource<int>>( key: $"row-{row.Id}", configure: s =>
{
s.Context = _dnd;
s.Payload = row.Id;
s.Content = dragging => new Container
{
Height = Px.Of( RowHeight ),
PaddingLeft = 10,
AlignItems = Align.Center,
PointerEvents = PointerEvents.All,
BackgroundColor = new Color( 0.15f, 0.15f, 0.15f ),
BorderBottomWidth = 1,
BorderBottomColor = new Color( 0.25f, 0.25f, 0.25f ),
Opacity = dragging ? 0.4f : 1f,
Children = { new Text( row.Name ) { FontColor = Color.White } },
};
} );
static Container GapLine( int index ) => new Container
{
Key = "gap",
Position = PositionMode.Absolute,
Top = System.MathF.Max( 0f, index * RowHeight - 1.5f ),
Left = 0,
Width = Px.Of( 200 ),
Height = Px.Of( 3 ),
BackgroundColor = new Color( 1f, 0.85f, 0.1f ),
};
}
drop ReorderList on a screen panel, press play, and drag a row up or down. the yellow line tracks the slot boundary and the row lands there on release.
what just happened¶
three rules made the preview accurate:
PointerEvents.Allon every row, including the sentinel, makes each row a separate hover surface. the zone receives a newOnHovercall each time the cursor crosses a row boundary, so the preview index updates continuously rather than once at zone entry.SlotAtdivides byZoneSize.y, notRowHeight. bothLocalandZoneSizeare rendered pixels; the scale cancels. dividing by the layout-pixel constantRowHeightleaves the index off by the root scale factor, which shows up as a preview that lags one row behind at the row boundaries.GapLineisPositionMode.Absolute. an in-flow gap line would shift the rows below it, which changes the grid the very nextLocal.yis measured against and can make the preview oscillate at boundaries.
one more rule makes this a reorder rather than a duplicate: the Id field on each row never changes, so Key = $"row-{row.Id}" on Row's Cell.Mount call keeps a row's identity stable even as its position in _rows moves, and that key wins over whatever Each.Of would have auto-assigned. OnDropPayload uses that same Id to find the dragged row, remove it from its old slot, and reinsert it at the new one. skip the removal and you get an insert, not a move: the source row stays put and a copy lands at the drop point.
celebrating a drop with particles¶
give the user a quick confirmation when a row lands: burst a handful of particles at the slot it dropped into. own a ParticleField as a field, mount Hud.Particles once at the root as a third sibling, and spawn into the field from OnDropPayload.
readonly ParticleField _fx = new();
Hud.Particles steps and draws the field from its own paint callback every frame, so ReorderList needs no Tick override for the burst to animate and expire:
protected override Container Build() => new Container
{
Position = PositionMode.Absolute,
Left = 60f,
Top = 60f,
PointerEvents = PointerEvents.None,
Children =
{
ListZone(),
Cell.Mount<DragLayer<int>>( key: "drag-layer", configure: l => l.Context = _dnd ),
Hud.Particles( _fx ),
},
};
burst at the row's landing slot, at the end of OnDropPayload:
z.OnDropPayload = ( id, loc ) =>
{
int from = _rows.FindIndex( r => r.Id == id );
if ( from < 0 ) return; // payload did not originate from this list
int at = System.Math.Clamp( SlotAt( loc ), 0, _rows.Count );
var row = _rows[from];
_rows.RemoveAt( from );
if ( from < at ) at--; // the removal shifted everything after it up by one
_rows.Insert( at, row );
_fx.Burst( new Vector2( 100f, at * RowHeight ), count: 16, speed: 220f, color: new Color( 1f, 0.85f, 0.1f ) );
_previewIndex = -1;
Rebuild();
};
the burst origin is root-relative, not zone-relative: Hud.Particles renders as an absolute full-screen sibling of ListZone(), so its coordinates resolve against the root, the nearest Position = Absolute ancestor. the hand-picked x = 100f centers roughly under the 200px-wide list, and at * RowHeight lines up with the row the gap line was just showing. this lines up because ListZone() sits flush at the root's own origin with no padding in between; wrap the list in anything that offsets it and the burst position needs that same offset added.
see also¶
- drag and drop - full surface:
DragContext,DragSource,DropZone,DragLayer,OnHover,DropLocation, nesting, andPointerDrag. - cells - what
Cell.Mount,key, andconfiguredo and why cells exist outside the normal children list. - build method - how
Build()andRebuild()work and howKeydrives structural diffing. - dynamic children -
Each.OfandChildren.AddRange, and keying them for stable identity. - container reference - full layout and style surface for
Container, includingPosition,PointerEvents,BorderBottomWidth, andOverflow. - your first panel - the
GooPanel<T>base,Build(), and the render loop this guide extends. - overlay layout - the
Goo.Hudfactory family, includingParticlesand the rest of the full-bleed overlay helpers.