WinUI 参考: 完整的属性清单与设计指南见 Input。
输入从原始的指针与键盘事件出发,穿过 Microsoft.UI.Reactor(Reactor)的修饰符塔,
进入组件的渲染逻辑,上面还叠着手势识别器与焦点状态。最底层是原始的
.OnPointerPressed / .OnKeyDown / .OnGotFocus 等接口面,
它与 WinUI 的路由事件一一对应,并默认使用冒泡 —— 事件从最深的元素开始,
沿元素树向上传播。其上坐落着高层识别器:.OnTapped、.OnDoubleTapped、
.OnPan、.OnPinch、.OnRotate、.OnLongPress —— 每一个都会自动启用
底层的 WinUI 标志(例如 IsDoubleTapEnabled),并安装一个稳定的蹦床
(trampoline),因此带着新的处理器闭包重新渲染不会付出重新订阅的代价。
焦点是第三座塔:声明式的 .TabIndex / .AccessKey / .IsTabStop 修饰符、
用于命令式聚焦的 UseElementFocus / UseElementRef
Hook,以及用于模态围困的 UseFocusTrap。
一切从设计上就是可重渲染安全的;想了解实现细节的读者,
focus-and-input-internals 一页覆盖了
调度器与蹦床机制。
输入与手势¶
Reactor 的输入接口面是声明式的:通过 .On* 修饰符挂上处理器,协调器就会
接上 WinUI 事件、自动启用匹配的标志(例如 IsDoubleTapEnabled),
并且在重新渲染时无需重新订阅底层 WinUI 事件。
本页覆盖大多数屏幕上会用到的修饰符、面向连续输入的高层手势识别器, 以及那些补齐命令体系的故事的命令式逃生舱 —— 基于 ref 的聚焦与访问键。
参考表¶
| 修饰符 / Hook | 用途 |
|---|---|
.OnPointerPressed / .OnPointerReleased / .OnPointerEntered / .OnPointerExited / .OnPointerMoved |
原始指针事件。默认冒泡。 |
.OnTapped / .OnDoubleTapped / .OnRightTapped / .OnHolding |
高层点击识别器。自动启用匹配的 IsXEnabled 标志。 |
.OnDoubleTap(Action) / .OnDoubleTap(Action<Point>) |
.OnDoubleTapped 的无参便捷重载,用于你不需要路由事件参数时。 |
.OnPan / .OnPinch / .OnRotate / .OnLongPress |
连续手势识别器。接受 onBegan / onChanged / onEnded。 |
.OnKeyDown / .OnKeyUp / .OnPreviewKeyDown / .OnPreviewKeyUp / .OnCharacterReceived |
键盘事件。带 Preview 前缀的一对走隧道;不带前缀的一对冒泡。 |
.OnGotFocus / .OnLostFocus |
焦点事件;默认冒泡。 |
.OnDragStart<TEl,T> / .OnDrop<TEl,T> / .OnDragOver |
带类型进程内载荷的拖放源与目标。 |
.TabIndex(n) / .IsTabStop() / .AccessKey("S") / .TabNavigation(...) |
声明式的焦点顺序与键盘导航。 |
UseElementFocus |
非泛型的元素 ref + 由调度器排期的 RequestFocus() 动作。 |
UseElementRef<T> |
带类型的元素 ref,用于在底层控件上调用方法。 |
UseFocusTrap |
为模态与浮出层提供键盘焦点围困。 |
指针、点击与键盘修饰符¶
每个组件都把完整的 WinUI 输入事件接口面暴露为 .On* 修饰符。
挂上处理器,Reactor 就会自动启用匹配标志并安装蹦床,重新渲染时不会重新订阅
底层事件。
class PointerModifiersExample : Component
{
public override Element Render()
{
var (hover, setHover) = UseState(false);
var (tapCount, setTapCount) = UseState(0);
return VStack(12,
Border(TextBlock(hover ? "hovered" : "hover me")
.HAlign(HorizontalAlignment.Center).VAlign(VerticalAlignment.Center))
.Width(240).Height(120)
.Background(hover ? Theme.AccentTertiary : Theme.ControlFillSecondary)
.CornerRadius(8)
.IsTabStop(true)
.OnPointerEntered((_, _) => setHover(true))
.OnPointerExited((_, _) => setHover(false))
.OnTapped((_, _) => setTapCount(tapCount + 1))
.OnKeyDown((_, e) =>
{
if (e.Key is VirtualKey.Enter or VirtualKey.Space)
setTapCount(tapCount + 1);
})
.OnDoubleTap(() => setTapCount(0)),
TextBlock($"Tapped {tapCount} time(s) — double-tap to reset")
).Padding(24);
}
}

自动启用让处理器保持诚实:挂上 .OnDoubleTapped(...) 会在已挂载的控件上
设置 IsDoubleTapEnabled = true,这样 WinUI 事件才真的会触发。
在后续渲染中移除该处理器,标志就会回到默认值。形状子类(例如 Rectangle
和 Ellipse)在挂上指针处理器且未设置填充时,还会被自动指派一个透明的
Fill —— 否则命中测试会整个漏掉这个未填充的形状。
键盘事件¶
class KeyboardEventsExample : Component
{
public override Element Render()
{
var (value, setValue) = UseState("");
var (log, setLog) = UseState("press Enter to submit");
return VStack(12,
TextBox(value, setValue, placeholderText: "type here").Width(280)
.AutomationName("Text to submit")
// 先走隧道 —— 在冒泡之前拦截的正确位置。
.OnPreviewKeyDown((_, _) => setLog("preview"))
// 冒泡 —— 在 preview 那一对之后触发。
.OnKeyDown((_, e) =>
{
if (e.Key == VirtualKey.Enter)
setLog($"submitted: {value}");
})
.OnCharacterReceived((_, e) => setLog($"typed '{e.Character}'")),
TextBlock(log)
).Padding(24);
}
}
OnPreviewKeyDown / OnPreviewKeyUp 映射到 WinUI 的隧道事件,
先于冒泡的 OnKeyDown / OnKeyUp 那一对触发 —— 对于需要压制后续路由的
快捷键拦截来说,这才是正确的位置。
焦点事件¶
class FocusEventsExample : Component
{
public override Element Render()
{
var (value, setValue) = UseState("");
var (hint, setHint) = UseState("");
return VStack(12,
TextBox(value, setValue, placeholderText: "email").Width(280)
.AutomationName("Email")
.OnGotFocus((_, _) => setHint("We never share your address."))
.OnLostFocus((_, _) => setHint("")),
TextBlock(hint).Foreground(Theme.SecondaryText)
).Padding(24);
}
}
焦点事件走的是与指针事件相同的蹦床模式,因此处理器闭包在每次渲染时就地更新, 而 WinUI 订阅在每个元素上只安装一次。
连续手势¶
平移、捏合与旋转属于连续手势:一次用户交互会产生一串回调,
其中的增量是相对于手势起点而言的。每个手势接受一个 onChanged 动作,
外加每次交互恰好触发一次的 onBegan / onEnded 回调。
平移¶
Rectangle()
.OnPan(
onChanged: g => Translate(g.Translation),
onEnded: g => SnapToGrid(g.Translation),
minimumDistance: 8.0,
axis: PanAxis.Both,
withInertia: true);
minimumDistance 是一个门槛:在累计位移超过阈值之前,不会派发任何回调。
首次越过时,协调器先发出 onBegan(Phase = Began),随后把当前增量
作为 Phase = Changed 发出。如果这个操作在越过阈值之前就结束了,
那么 onBegan 和 onEnded 都不会触发。
PanGesture.Translation 是自 Began 以来的累计值;
PanGesture.Delta 是每次回调的增量。
让平移保持在 60 Hz:直接写 Translation¶
Reactor 的渲染循环在每个 tick 上以 DispatcherQueuePriority.Low 重新入队,
以免布局与绘制被密集的 setState 调用饿死。对一般界面来说这是正确的,
但这意味着平移过程中按事件触发的 setState 会掉帧 —— 等到 Low 优先级的渲染
排空时,已经有好几个操作事件堆积上来了。而且 setOffset(offset + g.Delta)
还会读到一个过期的 offset 闭包,让问题雪上加霜。
要想获得 60 Hz 的平滑平移,请拿到已挂载元素的 ref,在 onChanged 内部
直接写 Translation。不需要走协调器往返;这个合成器属性更新足够便宜,
每个 tick 都能跑。只在手势结束时调用 setState —— 每次拖拽一次,
而不是每秒 60 次。
class PanGestureExample : Component
{
public override Element Render()
{
// 为了 60 Hz 的平滑平移,在 onChanged 内部直接写已挂载元素的
// Translation。走 setState 会排队 Low 优先级的重新渲染,
// 而它们会被操作事件流本身饿死,导致拖拽卡顿。committedRef
// 保存的是上一次手势结束时的位置,好让连续多次拖拽累加。
// 重置放在兄弟 Button 上,因为当 ManipulationMode ≠ System 时
// WinUI 会抑制点击识别器 —— 同一个元素上的 .OnDoubleTap 不会触发。
var cardRef = UseRef<FrameworkElement?>(null);
var committedRef = UseRef(Vector2.Zero);
var (offset, setOffset) = UseState(Vector2.Zero);
void Reset()
{
committedRef.Current = Vector2.Zero;
setOffset(Vector2.Zero);
if (cardRef.Current is { } fe)
fe.Translation = System.Numerics.Vector3.Zero;
}
return VStack(8,
Border(
Border(TextBlock("drag me")
.HAlign(HorizontalAlignment.Center).VAlign(VerticalAlignment.Center)
.Foreground(Theme.AccentText))
.Width(120).Height(120)
.Background(Theme.Accent)
.CornerRadius(8)
.Translation(offset.X, offset.Y, 0)
.OnMount(fe => cardRef.Current = fe)
.OnPan(
onChanged: g =>
{
var next = committedRef.Current +
new Vector2((float)g.Translation.X, (float)g.Translation.Y);
if (cardRef.Current is { } fe)
fe.Translation = new System.Numerics.Vector3(next.X, next.Y, 0);
},
onEnded: g =>
{
committedRef.Current += new Vector2((float)g.Translation.X, (float)g.Translation.Y);
setOffset(committedRef.Current);
},
withInertia: true)
).Height(260).Background(Theme.CardBackground).CornerRadius(8).Padding(16),
Button("Reset position", Reset)
);
}
}

如果被平移的位置并不驱动屏幕上的其他东西(没有相邻的计数器、没有吸附到网格的 指示器),你甚至可以在手势结束时跳过 setState,把这个 ref 当作唯一的数据源。
我能在同一个元素上同时使用 .OnPan 和 .OnTapped / .OnDoubleTapped 吗?¶
不可靠。只要 ManipulationMode 被设为 System 以外的任何值,WinUI 就会抑制
点击、双击、右键点击和长按识别器 —— 加上 .OnPan(或 .OnPinch / .OnRotate)
会把模式切换到具体的手势位,于是同一元素上的点击就不再触发了。如果你需要
"能拖拽,也能点击重置",请把点击目标放到另一个兄弟元素上(一个 Reset 按钮、
一个覆盖层、一个父级 Border),而不是放在拖拽表面本身。
axis: PanAxis.Horizontal 把平移限制在 X 轴上;withInertia: true 会加上
惯性标志,这样在指针抬起后 WinUI 仍会继续发出回调。
捏合与旋转¶
Image(imageUrl)
.AutomationName("Photo preview")
.OnPinch(
onChanged: g => Scale(g.Scale),
withInertia: true)
.OnRotate(
onChanged: g => Rotate(g.Angle));
两个手势与平移共用同一套 Phase 生命周期。ScaleDelta 和 AngleDelta
报告每次回调的增量;Scale 和 Angle 是自 Began 以来的累计值。
在一个元素上组合多个手势是预期用法 —— 协调器会对所需的
ManipulationModes 标志取并集。
长按¶
class LongPressExample : Component
{
public override Element Render()
{
var (log, setLog) = UseState("hold the card for 500 ms");
return VStack(12,
Border(TextBlock("Hold me")
.HAlign(HorizontalAlignment.Center).VAlign(VerticalAlignment.Center))
.Height(80).Background(Theme.SystemCautionBackground).CornerRadius(6).Padding(12)
.OnLongPress(
g => setLog($"long-press after {g.Duration.TotalMilliseconds:F0}ms"),
enableMouseEmulation: true),
TextBlock(log)
).Padding(24);
}
}

长按以触摸和手写笔为先:协调器把 Holding 事件路由进回调,并设置
IsHoldingEnabled = true。鼠标长按默认关闭,因为 WinUI 的 Holding 事件
不会为鼠标指针触发;通过 enableMouseEmulation: true 选择启用后,协调器会
布下一个 DispatcherTimer,它在释放、捕获丢失或指针移动超过 cancelDistance
时取消。
焦点与访问键¶
声明式焦点修饰符¶
class FocusModifiersExample : Component
{
public override Element Render() => VStack(12,
// 声明式焦点顺序 + 主操作上的访问键(Alt+S)。
Button("Submit", () => { })
.TabIndex(3)
.AccessKey("S")
.IsTabStop(), // 默认为 true 的重载
// 高级焦点旋钮同样是一等公民。
VStack(8,
Button("One", () => { }),
Button("Two", () => { }))
.TabNavigation(KeyboardNavigationMode.Once)
.XYFocusKeyboardNavigation(XYFocusKeyboardNavigationMode.Enabled)
).Padding(24);
}
绑定在 .Command(...) 上的 AccessKey 可以逐处覆盖:后写的
.AccessKey(...) 通过正常的"修饰符在命令之后"顺序胜出。
class CommandAccessKeyExample : Component
{
public override Element Render()
{
var save = new Command { Label = "Save", Execute = () => { }, AccessKey = "S" };
// 后写的 .AccessKey(...) 胜出,覆盖 Command 自带的那个。
return Button(save).AccessKey("F").Padding(24);
}
}
用 ref 做命令式聚焦¶
有时你需要从副作用或事件处理器里聚焦某个控件 —— 比如挂载时自动聚焦第一个
输入框。用 UseElementFocus 拿到一个稳定的 ElementRef 外加一个
RequestFocus 动作,后者把聚焦排期到 UI 调度器上,让它在当前这轮协调
之后执行。
class UseElementFocusExample : Component
{
public override Element Render()
{
var (name, setName) = UseState("");
var (inputRef, requestFocus) = this.UseElementFocus();
UseEffect(() => requestFocus(), Array.Empty<object>());
return VStack(12,
TextBlock("The field below auto-focuses on mount via UseElementFocus()."),
TextBox(name, setName, placeholderText: "name").Width(280)
.AutomationName("Name")
.Ref(inputRef)
).Padding(24);
}
}

ElementRef.Current 在所引用的元素挂载之前为 null;这个 ref 能跨重新渲染存活,
因此同一个实例可以可靠地指向当前已挂载的控件。调用
Microsoft.UI.Reactor.Input.FocusManager.Focus(ref) 做同步的聚焦尝试,
或用 FocusManager.FocusAsync(ref) 走 WinUI 的异步 API 并拿到成功与否的结果。
带类型的 ref¶
当你确实需要在底层控件上调用方法时(例如 TextBox 上的 SelectAll()、
Button 上的 Focus(FocusState.Programmatic)),请改用 UseElementRef<T>()。
它会给你一个 ElementRef<T>,其 .Current 已经带类型 T —— 调用处不需要
as TextBox 这样的转换。它是 Component(以及 RenderContext)上的扩展方法,
因此在 Render() 内部请以 this.UseElementRef<T>() 的形式调用:
class SearchBoxExample : Component
{
public override Element Render()
{
var (query, setQuery) = UseState("");
// ElementRef<T>.Current 已经带类型 —— 调用处不需要 `as TextBox`。
var inputRef = this.UseElementRef<TextBox>();
UseEffect(() => inputRef.Current?.SelectAll(), Array.Empty<object>());
return VStack(12,
TextBlock("Text is pre-selected on mount via UseElementRef<TextBox>()."),
TextBox(query, setQuery).Width(280)
.AutomationName("Search query")
.Ref(inputRef)
).Padding(24);
}
}
约束 T : FrameworkElement 让这个 ref 保持类型受检。在 DEBUG 构建下,
Reactor 会断言实际挂载的元素确实是一个 T;在发布构建下,不匹配是静默的,
.Current 返回 null。
底层原理:蹦床派发¶
带着一个全新的处理器闭包重新渲染某个元素,在数据驱动的界面里是家常便饭。 朴素地在每次渲染时订阅/退订,会让每个元素的每个事件都付出一次 COM 往返 —— 足以吃掉一个 1000 项列表的帧预算。Reactor 为每个元素的每个事件安装一个 稳定的蹦床委托,并更新一个供该蹦床读取的可变字段。重新渲染只是换掉这个字段; WinUI 订阅毫发无损。
这个机制不需要你选择启用或配置 —— 每一个 .On* 修饰符都会自动走蹦床路径。
想确切看到每个蹦床何时挂载与派发,可以用 devtools 的 ETW
EventDispatch 关键字(0x40)。
从 .Set(...) 直通迁移¶
Tier-1 之前的代码常常会通过 .Set(...) 去订阅某个元素类型尚未以声明式建模的
事件:
// 之前 —— 逃出声明式接口面,并绕过蹦床派发。
Rectangle().Set(r =>
{
r.PointerEntered += (_, _) => Hover();
r.PointerExited += (_, _) => Unhover();
});
Tier 1 落地之后,每一个指针、点击、键盘和焦点事件都有了一等修饰符。
把 .Set(...) 块替换成每个事件一个修饰符调用 —— 更短、可重渲染安全,
而且被协调器的自动启用逻辑覆盖:
拖放¶
Reactor 的拖放接口面是对完整 Windows 拖放协议(CanDrag 源、AllowDrop
目标、DragStarting / Drop / DropCompleted,带文本 / URI / HTML / RTF /
文件 / 位图 / 自定义格式的 DataPackage,以及 DragUIOverride 放置指示器调整)
的声明式包装。源和目标会自动接上底层标志 —— 你设一个修饰符,协调器翻对应的位,
并通过与其他事件相同的蹦床路径订阅一次。
带类型的进程内载荷¶
对于"在单个应用内重排"的场景(看板列、可排序列表),在源上附一个带类型的
载荷,并在目标上配一个匹配的带类型放置处理器。载荷通过内存中的传输注册表
传递,该注册表以写入 DataPackage.Properties 的一个 GUID 为键,
因此任意 CLR 对象都能往返而无需序列化器。
sealed record KanbanCard(string Id, string Title);
class KanbanDndExample : Component
{
public override Element Render()
{
var initialTodo = UseMemo(() => new KanbanCard[]
{
new("k1", "Write docs"),
new("k2", "Ship feature"),
}, Array.Empty<object>());
var (todo, setTodo) = UseState<IReadOnlyList<KanbanCard>>(initialTodo);
var (done, setDone) = UseState<IReadOnlyList<KanbanCard>>(Array.Empty<KanbanCard>());
Element Column(string label,
IReadOnlyList<KanbanCard> cards,
Action<IReadOnlyList<KanbanCard>> setThis)
{
var children = new List<Element> { TextBlock(label).SemiBold() };
foreach (var card in cards)
{
var captured = card;
children.Add(
Border(TextBlock(captured.Title).Foreground(Theme.AccentText))
.Background(Theme.Accent).CornerRadius(6).Padding(10)
.OnDragStart<BorderElement, KanbanCard>(
getPayload: () => captured,
allowedOperations: DragOperations.Move,
onEnd: ctx =>
{
if (!ctx.WasCancelled && ctx.CompletedOperation == DragOperations.Move)
setThis(cards.Where(c => c.Id != captured.Id).ToList());
}));
}
return VStack(6, children.ToArray())
.OnDrop<StackElement, KanbanCard>(
onDrop: c =>
{
if (!cards.Any(x => x.Id == c.Id))
setThis(cards.Append(c).ToList());
},
acceptedOps: DragOperations.Move);
}
return HStack(12,
Border(Column("Todo", todo, setTodo))
.Width(240).Background(Theme.CardBackground).CornerRadius(6).Padding(10),
Border(Column("Done", done, setDone))
.Width(240).Background(Theme.SystemSuccessBackground).CornerRadius(6).Padding(10)
).Padding(24);
}
}

标准格式 + 跨进程互操作¶
即时设置器(.WithText、.WithUri、.WithHtml、.WithRtf、.WithFiles、
.WithBitmap、.WithCustomFormat)直接写入 DataPackage,
因此记事本 / Word / 文件资源管理器能原生接收这个放置。在目标一侧,
无论拖拽来自同一进程还是不同进程,TryGetText(out string) 和
GetTextAsync(CancellationToken) 的行为都是一样的。
Border(TextBlock("Drag me to Notepad"))
.OnDragStart<BorderElement>(() => new DragData().WithText("hello world"));
Rectangle()
.OnDrop<RectangleElement>(args =>
{
if (args.Data.TryGetText(out var text))
Log(text);
args.AcceptedOperation = DragOperations.Copy;
});
惰性提供者 —— 目标要时才付代价¶
如果生成载荷很昂贵(从视图模型渲染 HTML、读取一个大文件、绕一趟服务器),
那就注册一个提供者,而不是一个即时值。Reactor 会把你的 Func<T> 或
Func<CancellationToken, Task<T>> 适配到 WinUI 的 DataProviderHandler 上:
获取延迟对象,你的代码在线程池上运行,结果在目标请求该格式时才发布。
一个只读文本的目标永远不会付出 HTML 的代价。
Border(TextBlock("Rich content"))
.OnDragStart<BorderElement>(() => new DragData()
.WithText("plain fallback")
.WithHtml(ct => RenderExpensiveHtmlAsync(ct)));
放置指示器覆盖¶
拖过回调可以通过 DragTargetArgs.UIOverride 调整标题文字、字形和内容预览的
可见性 —— Reactor 会在你的回调返回后把这些改动写回 WinUI 的 DragUIOverride。
VStack(children.ToArray())
.OnDragOver(args =>
{
args.UIOverride.Caption = "Move to Inbox";
args.UIOverride.IsGlyphVisible = false;
args.AcceptedOperation = DragOperations.Move;
})
.OnDrop<StackElement, Card>(card => inbox.Add(card));
VStack(...) 和 HStack(...) 都产出 StackElement,
因此在带类型的 .OnDrop<TEl, T> 重载上就该用这个元素类型参数。
放置的文件:优先用 TryGetSafeLocalFiles¶
跨进程的文件拖放属于受攻击者影响的输入。TryGetFiles 会把源提供的任何东西
原样交回来 —— 包括 UNC 路径、重解析点,以及虚拟(非文件系统)的 shell 项,
它们可能让后续的一次读取被重定向到本机之外。TryGetSafeLocalFiles 替你
施加了这层过滤,只返回真实的本地文件系统项。
Border(TextBlock("Drop files here"))
.OnDrop<BorderElement>(args =>
{
if (args.Data.TryGetSafeLocalFiles(out var files))
Import(files);
args.AcceptedOperation = DragOperations.Copy;
});
REACTOR_INPUT_002 分析器会标记 .OnDrop 处理器中的 TryGetFiles,
并建议使用安全的变体。
确认后移动模式¶
当源声明 DragOperations.Move 时,源负责移除被移动的项 —— 但必须等到放置被
确认之后。绝不要在 getPayload 里乐观地移除:用户可能取消(ESC),
放置可能落在任何目标之外,或者一次 Ctrl 拖拽可能把 Move 降级为 Copy。
请等 onEnd,再基于 CompletedOperation 分支:
Border(TextBlock(card.Title))
.OnDragStart<BorderElement, Card>(
getPayload: () => card,
allowedOperations: DragOperations.Move | DragOperations.Copy,
onEnd: ctx =>
{
if (ctx.WasCancelled) return;
if (ctx.CompletedOperation == DragOperations.Move)
column.Remove(card); // 移动已确认 —— 可以安全移除
// 否则:Copy 成功,源保留该项
});
当放置落在每个有效目标之外时(ESC、落在空白处、系统中止),
DragEndContext.WasCancelled 为 true。DragEndContext.CompletedOperation
携带最终协商出来的操作 —— 即目标通过 DragTargetArgs.AcceptedOperation
设置的那个 —— 取消时则为 DragOperations.None。
注意: 在默认的冒泡阶段,父元素上的
.OnPointerPressed会在其子元素之后 触发 —— Reactor 镜像了 WinUI 的路由事件模型,事件从命中测试到的最深元素 开始向上传播。修饰符接口面上没有.On<Event>Handled(...)这种 "已处理也要收"的重载:每个.On*修饰符都通过正常的冒泡路径订阅, 并在某个子元素把事件标记为已处理后停止。如果父元素必须无条件看到这次按下, 你有两个选择:(1) 挂到 preview/隧道那一对上(按键用.OnPreviewKeyDown/.OnPreviewKeyUp—— 指针事件没有 preview 对);(2) 通过.Set(c => ...)降级到手动的AddHandler(PointerPressedEvent, handler, handledEventsToo: true), 协调器自己在手势和拖放内部用的就是这一招。陷阱在于:指望用隧道来做指针捕获, 在 80% 的情况下会静默地做错事,因为指针事件根本不存在隧道对。 focus-and-input-internals 一页端到端地 走了一遍路由事件的各个阶段。
模式¶
拖拽重排一个列表¶
把每一项的 .OnDragStart<TEl, T> 与列表容器上的 .OnDrop<TEl, T> 组合起来。
上面的看板示例就是标准形态 —— 带类型的载荷、GUID 传输注册表、确认后移动。
想要包含完整状态管理与动画的食谱式讲解,见
recipes/drag-reorder。
图片上的捏合缩放¶
把 .OnPinch 与元素上由状态驱动的 Scale 合成器属性结合起来。
Scale 是自 Began 以来的累计值 —— 为了 60 Hz 的表现,请在 onChanged 里
直接应用它(与上面平移同样的模式),然后在 onEnded 里提交到 UseState。
协调器会对 ManipulationModes 取并集,因此在同一元素上再加 .OnRotate
是免费的。
在模态中围困焦点¶
把模态的内容树包进一个带 .FocusTrap(handle) 的容器,handle 来自
UseFocusTrap(isActive)。Tab 与 Shift+Tab 会在陷阱内循环;
模态的关闭处理器把 isActive 翻为 false,这会解除陷阱并把焦点还给
之前获得焦点的元素。accessibility 一页覆盖了完整的
ARIA 对话框形态(播报、关闭时恢复焦点、通过 AutomationName 实现的
aria-labelledby)。
常见错误¶
指望用隧道来做指针捕获¶
Reactor 只通过冒泡阶段暴露指针事件 —— 并没有 .OnPreviewPointerPressed /
.OnPreviewPointerReleased 修饰符,因为 WinUI 面向指针事件的路由事件接口面
并没有像键盘那样提供一对隧道事件。如果父元素需要在子元素消费掉按下事件之前
先看到它,请降级到 .Set(c => c.AddHandler(PointerPressedEvent, handler,
handledEventsToo: true)) —— .On* 修饰符本身没有"已处理也要收"的重载。
上面的注意事项展开了路由细节。
用 .OnKeyDown 去做加速器组合键¶
.OnKeyDown 是在元素获得焦点时按元素触发的 —— 挂在一个 TextBox 上的
Ctrl+S 处理器只有在该字段持有焦点时才会触发。对于应用级的加速器
(保存、查找、运行),请用 Reactor 的命令体系:
new Command { ..., Accelerator = Accelerator(VirtualKey.S, VirtualKeyModifiers.Control) }
会把这个组合键注册进 WinUI 的加速器基础设施,它经由窗口的
AccessKeyManager 路由,与焦点无关。分析器 REACTOR_INPUT_001 会标记
通过 .OnKeyDown 挂上的 Ctrl/Alt 组合键,并建议改写成 Command。
可点击的 Border 上忘了 .IsTabStop(true)¶
一个带 .OnTapped(...) 的 Border 对指针事件是可命中测试的,但默认并不在
键盘 Tab 顺序里 —— 按 Tab 会直接跳过它,这不符合无障碍
要求(每个可交互控件都必须能通过键盘到达)。加上 .IsTabStop(true)
把这个 Border 放进 Tab 顺序 —— 光写一个 .TabIndex(n) 是不够的,
因为协调器只对可聚焦的 Control 应用 TabIndex,而不对 Border 应用 ——
并配合 .OnKeyDown 处理 Enter/Space 激活。Roslyn 分析器
REACTOR_A11Y_004 会在构建期标记这一点。
请注意,运行时的 AccessibilityScanner没有对应的
键盘可达性规则(它的规则是 A11Y_001–A11Y_008),
因此构建期分析器是这一点上唯一的自动化防线。
小贴士¶
存在高层识别器时就用它。 .OnTapped 是点击场景下的正确原语 ——
它会滤掉拖拽、统一处理触摸与鼠标,并遵循系统的点击距离阈值。
只有在你确实需要原始事件(预览、捕获、多点触控协调)时才用
.OnPointerPressed。
平移过程中直接写 Translation。 按事件触发的 setState 会掉帧 ——
Reactor 渲染循环的优先级输给了操作事件流。拿到已挂载元素的 ref,
在 onChanged 内部写 Translation;在 onEnded 时一次性提交到状态。
上面的平移片段就是标准形态。
长按需要 enableMouseEmulation: true 才能服务鼠标用户。
WinUI 的 Holding 事件在设计上不为鼠标指针触发。如果你的上下文菜单触发器
需要鼠标支持(大多数都需要),请显式选择启用。触摸和手写笔无需它即可工作。
任何"快捷键形状"的东西都交给 Command。 逐元素的 .OnKeyDown 处理器
适合"按 Enter 提交这个表单";应用级的组合键应该放在一个带
Accelerator = Accelerator(VirtualKey.S, VirtualKeyModifiers.Control) 的
Command 上。Command.AccessKey 是另一回事,是 Alt 键助记符,不是组合键。
完整接口面见 commanding。
跑一遍 a11y 扫描器。 一个可通过键盘聚焦的交互控件若没有 AutomationName
或可见的聚焦提示,就是 bug。AccessibilityScanner
会在运行时把这两点都标记出来。
下一步¶
- Focus and Input Internals —— 底层原理:蹦床派发、路由事件阶段、聚焦的调度器合并
- Accessibility ——
UseFocusTrap、UseAnnounce、AccessibilityScanner、ARIA 模式 - Commanding —— 应用级加速器、命令栏、
Command与Command<T>类型 - Animation —— 把手势与
.InteractionStates()搭配,做出悬停/按下/聚焦的视觉效果 - recipes/drag-reorder —— 完整的拖拽重排列表食谱
- Components —— 上一篇:组件、props 与组合