扩展 WPF 命令






4.65/5 (15投票s)
如何克服 WPF 命令系统的局限性。
引言
WPF 命令允许您拥有松散耦合的 UI 元素,尽管如此,这些元素仍然能够很好地协同工作。请看这个独立的 XAML 代码片段
<Window x:Class="ExtendedCommands.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="Window1" Height="150" Width="150">
    <Grid>
        <DockPanel>
            <Menu DockPanel.Dock="Top">
                <MenuItem Command="{x:Static EditingCommands.ToggleBold}" 
                          Header="Bold"/>
            </Menu>
            <RichTextBox x:Name="richTextBox"/>
        </DockPanel>
    </Grid>
</Window>

在此示例中,如果文本框具有输入焦点,则“加粗”菜单项将被禁用,但当 RichTextBox 具有输入焦点时,它将被启用。此外,当单击菜单项时,RichTextBox 中当前文本选区的加粗标志将在单击按钮时自动切换。无需自定义代码!
您可能以前在 WPF 演示中见过这种“魔法”。之所以有效,是因为 RichTextBox 对大多数 EditingCommands(如 ToggleBold 或 ToggleItalic)都有默认的命令实现。
这种行为令人印象深刻,但在实际应用程序中,情况会有点复杂。以文本编辑器为例,加粗按钮不仅会根据是否有合适的输入元素获得焦点而启用/禁用,它还会反映当前选定文本的状态。如果用户选择加粗文本,按钮将被选中;如果用户选择普通文本,按钮将不被选中。WPF 命令不支持开箱即用的这种场景。本文讨论解决此问题的不同方法,并提供了一种我认为最强大的解决方案的实现。
本文不对 WPF 命令进行介绍,并假设读者已经了解命令的工作原理以及如何实现自定义 CommandBinding。有关 WPF 命令的介绍,请参阅这篇文章:WPF:Sacha Barber 的初学者指南 - 第 3 部分[^]。
为什么它不起作用

在实现 CommandBinding 时,您可以选择实现两种方法。一种是在命令执行时调用,另一种是在命令查询命令是否可以执行时调用。在执行方法中,您可以访问类型为 CanExecuteRoutedEventArgs 的参数,该参数允许您(除其他外)访问 Command、Parameter,最重要的是,允许您设置 CanExecute 值。支持命令的控件(通过实现 ICommandSource)如 MenuItem 或派生自 ButtonBase 的控件(Button、ToggleButton)可以根据 CanExecuteRoutedEventArgs 的 CanExecute 值启用/禁用自身。
因为这是它们可以访问的所有信息,所以它们的行为仅限于此,这就是为什么 ToggleButton 或 MenuItem(两者都有 IsChecked 属性)在使用命令时不能自行选中。它们只是无法访问必要的信息!
选项
我在这里简要讨论几种不同的解决方案。本节的某些部分需要对 WPF 命令系统有高级理解,因此您可能希望跳过此部分。据我所知,有四种可能的解决方案
1. 使用普通命令并编写一些“胶水代码”来克服这个缺点
解决此问题的最快捷、最脏的方案是,将 CommandSource (MenuItem) 和 CommandTarget (RichTextBox) 粘合在一起,并通过观察 CommandTarget 手动设置 CommandSource 的 IsChecked 属性。尽管这样做很容易,但它可能会耗费大量精力,因为您必须为每个对象都这样做,并且容易出错,但最重要的是,它完全违背了命令的目的,并且不允许您拥有松散耦合的 UI。
2. 使用可以保存 CurrentValue 的自定义 ICommand 实现
编写您自己的 RoutedCommand 类,并提供一个可设置的 CurrentValue 属性,该属性可以在 CanExecute 方法中设置,并由使用 Command 的 CommandTarget 观察。我不想详细解释缺点,所以这里是这种方法的底线
- Command是静态对象,因此每个- Command只能有一个- CurrentValue。虽然在简单场景中这可能不是问题,但一旦您有多个顶级窗口或同一- Command在不同- CommandSource上针对不同的- CommandTarget,它就不再起作用了。
- 如果您走这条路,您将无法使用任何已提供的 Command,并且一旦您需要知道的不仅仅是CanExecute标志,您就必须发明一个新的Command。
3. 不使用命令
WPF 命令不允许您直接做到这一点,因此您可能希望编写自己的命令解决方案,根本不使用内置命令。虽然我不确定这将涉及多少工作,但我认为这个解决方案将是最干净的解决方案。尽管如此,我还是决定采用不同的解决方案。解决方案编号 4。
这样做的主要原因是,解决方案 4 相对容易实现,只有一些微不足道的缺点(我稍后会讨论),并且希望在微软将来版本中解决这个缺点时(如果会发生)不需要进行大量更改。
4. 使用自定义类作为参数来传输更多信息
在此解决方案中,我们利用现有命令系统,但不是将普通 Parameter 传递给 CanExecute 处理程序,而是将 Parameter 封装到我们自己的类中,该类允许设置 CurrentValue。更简洁的方法是创建我们自己的继承自 CanExecuteRoutedEventArgs 类的类,但不幸的是,该类是 sealed,所以我们没有这个选项。本文的其余部分将向您展示如何实现此解决方案并讨论实现细节。
解决方案
目前,命令分两步执行
- 查看命令是否可以在目标上执行
- 执行命令
第一步会重复执行,例如当焦点元素改变或用户点击鼠标或敲击键盘时,以确保 CommandSources 相应地启用/禁用。为了提供更大的灵活性并实现我在介绍中描述的 ToggleBold 场景,我们需要改变第一步。我们不再仅仅检查命令是否可以执行,而是希望能够访问更多信息。
- 查看命令是否可以在目标上执行以及更多!
- 执行命令。
为了做到这一点,我们需要稍微调整一下相关的组件。最终,我们将拥有一个 MenuItem,它会根据 CommandBinding 的实现提供的当前值来选中自己。
工作原理
该解决方案使用一个名为 CommandCanExecuteParameter 的类,该类充当从 CommandTarget (RichTextBox) 向 CommandSource (MenuItem) 报告信息的媒介。
public class CommandCanExecuteParameter
{
    public CommandCanExecuteParameter(object parameter)
    {
        Parameter = parameter;
    }
    public object Parameter { get; private set; }
    public object CurrentValue { get; set; }
}
在 CommandBinding 实现中,如果 CanExecuteRoutedEventArgs 的 Parameter 类型为 CommandCanExecuteParameter,我们简单地设置 CurrentValue。
private void command_ToggleBold_canExecute (object sender, CanExecuteRoutedEventArgs e)
{
    if (e.Parameter is CommandCanExecuteParameter)
    {
        (e.Parameter as CommandCanExecuteParameter).CurrentValue = GetBold(richTextBox);
    }
    e.CanExecute = true;
    e.Handled = true;
}
接下来,我们实现一个继承自 MenuItem 的 XMenuItem 类,并提供其自己的 UpdateCanExecute 方法实现(以利用我们的 CommandCanExecuteParameter)。在内部,MenuItem 使用 UpdateCanExecute 方法来实现默认行为,但不幸的是,MenuItem 的 UpdateCanExecute 方法是私有的,我们无法修改其行为。相反,我们连接自己的代码,并在当前命令更改时设置我们自己的 UpdateCanExecute 方法。这里需要知道的重要一点是,系统内部将 UpdateCanExecute 事件的处理程序存储在 WeakReference 列表中。这意味着我们必须持有对我们自己的处理程序的引用,以防止处理程序被垃圾回收。
static XMenuItem()
{
    CommandProperty.OverrideMetadata(typeof(XMenuItem), 
       new FrameworkPropertyMetadata(OnCommandChanged));
}
private static void OnCommandChanged(DependencyObject d, 
        DependencyPropertyChangedEventArgs e)
{
    ((XMenuItem)d).OnCommandChanged(e.OldValue as ICommand, e.NewValue as ICommand);
}
private void OnCommandChanged(ICommand oldValue, ICommand newValue)
{
    if (oldValue != null)
        oldValue.CanExecuteChanged -= OnCanExecuteChanged;
    if (newValue != null)
    {
        //the command system uses WeakReferences internally,
        //so we have to hold a reference to the canExecuteChanged handler ourselves
        if (canExecuteChangedHandler == null)
            canExecuteChangedHandler = OnCanExecuteChanged;
        
        newValue.CanExecuteChanged += canExecuteChangedHandler;
    }
    else
        canExecuteChangedHandler = null;
}
//hold a reference to the canExecuteChangedHandler
//so that it is not garbage collected
private EventHandler canExecuteChangedHandler;
private void OnCanExecuteChanged(object sender, EventArgs e)
{
    UpdateCanExecute();
}
在 UpdateCanExecute 方法中,我们创建一个 CommandCanExecuteParameter 类的实例,并调用命令的 CanExecute 处理程序。然后,我们根据报告的 CurrentValue 设置 IsChecked 属性。
请注意,我们无需设置 IsEnabled 属性,因为父类 (MenuItem) 的默认行为仍在执行。我们只需完成额外的工作。
private void UpdateCanExecute()
{
    if (IsCommandExecuting)
        return;
    IsCommandExecuting = true;
    try
    {
        //use our custom class as the parameter
        var parameter = new CommandCanExecuteParameter(null);
        CommandUtil.CanExecute(this, parameter);
        //we set the current status independent on whether the command can execute
        {
            if (parameter.CurrentValue is bool)
            {
                IsChecked = (bool)parameter.CurrentValue;
            }
            else
            {
                IsChecked = false;
            }
        }
    }
    finally
    {
        IsCommandExecuting = false;
    }
}
然后我们将 XMenuItem 添加到 XAML 文件中,瞧,菜单项现在自动设置 IsChecked 属性了!


<Window x:Class="ExtendedCommands.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:Controls="clr-namespace:Wpf.Controls;assembly=Wpf"
    Title="Window1" 
    Height="150" 
    Width="150">
    <Grid>
        <DockPanel>
            <Menu DockPanel.Dock="Top">
                <Controls:XMenuItem 
                    Command="{x:Static EditingCommands.ToggleBold}" 
                    Header="Bold (XMenuItem)"/>
                <Separator/>
                <MenuItem 
                    Command="{x:Static EditingCommands.ToggleBold}" 
                    Header="Bold (MenuItem)"/>
            </Menu>
            <TextBox DockPanel.Dock="Top"/>
            <RichTextBox x:Name="richTextBox"/>
        </DockPanel>
    </Grid>
</Window>
您应该注意什么
由于此解决方案是在正常的 WPF 命令系统之上构建的,因此 UpdateCanExecute 处理程序会被调用两次。一次来自正常实现,一次来自自定义实现。虽然这看起来是多余的开销,但实际上是件好事,因为该解决方案不会干扰已存在的 CommandBinding,并且如果 CommandBinding 知道如何处理 CommandCanExecuteParameter,它只会增加价值。为了在您自己的 ICommandSource 感知类中保持这种行为一致,您应该调用 CanExecute 处理程序两次。一次使用正常的 Parameter,另一次使用 CommandCanExecuteParameter。
提供更完整的解决方案
到目前为止,我们只调整了 MenuItem,第一个示例文件包含我上面描述的解决方案。
在实际应用程序中,当然还有其他重要的控件应该利用这个扩展的命令系统。第二个示例包含以下控件,其中有些根本不支持命令!
- XToggleButton
- XCheckBox
- XSlider
- XComboBox
XToggleButton 和 XCheckBox
XToggleButton 和 XCheckBox 的实现与 XMenuItem 几乎相同,并支持 bool 作为 CurrentValue。
XSlider
默认的 Slider 不支持开箱即用的命令,因此 XSlider 实现了 ICommandSource 并支持 float、double 或更复杂的 RangedValue 作为 CurrentValue。前者允许从 CanExecute 处理程序内部设置 Minimum 和 Maximum 属性。XSlider 还添加了一个 Precision 属性,使得获取像 1.23 而不是 1.235837128 这样的值更容易。
XSlider 还包含一个 hack(感谢 Dr. WPF),以使行为与 Office 滑块更加一致。有关详细信息,请参阅此论坛帖子:http://forums.msdn.microsoft.com/en-US/wpf/thread/5fa7cbc2-c99f-4b71-b46c-f156bdf0a75a。
XComboBox
默认的 ComboBox 像默认的 Slider 一样缺乏对命令的支持。XComboBox 支持 XComboBox 中包含的任何对象作为 CurrentValue,也支持对象的 string 表示作为 CurrentValue。
第二个例子
在第二个示例中,我将所有这些控件放在主窗口上,并实现了一些简单的 CommandBinding。如您所见,我花了很多时间在外观上

<Window x:Class="ExtendedCommands.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:Controls="clr-namespace:Wpf.Controls;assembly=Wpf"
    xmlns:Wpf="clr-namespace:Wpf;assembly=Wpf"
    Title="Window1" Height="300" Width="300">
    <Grid>
        <DockPanel>
            <Menu DockPanel.Dock="Top">
                <Controls:XMenuItem 
                    Command="{x:Static EditingCommands.ToggleBold}" 
                    Header="Bold"/>
            </Menu>
            <StackPanel 
                DockPanel.Dock="Top" 
                Orientation="Horizontal" 
                FocusManager.IsFocusScope="True">
                <Controls:XCheckBox 
                    Command="{x:Static EditingCommands.ToggleBold}">Bold
                </Controls:XCheckBox>
                <Controls:XToggleButton 
                    Command="{x:Static EditingCommands.ToggleItalic}">Italic
                </Controls:XToggleButton>
                <TextBlock>Size:</TextBlock>
                <Controls:XSlider 
                    Precision="0" 
                    Command="{x:Static Wpf:MyCommands.SetFontSize}" 
                    Width="100"/>
                <TextBlock>Color:</TextBlock>
                <Controls:XComboBox x:Name="combo"
                    Command="{x:Static Wpf:MyCommands.SetFontColor}"/>
            </StackPanel>
            <RichTextBox x:Name="richTextBox"/>
        </DockPanel>
    </Grid>
</Window>
致谢
感谢我的雇主 NovaMind (http:/www.novamind.com[^]) 允许我与世界分享。感谢我的前队友 Super Lloyd[^],他是在我们共同提出这个想法后第一个实现这个解决方案的人。
谢谢
感谢 Code Project 上所有帮助我成为一名更好程序员的好心人。我已加入 CodeProject 超过五年,这是我的第一篇贡献。特别感谢 WPF Disciples,尤其是 Josh Smith、Dr. WPF、Karl Shifflett 和 Sacha Barber 对 Code Project 的诸多贡献,感谢 Adam Nathan 的优秀书籍 (WPF Unleashed),感谢 MSDN WPF 论坛的活跃成员,感谢 Charles Petzold,以及我们当地(澳大利亚)的 WPF MVP:Joseph Cooney (www.learnwpf.com[^]) 和 Paul Stovell (www.paulstovell.com[^])。


