WPF命令路由行为的不一致性取决于UI焦点状态

nos*_*tio 17 .net c# wpf xaml focus

我有一个RoutedUICommand命令,可以通过两种不同的方式触发:

  • 直接通过ICommand.Execute按钮点击事件;
  • 使用声明性语法:<button Command="local:MainWindow.MyCommand" .../>.

该命令仅由顶部窗口处理:

<Window.CommandBindings>
    <CommandBinding Command="local:MainWindow.MyCommand" CanExecute="CanExecuteCommmand" Executed="CommandExecuted"/>
</Window.CommandBindings>
Run Code Online (Sandbox Code Playgroud)

第一种方法仅在窗口中存在聚焦元素时才有效.无论焦点如何,第二个总是这样.

我看着BCL的ICommand.Execute实施,发现该命令不被解雇,如果Keyboard.FocusedElementnull,那么这是由设计.我仍然质疑,因为顶层可能有一个处理程序(就像在我的情况下)仍然希望接收命令,即使应用程序没有UI焦点(例如,我可能想要ICommand.Execute从收到套接字消息时的异步任务).让它如此,我仍然不清楚为什么第二种(声明性)方法总是有效,无论焦点状态如何.

在理解WPF命令路由时我缺少什么?我敢肯定这不是一个错误,而是一个功能.

下面是代码.如果您喜欢玩它,这是完整的项目.单击第一个按钮 - 命令将被执行,因为焦点位于TextBox.点击第二个按钮 - 一切都很好.单击Clear Focus按钮.现在第一个按钮(ICommand.Execute)不执行命令,而第二个按钮()仍然执行.您需要单击TextBox以使第一个按钮再次工作,因此有一个聚焦元素.

这是一个人为的例子,但它具有现实意义.我将发布一个关于托管WinForms控件的相关问题WindowsFormsHost([EDITED] 在这里问),在这种情况下Keyboard.FocusedElement总是null在焦点在里面WindowsFormsHost(有效地杀死命令执行ICommand.Execute).

XAML代码:

<Window x:Class="WpfCommandTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfCommandTest" 
        Title="MainWindow" Height="480" Width="640" Background="Gray">

    <Window.CommandBindings>
        <CommandBinding Command="local:MainWindow.MyCommand" CanExecute="CanExecuteCommmand" Executed="CommandExecuted"/>
    </Window.CommandBindings>

    <StackPanel Margin="20,20,20,20">
        <TextBox Name="textBoxOutput" Focusable="True" IsTabStop="True" Height="300"/>

        <Button FocusManager.IsFocusScope="True" Name="btnTest" Focusable="False" IsTabStop="False" Content="Test (ICommand.Execute)" Click="btnTest_Click" Width="200"/>
        <Button FocusManager.IsFocusScope="True" Focusable="False" IsTabStop="False" Content="Test (Command property)" Command="local:MainWindow.MyCommand" Width="200"/>
        <Button FocusManager.IsFocusScope="True" Name="btnClearFocus" Focusable="False" IsTabStop="False" Content="Clear Focus" Click="btnClearFocus_Click" Width="200" Margin="138,0,139,0"/>
    </StackPanel>

</Window>
Run Code Online (Sandbox Code Playgroud)

C#代码,大部分与焦点状态记录有关:

using System;
using System.Windows;
using System.Windows.Input;

namespace WpfCommandTest
{
    public partial class MainWindow : Window
    {
        public static readonly RoutedUICommand MyCommand = new RoutedUICommand("MyCommand", "MyCommand", typeof(MainWindow));
        const string Null = "null";

        public MainWindow()
        {
            InitializeComponent();
            this.Loaded += (s, e) => textBoxOutput.Focus(); // set focus on the TextBox
        }

        void CanExecuteCommmand(object sender, CanExecuteRoutedEventArgs e)
        {
            e.CanExecute = true;
        }

        void CommandExecuted(object sender, ExecutedRoutedEventArgs e)
        {
            var routedCommand = e.Command as RoutedCommand;
            var commandName = routedCommand != null ? routedCommand.Name : Null;
            Log("*** Executed: {0} ***, {1}", commandName, FormatFocus());
        }

        void btnTest_Click(object sender, RoutedEventArgs e)
        {
            Log("btnTest_Click, {0}", FormatFocus());
            ICommand command = MyCommand;
            if (command.CanExecute(null))
                command.Execute(null);
        }

        void btnClearFocus_Click(object sender, RoutedEventArgs e)
        {
            FocusManager.SetFocusedElement(this, this);
            Keyboard.ClearFocus();
            Log("btnClearFocus_Click, {0}", FormatFocus());
        }

        void Log(string format, params object[] args)
        {
            textBoxOutput.AppendText(String.Format(format, args) + Environment.NewLine);
            textBoxOutput.CaretIndex = textBoxOutput.Text.Length;
            textBoxOutput.ScrollToEnd();
        }

        string FormatType(object obj)
        {
            return obj != null ? obj.GetType().Name : Null;
        }

        string FormatFocus()
        {
            return String.Format("focus: {0}, keyboard focus: {1}",
                FormatType(FocusManager.GetFocusedElement(this)),
                FormatType(Keyboard.FocusedElement));
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

[更新]让我们稍微改变一下代码:

void btnClearFocus_Click(object sender, RoutedEventArgs e)
{
    //FocusManager.SetFocusedElement(this, this);
    FocusManager.SetFocusedElement(this, null);
    Keyboard.ClearFocus();
    CommandManager.InvalidateRequerySuggested();
    Log("btnClearFocus_Click, {0}", FormatFocus());
}
Run Code Online (Sandbox Code Playgroud)

现在我们有另一个有趣的案例:没有逻辑焦点,没有键盘焦点,但命令stil被第二个按钮触发,到达顶部窗口的处理程序并被执行(我认为是正确的行为):

在此输入图像描述

Ana*_*aev 13

好的,我会尝试描述这个问题,正如我所理解的那样.让我们从MSDNFAQ(Why are WPF commands not used?)部分的引用开始:

此外,路由事件传递到的命令处理程序由UI中的当前焦点确定.如果命令处理程序在窗口级别,这可以正常工作,因为窗口始终位于当前聚焦元素的焦点树中,因此它将被调用命令消息.但是,它对于拥有自己的命令处理程序的子视图不起作用,除非它们当时具有焦点.最后,只有一个命令处理程序可以查询路由命令.

请注意一下:

谁拥有自己的命令处理程序,除非他们当时关注.

很明显,当焦点不是时,命令将不会被执行.现在的问题是:文档的重点是什么?这是指焦点的类型?我提醒有两种类型的焦点:逻辑键盘焦点.

现在让我们从这里引用:

Windows焦点范围内具有逻辑焦点的元素将用作命令目标. Note这是Windows焦点范围而非活动焦点范围.这是合乎逻辑的焦点而非键盘焦点.在命令路由方面,FocusScopes会删除您放置它们的任何项目,并从命令路由路径中删除它们的子元素.因此,如果您在应用程序中创建焦点范围并希望命令路由到它,则必须手动设置命令目标.或者,您不能使用除工具栏,菜单等之外的FocusScopes并手动处理容器焦点问题.

根据这些来源,可以假设焦点必须是活动的,即可以与键盘焦点一起使用的元素,例如:TextBox.

为了进一步调查,我稍微改变了你的例子(XAML部分):

<StackPanel Margin="20,20,20,20">
    <StackPanel.CommandBindings>
        <CommandBinding Command="local:MainWindow.MyCommand" CanExecute="CanExecuteCommmand" Executed="CommandExecuted"/>
    </StackPanel.CommandBindings>

    <TextBox Name="textBoxOutput" Focusable="True" IsTabStop="True" Height="150" Text="WPF TextBox&#x0a;"/>

    <Menu>
        <MenuItem Header="Sample1" Command="local:MainWindow.MyCommand" />
        <MenuItem Header="Sample2" />
        <MenuItem Header="Sample3" />
    </Menu>

    <Button FocusManager.IsFocusScope="True" 
            Name="btnTest" Focusable="False" 
            IsTabStop="False" 
            Content="Test (ICommand.Execute)" 
            Click="btnTest_Click" Width="200"/>

    <Button FocusManager.IsFocusScope="True" 
            Content="Test (Command property)"
            Command="local:MainWindow.MyCommand" Width="200"/>

    <Button FocusManager.IsFocusScope="True" 
            Name="btnClearFocus" Focusable="False" 
            IsTabStop="False" Content="Clear Focus"
            Click="btnClearFocus_Click" Width="200"
            Margin="138,0,139,0"/>
</StackPanel>
Run Code Online (Sandbox Code Playgroud)

我添加了命令StackPanel并添加了Menu控件.现在,如果单击以清除焦点,则与命令关联的控件将不可用:

在此输入图像描述

现在,如果我们点击按钮,Test (ICommand.Execute)我们会看到以下内容:

在此输入图像描述

键盘焦点设置在Window,但命令仍然不运行.再一次,记住上面的注释:

请注意,它是Windows焦点范围而不是活动焦点范围.

他没有主动焦点,所以命令不起作用.它仅在焦点处于活动状态时有效,设置为TextBox:

在此输入图像描述

让我们回到你原来的例子.

显然,Button没有主动聚焦,第一个不会导致命令.唯一的区别是,在这种情况下,第二个按钮没有被禁用,因为没有活动焦点,所以点击它,我们直接调用命令.也许,这可以用一串MSDN引号来解释:

如果命令处理程序在窗口级别,这可以正常工作,因为窗口始终位于当前聚焦元素的焦点树中,因此它将被调用命令消息.

我想,我找到了另一个可以解释这种奇怪行为的来源.从这里引用:

菜单项或工具栏按钮默认放在单独的FocusScope中(分别用于菜单或工具栏).如果任何此类项目触发路由命令,并且它们没有已设置的命令目标,则WPF始终通过在包含窗口(即下一个更高的焦点范围)内搜索具有键盘焦点的元素来查找命令目标.

因此,WPF并不是简单地查找包含窗口的命令绑定,正如您直觉所期望的那样,而是始终寻找一个以键盘为中心的元素来设置为当前命令目标!显然,WPF团队采取了最快的路线来制作内置命令,例如复制/剪切/粘贴工作,窗口包含多个文本框等; 不幸的是,他们在途中打破了所有其他命令.

这就是原因:如果包含窗口中的聚焦元素无法接收键盘焦点(例如,它是非交互式图像),则禁用所有菜单项和工具栏按钮 - 即使它们不需要执行任何命令目标!简单地忽略这些命令的CanExecute处理程序.

显然,问题#2的唯一解决方法是将任何此类菜单项或工具栏按钮的CommandTarget显式设置为包含窗口(或某些其他控件).


小智 5

为了详细说明 Noseratio 的答案,显式RoutedCommand实现ICommand,但也有自己的方法ExecuteCanExcute带有附加target参数的方法。当您调用andRoutedCommand的显式实现时,它将调用这些函数的自己版本,并传递 null 作为。如果为 null,则默认使用. 如果之后仍然为 null(即没有焦点),则跳过该函数的主体并仅返回 false。请参阅RoutedCommand 源代码第 146 行和第 445 行。ICommand.ExecuteICommand.CanExcutetargettargetKeyboard.FocusedElementtarget

如果您知道该命令是 RoutedCommand,您可以通过调用RoutedCommand.Execute(object, IInputElement)并提供目标来解决焦点问题。这是我写的相关扩展方法:

public static void TryExecute(this ICommand command, object parameter, IInputElement target)
{
    if (command == null) return;

    var routed = command as RoutedCommand;
    if (routed != null)
    {
        if (routed.CanExecute(parameter, target))
            routed.Execute(parameter, target);
    }
    else if (command.CanExecute(parameter))
        command.Execute(parameter);
}
Run Code Online (Sandbox Code Playgroud)

对于自定义控件,我通常将其称为Command.TryExecute(parameter, this).


nos*_*tio 3

我的同事JoeGaggler显然已经找到了这种行为的原因:

我想我发现它使用反射器:如果命令目标为空(即键盘焦点为空),则 ICommandSource使用自身(而不是窗口)作为命令目标,最终命中窗口的 CommandBinding (这就是为什么声明性绑定有效)。

我正在将这个答案作为社区维基,所以我不会因为他的研究而获得学分。