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 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:

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