Skip to content

实践范例:拖拽重排

拖拽重排就是一个 UseState<List<T>> 加上一个把某项从一个索引挪到另一个索引的函数。 Microsoft.UI.Reactor(Reactor)的键控协调器负责其余部分 —— 移动会保持身份, 因此同一个行元素会保住它的焦点、它的悬停状态,以及它在可视化树中的位置。 指针拖拽是一种入口;Alt+Up / Alt+Down 键盘 快捷键是第二种,它承载了无障碍方面的故事。

原语

关注点 API
有序状态 UseState<List<T>>
跨移动的稳定身份 记录上的 Id 字段;在 Select 中为行设置键
源侧拖拽 .OnDragStart<T, TPayload>
目标侧放置 .OnDrop<T, TPayload>
悬停指示 写入本地状态的 .OnDragEnter
键盘替代方案 .OnKeyDown + InputKeyboardSource.GetKeyStateForCurrentThread
可聚焦的行 .IsTabStop(true) + .OnGotFocus

数据

// 身份活在记录上。移动会保住 `Id`,因此重排之后协调器仍然保留
// 同一个行元素及其焦点状态。
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"),
    };
}

记录带有 Id。移动绝不生成新的 id —— 正是这一点让协调器能在重排后 保留同一个行元素,而不是卸载一个、再在新槽位挂载另一个。

状态

// 列表本身就是 UseState<List<TaskItem>>。`draggingId` 跟踪
// 当前被拖拽的那一行,好让我们把它变淡;`hoverId` 跟踪
// 放置目标,好让我们画出插入提示。两者都在放置完成后重置。
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);

四个 UseState Hook:列表本身、被拖拽行的 id(好把它变淡)、 被悬停的放置目标的 id(好给它描边),以及获得焦点的行 id(好让键盘 快捷键知道该移动哪一行)。四个中有两个只存在于 拖拽进行期间,并在完成时重置。

移动操作

// 把单个项从 `fromIndex` 挪到 `toIndex`。协调器按 `Id`
// 为行设置键,因此这是一次纯数据移动 —— 没有行被重新挂载,
// 没有焦点丢失,也没有动画接缝。
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);
}

整个重排就是一次列表拼接。在副本上先 RemoveAtInsert, 然后 setItems —— 协调器按 Id 对新旧做差异比对, 看到同一组记录以不同顺序排列,于是复用每一行。MoveById 是个小小的 易用封装,好让指针处理函数可以直接传载荷 id 而不必计算索引。

键盘替代方案

// Alt+Up / Alt+Down 移动获得焦点的行。这是承重的
// 无障碍故事 —— 只有拖放会挡住屏幕阅读器用户与
// 运动障碍用户;有了键盘替代方案,这个范例才符合
// WCAG。
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;
    }
}

指针拖拽是这个范例的一半 —— 键盘路径是另一半。Alt+UpAlt+Down 把获得焦点的行移动一个槽位; e.Handled = true 阻止事件继续冒泡到父级 ScrollViewer。修饰键的检测使用 InputKeyboardSource.GetKeyStateForCurrentThread, 因为 KeyRoutedEventArgs.KeyStatus 无法干净地携带 Alt。

渲染

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 ? Theme.SubtleFill : Theme.CardBackground)
        .WithBorder(isHover ? Theme.Accent : Theme.ControlStroke, 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);
        })
        .OnDragLeave(_ =>
        {
            if (hoverId == item.Id)
                setHoverId(null);
        })
        .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(item => Row(item).WithKey(item.Id.ToString())).ToArray()
    )
).Padding(16).Width(320);

带一行正在拖拽中的可重排列表

每一行都是带抓手字形与标题的 HStack.OnDragStart<StackElement, int> 把该行的 Id 作为有类型的 int 载荷广播出去; .OnDrop<StackElement, int> 则在任意兄弟行上接受 int 载荷并调用 MoveById。被拖拽的行淡到 0.4 不透明度;悬停目标获得 2 像素的强调色边框。 IsTabStop(true) 加上 .OnGotFocus 让每一行都成为键盘 落脚点,从而给 Alt+方向键快捷键一个可以锚定的位置。

提示

按记录的 Id 为行设键,而不是按索引。 按索引设键 会让整件事失去意义 —— 每次移动都会让落点之后的每一行失效, 协调器就会重新挂载它们。 集合 深入讲解了键选择器模式。

把键盘路径当作承重部分,而不是兜底。 只能拖拽的重排 对屏幕阅读器用户、纯键盘工作流用户,或无法做精细指针控制的 运动障碍用户都是不可用的。Alt+方向键快捷键与指针路径用的是同一个 Move 函数 —— 逻辑没有重复,而 无障碍 方面的考虑也顺着同一条代码 路径流淌出来。

十行数据不要动用 ListView<T> 虚拟化集合的拖拽面确实更难做 (指针下的行元素可能在用户滚动时于拖拽途中被卸载),在范例这种规模下, 用普通 VStack 装行就够了。只有当行数上到几十行时, 才升级为 ListView<T>

动画是可选的点缀。 Reactor 目前还没有提供开箱即用的 列表重排动画原语;对上面的文档应用而言,位置变化是瞬时的。如果你想 叠上一层 150 毫秒的滑动,动画 讲解了相关构建块(UseAnimation、缓动曲线)。

后续阅读

  • 集合 —— 键选择器、底层列表原语,以及何时升级为 ListView<T>
  • 动画 —— 为重排过渡可选地加上动效点缀。
  • 无障碍 —— 焦点、Tab 顺序,以及 Alt+方向键 处理函数所参与的完整键盘替代方案。
  • 输入与手势 —— 完整的 拖放面:类型化载荷、允许的操作、UI 覆盖钩子、拖拽结束回调。
  • 实践范例:主从视图 —— 面向选择驱动形态的邻近范例。
  • 实践范例索引 —— 回到图库。