65.9K
CodeProject 正在变化。 阅读更多。
Home

扩展 WPF 命令

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.65/5 (15投票s)

2008年7月27日

CPOL

9分钟阅读

viewsIcon

71364

downloadIcon

1082

如何克服 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>

sample0.gif

在此示例中,如果文本框具有输入焦点,则“加粗”菜单项将被禁用,但当 RichTextBox 具有输入焦点时,它将被启用。此外,当单击菜单项时,RichTextBox 中当前文本选区的加粗标志将在单击按钮时自动切换。无需自定义代码!

您可能以前在 WPF 演示中见过这种“魔法”。之所以有效,是因为 RichTextBox 对大多数 EditingCommands(如 ToggleBoldToggleItalic)都有默认的命令实现。

这种行为令人印象深刻,但在实际应用程序中,情况会有点复杂。以文本编辑器为例,加粗按钮不仅会根据是否有合适的输入元素获得焦点而启用/禁用,它还会反映当前选定文本的状态。如果用户选择加粗文本,按钮将被选中;如果用户选择普通文本,按钮将不被选中。WPF 命令不支持开箱即用的这种场景。本文讨论解决此问题的不同方法,并提供了一种我认为最强大的解决方案的实现。

本文不对 WPF 命令进行介绍,并假设读者已经了解命令的工作原理以及如何实现自定义 CommandBinding。有关 WPF 命令的介绍,请参阅这篇文章:WPF:Sacha Barber 的初学者指南 - 第 3 部分[^]。

为什么它不起作用

CanExecuteRoutedEventArgs.gif

在实现 CommandBinding 时,您可以选择实现两种方法。一种是在命令执行时调用,另一种是在命令查询命令是否可以执行时调用。在执行方法中,您可以访问类型为 CanExecuteRoutedEventArgs 的参数,该参数允许您(除其他外)访问 CommandParameter,最重要的是,允许您设置 CanExecute 值。支持命令的控件(通过实现 ICommandSource)如 MenuItem 或派生自 ButtonBase 的控件(ButtonToggleButton)可以根据 CanExecuteRoutedEventArgsCanExecute 值启用/禁用自身。

因为这是它们可以访问的所有信息,所以它们的行为仅限于此,这就是为什么 ToggleButtonMenuItem(两者都有 IsChecked 属性)在使用命令时不能自行选中。它们只是无法访问必要的信息!

选项

我在这里简要讨论几种不同的解决方案。本节的某些部分需要对 WPF 命令系统有高级理解,因此您可能希望跳过此部分。据我所知,有四种可能的解决方案

1. 使用普通命令并编写一些“胶水代码”来克服这个缺点

解决此问题的最快捷、最脏的方案是,将 CommandSource (MenuItem) 和 CommandTarget (RichTextBox) 粘合在一起,并通过观察 CommandTarget 手动设置 CommandSourceIsChecked 属性。尽管这样做很容易,但它可能会耗费大量精力,因为您必须为每个对象都这样做,并且容易出错,但最重要的是,它完全违背了命令的目的,并且不允许您拥有松散耦合的 UI。

2. 使用可以保存 CurrentValue 的自定义 ICommand 实现

编写您自己的 RoutedCommand 类,并提供一个可设置的 CurrentValue 属性,该属性可以在 CanExecute 方法中设置,并由使用 CommandCommandTarget 观察。我不想详细解释缺点,所以这里是这种方法的底线

  1. Command 是静态对象,因此每个 Command 只能有一个 CurrentValue。虽然在简单场景中这可能不是问题,但一旦您有多个顶级窗口或同一 Command 在不同 CommandSource 上针对不同的 CommandTarget,它就不再起作用了。
  2. 如果您走这条路,您将无法使用任何已提供的 Command,并且一旦您需要知道的不仅仅是 CanExecute 标志,您就必须发明一个新的 Command

3. 不使用命令

WPF 命令不允许您直接做到这一点,因此您可能希望编写自己的命令解决方案,根本不使用内置命令。虽然我不确定这将涉及多少工作,但我认为这个解决方案将是最干净的解决方案。尽管如此,我还是决定采用不同的解决方案。解决方案编号 4。

这样做的主要原因是,解决方案 4 相对容易实现,只有一些微不足道的缺点(我稍后会讨论),并且希望在微软将来版本中解决这个缺点时(如果会发生)不需要进行大量更改。

4. 使用自定义类作为参数来传输更多信息

在此解决方案中,我们利用现有命令系统,但不是将普通 Parameter 传递给 CanExecute 处理程序,而是将 Parameter 封装到我们自己的类中,该类允许设置 CurrentValue。更简洁的方法是创建我们自己的继承自 CanExecuteRoutedEventArgs 类的类,但不幸的是,该类是 sealed,所以我们没有这个选项。本文的其余部分将向您展示如何实现此解决方案并讨论实现细节。

解决方案

目前,命令分两步执行

  1. 查看命令是否可以在目标上执行
  2. 执行命令

第一步会重复执行,例如当焦点元素改变或用户点击鼠标或敲击键盘时,以确保 CommandSources 相应地启用/禁用。为了提供更大的灵活性并实现我在介绍中描述的 ToggleBold 场景,我们需要改变第一步。我们不再仅仅检查命令是否可以执行,而是希望能够访问更多信息。

  1. 查看命令是否可以在目标上执行以及更多!
  2. 执行命令。

为了做到这一点,我们需要稍微调整一下相关的组件。最终,我们将拥有一个 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 实现中,如果 CanExecuteRoutedEventArgsParameter 类型为 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;
}

接下来,我们实现一个继承自 MenuItemXMenuItem 类,并提供其自己的 UpdateCanExecute 方法实现(以利用我们的 CommandCanExecuteParameter)。在内部,MenuItem 使用 UpdateCanExecute 方法来实现默认行为,但不幸的是,MenuItemUpdateCanExecute 方法是私有的,我们无法修改其行为。相反,我们连接自己的代码,并在当前命令更改时设置我们自己的 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 属性了!

sample1_2.gifsample1_2.gif

<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

XToggleButtonXCheckBox 的实现与 XMenuItem 几乎相同,并支持 bool 作为 CurrentValue

XSlider

默认的 Slider 不支持开箱即用的命令,因此 XSlider 实现了 ICommandSource 并支持 floatdouble 或更复杂的 RangedValue 作为 CurrentValue。前者允许从 CanExecute 处理程序内部设置 MinimumMaximum 属性。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。如您所见,我花了很多时间在外观上

sample2.gif

<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[^])。

© . All rights reserved.