Skip to content

Recipe: Drag to Reorder

Drag-reorder is a UseState<List<T>> plus a function that splices one item from one index to another. Microsoft.UI.Reactor (Reactor)'s keyed reconciler does the rest — moves preserve identity, so the same row element keeps its focus, its hover state, and its place in the visual tree. Pointer drag is one entry point; an Alt+Up / Alt+Down keyboard shortcut is the second, and it carries the accessibility story.

Primitives

Concern API
Ordered state UseState<List<T>>
Stable identity across moves Record Id field; row keyed in Select
Source-side drag .OnDragStart<T, TPayload>
Target-side drop .OnDrop<T, TPayload>
Hover indicator .OnDragEnter writing to local state
Keyboard alternative .OnKeyDown + InputKeyboardSource.GetKeyStateForCurrentThread
Focusable rows .IsTabStop(true) + .OnGotFocus

Data

// Identity lives on the record. Moves preserve `Id`, so the reconciler keeps
// the same row element and its focus state across a reorder.
record TaskItem(int Id, string Title);

static class Seed
{
    public static readonly TaskItem[] Initial = new[]
    {
        new TaskItem(1, "Write the recipe lead"),
        new TaskItem(2, "Wire the drag source"),
        new TaskItem(3, "Wire the drop target"),
        new TaskItem(4, "Add the keyboard alternative"),
        new TaskItem(5, "Land the snippets"),
        new TaskItem(6, "Run tier-lint"),
    };
}

The record carries an Id. Moves never mint new ids — that's what lets the reconciler keep the same row element across a reorder instead of unmounting one and mounting another at the new slot.

State

// The list itself is a UseState<List<TaskItem>>. `draggingId` tracks
// the row currently being dragged so we can dim it; `hoverId` tracks
// the drop target so we can draw the insertion hint. Both reset on
// drop-completed.
var (items, setItems) = UseState<List<TaskItem>>(Seed.Initial.ToList());
var (draggingId, setDraggingId) = UseState<int?>(null);
var (hoverId, setHoverId) = UseState<int?>(null);
var (focusedId, setFocusedId) = UseState(Seed.Initial[0].Id);

Four UseState hooks: the list itself, the id of the row being dragged (so we can dim it), the id of the hovered drop target (so we can outline it), and the focused row id (so the keyboard shortcut knows which row to move). Two of the four exist only during an active drag and reset on completion.

The move

// Splice a single item from `fromIndex` to `toIndex`. The reconciler
// keys rows by `Id`, so this is a pure data move — no row remounts,
// no lost focus, no animation seam.
void Move(int fromIndex, int toIndex)
{
    if (fromIndex == toIndex) return;
    var copy = new List<TaskItem>(items);
    if (fromIndex < 0 || fromIndex >= copy.Count) return;
    toIndex = System.Math.Clamp(toIndex, 0, copy.Count - 1);
    var item = copy[fromIndex];
    copy.RemoveAt(fromIndex);
    copy.Insert(toIndex, item);
    setItems(copy);
}

void MoveById(int sourceId, int targetId)
{
    var from = items.FindIndex(i => i.Id == sourceId);
    var to = items.FindIndex(i => i.Id == targetId);
    if (from >= 0 && to >= 0) Move(from, to);
}

The whole reorder is a list splice. RemoveAt then Insert on a fresh copy, then setItems — the reconciler diffs old vs. new by Id, sees the same set of records in a different order, and reuses every row. MoveById is a small ergonomic wrapper so the pointer handlers can pass payload ids without computing indices.

Keyboard alternative

// Alt+Up / Alt+Down moves the focused row. This is the load-bearing
// accessibility story — drag-and-drop alone fails screen-reader and
// motor-impaired users; a keyboard alternative makes the recipe
// WCAG-conformant.
void HandleKey(int rowId, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs e)
{
    var alt = (Microsoft.UI.Input.InputKeyboardSource
        .GetKeyStateForCurrentThread(VirtualKey.Menu)
        & Windows.UI.Core.CoreVirtualKeyStates.Down) != 0;
    if (!alt) return;

    var idx = items.FindIndex(i => i.Id == rowId);
    if (idx < 0) return;

    if (e.Key == VirtualKey.Up && idx > 0)
    {
        Move(idx, idx - 1);
        setFocusedId(rowId);
        e.Handled = true;
    }
    else if (e.Key == VirtualKey.Down && idx < items.Count - 1)
    {
        Move(idx, idx + 1);
        setFocusedId(rowId);
        e.Handled = true;
    }
}

Pointer drag is half the recipe — the keyboard path is the other half. Alt+Up and Alt+Down move the focused row by one slot; e.Handled = true keeps the event from bubbling to a parent ScrollViewer. Modifier detection uses InputKeyboardSource.GetKeyStateForCurrentThread because KeyRoutedEventArgs.KeyStatus doesn't carry Alt cleanly.

Render

Element Row(TaskItem item)
{
    var isDragging = draggingId == item.Id;
    var isHover = hoverId == item.Id && draggingId is not null && draggingId != item.Id;
    var isFocused = focusedId == item.Id;

    return HStack(8,
            TextBlock("☰").Opacity(0.4).Width(20),     // grab handle glyph
            TextBlock(item.Title)
        )
        .Padding(10)
        .Background(isFocused ? "#EEF4FB" : "#FFFFFF")
        .WithBorder(isHover ? "#0078D4" : "#E1E1E1", isHover ? 2 : 1)
        .Opacity(isDragging ? 0.4 : 1.0)
        .IsTabStop(true)
        .OnGotFocus((_, _) => setFocusedId(item.Id))
        .OnKeyDown((_, e) => HandleKey(item.Id, e))
        .OnDragStart<StackElement, int>(
            getPayload: () => { setDraggingId(item.Id); return item.Id; },
            allowedOperations: DragOperations.Move,
            onEnd: _ => { setDraggingId(null); setHoverId(null); })
        .OnDragEnter(args =>
        {
            if (args.Data.TryGetTypedPayload<int>(out var srcId) && srcId != item.Id)
                setHoverId(item.Id);
        })
        .OnDrop<StackElement, int>(srcId =>
        {
            MoveById(srcId, item.Id);
            setDraggingId(null);
            setHoverId(null);
        }, acceptedOps: DragOperations.Move);
}

return VStack(8,
    Heading("Reorder tasks"),
    TextBlock("Drag a row, or focus one and press Alt+Up / Alt+Down.")
        .Opacity(0.7),
    VStack(4,
        items.Select(Row).ToArray()
    )
).Padding(16).Width(320);

Reorderable list with a dragged row in flight

Each row is a HStack with a grab-handle glyph and the title. .OnDragStart<StackElement, int> advertises the row's Id as a typed int payload; .OnDrop<StackElement, int> accepts an int payload on any sibling row and calls MoveById. The dragged row fades to 0.4 opacity; the hover target gets a 2-px accent border. IsTabStop(true) plus .OnGotFocus makes every row a keyboard landing pad so the Alt+Arrow shortcut has somewhere to anchor.

Tips

Key your rows by the record's Id, not by index. Index keying defeats the whole point — every move would invalidate every row after the drop position and the reconciler would remount them. Collections covers the key-selector pattern in depth.

Treat the keyboard path as load-bearing, not as a fallback. A drag-only reorder is unusable with a screen reader, with a keyboard-only workflow, or with motor impairments that preclude fine pointer control. The Alt+Arrow shortcut is the same Move function as the pointer path — there's no duplicated logic and the Accessibility story flows from one code path.

Don't reach for a ListView<T> for ten rows. A virtualized collection's drag surface is genuinely harder (the row element under the pointer may unmount mid-drag as the user scrolls), and a plain VStack of rows is enough for recipe-sized lists. Promote to ListView<T> only when row counts cross the dozens.

Animation is optional polish. Reactor doesn't ship a turnkey list-reorder animation primitive at the moment; for the doc-app above, the position change is instant. Animation covers the building blocks (UseAnimation, easing curves) if you want to layer a 150ms slide on top.

Next Steps

  • Collections — Key selectors, the underlying list primitives, and when to promote to ListView<T>.
  • Animation — Optional motion polish for the reorder transition.
  • Accessibility — Focus, tab order, and the full keyboard-alternative story that the Alt+Arrow handler participates in.
  • Input and Gestures — The full drag/drop surface: typed payloads, allowed operations, UI override hooks, drag-end callbacks.
  • Recipe: Master-detail — Adjacent recipe for the selection-driven shape.
  • Recipes index — Back to the gallery.