Skip to content

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);
    }
}

Border 上的指针修饰符:悬停、点击与双击重置

自动启用让处理器保持诚实:挂上 .OnDoubleTapped(...) 会在已挂载的控件上 设置 IsDoubleTapEnabled = true,这样 WinUI 事件才真的会触发。 在后续渲染中移除该处理器,标志就会回到默认值。形状子类(例如 RectangleEllipse)在挂上指针处理器且未设置填充时,还会被自动指派一个透明的 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 是一个门槛:在累计位移超过阈值之前,不会派发任何回调。 首次越过时,协调器先发出 onBeganPhase = Began),随后把当前增量 作为 Phase = Changed 发出。如果这个操作在越过阈值之前就结束了, 那么 onBeganonEnded 都不会触发。

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)
        );
    }
}

用 .OnPan 直接写 Translation 实现 60 Hz 拖拽的卡片

如果被平移的位置并不驱动屏幕上的其他东西(没有相邻的计数器、没有吸附到网格的 指示器),你甚至可以在手势结束时跳过 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 生命周期。ScaleDeltaAngleDelta 报告每次回调的增量;ScaleAngle 是自 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 时取消。

listItem.OnLongPress(() => ShowContextMenu(), enableMouseEmulation: true);

焦点与访问键

声明式焦点修饰符

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);
    }
}

表单在挂载时通过 UseElementFocus 自动聚焦第一个输入框

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(...) 块替换成每个事件一个修饰符调用 —— 更短、可重渲染安全, 而且被协调器的自动启用逻辑覆盖:

// 之后
Rectangle()
    .OnPointerEntered((_, _) => Hover())
    .OnPointerExited((_, _) => Unhover());

拖放

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_001A11Y_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 —— UseFocusTrapUseAnnounceAccessibilityScanner、ARIA 模式
  • Commanding —— 应用级加速器、命令栏、CommandCommand<T> 类型
  • Animation —— 把手势与 .InteractionStates() 搭配,做出悬停/按下/聚焦的视觉效果
  • recipes/drag-reorder —— 完整的拖拽重排列表食谱
  • Components —— 上一篇:组件、props 与组合