创建 WPF 自定义控件,第 2 部分






4.90/5 (39投票s)
如何使用 Expression Blend 和 Visual Studio 创建 WPF 自定义控件

引言
在本文章的第一部分中,我们在 Expression Blend 中为 Outlook 2010 任务按钮创建了一个控件模板。它看起来很不错,但实用性不强。一方面,所有颜色都硬编码到模板中,这严重限制了模板的灵活性。另一方面,模板使用的图像和文本也都是硬编码的,这意味着模板必须复制到每个使用它的按钮中。
在本文的这一部分中,我们将把控件模板封装在一个自定义控件中,这将消除我们在第一部分中使用的硬编码。结果将是一个灵活的、通用的任务按钮,我们可以将其添加到项目中,并像使用其他任何按钮一样使用它。
版本 1.0.0 的更改
演示项目的当前版本是 1.0.1。此版本纠正了读者freefeb在 Generic.xaml 的 <Image>
声明中发现的错误。现在图像应该在任务按钮中正确显示。
第四步:创建值转换器
在讨论自定义控件本身之前,我们还有一些收尾工作要做。按钮的颜色值都是基本颜色的深浅色调。在第一部分中,我们使用 Expression Blend 吸管工具从 Outlook 2010 中获取了各种深浅的蓝色。在本文的稍后部分,我们将这些值绑定到按钮的 Background
属性。为此,我们需要指定背景颜色的不同深浅色调。而这项任务将需要使用 IValueConverter
对象。
我们在这里不会详细介绍 IValueConverter
。如果您不熟悉该接口,请另行学习基础知识。从现在开始,我们将假设您对 IValueConverter
有一个大致的了解。
我们的值转换器将接受一个参数,即我们希望从转换器返回的基本颜色的百分比。转换器将 SolidColorBrush
(作为十六进制值从 XAML 传入)作为其值,并将画笔颜色转换为 HLS 值。然后,它使用作为“parameter”参数传入转换器的百分比,调整 HLS 值的亮度,使其更亮或更暗。调整是根据基本颜色亮度的百分比进行的。例如,如果基本颜色的亮度为 80%,而我们传入 85% 作为调整因子(通过“parameter”参数),则 HSL 颜色的亮度将调整为 68%(80% 的 85%)。
快速补充一句,执行颜色调整有两种模型:HLS 和 HSB。我更喜欢 HLS 模型,但许多人更喜欢 HSB 模型。在我的文章WPF 颜色转换(在 CodeProject 上)中,我包含了两种模型的转换方法和 IValueConverter
类。因此,如果您更喜欢 HSB,演示项目中的 IValueConverter
可以轻松替换为该文章中的 HSB 转换器。
我们暂时将 IValueConverter
放在一边——稍后在组装自定义控件时我们会用到它。现在,让我们把注意力转向创建控件本身。
第五步:创建自定义控件
终于,我们旅程到了真正创建自定义控件的时候了。感觉我们已经在大峡谷里徒步了两天,终于抵达了科罗拉多河。但在创建自定义控件之前,让我们先了解一下用户控件和自定义控件的区别。
自定义控件与用户控件
自定义控件无非是一个包装控件模板的类。自定义控件可能有点令人困惑,因为 WPF 不提供设计图面供您使用。这是用户控件和自定义控件之间的一个主要区别。
用户控件实际上是视图的一个片段。像窗口一样,用户控件有一个表面,其他控件可以放置在上面。开发人员将控件放置在设计图面上,以构成用户控件将表示的视图。因此,用户控件有时被称为“复合控件”。
用户控件的一个标志性示例是颜色选择器。颜色选择器由多个控件组成,包括用于 RGB 值的滑块、一个用于预览所选颜色的 Rectangle
以及用于提交或取消选择的按钮。用户控件通常使用 Model-View-ViewModel 模式与应用程序的其余部分进行通信。其构成控件的属性直接绑定到视图模型,而不是绑定到控件本身的自定义属性。
自定义控件是一种非常不同的事物。自定义控件不是构成控件的组合。相反,它通常派生自单个控件。例如,我们将从 RadioButton
类派生我们的自定义控件。这种方法允许我们继承 RadioButton
的行为(我们特别希望使用 IsChecked
属性)并向控件添加我们自己的自定义属性(我们将添加 ImagePath
和 Text
属性)。
自定义控件的结构
正如我们上面所指出的,自定义控件不像用户控件那样提供设计图面。相反,自定义控件依赖于一点技巧。WPF 内置了对主题的支持。在名为 Themes 的根级文件夹中,任何名为 Generic.xaml 的资源字典中的控件模板都将被视为应用程序默认主题的一部分。WPF 使用此机制为我们自定义控件的模板提供资源字典。
因此,我们的自定义控件将由两个元素组成
- 一个将包含自定义控件代码的类;以及
- 一个 Themes/Generic.xaml 资源字典,它将包含其控件模板。
我们在第一部分中创建了控件模板。现在,是时候组装自定义控件本身了。
创建自定义控件
在 Visual Studio 2008 中,创建一个名为 Outlook2010TaskButton
的新 WPF 自定义控件库。Visual Studio 将创建一个具有以下结构的项目:

如您所见,Visual Studio 为我们的自定义控件(当前名为 CustomControl1.cs)创建了一个类,以及一个包含 Generic.xaml 资源字典的 Themes 子文件夹。我们将从填写自定义控件类开始。
创建类
我们首先将 CustomControl1.cs 重命名为 TaskButton.cs。我们知道我们需要两个自定义属性:
Image
:此属性将接受一个ImageSource
对象,表示我们希望在按钮上显示的图像。Text
:此属性将接受我们希望在按钮上显示的文本。
为什么不直接使用 ContentPresenter
而不是自定义属性,让模板按钮决定呈现什么内容呢?正如我们在第一部分中所指出的,我们正在创建一个特殊用途的按钮——一个模仿 Outlook 任务按钮的按钮。我们将内容锁定在控件模板中,以便我们可以强制执行此类按钮的标准。
添加依赖属性
我们需要将所需的两个自定义属性添加为依赖属性。我们在这里不会对依赖属性进行冗长的解释——如果您不熟悉它们,请另行学习。只需说自定义控件属性必须设置为依赖属性即可。
依赖属性对于我们这些习惯于普通 .NET 属性的人来说可能看起来很奇怪,但它们实际上并没有那么不同。它们只是遵循略有不同的约定:
- 依赖属性由
static
变量支持,这些变量以属性的名称命名,并附加“Property
”一词。因此,ImagePath
属性的后端变量是ImagePathProperty
。 - 依赖属性的 getter 和 setter 只能调用
GetValue()
和SetValue()
方法,并且只能传递属性的后端变量。任何可能放在 getter 或 setter 中的其他代码都通过回调系统处理。 - 后端变量在使用前必须在 WPF 中注册。初始化可以在变量声明时执行,也可以在类构造函数中执行。我使用后一种方法。
对于我们的任务按钮,所有自定义控件类所要做的就是实现我们需要的两个依赖属性。因此,它看起来像这样:
using System.Windows.Controls
namespace Outlook2010TaskButton
{
/// <summary>
/// An Outlook 2010 Task Button
/// </summary>
public class TaskButton : RadioButton
{
#region Fields
// Dependency property backing variables
public static readonly DependencyProperty ImageProperty;
public static readonly DependencyProperty TextProperty;
#endregion
#region Constructor
/// <summary>
/// Default constructor.
/// </summary>
static TaskButton()
{
// Initialize as lookless control
DefaultStyleKeyProperty.OverrideMetadata(typeof(TaskButton),
new FrameworkPropertyMetadata(typeof(TaskButton)));
// Initialize dependency properties
ImageProperty = DependencyProperty.Register("Image",
typeof(ImageSource), typeof(TaskButton), new UIPropertyMetadata(null));
TextProperty = DependencyProperty.Register("Text", typeof(string),
typeof(TaskButton), new UIPropertyMetadata(null));
}
#endregion
#region Custom Control Properties
/// <summary>
/// The image displayed by the button.
/// </summary>
/// <remarks>The image is specified in XAML as an absolute or relative path.
/// </remarks>
[Description("The image displayed by the button"), Category("Common Properties")]
public ImageSource Image
{
get { return (ImageSource)GetValue(ImageProperty); }
set { SetValue(ImageProperty, value); }
}
/// <summary>
/// The text displayed by the button.
/// </summary>
[Description("The text displayed by the button."), Category("Common Properties")]
public string Text
{
get { return (string)GetValue(TextProperty); }
set { SetValue(TextProperty, value); }
}
#endregion
}
}
请注意,ImageProperty
的类型为 ImageSource
,即使我们在 XAML 中向它传递相对路径。WPF 有一个内置的 ImageSourceConverter
,它从传入的相对路径加载图像并将其传递给 Image
属性。在本文的原始版本中,我使用了 ImagePath
属性,它接受从 XAML 传入的相对路径。事实证明这不是正确的方法,并且 WPF 无法始终将相对路径解析为按钮图像。将 ImagePath
属性(String
类型)更改为 Image
属性(ImageSource
类型)解决了这个问题。
另请注意,我们可以将标准 .NET 属性特性应用于我们的自定义控件属性。例如,Category
特性指定了该属性应在 Expression Blend 和 Visual Studio 中出现的属性类别,而 Description
特性指定了将在 Visual Studio 中为该属性显示的文本描述。
添加控件模板
当我们创建自定义控件项目时,Visual Studio 为我们的任务按钮创建了一个简单的控件模板:
<ControlTemplate TargetType="{x:Type local:TaskButton}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
</Border>
</ControlTemplate>
我们将用我们在第一部分中创建的控件模板替换它,但在执行此操作之前,请注意默认模板中的 TemplateBinding
对象。TemplateBinding
将控件模板中的属性绑定到模板控件的属性。当我们创建任务按钮的实例并设置其 Background
、BorderBrush
和 BorderThickness
属性时,TemplateBinding
对象将把这些值拉入控件模板。在复制控件模板后,我们将在控件模板中使用此技术。
现在,我们只需用第一部分的模板替换默认控件模板。首先,我们删除默认模板,保留外部的 <ControlTemplate>
元素。然后,我们复制第一部分的控件模板,省略其外部的 <ControlTemplate>
元素,并将其粘贴到默认模板的外部 <ControlTemplate>
元素中。
常规绑定与模板绑定
正如我们稍后将看到的那样,属性绑定存在一些怪异之处。在某些情况下,您必须使用常规的 Binding
对象。一个值得注意的例子是,当您需要使用 WPF 的内置值转换器之一时,例如从图像路径返回 ImageSource
对象的转换器。TemplateBinding
对象无法访问这些转换器,因此正如我们将在下面看到的,我们必须使用常规的 Binding
对象来获取任务按钮的 ImagePath
属性指定的图像。
在其他情况下,常规的 Binding
对象将不起作用。例如,我在这个项目中了解到,如果绑定依赖于自定义值转换器(例如我们用于任务按钮的 HlsValueConverter
),则常规的 Binding
对象在控件模板内部将不起作用。如果使用常规的 Binding
对象,则值转换器永远不会被调用。因此,您必须使用 TemplateBinding
对象。
在其他情况下,您可以使用常规的 Binding
对象或 TemplateBinding
对象。我建议始终从 TemplateBinding
对象开始。如果不起作用,请尝试将属性绑定更改为常规的 Binding
对象,看看该更改是否能解决问题。
设置按钮图像的路径
如果您真的按照这些步骤进行操作,那么在您粘贴控件模板后,您可能注意到的第一件事是出现一个异常,显示:文件 calendar.png 不是项目的一部分,或者其“生成操作”属性未设置为“资源”。请记住,在我们的控件模板中,我们在第一部分中硬编码了图像路径——现在,我们需要将硬编码路径更改为属性绑定。
您可以在 Generic.xaml 的大约第 115 行找到按钮的内容标记。这是我们修改前图像路径的样子:
<Image Source="calendar.png" ... />
修改后,它看起来像这样:
<Image Source="{Binding Path=ImagePath,
RelativeSource={RelativeSource TemplatedParent}}" ... />
重要的更改是 Source
属性。我们没有硬编码源,而是将其绑定到自定义控件的 ImagePath
属性。
关于此绑定,有几点需要注意:
- 我们使用了
Binding
对象,而不是TemplateBinding
。这是因为 WPF 需要将传入的图像路径解析为ImageSource
对象。TemplateBinding
对象无法做到这一点,因为它无法访问 WPF 的内置值转换器。 - 我们在绑定中添加了一个
RelativeSource
对象。这是因为我们需要从设计图面(例如 WPF 窗口)上实例化任务按钮的位置解析图像路径。如果没有RelativeSource
,WPF 将尝试从控件模板的位置解析图像路径。
我们设置 RelativeSource
对象的 TemplatedParent
值实际上是 RelativeSourceMode
枚举的一部分,该枚举列出了 RelativeSource
可以采用的各种模式。稍后,当我们设置任务按钮的 Background
属性时,我们将看到 System.Windows.Data.RelativeSource
对象的另一种用法。
设置按钮文本
我们的控件模板仍然硬编码了按钮文本:
<TextBlock Text="Calendar" ... />
我们希望将硬编码的“日历”替换为绑定到自定义控件的 Text
属性。在这种情况下,这非常简单:
<TextBlock Text="{TemplateBinding Text}" ... />
我们可以使用 TemplateBinding
对象进行绑定,因为我们不需要访问内置值转换器或需要完整 Binding
对象的其他功能。而且,我们不需要 RelativeSource
对象,因为我们不需要解析相对于控件实例位置的任何内容。因此,一个简单的 TemplateBinding
,就像我们在 Visual Studio 为我们创建的默认模板中看到的那样,就非常适合。
创建演示项目
至此,我们有了一个功能正常的任务按钮。让我们看看它的样子:
- 在 Visual Studio 2008 中,向您的解决方案添加一个 WPF 应用程序项目,并将其命名为 TaskButtonDemo。
- 向演示项目添加一个图像文件;图像应为 24 x 24 像素。演示项目使用 calendar.png,这是第一部分的图像文件。
- 接下来,向演示项目添加对自定义控件项目的引用。
- 编译解决方案,并向 Window1.xaml 添加一个
<TaskButton>
。
要添加任务按钮,您需要向自定义控件程序集添加 XML 命名空间声明。Window1.xaml 现在应该如下所示:
<Window x:Class="TaskButtonDemo.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:custom="clr-namespace:Outlook2010TaskButton;assembly=Outlook2010TaskButton"
Title="Window1" Height="300" Width="300">
<Grid>
<custom:TaskButton ImagePath="calendar.png" Text="Calendar" />
</Grid>
</Window>
编译应用程序并运行。您应该会看到一个如下所示的窗口:

控件的设置方式有些特殊。由于背景和文本颜色都绑定到控件的 Background
属性,因此在设置其 Background
属性之前,文本不会出现在控件上;状态效果也不会出现。一旦设置了 Background
属性,所有内容都应该显示出来。
添加值转换器
我们按钮唯一的缺点是它仍然硬编码为蓝色阴影。在我们将 XAML 重构以数据绑定控件的颜色属性之前,我们需要添加一个 IValueConverter
来执行颜色调整。我们将添加上面讨论的WPF 颜色转换文章中的 HlsValueConverter.cs 文件。
我们还需要将转换器添加到控件模板中。它位于 <ControlTemplate.Resources>
部分,看起来像这样:
<local:HlsValueConverter x:Key="ColorConverter" />
现在,我们准备在控件模板中使用值转换器。
数据绑定颜色属性
我们需要将按钮的颜色属性链接到自定义控件的颜色属性。而且,如果我们要模仿 Outlook 2010 任务按钮,我们将非常具体地说明如何进行这种绑定:
- 按钮的背景颜色应与宿主窗口的背景颜色相同。我们可以通过在创建的控件实例中创建数据绑定来实现此结果。
- 各种状态效果中使用的边框应该是背景颜色的深浅。这样,当我们更改按钮的背景颜色时,边框也会随之改变。
最初,我曾计划将控件模板绑定设置为自动将按钮背景绑定到主机窗口背景,所有这些都从控件模板内部完成。我最终认为这种方法有点过于限制,因此控件模板绑定到自定义控件的 Background
属性。当在 WPF 窗口(或用户控件)中实例化任务按钮时,开发人员可以将控件的 Background
属性绑定到窗口的 Background
属性。这样,如果窗口颜色发生变化,变化将自动流向窗口上的任何任务按钮。
现在,在演示项目中将 Window1.Background
属性设置为 #FFB2C5DD,这将使窗口着色以匹配按钮。
现在,我们开始将硬编码的颜色值重构为数据绑定颜色的过程。我们将所有内容都基于自定义控件的 Background
属性。让我们从 BorderGrid
层开始,它由背景、外边框和内边框组成。这是我们开始之前的标记:
<Grid x:Name="BorderGrid" Margin="0" Background="#FFB2C5DD" Opacity="0">
<Grid.Effect>
<DropShadowEffect ShadowDepth="4" Opacity="0.1"/>
</Grid.Effect>
<Rectangle x:Name="OuterStroke"
Stroke="#FF859EBF" Margin="0"/>
<Rectangle x:Name="InnerStroke"
Stroke="#FFD9E7F5" Margin="1" Opacity="1"/>
</Grid>
首先,我们将 Grid
的 Background
绑定到自定义控件的 Background
:
<Grid x:Name="BorderGrid" Margin="0"
Background="{TemplateBinding Background}" Opacity="0">
这个很简单。接下来,我们设置外边框。这是 Background
的较深阴影——让我们尝试背景颜色的 80%:
<Rectangle x:Name="OuterStroke" Stroke="{TemplateBinding Background,
Converter={StaticResource ColorConverter}, ConverterParameter='0.8'}"
Margin="0"/>
如您所见,我们已将颜色转换器添加到绑定中,这将把边框颜色调整为较深的阴影。
接下来,让我们设置内部边框。此对象的设置方式相同,只是它是背景颜色的较浅阴影。让我们尝试 120%:
<Rectangle x:Name="InnerStroke" Stroke="{TemplateBinding Background,
Converter={StaticResource ColorConverter}, ConverterParameter='1.2'}"
Margin="1" />
请注意,我们使用 TemplateBinding
对象来执行这些 Rectangle
上的颜色绑定。我们必须使用 TemplateBinding
对象,因为我们使用了值转换器。如果我们使用常规 Binding
对象,值转换器将永远不会被调用,并且外边框和内边框不会出现在 MouseOver 或 Selected 状态中。
设置背景属性
我们现在已经将 MouseOver
状态数据绑定,而不是硬编码。要查看它的外观,让我们切换回演示项目中的 Window1.xaml。我们之前在那里创建了一个任务按钮;现在,我们需要将任务按钮的 Background
属性绑定到窗口的相同属性:
<custom:TaskButton ImagePath="calendar.png" Text="Calendar"
Background="{Binding Path=Background,
RelativeSource={RelativeSource FindAncestor,
AncestorType={x:Type Window}}}" />
我们再次使用 RelativeSource
对象来绑定 Background
属性。但这次,我们使用 RelativeSourceMode
,它沿着 WPF 元素树向上搜索以查找所设置控件的特定祖先。在这种情况下,它是一个 Window
对象——托管任务按钮的窗口。
请注意,所有颜色属性值都是背景颜色值的派生值。因此,当我们在项目中实例化 TaskButton
时,我们只需要设置它的 Background
属性,所有状态效果都会自动生成。
编译解决方案并运行。当您将鼠标移到任务按钮上时,它应该以通常的方式亮起。如果您更改 Window1
的背景颜色,任务按钮也应该更改为相同的颜色。
完成控制
控件模板中还有其他颜色属性需要重构为属性绑定。我们在此不详细介绍这些内容,因为它们的完成方式与 BorderGrid
相同。您可以检查所附解决方案中项目中的 Generic.xaml 以查看控件模板的 XAML。
完成控件后,向 Window1
添加更多任务按钮。当您选择其中一个时,您会看到其他任何选定的按钮都会取消选择,就像 Outlook 2010 中的任务按钮一样。这种安排的优点是,每个任务按钮都可以通过一行标记来实现。
结论
至此,本项目就告一段落了。创建一个模板按钮可能看起来工作量很大,但看看它取得了什么成就:该按钮在项目中易于实现,具有一致的外观和感觉,并且具有相当复杂的效果。此外,弄清楚如何创建按钮教会了我许多 WPF 技能,我知道这些技能我已经逃避了太长时间。我希望您觉得这次旅程和我一样值得——总而言之,在与家人和朋友享受假期时光的同时,这是一种非常有价值的保持一定生产力的方式。
一如既往,欢迎评论和更正。您的投票总是受到赞赏!
历史
- 2010-01-06
- 将
ImagePath
属性(String
类型)更改为Image
属性(ImageSource
类型) - 2010-01-26
- 更正了依赖属性代码片段中的拼写错误
- 更新源代码以纠正
TaskButton
类中的错误 - 2010-10-09
- 更正了文章随附源代码中的错误
- 在文章开头添加了一段解释更新的内容