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 that a user can reorder by dragging. while a drag is in flight an insertion line tracks where the row will land. releasing drops it there. the guide distills the DocsDndProbeUI demo to its smallest faithful core; you can find that demo at Code/Demo/DocsProbes/DocsDndProbeUI.cs.

what you are building

the finished panel has three pieces:

a DragLayer<string> 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. the list owns its data, a shared drag context, and a preview index field.

using System.Collections.Generic;
using Goo;
using Goo.Components;
using Sandbox;
using Sandbox.UI;

public class ReorderList : GooPanel<Container>
{
    const float RowHeight = 36f;

    readonly DragContext<string> _dnd = new();
    readonly List<(int Id, string Name)> _rows = new()
    {
        (0, "alpha"),
        (1, "beta"),
        (2, "gamma"),
    };
    int _nextId = 3;
    int _previewIndex = -1;
}

DragContext<string> is the shared hub. every source, zone, and the layer get the same instance. _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<string>>( key: "drag-layer", configure: l => l.Context = _dnd ),
    },
};

drag sources: the rows

each row is a DragSource<string>. 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.

CellElement Row( int i ) =>
    Cell.Mount<DragSource<string>>( key: $"row-{_rows[i].Id}", configure: s =>
    {
        s.Context = _dnd;
        s.Payload = _rows[i].Name;
        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( _rows[i].Name ) { FontColor = Color.White } },
        };
    } );

the drop zone and hover preview

wrap the list in a DropZone<string>. 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().

CellElement ListZone() =>
    Cell.Mount<DropZone<string>>( key: "list-zone", configure: z =>
    {
        z.Context = _dnd;
        z.OnHover = loc =>
        {
            _previewIndex = loc is { } l ? SlotAt( l ) : -1;
            Rebuild();
        };
        z.OnDropPayload = ( payload, loc ) =>
        {
            int at = System.Math.Clamp( SlotAt( loc ), 0, _rows.Count );
            _rows.Insert( at, (_nextId++, payload) );
            _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 insertion line is rendered as an absolute overlay so it never shifts the rows that the slot math is measured against.

Container ListBox( bool hovered )
{
    var box = 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 ),
    };

    for ( int i = 0; i < _rows.Count; i++ )
        box.Children.Add( Row( i ) );

    box.Children.Add( new Container
    {
        Key           = "append",
        Height        = Px.Of( RowHeight ),
        PaddingLeft   = 10,
        AlignItems    = Align.Center,
        PointerEvents = PointerEvents.All,
        Children      = { new Text( "drop here to append" ) { FontColor = new Color( 0.5f, 0.5f, 0.5f ) } },
    } );

    if ( _previewIndex >= 0 )
        box.Children.Add( GapLine( System.Math.Min( _previewIndex, _rows.Count ) ) );

    return box;
}

Overflow = OverflowMode.Hidden clips the rounded corners.

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 Goo.Components;
using Sandbox;
using Sandbox.UI;

public class ReorderList : GooPanel<Container>
{
    const float RowHeight = 36f;

    readonly DragContext<string> _dnd = new();
    readonly List<(int Id, string Name)> _rows = new()
    {
        (0, "alpha"),
        (1, "beta"),
        (2, "gamma"),
    };
    int _nextId = 3;
    int _previewIndex = -1;

    protected override Container Build() => new Container
    {
        Position      = PositionMode.Absolute,
        Left          = 60f,
        Top           = 60f,
        PointerEvents = PointerEvents.None,
        Children =
        {
            ListZone(),
            Cell.Mount<DragLayer<string>>( key: "drag-layer", configure: l => l.Context = _dnd ),
        },
    };

    CellElement ListZone() =>
        Cell.Mount<DropZone<string>>( key: "list-zone", configure: z =>
        {
            z.Context = _dnd;
            z.OnHover = loc =>
            {
                _previewIndex = loc is { } l ? SlotAt( l ) : -1;
                Rebuild();
            };
            z.OnDropPayload = ( payload, loc ) =>
            {
                int at = System.Math.Clamp( SlotAt( loc ), 0, _rows.Count );
                _rows.Insert( at, (_nextId++, payload) );
                _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 )
    {
        var box = 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 ),
        };

        for ( int i = 0; i < _rows.Count; i++ )
            box.Children.Add( Row( i ) );

        box.Children.Add( new Container
        {
            Key           = "append",
            Height        = Px.Of( RowHeight ),
            PaddingLeft   = 10,
            AlignItems    = Align.Center,
            PointerEvents = PointerEvents.All,
            Children      = { new Text( "drop here to append" ) { FontColor = new Color( 0.5f, 0.5f, 0.5f ) } },
        } );

        if ( _previewIndex >= 0 )
            box.Children.Add( GapLine( System.Math.Min( _previewIndex, _rows.Count ) ) );

        return box;
    }

    CellElement Row( int i ) =>
        Cell.Mount<DragSource<string>>( key: $"row-{_rows[i].Id}", configure: s =>
        {
            s.Context = _dnd;
            s.Payload = _rows[i].Name;
            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( _rows[i].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:

the _nextId counter and the Id field on each row decouple identity from list position so goo reconciles a moved row in place rather than rebuilding it.

see also