【问题标题】:Inconsistency in WPF command routing behavior depending on the UI focus stateWPF 命令路由行为的不一致取决于 UI 焦点状态
【发布时间】:2013-08-27 09:06:53
【问题描述】:

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

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

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

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

第一种方法只有在窗口中有焦点元素时才有效。无论焦点如何,第二个总是如此。

我查看了 BCL 的 ICommand.Execute 实现,发现如果 Keyboard.FocusedElementnull,则不会触发该命令,因此这是设计使然。我仍然会对此提出疑问,因为即使应用程序没有 UI 焦点(例如,我可能想调用 @ 987654331@ 收到套接字消息时来自异步任务)。就这样吧,我仍然不清楚为什么第二种(声明式)方法总是不管焦点状态如何都有效。

我对 WPF 命令路由的理解缺少什么?我确信这“不是错误,而是一项功能”。

下面是代码。如果你喜欢玩它,这里是full project。单击第一个按钮 - 该命令将被执行,因为焦点在 TextBox 内。单击第二个按钮 - 一切都很好。单击Clear Focus 按钮。现在第一个按钮 (ICommand.Execute) 不执行命令,而第二个按钮仍然执行。您需要点击TextBox 以使第一个按钮再次起作用,因此有一个焦点元素。

这是一个人为的例子,但它具有现实生活的影响。我将发布一个有关使用WindowsFormsHost ([EDITED] asked here) 托管 WinForms 控件的相关问题,在这种情况下,Keyboard.FocusedElement 始终是 null,当焦点位于 @ 内部时987654339@(通过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>

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

[UPDATE]让我们稍微修改一下代码:

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

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

【问题讨论】:

  • IsFocusScope 应位于包含堆栈面板上,而不是控件本身。
  • 感谢您的想法,但是将 IsFocusScope="True" 移出到包含 StackPanel 并不会改变所描述的在没有焦点时的行为。然而,它确实以这种方式改变了路由语义:如果TextBox 被聚焦并且对于MainWindow.MyCommand(与顶部窗口相同)具有自己的绑定,则生成的命令按第二个按钮永远不会到达TextBox。顶部的窗户会先吞下它。这是不可取的:我希望首先让聚焦的 UI 元素有机会处理 RoutedUICommand,在没有特定目标的情况下触发。

标签: c# .net wpf xaml focus


【解决方案1】:

为了详细说明 Noseratio 的答案,RoutedCommand 显式实现了ICommand,但也有自己的ExecuteCanExcute 方法,它们采用额外的target 参数。 当您调用RoutedCommandICommand.ExecuteICommand.CanExcute 的显式实现时,它将调用这些函数的自己版本,并将null 作为target 传递。如果target 为空,则默认使用Keyboard.FocusedElement。如果之后target 仍然为null(即没有焦点),则跳过函数的主体并返回false。 请参阅第 146 和 445 行的 RoutedCommand source code

如果您知道该命令是一个 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);
}

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

【讨论】:

    【解决方案2】:

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

    我想我是使用反射器找到的:如果命令目标为空(即键盘焦点为空),则 ICommandSource 使用自身(而不是窗口)作为命令目标,最终命中 CommandBinding对于窗口(这就是声明式绑定起作用的原因)。

    我正在将此答案设为社区 wiki,因此我不会因他的研究而获得学分。

    【讨论】:

      【解决方案3】:

      好的,我会尽量描述这个问题,据我所知。让我们从MSDN 部分的引用开始,并附上常见问题解答 (Why are WPF commands not used?):

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

      请注意以下行:

      他们有自己的命令处理程序,除非他们当时有焦点。

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

      现在引用here:

      Windows 焦点范围内具有逻辑焦点的元素将用作命令目标。 Note 是窗口焦点范围而不是活动焦点范围。它是逻辑焦点而不是键盘焦点。当涉及命令路由时,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>
      

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

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

      键盘焦点设置在Window,但该命令仍然没有运行。再次记住上面的注释:

      请注意,它是窗口焦点范围而不是活动焦点范围。

      他没有激活焦点,所以命令不起作用。仅当焦点处于活动状态时才有效,设置为TextBox

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

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

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

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

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

      因此 WPF 不会像您直观地期望的那样简单地查找包含窗口的命令绑定,而是始终查找以键盘为中心的元素以设置为当前命令目标!显然 WPF 团队在这里采取了最快的方法来使复制/剪切/粘贴等内置命令与包含多个文本框等的窗口一起工作;不幸的是,他们一路上破坏了所有其他命令。

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

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

      【讨论】:

      • 感谢您的研究,我很高兴我并不孤单 :) 我相信我已经很好地掌握了逻辑焦点的概念及其范围(尽管确实令人困惑;IMO,最好的解释是here)。这就是为什么我将FocusManager.IsFocusScope="True" 放在按钮上(使它们表现得像一个菜单项),除了主窗口的焦点范围之外,我不需要另一个焦点范围。检查我的最新更新以了解我的意思。我认为这里重要的是,在第二种情况下,命令确实有一个调用程序(按钮),而在第一种情况下它没有。
      • 关于您指向 MSDN article on PRISM 的链接,IMO 实际上相当有指示意义,它们如何阻止用于严肃的 MVVM WPF 框架的本机 WPF 命令。
      • 我已对您的答案投了赞成票,因为它提供了有用的信息,并感谢您的研究。不过,我仍在寻找关于为什么 ICommand.CanExecute/Execute 案例不起作用的解释,特别是鉴于此引用:此外,路由事件传递到的命令处理程序由当前焦点确定在用户界面中。如果命令处理程序位于窗口级别,这将正常工作,因为窗口始终位于当前焦点元素的焦点树中,因此它会被调用以获取命令消息。
      • @Noseratio:关于您的更新,CommandManager.InvalidateRequerySuggested(); 您的行会强制 CommandManager 引发 RequerySuggested 事件,绕过 Button.Click 事件(它应该向系统发出执行它的消息)。适用于导致命令无法执行的情况。 MSDNlink。我认为所有的焦点都在这个问题上,而事实上文档并没有详细描述所有内容,即关注什么,以及什么类型等。
      • Anatoliy,为了测试你的理论,我这样做了:1) 将代码更改为使用标准 ApplicationCommands.Paste,2) 在第二个按钮旁边添加了一个菜单项,如下所示:pastebin.com/um3nhSqV。这并没有改变现有的行为。在没有焦点的情况下,我仍然可以通过菜单(仍然启用)或第二个按钮执行Paste,命令由顶部窗口处理。
      猜你喜欢
      • 1970-01-01
      • 2014-12-31
      • 2013-12-02
      • 1970-01-01
      • 1970-01-01
      • 2013-08-28
      • 1970-01-01
      • 2017-06-24
      • 1970-01-01
      相关资源
      最近更新 更多