Skip to content

实践范例:命令面板

命令面板是一个边输入边过滤的列表,用键盘加速器打开、用回车执行。在 Microsoft.UI.Reactor(Reactor)里,它就是三个 UseState Hook、一份由 Command 记录组成的静态目录,以及一个 OnPreviewKeyDown 处理函数 —— 没有菜单栏、没有模态 管理器、没有视图模型。命令面板只是又一个条件式 叠加层,叠在页面之上,方式与 模态对话框范例叠放其确认面板相同。

原语

关注点 API
打开/查询/选择状态 UseState<bool> / UseState<string> / UseState<int>
命令目录 Command 记录 —— 与 ButtonMenuItemAppBarButton 所用的形态相同
过滤 LINQ Where(...Contains(query, OrdinalIgnoreCase))
打开时聚焦 open 变化时用 UseElementFocus() + UseEffect
打开加速器 根表面上的 .OnKeyDown
列表导航 命令面板容器上的 .OnPreviewKeyDown
叠加层组合 Group(page, palette) —— 与实践范例:模态对话框形态相同

命令目录

// 目录是一个由 Reactor Command 记录组成的静态数组。同一条
// 记录也完全可以绑定到 Button 或 MenuItem —— 命令面板
// 不过是消费它的又一个表面。
private static readonly Command[] Catalog = new[]
{
    new Command { Label = "File: New",        Execute = () => Log("new") },
    new Command { Label = "File: Open…",      Execute = () => Log("open") },
    new Command { Label = "File: Save",       Execute = () => Log("save") },
    new Command { Label = "Edit: Find",       Execute = () => Log("find") },
    new Command { Label = "Edit: Replace",    Execute = () => Log("replace") },
    new Command { Label = "View: Toggle Theme", Execute = () => Log("theme") },
    new Command { Label = "View: Zen Mode",   Execute = () => Log("zen") },
    new Command { Label = "Go: Go to Line…",  Execute = () => Log("goto-line") },
    new Command { Label = "Go: Go to Symbol…",Execute = () => Log("goto-symbol") },
    new Command { Label = "Help: About",      Execute = () => Log("about") },
};

private static void Log(string id) { /* 真实应用中在此接入遥测 */ }

Command 把标签与一个动作及元数据打包在一起 (命令讲解了完整的面:图标、 加速器、CanExecute、异步跟踪)。命令面板只需要 LabelExecute;当你想把同一条记录共用给工具栏按钮或上下文菜单项时, 其余的都在那里。

状态

// 三份状态驱动命令面板:是否打开、已输入的查询,
// 以及过滤后列表中被高亮的那一行。焦点辅助方法另算;
// 它在打开时把焦点移进查询框。
var (open, setOpen) = UseState(false);
var (query, setQuery) = UseState("");
var (index, setIndex) = UseState(0);
var (last, setLast) = UseState<string?>(null);
var (queryRef, requestQueryFocus) = this.UseElementFocus();

三个状态 Hook 持有命令面板:open 切换叠加层,query 驱动过滤,index 是被高亮的那一行。第四个 last Hook 只是为演示回显最近执行的命令 —— 它不属于命令面板模式本身。焦点辅助方法 与命令面板状态无关;它在叠加层打开时把焦点移进查询框。

过滤

// 每次渲染都重新推导过滤后的列表。目录很小;
// 一个真实的上百条命令的命令面板会通过 UseMemo 以 `query`
// 为键来做这件事。
var matches = string.IsNullOrWhiteSpace(query)
    ? Catalog
    : Catalog.Where(c => c.Label.Contains(query,
        StringComparison.OrdinalIgnoreCase)).ToArray();
// 夹紧选择索引,使它永远不指向列表末尾之外。
var safeIndex = matches.Length == 0
    ? 0
    : Math.Clamp(index, 0, matches.Length - 1);

过滤器在每次渲染时运行。对于小目录,这是正确的取舍 —— 不需要 UseMemo 那套仪式,而且过滤器直接用最新的 query,无需额外的依赖数组。一旦目录超过几百条, 或谓词复杂到超出 Contains,就升级为 UseMemosafeIndex 的夹紧保证了: 输入变短导致匹配列表在旧选择之下缩短时,高亮仍然有效。

键盘处理函数

// Esc 关闭;上/下移动选择;回车调用被高亮的
// 命令。OnPreviewKeyDown 处理函数会在 TextBox 拿到按键
// 之前拦截,因此方向键移动的是列表而不是插入符。
void OnPaletteKey(object _, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs e)
{
    switch (e.Key)
    {
        case VirtualKey.Escape:
            setOpen(false);
            e.Handled = true;
            break;
        case VirtualKey.Down:
            if (matches.Length > 0)
                setIndex((safeIndex + 1) % matches.Length);
            e.Handled = true;
            break;
        case VirtualKey.Up:
            if (matches.Length > 0)
                setIndex((safeIndex - 1 + matches.Length) % matches.Length);
            e.Handled = true;
            break;
        case VirtualKey.Enter:
            if (matches.Length > 0)
            {
                var cmd = matches[safeIndex];
                cmd.Execute?.Invoke();
                setLast(cmd.Label);
                setOpen(false);
                setQuery("");
                setIndex(0);
            }
            e.Handled = true;
            break;
    }
}

OnPreviewKeyDown 走的是隧道阶段 —— 它在冒泡那一对之前触发,因此 上/下移动的是选择而不是 TextBox 的插入符,回车触发的是被高亮的命令 而不是插入换行。 设置 e.Handled = true 会停止后续路由,让底层 控件让开。同一个处理函数在 按 Esc 时关闭命令面板;输入与手势 页 详细讲解了预览与冒泡的配对关系。

渲染

// 页面照常渲染;命令面板是叠在上面的条件式
// 叠加层,与模态对话框范例一样。根表面
// 持有 Ctrl+K 加速器,因此命令面板可以从页面任意位置
// 打开。
var page = VStack(12,
    Heading("Command Palette Demo"),
    TextBlock("Press Ctrl+K to open the palette.").Opacity(0.7),
    last is null
        ? Empty()
        : TextBlock($"Last command: {last}").Opacity(0.6)
).Padding(24);

UseEffect(() =>
{
    if (open)
        requestQueryFocus();
    return () => { };
}, open);

Element palette = Border(
    VStack(0,
        TextBox(query, v => { setQuery(v); setIndex(0); },
            placeholderText: "Type a command…")
            .AutomationName("Command search")
            .Width(420)
            .Ref(queryRef),
        matches.Length == 0
            ? (Element)TextBlock("No commands match.").Padding(12).Opacity(0.6)
            : VStack(0,
                matches.Select((c, i) =>
                    Border(
                        TextBlock(c.Label).Padding(10)
                    ).Background(i == safeIndex ? Theme.AccentTertiary : Theme.CardBackground)
                     .WithKey(c.Label)
                ).ToArray<Element>()
            )
    ).Background(Theme.CardBackground).CornerRadius(8).Width(440)
).Background(Theme.SmokeFill).Padding(60)
 .OnPreviewKeyDown(OnPaletteKey);

var root = (open ? Group(page, palette) : page)
    .OnKeyDown((_, e) =>
    {
        // Ctrl+K 切换命令面板。真实应用会倾向于在窗口根上使用
        // KeyboardAccelerator;这里保持范例自包含。
        var ctrl = (Microsoft.UI.Xaml.Window.Current?.CoreWindow
            .GetKeyState(VirtualKey.Control)
            & Windows.UI.Core.CoreVirtualKeyStates.Down)
            == Windows.UI.Core.CoreVirtualKeyStates.Down;
        if (ctrl && e.Key == VirtualKey.K)
        {
            setOpen(!open);
            e.Handled = true;
        }
    });
return root;

命令面板打开并显示过滤后的列表

页面照常渲染;命令面板是一个包裹 VStackBorder, 与页面一同放在 Group(page, palette) 中返回。主题的 smoke-fill 遮罩让下方的页面变暗。根 表面的 .OnKeyDown 监听 Ctrl+K 并切换 open —— 这是从页面任意焦点状态都能生效的最省事的「全局加速器」。

在真实应用中,请把打开加速器上提为窗口级的 KeyboardAccelerator,这样即使焦点在某个吞掉 路由键事件的图表或第三方控件内部,命令面板也能打开。 本范例把处理函数留在原地,是为了让组合过程从头到尾都可见。

提示

命令面板不是模态框。 它是一个带输入的叠加层,打开时 自动聚焦。除非你的命令面板长出了一行帮助、一个设置浮出,或 其他会夺取焦点的东西,否则不要动用 UseFocusTrap —— 对于一个文本框加一个结果列表,按 Esc 关闭就够了。

绑定到 Command,而不是裸 Action 尽管命令面板眼下只需要 LabelExecute,把目录声明为 Command[] 意味着同一条记录明天就能驱动 工具栏按钮或菜单项,而不必重写目录。 元数据跟着动作走 —— 这正是命令这个面的全部意义。

每次按键都重新过滤;按目录规模决定是否记忆化。 过滤 每次按键是 O(n);对于命令面板实际具有的目录规模(几十到 低几百条),每次渲染重算都远在一帧之内。当目录变大、 或匹配器变复杂时再动用 UseMemo —— 一个真正的 模糊排序器值得拥有自己的缓存,也值得单独写一个范例。

关闭时重置。 用户关掉时应清空 query 并把 index 重置为 0,让下次打开从干净状态开始。范例里的回车 处理函数做了这两件事;如果陈旧的查询会让用户下次打开时感到意外, Esc 分支也可以照样收紧。

后续阅读