Несоответствие поведения маршрутизации команд WPF в зависимости от состояния фокусировки пользовательского интерфейса

У меня есть команда 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.FocusedElement is null, так что это по дизайну. Я все еще сомневаюсь в этом, потому что может быть обработчик на верхнем уровне (как и в моем случае), который все еще хочет получать команды, даже если приложение не имеет фокуса пользовательского интерфейса (например, я могу позвонить ICommand.Execute из задачи async, когда он получил сообщение сокета). Пусть это так, мне все еще непонятно, почему второй (декларативный) подход всегда работает независимо от состояния фокусировки.

Что мне не хватает в моем понимании маршрутизации команд 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>

Код С#, большинство из них связано с регистрацией состояния фокусировки:

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

[ОБНОВЛЕНИЕ] Немного измените код:

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

Теперь у нас есть еще один интересный случай: нет логического фокуса, нет фокуса клавиатуры, но команда stil запускается второй кнопкой, достигает обработчика верхнего окна и запускается (что я считаю правильным):

enter image description here

Ответ 1

JoeGaggler, мой коллега, по-видимому, нашел причину такого поведения:

Я думаю, что нашел его с помощью рефлектора: если цель команды равна нулю (т.е. фокус клавиатуры равен нулю), ICommandSource сам использует (а не окно) как целевую команду, которая в конечном итоге удаляет CommandBinding для окна (поэтому работает декларативное связывание).

Я делаю этот ответ вики-сообществом, поэтому я не получаю кредиты для его исследований.

Ответ 2

Хорошо, я постараюсь описать эту проблему, как я ее понимаю. Начните с цитаты из раздела MSDN с FAQ (Why are WPF commands not used?):

Кроме того, обработчик команд, который передается маршрутизируемым событием, определяется текущим фокусом в пользовательском интерфейсе. Это отлично работает, если обработчик команд находится на уровне окна, потому что окно всегда находится в дереве фокуса текущего сфокусированного элемента, поэтому его вызывают для командных сообщений. Тем не менее, он не работает для дочерних представлений, у которых есть свои собственные обработчики команд, если они не имеют фокуса в то время. Наконец, только один обработчик команд когда-либо консультируется с маршрутизируемыми командами.

Обратите внимание на строку:

у которых есть свои собственные обработчики команд, если они не имеют фокуса в то время.

Понятно, что когда фокус отсутствует, команда не будет выполнена. Теперь встает вопрос: что означает документация? Это относится к типу фокуса? Напоминаю, что есть два типа фокуса: логический и фокус клавиатуры.

Теперь дайте цитату из здесь:

Элемент в области фокуса 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. Теперь, если вы нажмете кнопку "Очистить фокус", элементы управления, связанные с этой командой, не будут доступны:

enter image description here

Теперь, если мы нажмем на кнопку Test (ICommand.Execute), мы увидим следующее:

enter image description here

Фокус клавиатуры установлен на Window, но команда все еще не работает. Еще раз вспомните примечание, выше:

Обратите внимание, что область фокуса окон не является активной областью фокуса.

У него нет активного фокуса, поэтому команда не работает. Он будет работать, только если фокус активен, установлен на TextBox:

enter image description here

Вернемся к исходному примеру.

Очевидно, что первый Button не вызывает команду без активной фокусировки. Единственное различие заключается в том, что в этом случае вторая кнопка не отключается, потому что нет активной фокусировки, поэтому, щелкнув по ней, мы вызываем команду напрямую. Возможно, это объясняется строкой MSDN кавычек:

Это отлично работает, если обработчик команд находится на уровне окна, потому что окно всегда находится в дереве фокуса текущего сфокусированного элемента, поэтому его вызывают для командных сообщений.

Я думаю, я нашел другой источник, который должен объяснить это странное поведение. Цитата из здесь:

Элементы меню или кнопки панели инструментов по умолчанию помещаются в отдельный FocusScope (для меню или панели инструментов соответственно). Если какие-либо такие элементы запускают маршрутизированные команды, и у них нет уже заданной цели, WPF всегда ищет целевую команду, просматривая элемент, который имеет фокус клавиатуры внутри содержащего окна (т.е. Следующую область с более высоким фокусом).

Таким образом, WPF НЕ просто ищет командные привязки содержащего окна, как вы бы интуитивно ожидали, а скорее всегда ищет элемент, ориентированный на клавиатуру, для установки в качестве текущей целевой команды! Очевидно, команда WPF заняла самый быстрый маршрут здесь, чтобы сделать встроенные команды, такие как Copy/Cut/Paste, работать с окнами, которые содержат несколько текстовых полей или тому подобное; к сожалению, они сломали друг друга по пути.

И вот почему: если сфокусированный элемент внутри содержащего окна не может получить фокус клавиатуры (скажем, это неинтерактивное изображение), тогда ВСЕ пункты меню и кнопки панели инструментов будут отключены - даже если они не требуют какой-либо команды цель выполнить! Обработчик CanExecute таких команд просто игнорируется.

По-видимому, единственным обходным решением проблемы № 2 является явно задание CommandTarget любых таких элементов меню или кнопок панели инструментов в содержащем окне (или каком-либо другом элементе управления).

Ответ 3

Чтобы развить ответ Noseratio, RoutedCommand реализует ICommand явно, но также имеет свои собственные методы Execute и CanExcute, которые принимают дополнительный параметр target. Когда вы вызываете RoutedCommand явную реализацию ICommand.Execute и ICommand.CanExcute, он будет вызывать свою собственную версию этих функций, передавая null как target. Если target равен нулю, по умолчанию используется Keyboard.FocusedElement. Если после этого значение target все еще равно нулю (т.е. ничто не имеет фокуса), основная часть функции пропускается, и она просто возвращает false. См. исходный код RoutedCommand в строках 146 и 445.

Если вы знаете, что команда является 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).