实践范例:命令面板¶
命令面板是一个边输入边过滤的列表,用键盘加速器打开、用回车执行。在
Microsoft.UI.Reactor(Reactor)里,它就是三个
UseState Hook、一份由 Command
记录组成的静态目录,以及一个 OnPreviewKeyDown 处理函数 —— 没有菜单栏、没有模态
管理器、没有视图模型。命令面板只是又一个条件式
叠加层,叠在页面之上,方式与
模态对话框范例叠放其确认面板相同。
原语¶
| 关注点 | API |
|---|---|
| 打开/查询/选择状态 | UseState<bool> / UseState<string> / UseState<int> |
| 命令目录 | Command 记录 —— 与 Button、MenuItem、AppBarButton 所用的形态相同 |
| 过滤 | 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、异步跟踪)。命令面板只需要
Label 与 Execute;当你想把同一条记录共用给工具栏按钮或上下文菜单项时,
其余的都在那里。
状态¶
// 三份状态驱动命令面板:是否打开、已输入的查询,
// 以及过滤后列表中被高亮的那一行。焦点辅助方法另算;
// 它在打开时把焦点移进查询框。
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,就升级为
UseMemo。safeIndex 的夹紧保证了:
输入变短导致匹配列表在旧选择之下缩短时,高亮仍然有效。
键盘处理函数¶
// 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;

页面照常渲染;命令面板是一个包裹 VStack 的 Border,
与页面一同放在 Group(page, palette) 中返回。主题的
smoke-fill 遮罩让下方的页面变暗。根
表面的 .OnKeyDown 监听 Ctrl+K 并切换 open ——
这是从页面任意焦点状态都能生效的最省事的「全局加速器」。
在真实应用中,请把打开加速器上提为窗口级的
KeyboardAccelerator,这样即使焦点在某个吞掉
路由键事件的图表或第三方控件内部,命令面板也能打开。
本范例把处理函数留在原地,是为了让组合过程从头到尾都可见。
提示¶
命令面板不是模态框。 它是一个带输入的叠加层,打开时
自动聚焦。除非你的命令面板长出了一行帮助、一个设置浮出,或
其他会夺取焦点的东西,否则不要动用 UseFocusTrap
—— 对于一个文本框加一个结果列表,按 Esc 关闭就够了。
绑定到 Command,而不是裸 Action。 尽管命令面板眼下只需要
Label 与 Execute,把目录声明为
Command[] 意味着同一条记录明天就能驱动
工具栏按钮或菜单项,而不必重写目录。
元数据跟着动作走 —— 这正是命令这个面的全部意义。
每次按键都重新过滤;按目录规模决定是否记忆化。 过滤
每次按键是 O(n);对于命令面板实际具有的目录规模(几十到
低几百条),每次渲染重算都远在一帧之内。当目录变大、
或匹配器变复杂时再动用 UseMemo —— 一个真正的
模糊排序器值得拥有自己的缓存,也值得单独写一个范例。
关闭时重置。 用户关掉时应清空 query 并把 index
重置为 0,让下次打开从干净状态开始。范例里的回车
处理函数做了这两件事;如果陈旧的查询会让用户下次打开时感到意外,
Esc 分支也可以照样收紧。
后续阅读¶
- 命令 —— 完整的
Command面, 包括异步跟踪与共享加速器。 - 输入与手势 —— 预览与冒泡之间的 键盘路由,以及命令面板所叠加的修饰键塔。
- 实践范例:模态对话框 —— 相同的叠加层形态, 不同的工效学 —— 用确认面板代替过滤器。
- 实践范例:带建议的搜索 —— 单独看「边输入边过滤」模式,不带叠加层。
- 无障碍 —— 命令面板长出次级表面时的 焦点管理。
- 实践范例索引 —— 回到图库。