WPF 控件模式。(WPF 和 XAML 代码重用模式的简易示例。第一部分)






4.96/5 (55投票s)
本文描述了用于代码和功能重用的 WPF 和 XAML 模式。
引言
有一种流行的误解,认为 XAML 不允许良好地分离关注点和代码重用。实际上,情况恰恰相反。WPF 实现了独特的编程技术,一旦你理解了它,就会将代码重用和定制提升到一个新的水平。
WPF 和 XAML 引入了许多革命性的编程概念,这些概念可以应用于 WPF 之外的纯非可视化编程,并且可以用于其他(非 .NET)平台。
在我看来,WPF 风格的概念比通常的面向对象编程更进一步,其重要性类似于 OOP 与过程式编程的比较。
不幸的是,微软领导层本身并没有完全理解他们的工程师所提出的东西,并改变了他们的优先级。
如上所述,WPF 概念可以被泛化并应用于 WPF 领域之外,例如构建非可视化软件。事实上,我发表了一系列关于在 WPF 领域之外应用 WPF 范式的博客文章。然而,本文不涉及这一点。它只是通过使用 WPF 示例来呈现和分类这些模式。
使用下面描述的 XAML/WPF 范式,我能够以极快的速度进行编程,同时生成高质量、高密度的代码(具有大量重用和单位代码功能丰富的代码)。
这不是 WPF 初学者教程——我假设读者熟悉 WPF 基本概念——依赖项属性和附加属性、绑定、样式、模板、数据模板、触发器、路由事件。
我计划将本文作为 2 或 3 篇文章系列的第一部分。这一部分致力于基于 `Control` 和 `ControlTemplate` 的重用模式。后续文章将更多地讨论 MVVM 模式以及如何将非可视化对象映射到可视化对象。
控件和控件模板
控件
WPF 控件是 WPF 中基本的视觉重用单元之一。控件可以放置在 XAML 代码中,包括其他控件,并使用各种方法协同工作。这里我们将通过非常简单的例子来介绍 WPF 控件编程的注意事项。
让我们首先考虑一个非常简单的控件——它将允许用户在可编辑字段中输入一些文本(例如使用 `TextBox`),它将为该可编辑字段提供一个标签。(该标签将给出字段的名称,解释用户正在输入什么)。此外,该控件还将有一个“保存” `Button`,作为对输入的文本执行某些操作的示例。我们将把这个控件命名为 `EditableTextAndLabelControl`。
反模式 - 带有代码隐藏的用户控件
创建此类控件最简单也是最糟糕的方法是使用 WPF 的 `UserControl` 功能。此反模式的代码位于 `UserControlWithCodeBehind` 项目下。不幸的是,Visual Studio 提供了一种现成的创建用户控件的方法——您只需在解决方案资源管理器中右键单击项目,选择“添加”菜单下的“新建项”,然后选择“用户控件 (WPF)”(不要只选择“用户控件”——这会创建一个 Win 窗体用户控件)。将控件命名为“EditableTextAndLabelControl”。
点击确定按钮后,你会看到创建了两个文件:XAML 文件“EditableTextAndLabelControl.xaml”和代码隐藏文件“EditableTextAndLabelControl.xaml.cs”。XAML 文件将用于指定控件的布局,而代码隐藏可以用于定义控件的属性和行为。代码隐藏可以通过名称访问 XAML 中定义的控件,这非常方便(但这是使用代码隐藏编程的一个欺骗性优势)。
在 `MainWindow` 中,我们显示了两个这样的控件,标签分别为“MyText”和“MyOtherText”。当按下相应控件的“保存”按钮时,控件的 `SaveEvent` 会触发。它在 `MainWindow` 级别处理,我们会在控件下方打印以下行,例如:“已保存标签 'MyText' 的字符串 'Hello'”。
这是 `MainWindow` 类 XAML 代码的重要部分
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<this:EditableTextAndLabelControl x:Name="MyUserControl1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TheLabel="MyText"
Margin="0,10"/>
<this:EditableTextAndLabelControl x:Name="MyUserControl2"
Grid.Row="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TheLabel="MyOtherText"
Margin="0,10"/>
<TextBlock x:Name="TheSaveEventLog"
Grid.Row="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"/>
</Grid>
我们简单地定义了两个控件,并在它们下方定义了一个 `TextBlock` 来显示 `SaveEvent` 触发的结果。
`MainWindow.xaml.cs` 代码也非常简单
public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); MyUserControl1.SaveEvent += MyUserControl_SaveEvent; MyUserControl2.SaveEvent += MyUserControl_SaveEvent; } void MyUserControl_SaveEvent(string arg1, string arg2) { TheSaveEventLog.Text += "\nSaved string \"" + arg2 + "\" for label \"" + arg1 + "\""; } }
我们通过名称访问两个用户控件,并将 `MyUserControl_SaveEvent` 分配为它们 `SaveEvent` 的处理程序。`MyUserControl_SaveEvent` 函数向 `TheSaveEventLog` 控件的 `Text` 属性添加一行新内容。
现在让我们看看 `EditableTextAndLabelControl` 本身的代码
。控件的代码隐藏定义了控件上的两个依赖属性 `TheLabel` 和 `TheEditableText`。它还定义了当按钮被点击时触发的 `SaveEvent`。
public partial class EditableTextAndLabelControl : UserControl { public event Action<string, string> SaveEvent = null; public EditableTextAndLabelControl() { InitializeComponent(); SaveButton.Click += SaveButton_Click; } // fires SaveEvent of the control void SaveButton_Click(object sender, RoutedEventArgs e) { if (SaveEvent != null) { SaveEvent(TheLabel, TheEditableText); } } #region TheLabel Dependency Property public string TheLabel { get { return (string)GetValue(TheLabelProperty); } set { SetValue(TheLabelProperty, value); } } public static readonly DependencyProperty TheLabelProperty = DependencyProperty.Register ( "TheLabel", typeof(string), typeof(EditableTextAndLabelControl), new PropertyMetadata(null) ); #endregion TheLabel Dependency Property #region TheEditableText Dependency Property public string TheEditableText { get { return (string)GetValue(TheEditableTextProperty); } set { SetValue(TheEditableTextProperty, value); } } public static readonly DependencyProperty TheEditableTextProperty = DependencyProperty.Register ( "TheEditableText", typeof(string), typeof(EditableTextAndLabelControl), new PropertyMetadata(null) ); #endregion TheEditableText Dependency Property }
请注意,当“保存”按钮被点击时触发 `SaveEvent` 的机制包含在代码隐藏 `SaveButton.Click += SaveButton_Click;` 中,而控件和依赖属性之间的绑定则在 XAML 中定义。
<StackPanel Orientation="Horizontal"> <TextBlock x:Name="TheLabelTextBlock" Text="{Binding Path=TheLabel, RelativeSource={RelativeSource AncestorType=this:EditableTextAndLabelControl}}" VerticalAlignment="Bottom"/> <TextBox x:Name="TheEditableTextTextBox" Grid.Column="1" Width="100" Text="{Binding Path=TheEditableText, RelativeSource={RelativeSource AncestorType=this:EditableTextAndLabelControl}}" VerticalAlignment="Bottom" Margin="10,0,10,0"/> <Button x:Name="SaveButton" Content="Save" Width="70" Grid.Column="2" VerticalAlignment="Bottom"/> </StackPanel>
模式 - 无外观控件
上面介绍的(反)模式的问题在于,控件与视觉表示高度耦合。假设我们想要控件的不同视觉表示,例如我们想水平显示其中一个控件(如上),而垂直显示另一个控件,即标签位于 `TextBox` 的顶部,底部带有“保存” `Button`。我们将不得不创建两个完全不同的控件,它们具有相同的代码隐藏功能,甚至非常相似但不完全相同的 XAML 代码。
在本节中,我们创建了一个非常相似的无外观控件,并展示它如何拥有不同的视觉表示。本节的代码包含在 `LooklessControlSample` 项目下。
当你构建一个无外观控件时,不要试图首先确定它的视觉表现。相反,考虑它有哪些属性和事件,以便与其他控件或视图模型进行交互。
就像上一节一样,创建一个新项,但不要选择“用户控件 (WPF)”作为项类型,而只需创建一个 C# 类 `EditableTextAndLabelControl`。将该类设为公共,并使其继承自 WPF `Control` 类。
public class EditableTextAndLabelControl : Control { }
就像在之前的示例中一样,向它添加两个 `string` 类型的依赖属性:`TheLabel` 和 `TheEditableText`;另外,添加类型为 `Action
public partial class EditableTextAndLabelControl : Control { public event Action<string, string> SaveEvent = null; #region TheLabel Dependency Property public string TheLabel { get { return (string)GetValue(TheLabelProperty); } set { SetValue(TheLabelProperty, value); } } public static readonly DependencyProperty TheLabelProperty = DependencyProperty.Register ( "TheLabel", typeof(string), typeof(EditableTextAndLabelControl), new PropertyMetadata(null) ); #endregion TheLabel Dependency Property #region TheEditableText Dependency Property public string TheEditableText { get { return (string)GetValue(TheEditableTextProperty); } set { SetValue(TheEditableTextProperty, value); } } public static readonly DependencyProperty TheEditableTextProperty = DependencyProperty.Register ( "TheEditableText", typeof(string), typeof(EditableTextAndLabelControl), new PropertyMetadata(null) ); #endregion TheEditableText Dependency Property }
基本上就是这样了——我们已经定义了无外观控件。让我们将控件放置到 MainWindow.xaml 文件中,看看它们的样子。这是 MainWindow.xaml 的 XAML 代码(实际上它与上一个示例的代码相同)。
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<this:EditableTextAndLabelControl x:Name="MyControl1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TheLabel="MyText"
Margin="0,10"/>
<this:EditableTextAndLabelControl x:Name="MyControl2"
Grid.Row="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TheLabel="MyOtherText"
Margin="0,10"/>
<TextBlock x:Name="TheSaveEventLog"
Grid.Row="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"/>
</Grid>
如果我们就这样尝试运行项目,将会得到一个空窗口。原因是,我们从未为无外观控件定义视觉表示。我们应该将其定义为 `ControlTemplate`。为了清晰起见,XAML 编码人员通常会为模板和样式创建一个特殊的项目文件夹。让我们在项目中创建文件夹“Themes”,并在其中创建一个 WPF 资源字典 SampleControls.xaml。我们可以通过将以下行添加到资源字典的 header 标签来定义 XML 命名空间 `this`:`xmlns:this="clr-namespace:LooklessControlSample"`。然后,在 `ResourceDictionary` 中,我们可以定义我们的控件模板。
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:this="clr-namespace:LooklessControlSample">
<ControlTemplate x:Key="TheHorizontalTemplateForEditableTextAndLabelControl">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="TheLabelTextBlock"
Text="{Binding Path=TheLabel, RelativeSource={RelativeSource AncestorType=this:EditableTextAndLabelControl}}"
VerticalAlignment="Bottom" />
<TextBox x:Name="TheEditableTextTextBox"
Grid.Column="1"
Width="100"
Text="{Binding Path=TheEditableText, RelativeSource={RelativeSource AncestorType=this:EditableTextAndLabelControl}}"
VerticalAlignment="Bottom"
Margin="10,0,10,0" />
<Button x:Name="SaveButton"
Content="Save"
Width="70"
Grid.Column="2"
VerticalAlignment="Bottom" />
</StackPanel>
</ControlTemplate>
<ResourceDictionary/>
为了让 MainWindow.xaml 文件注意到 SampleControls.xaml 文件,我们需要在其顶部添加以下 XAML 代码。
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/SampleControls.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
请注意,如果 XAML 资源字典文件位于不同的项目中,则上述 `Source` 属性的简短表示法不足,还需要指定程序集名称。
为了让控件识别它们具有该模板,我们将它们的 `Template` 属性设置为 `"{StaticResource TheHorizontalTemplateForEditableTextAndLabelControl}"`。
现在,如果我们运行代码,将看到一个与我们上一个示例几乎相同的应用程序,只是当我们按下控件上的“保存”按钮时,什么也不会发生。
有 3 种主要方法可以让无外观控件感知其视觉效果上发生的事件
- 使用内置的 WPF 命令功能
- 使用 MS Expression Blend SDK
- 通过 C# 代码访问控件的视觉部分。
我们将在下面描述所有这些方法。
使用内置的 WPF 命令功能在无外观控件的视觉和非视觉部分之间进行通信
这是大多数 WPF 教程描述的方式,也是视觉和非视觉功能之间通信最糟糕的方式。它之所以糟糕,原因有二:
- 只有少数控件上的少数事件,例如 `Button` 或 `Menu` 控件上的 `Click` 事件,可以使用命令功能进行处理。
- 它需要修改无外观控件类,为其添加一个 `DelegateCommand` 对象。
由于我们不认为这是视觉和非视觉功能之间通信的好方法,而且它已经在其他地方广泛讨论过,因此我们在此不提供示例。
使用 MS Expression Blend SDK 在无外观控件的视觉和非视觉部分之间进行通信
这可能是将事件从视觉(XAML)传递到非视觉代码最优雅的方式。我已经在 MVVM Pattern Made Simple 一文中详细描述了这种方法,并将其应用于 MVVM 模式。相同的原理也可以应用于无外观控件。
首先,不要被“Expression Blend”这个词吓到——这只是一个 SDK,你不需要安装 MS Expression Blend 就能获取它。事实上,它可以从 MS Expression Blend SDK 免费下载和使用。最重要的是,我们只需要 SDK 中的两个 dll 文件:Microsoft.Expression.Interactions.dll 和 System.Windows.Interactivity.dll,并且我们提供这些文件以及我们的代码在 MSExpressionBlendSDKDlls 文件夹中。
由于这些文件是从互联网下载的,您可能需要解除它们。要解除它们,右键单击每个文件,选择“属性”菜单项,然后单击“解除阻止”按钮。
当然,项目需要引用这些文件,并且为了访问 SDK 功能,应将以下 xml 命名空间添加到“SampleControls.xaml”的顶层标签中。
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
让我们向我们的无外观控件 `EditableTextAndLabelControl` 添加一个方法 `Save`。
public void Save() { if (SaveEvent != null) SaveEvent(TheLabel, TheEditableText); }
这个方法只是简单地触发 `SaveEvent`。
现在我们可以通过在 `
<i:Interaction.Triggers>
<i:EventTrigger EventName="Click"
SourceObject="{Binding ElementName=SaveButton}">
<ei:CallMethodAction MethodName="Save"
TargetObject="{Binding RelativeSource={RelativeSource AncestorType=this:EditableTextAndLabelControl}}" />
</i:EventTrigger>
</i:Interaction.Triggers>
现在,如果运行项目,它将像上一节中的项目一样运行——在按下“保存”按钮时添加行。
通过 C# 代码访问控件的视觉部分,在无外观控件的视觉和非视觉部分之间进行通信。
这种方法最复杂,只在需要从 C# 代码完全访问控件的视觉效果时才应使用。为了不打断叙述,我们稍后将详细解释这种方法。
相同无外观控件的不同布局
这里我们将展示如何为相同的无外观控件生成不同的视觉布局。
打开“SampleControls.xaml”文件,并创建另一个 `ControlTemplate`,它与我们的“TheHorizontalTemplateForEditableTextAndLabelControl”模板几乎完全相同,只是使用垂直的 `StackPanel` 方向而不是水平的。我们将这个 `ControlTemplate` 命名为“TheVerticalTemplateForEditableTextAndLabelControl”。
<ControlTemplate x:Key="TheVerticalTemplateForEditableTextAndLabelControl"
TargetType="this:EditableTextAndLabelControl">
<StackPanel Orientation="Vertical">
<TextBlock x:Name="TheLabelTextBlock"
Text="{Binding Path=TheLabel, RelativeSource={RelativeSource AncestorType=this:EditableTextAndLabelControl}}"
VerticalAlignment="Bottom" />
<TextBox x:Name="TheEditableTextTextBox"
Grid.Column="1"
Width="100"
Text="{Binding Path=TheEditableText, RelativeSource={RelativeSource AncestorType=this:EditableTextAndLabelControl}}"
VerticalAlignment="Bottom"
Margin="10,0,10,0" />
<Button x:Name="SaveButton"
Content="Save"
Width="70"
Grid.Column="2"
VerticalAlignment="Bottom">
<i:Interaction.Triggers>
<i:EventTrigger EventName="Click"
SourceObject="{Binding ElementName=SaveButton}">
<ei:CallMethodAction MethodName="Save"
TargetObject="{Binding RelativeSource={RelativeSource AncestorType=this:EditableTextAndLabelControl}}" />
</i:EventTrigger>
</i:Interaction.Triggers>
</Button>
</StackPanel>
</ControlTemplate>
让我们将此模板分配给第二个控件。
<this:EditableTextAndLabelControl x:Name="MyControl2" Grid.Row="1" HorizontalAlignment="Center" VerticalAlignment="Center" Template="{StaticResource TheVerticalTemplateForEditableTextAndLabelControl}" TheLabel="MyOtherText" Margin="0,10" />
我们将得到以下结果
同一种类型的两个控件,通过两种不同的模板以两种不同的方式进行样式设置。
使用 WPF 样式而非模板
WPF 开发者通常更喜欢使用 `Style` 而不是 `ControlTemplate`。`Style` 允许设置控件上的 `Template` 属性,但它也可以用于设置其他属性,例如 `Width`、`Height`、对齐方式等。此外,没有键(但定义了 `TargetType` 属性)的样式可以被视为控件的默认样式。例如,如果我们假设在大多数情况下我们需要水平模板,我们可以将相应的样式设为默认样式,而为了让控件获得垂直样式,就必须提供键。
这些样式在示例的 `Themes/SampleControls.xaml` 文件中定义
<!-- Horizontal Style for EditableTextAndLabelControl (this is a default style for the
control - a style without a resource key) -->
<Style TargetType="this:EditableTextAndLabelControl">
<Setter Property="Template"
Value="{StaticResource TheHorizontalTemplateForEditableTextAndLabelControl}" />
</Style>
<!-- Vertical Style for EditableTextAndLabelControl -->
<Style TargetType="this:EditableTextAndLabelControl"
x:Key="TheVerticalStyleForEditableTextAndLabelControl">
<Setter Property="Template"
Value="{StaticResource TheVerticalTemplateForEditableTextAndLabelControl}" />
</Style>
在 `MainWindow.xaml` 文件中定义的控件不应该再设置 `Template`(`Style` 将为它们完成这项工作)。水平控件不必定义任何额外内容(默认样式将默认应用于它),而垂直控件应将其样式设置为相应的资源。
<this:EditableTextAndLabelControl x:Name="MyControl1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TheLabel="MyText"
Margin="0,10" />
<this:EditableTextAndLabelControl x:Name="MyControl2"
Grid.Row="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Style="{StaticResource TheVerticalStyleForEditableTextAndLabelControl}"
TheLabel="MyOtherText"
Margin="0,10" />
通过使用方向依赖属性进一步重用
请注意,在上面的示例中,控件的水平和垂直表示非常相似。它们都由相同的构建块组成:`TextBlock`、`TextBox` 和 `Button`。通常它们不必相似,并且可以由完全不同的构建块组成——视觉设计完全取决于开发人员。然而,很多时候,控件相似是有意义的,在这种情况下,我们可以进一步提高代码重用,正如本小节所讨论的。
此示例的代码可以在 `LooklessControlWithOrientationPropertySample` 解决方案下找到。此项目中的 `EditableTextAndLabelControl` 几乎与上面相同,只是它有一个额外的依赖属性 - `TheOrientation`。
#region TheOrientation Dependency Property public Orientation TheOrientation { get { return (Orientation)GetValue(TheOrientationProperty); } set { SetValue(TheOrientationProperty, value); } } public static readonly DependencyProperty TheOrientationProperty = DependencyProperty.Register ( "TheOrientation", typeof(Orientation), typeof(EditableTextAndLabelControl), new PropertyMetadata(Orientation.Horizontal) ); #endregion TheOrientation Dependency Property
这个属性可以在控件模板中用来定义控件的方向。这正是我们在 `SampleControls.xaml` 文件中所做的。
<ControlTemplate x:Key="TheTemplateForEditableTextAndLabelControl"
TargetType="this:EditableTextAndLabelControl">
<StackPanel Orientation="{Binding Path=TheOrientation, RelativeSource={RelativeSource TemplatedParent}}">
<TextBlock x:Name="TheLabelTextBlock"
Text="{Binding Path=TheLabel, RelativeSource={RelativeSource AncestorType=this:EditableTextAndLabelControl}}"
VerticalAlignment="Bottom" />
<TextBox x:Name="TheEditableTextTextBox"
Grid.Column="1"
Width="100"
Text="{Binding Path=TheEditableText, RelativeSource={RelativeSource AncestorType=this:EditableTextAndLabelControl}}"
VerticalAlignment="Bottom"
Margin="10,0,10,0" />
<Button x:Name="SaveButton"
Content="Save"
Width="70"
Grid.Column="2"
VerticalAlignment="Bottom">
<i:Interaction.Triggers>
<i:EventTrigger EventName="Click"
SourceObject="{Binding ElementName=SaveButton}">
<ei:CallMethodAction MethodName="Save"
TargetObject="{Binding RelativeSource={RelativeSource AncestorType=this:EditableTextAndLabelControl}}" />
</i:EventTrigger>
</i:Interaction.Triggers>
</Button>
</StackPanel>
</ControlTemplate>
在模板的顶部,我们将 `StackPanel` 的 `Orientation` 属性绑定到控件的 `TheOrientation` 属性。
<StackPanel Orientation="{Binding Path=TheOrientation, RelativeSource={RelativeSource TemplatedParent}}">
在 `MainWindow.xaml` 文件(使用控件的地方)中,我们必须设置相应控件的方向。
<this:EditableTextAndLabelControl x:Name="MyControl1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TheLabel="MyText"
TheOrientation="Horizontal"
Margin="0,10" />
<this:EditableTextAndLabelControl x:Name="MyControl2"
Grid.Row="1"
TheOrientation="Vertical"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TheLabel="MyOtherText"
Margin="0,10" />
请注意,在这种情况下,我们只有一个控件模板用于控件的水平和垂直表示。额外的属性 `TheOrientation` 为我们提供了另一个控制定制的自由度!
另外请注意,我们可以在控件中添加更多依赖属性,例如,用于定义控件内不同元素之间的边距、颜色等。
最后,我想讨论一下 `StackPanel` 的 `Orientation` 属性与 `EditableTextAndLabel` 控件的 `TheOrientation` 属性之间的绑定,正是这个绑定极大地增加了我们视觉表示的灵活性。
<StackPanel Orientation="{Binding Path=TheOrientation, RelativeSource={RelativeSource TemplatedParent}}">
由于它是在控件的 `ControlTemplate` 中定义的,我们可以使用 `TemplatedParent` 模式将其绑定到控件上的属性。另一种更通用的执行此绑定的方法是使用 `AncestorType` 模式。
<StackPanel Orientation="{Binding Path=TheOrientation, RelativeSource={RelativeSource AncestorType=this:EditableTextAndLabelControl}}">
这种绑定模式可以使用,即使控件的组件未在控件的 `ControlTemplate` 中定义(只要我们确定该组件包含在控件中)。
顺便说一下,`{Binding Path=TheOrientation, RelativeSource={RelativeSource TemplatedParent}}` 的简写是 `{TemplateBinding TheOrientation}`——我们可以在上面使用它。
向控件添加属性的非侵入式方法
假设您想在不修改控件本身的情况下添加一个属性(就像我们向 `EditableTextAndLabel` 控件添加 `TheOrientation` 属性一样)。这在以下情况下很有用:例如,您没有控件的代码,并且不想或不能从它派生另一个类,或者当您想将相同的属性应用于许多不同的不相关控件时。
WPF 提供了一种通过使用附加属性轻松实现此目的的方法。
项目 `LooklessControlWithOrientationAttachedPropertySample` 包含相应的示例。如您所见,其代码与上一个示例中的代码相似,不同之处在于 `TheOrientation` 属性在静态类 `AttachedProperties` 中被定义为一个附加属性。
#region TheOrientation attached Property public static Orientation GetTheOrientation(DependencyObject obj) { return (Orientation)obj.GetValue(TheOrientationProperty); } public static void SetTheOrientation(DependencyObject obj, Orientation value) { obj.SetValue(TheOrientationProperty, value); } public static readonly DependencyProperty TheOrientationProperty = DependencyProperty.RegisterAttached ( "TheOrientation", typeof(Orientation), typeof(AttachedProperties), new PropertyMetadata(Orientation.Horizontal) ); #endregion TheOrientation attached Property
对属性的引用也已更新,以反映它现在是一个附加属性。这是 `SampleControls.xaml` 文件中包含此属性绑定的行。
<StackPanel Orientation="{Binding Path=(this:AttachedProperties.TheOrientation), RelativeSource={RelativeSource TemplatedParent}}">
请注意,在绑定路径中,附加属性定义在括号中。
此外,`MainWindow.xaml` 文件中设置附加属性的行也需要修改。
<this:EditableTextAndLabelControl x:Name="MyControl1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TheLabel="MyText"
this:AttachedProperties.TheOrientation="Horizontal"
Margin="0,10" />
<this:EditableTextAndLabelControl x:Name="MyControl2"
Grid.Row="1"
this:AttachedProperties.TheOrientation="Vertical"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TheLabel="MyOtherText"
Margin="0,10" />
我们的无外观控件示例所代表的通用范式
骨架-肉体范式
无外观控件展示了 WPF 框架中反复出现的范式——我称之为 骨架-肉体 范式。
骨架 代表了一些简单的功能,它确定了可重用组件的公共接口。在无外观控件的情况下,骨架 由我们的 `EditableTextAndLabelControl` 类定义,并由其属性、事件和函数组成。
肉体 部分是围绕 骨架 构建的。它通常比 骨架 复杂得多。它反映了 骨架 的属性并模仿了 骨架 的行为。它还可以提供自己的交互和行为,但它们始终是组件的私有行为,不影响组件与其他组件的交互。
在我们的例子中,肉体 由 XAML 中定义的 `Style` 和 `TemplateControl` 对象表示。
请注意,肉体 部分可以访问 骨架 部分(它需要知道要显示和/或修改的属性以及需要模仿的行为)。骨架 部分通常完全不了解 肉体——我说通常,因为有时对于更复杂的控件,C# 骨架 可能对控件的 `Template` 中的各个部分有一些“模糊”的了解。
请注意,在 `MainWindow.xaml` 文件中,我们使用了无外观控件,我们插入了控件本身,定义了其外部交互的属性——肉体 部分在那里基本没有使用。
非侵入性
我要在这里讨论的第二个范例是 非侵入性。WPF 提供了一种通过使用附加属性来向控件添加属性而无需修改控件本身的方法。此外,此属性可以在 C# 和 XAML 代码中用于修改控件的外观和行为。
在我看来,这非常重要,因为它允许更好地分离关注点。例如,控件本身可以只包含与应用程序外部部分进行主要交互的核心属性,而一些外观问题可以通过向控件附加属性来管理。
附加事件也是 非侵入性 范式的一部分,将在稍后讨论。
组合层次
我们观察到的另一个范例是 组合层次结构。我们可以将控件由其他控件组合而成,而这些其他控件又由更精细的控件组成,依此类推。在我们的例子中,`MainWindow` 控件由两个 `EditableTextAndLabelControl` 对象组成(放置在一个 `Grid` 面板中,并带有一个 `TextBlock` 用于显示消息)。反过来,`EditableTextAndLabelControl` 对象由一个 `TextBlock`、一个 `TextBox` 和一个 `Button` 组成。
在我们的案例中,`MainWindow` 更像是一个 `UserControl`,但同样的 组合层次结构 概念可以应用于无外观控件。我们可以将一堆无外观控件组合成一个“超级”无外观控件,并在其模板中通过绑定连接我们希望在“超级”无外观控件级别操作的属性。在我们的案例中,我们将 `TextBlock` 的 `Text` 属性连接到 `TheLabel` 属性,并将 `TextBox` 的 `Text` 属性连接到无外观控件的 `TheEditableText` 属性。
我们还可以在 `EditableTextAndLabelControl` 上创建更多属性,例如,负责标签文本颜色和可编辑文本颜色——例如,我们可以在 `EditableTextAndLabelControl` 上定义 `LabelForeground` 和 `EditableTextForeground` 属性,并通过绑定将它们连接到模板中相应组件的属性。
无外观控件的重新样式化
请注意,由于 肉体 部分不用于外部交互,因此可以轻松更换,而不会影响整个应用程序。在我们的案例中,我们展示了如何通过使用两种不同的模板,以两种不同的方式——水平和垂直——呈现我们的无外观控件。人们可以提出更戏剧性的例子,例如在 在 WPF 中创建无外观自定义控件 中,模拟时钟和数字时钟是基于相同的无外观控件创建的,或者在 WPF 中样式和模板的强大功能 中,WPF `ListBox` 控件被重新样式化为太阳系。
请注意,所有内置的 WPF 控件都是无外观控件,因此允许进行巨大的定制。大多数第三方控件,至少对于 Teleric 和 DevExpress 框架而言,也都是无外观控件。
从控件的 C# 代码访问 XAML 控件模板中定义的元素
现在,正如我上面承诺的,我想展示如何从无外观控件的 C# 代码中访问模板的部分。在这种情况下,骨架 会获得一些关于 肉体 的知识,这样它可能会对 肉体(`Template`)的构建方式施加一些限制。这只应在控件需要一些复杂行为(例如调整大小等)时应用,对于我们简单的 `EditableTextAndLabelControl` 来说完全没有必要,但为了简单起见,我将使用我们的控件作为此示例。
此示例的代码位于 `FindingTemplatePartInCodeSample` 项目下。我们不使用 Expression Blend SDK 功能,而是从 C# 代码中获取对模板定义的按钮的引用,并向其 `Click` 事件添加一个处理程序,当事件发生时触发控件的 `SaveEvent`。
`SampleControls.xaml` 文件中定义的 `ControlTemplate` 变得更简单了。它不需要引用 Expression Blend SDK,并且其按钮没有任何触发器。
<Button x:Name="PART_SaveButton"
Content="Save"
Width="70"
Grid.Column="2"
VerticalAlignment="Bottom" />
请注意,按钮的名称已更改为带有前缀“PART_”。这是 C# 代码期望找到的控件部分的通用约定。
`EditableTextAndLabelControl` 代码以以下方式更改。我删除了方法 `Save()`,而是添加了以下代码。
Button _saveButton = null; public override void OnApplyTemplate() { base.OnApplyTemplate(); if (this.Template == null) return; _saveButton = this.Template.FindName("PART_SaveButton", this) as Button; if (_saveButton != null) _saveButton.Click += _saveButton_Click; } void _saveButton_Click(object sender, RoutedEventArgs e) { if (SaveEvent != null) SaveEvent(TheLabel, TheEditableText); }
`OnApplyTemplate()` 方法通过其名称“PART_SaveButton”从模板中取出按钮控件。它还向 `Button` 的 `Click` 事件添加了 `_saveButton_Click` 处理程序。
请注意,使用此方法打破了 骨架 对 肉体 一无所知的原则,因此,应尽可能少地使用此方法。
依赖项或附加属性更改检测模式
当定义依赖项或附加属性时,可以指定一个回调,以便在该属性更改时触发。这有时非常有用,因为它允许在 骨架 中在属性更改后触发事件或进行其他处理。
例如,假设我们想从 `EditableTextAndLabelControl` 中移除按钮,并在 `TheText` 属性更改时触发 `SaveEvent`。请注意,`TextBox` 的 `Text` 属性(在 XAML 模板中定义)上的绑定控制着何时更改它所绑定的 `TheText`。默认情况下,它会在失去焦点时更改,而如果我们将其绑定的 `UpdateSourceTrigger` 属性设置为 `PropertyChanged`,它将在每次键入或删除字符时开始更改。
此示例的代码位于 `DependencyPropertyChangeDetectionSample` 项目下。
`TheEditibleText` 依赖属性的定义现在包含一个回调 - `OnEditableTextChanged`。这个回调调用我们旧的 `Save()` 方法,而该方法又触发 `SaveEvent`。
public static readonly DependencyProperty TheEditableTextProperty = DependencyProperty.Register ( "TheEditableText", typeof(string), typeof(EditableTextAndLabelControl), new PropertyMetadata(null, OnTheEditableTextChanged) ); // dependency property change callback private static void OnTheEditableTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { ((EditableTextAndLabelControl)d).Save(); }
`SampleControls.xaml` 文件中定义的模板现在没有“保存”`Button`,并且其 `TextBox` 控件的 `Text` 属性的绑定现在将 `UpdateSourceTrigger` 设置为 `PropertyChanged`,这样每次按键都会调用 `SaveEvent`。
<TextBox x:Name="TheEditableTextTextBox"
Grid.Column="1"
Width="100"
Text="{Binding Path=TheEditableText,
UpdateSourceTrigger=PropertyChanged,
RelativeSource={RelativeSource AncestorType=this:EditableTextAndLabelControl}}"
VerticalAlignment="Bottom"
Margin="10,0,10,0" />
当用户在 `MyOtherText` 控件中输入“Hello World”时,应用程序将如下所示
现在,假设我们想检测控件上依赖(或附加)属性的更改,但我们无法访问定义该属性的代码,因此无法向其添加处理程序。
处理这种情况的标准方法是定义我们自己的附加或依赖属性,将其绑定到正在更改的属性,并检测我们属性的更改(我们可以访问其代码)。实际上,这就是我们对 `TextBox` 控件的 `Text` 属性所做的。在上面的示例中,我们通过将其绑定到控件的 `TheEditableText` 属性并定义 `TheEditableText` 属性上的回调来检测它的更改。
行为模式
这是一种极佳的模式,可以改善关注点分离。据我所知,它最早由 Expression Blend SDK 引入。
实际上,无需使用 Expression Blend SDK 就可以很容易地创建行为功能,我在这里展示了如何实现。事实上,正如后续文章将展示的,行为模式并非 WPF 所独有,可以用于纯粹的非可视化功能——对本文中介绍的 IBehavior 接口进行轻微修改,可以使其完全与 WPF 无关。
行为是一个 C# 对象,通过附加到另一个对象(例如 WPF 控件)来改变其行为,通过向对象的事件添加特定于行为的处理程序。行为知道它修改的 WPF 对象,而 WPF 对象对附加到它的行为一无所知。因此,行为是 肉体 的一部分,但由于它是用 C# 构建的,它比 XAML 中包含的 肉体 具有更大的转换对象的能力。
将行为附加到 WPF 对象的最佳方法是非侵入性的——通过附加属性。通常,行为被定义为 XAML 资源并附加到 XAML 中的 WPF 对象。
要查看行为示例,请打开 `BehaviorSample` 项目。`IBehavior` 是所有行为都应实现的接口。
public interface IBehavior { void Attach(FrameworkElement el); void Detach(FrameworkElement el); }
静态类 `AttachedProperties` 包含 `TheBehavior` 附加属性,该属性允许将行为附加到任何 `FrameworkElement`。属性更改回调方法 `OnBehaviorChanged` 为旧行为调用 `OnDetach` 方法,为新行为调用 `OnAttach` 方法。
private static void OnTheBehaviorChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { FrameworkElement el = (FrameworkElement)d; IBehavior oldBehavior = (IBehavior) e.OldValue; if (oldBehavior != null) oldBehavior.Detach(el); IBehavior newBehavior = (IBehavior) e.NewValue; if (newBehavior != null) newBehavior.Attach(el); }
`ChangeOpacityOnMouseOverBehavior` 定义了一个行为,当鼠标移到元素上方时,它会将元素的透明度更改为 0.5,然后当鼠标移开时,它会将其更改回 1。
public class ChangeOpacityOnMouseOverBehavior : IBehavior { public void Attach(FrameworkElement el) { el.MouseEnter += el_MouseEnter; el.MouseLeave += el_MouseLeave; } void el_MouseLeave(object sender, System.Windows.Input.MouseEventArgs e) { FrameworkElement el = (FrameworkElement)sender; el.Opacity = 1; } void el_MouseEnter(object sender, System.Windows.Input.MouseEventArgs e) { FrameworkElement el = (FrameworkElement)sender; el.Opacity = 0.5; } public void Detach(FrameworkElement el) { el.MouseLeave -= el_MouseLeave; el.MouseEnter -= el_MouseEnter; } }
最后,在 `MainWindow.xaml` 文件中,我们在窗口中央定义一个带有绿色 `Background` 的 `Grid` 面板,并将行为附加到它上面。
<Window.Resources>
<this:ChangeOpacityOnMouseOverBehavior x:Key="TheChangeColorBehavior" />
</Window.Resources>
<Grid>
<Grid HorizontalAlignment="Center"
VerticalAlignment="Center"
Height="30"
Width="100"
Background="Green"
this:AttachedProperties.TheBehavior="{StaticResource TheChangeColorBehavior}" />
</Grid>
结果是,在窗口中央显示一个绿色矩形,当鼠标悬停在其上方时,其不透明度会降低。
请注意,上述模式只允许一个对象附加一个行为。这并不总是方便的。为了将多个行为附加到同一个对象,我们可以定义一个附加属性 `TheBehaviors` 而不是 `TheBehavior`。此属性的类型可以是 `IEnumerable
将多个行为附加到同一对象的示例位于 AttachingMultipleBehiorsToObjectSample 项目下。这里,我们没有 `TheBehavior` 附加属性,而是有一个类型为 `IEnumerable
<Window.Resources>
<x:Array Type="this:IBehavior" x:Key="TheBehaviors">
<this:ChangeOpacityOnMouseOverBehavior/>
<this:ChangeWidthBehavior />
</x:Array>
</Window.Resources>
<Grid>
<Grid HorizontalAlignment="Center"
VerticalAlignment="Center"
Height="30"
Width="100"
Background="Green"
this:AttachedProperties.TheBehaviors="{StaticResource TheBehaviors}" />
</Grid>
当运行示例时,您可以看到,窗口中央的绿色矩形不仅透明度发生了变化,而且当鼠标悬停在其上方时,矩形还会水平展开。
模板部件和松散耦合
也许与 `ControlTemplate` 相关最重要的优势是它们允许在控件的不同部分之间实现高度独立(松散耦合)。
我们将从一个简单的事实开始:任何 `Control` 对象在视觉树中都会被其模板替换。当然,控件对象仍然会存在于视觉树中,但仅作为容器。为了说明这一点,请打开 `ControlTemplateReplacementSample` 项目。
该项目的所有重要代码都位于 `MainWindow.xaml` 文件中。我们在 `Window.Resources` 部分定义了一个非常简单的模板。
<ControlTemplate x:Key="MyControlTemplate">
<Grid Background="Yellow">
<TextBlock Text="Hello World" Margin="30"/>
</Grid>
</ControlTemplate>
然后将此模板分配给一个控件。
<Control x:Name="MyControl"
Template="{StaticResource MyControlTemplate}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
结果,当我们打开窗口时,我们会在一个黄色矩形中看到文本“Hello World”,就好像我们直接将模板的代码放在主窗口中,而不是 `Control` “MyControl”一样。
模板部件松耦合模式的主要示例位于 `MultipleTemplatePartsSample` 项目下。它定义了一个无外观控件 `MyTestControlWithHeader`。该控件有 3 个依赖属性——`HeaderTemplate`、`MainTemplate` 和 `EventLog`。前两个属性是 `ControlTemplate` 类型;它们对应于两个模板——一个用于控件的头部,另一个用于控件的“主要”部分。第三个属性只是一个字符串,表示发生在控件上的某些可视化事件的“日志”。
该无外观控件还定义了一个方法 `LogClickEvent()`。每次调用时,它都会将以下行附加到 `EventLog` 字符串中:“Click Received”。
public class MyTestControlWithHeader : Control { #region HeaderTemplate Dependency Property public ControlTemplate HeaderTemplate { get { return (ControlTemplate)GetValue(HeaderTemplateProperty); } set { SetValue(HeaderTemplateProperty, value); } } public static readonly DependencyProperty HeaderTemplateProperty = DependencyProperty.Register ( "HeaderTemplate", typeof(ControlTemplate), typeof(MyTestControlWithHeader), new PropertyMetadata(null) ); #endregion HeaderTemplate Dependency Property #region MainTemplate Dependency Property public ControlTemplate MainTemplate { get { return (ControlTemplate)GetValue(MainTemplateProperty); } set { SetValue(MainTemplateProperty, value); } } public static readonly DependencyProperty MainTemplateProperty = DependencyProperty.Register ( "MainTemplate", typeof(ControlTemplate), typeof(MyTestControlWithHeader), new PropertyMetadata(null) ); #endregion MainTemplate Dependency Property #region EventLog Dependency Property public string EventLog { get { return (string)GetValue(EventLogProperty); } set { SetValue(EventLogProperty, value); } } public static readonly DependencyProperty EventLogProperty = DependencyProperty.Register ( "EventLog", typeof(string), typeof(MyTestControlWithHeader), new PropertyMetadata(null) ); #endregion EventLog Dependency Property public void LogClickEvent() { EventLog += "\nClick Recieved"; } }
控件的样式和模板在 `MainWindow.xaml` 文件中定义。
<Style TargetType="this:MyTestControlWithHeader">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="this:MyTestControlWithHeader">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Control x:Name="HeaderControl"
Template="{Binding Path=HeaderTemplate, RelativeSource={RelativeSource TemplatedParent}}" />
<Control x:Name="MainControl"
Grid.Row="1"
Template="{Binding Path=MainTemplate, RelativeSource={RelativeSource TemplatedParent}}" />
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
如您所见,控件的模板由一个带两行的 `Grid` 面板组成——这两行都包含 `Control` 对象。上面一行包含名为“HeaderControl”的 `Control`——其 `Template` 属性绑定到 `MyTestControlWithHeader` 对象的 `HeaderTemplate` 属性。下面一行包含名为“MainControl”的 `Control`——其 `Template` 属性绑定到 `MyTestControlWithHeader` 对象的 `MainTemplate` 属性。
重要的是,我们可以插入任何两个模板作为 `HeaderTemplate` 和 `MainTemplate` 属性,并通过 `MyTestControlWithHeader` 功能使它们协同工作,而无需实际了解彼此。
这正是我们所做的——我们定义了两个模板:`MyHeaderTemplate1` 和 `MyMainTemplate1`(它们的名称是为了强调它们可以更容易地替换)并将它们插入到主控件中。
<this:MyTestControlWithHeader HeaderTemplate="{StaticResource MyHeaderTemplate1}"
MainTemplate="{StaticResource MyMainTemplate1}"/>
这是“MyHeaderTemplate1”模板的代码。
<ControlTemplate x:Key="MyHeaderTemplate1">
<Grid Height="50"
Background="Yellow">
<StackPanel Orientation="Horizontal">
<Button Content="Button to Click"
Height="25"
Width="120"
Margin="5,0">
<i:Interaction.Triggers>
<i:EventTrigger EventName="Click">
<ei:CallMethodAction TargetObject="{Binding RelativeSource={RelativeSource AncestorType=this:MyTestControlWithHeader}}"
MethodName="LogClickEvent" />
</i:EventTrigger>
</i:Interaction.Triggers>
</Button>
</StackPanel>
</Grid>
</ControlTemplate>
它包含一个按钮,点击该按钮会导致调用包含 `MytestControlWithHeader` 对象的 `LogClickEvent` 方法。
这是“MyMainTemplate1”模板
<ControlTemplate x:Key="MyMainTemplate1">
<Grid Background="Green">
<TextBlock x:Name="LogText"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="Black"
Text="{Binding Path=EventLog, RelativeSource={RelativeSource AncestorType=this:MyTestControlWithHeader}}" />
</Grid>
</ControlTemplate>
它定义了一个文本块,其 `Text` 属性绑定到包含 `MyTestControlWithHeader` 对象的 `EventLog` 属性。
现在,如果您运行应用程序——单击标题中的按钮将导致文本行“Click Received”添加到控件的主体部分。
如您所见,这两个模板部分彼此之间没有任何了解。它们对包含的无外观控件有一些了解,并通过其方法和属性进行通信。单击标题 `Button` 会调用无外观控件的 `LogClickEvent()` 方法(通过 MS Expression Blend SDK 的内部机制)。此方法将一行文本添加到同一对象的 `EventLog` 属性中,而该属性又与 `MainTemplate` 中 `TextBlock` 的 `Text` 属性绑定。
结论
在本文中,我们讨论了基于 WPF `Control` 和 `ControlTemplate` 功能的代码重用实用模式。在后续文章中,我们计划讨论基于视图-视图模型模式,它们在代码重用和关注点分离方面具有更高的潜力。
致谢
感谢我的同事 Chad Wright 指出本文中的一些错误。