扩展 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
对大多数 EditingCommand
s(如 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
,所以我们没有这个选项。本文的其余部分将向您展示如何实现此解决方案并讨论实现细节。
解决方案
目前,命令分两步执行
- 查看命令是否可以在目标上执行
- 执行命令
第一步会重复执行,例如当焦点元素改变或用户点击鼠标或敲击键盘时,以确保 CommandSource
s 相应地启用/禁用。为了提供更大的灵活性并实现我在介绍中描述的 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[^])。