WPF支持样式、内容控件和模板,因此不再强调自定义控件。这些特性为每位开发人员提供了多种方式来完善和扩展标准的控件,而不用派生新的控件类。下面是几种可能的选择:

  • 样式。可使用样式方便地重用控件属性的组合。甚至可使用触发器应用效果
  • 内容控件。所有继承自ContentControl类的控件都支持嵌套的内容。使用内容控件,可以快速创建聚集其他元素的复合控件
  • 控件模板。所有WPF控件都是无外观的。这意味着它们具有硬编码的功能,但它们的外观是通过控件模板单独定义的。使用其他新的控件模板代替默认模板,可重新构建基本控件
  • 数据模板。所有派生自ItemsControl的类都支持数据模板,通过数据模板可创建某些数据对象类型的富列表示。通过恰当的数据模板,可使用许多元素的组合显示每个项,这些组合元素可以是文本、图像甚至可以是可编辑控件(都在所需的布局容器中)

  如果可能的话,在决定创建自定义控件或其他了;诶新的自定义元素之前,可继续使用这些方法。这是因为这些解决方案更简单,更容易实现,并且通常更容易重用

那么,何时应创建自定义元素呢?

  当希望微调元素的外观时,自定义元素并非最佳选择,但当希望改变底层的功能时,自定义元素就十分有用了。例如,WPF为TextBox控件和PasswordBox 控件使用不同的类是有原因的。它们使用不同的方法处理按键,以不同方式在内部保存它们的数据,以不同的方式与其他组件(如剪贴板)进行交互,等等。同样,如果希望设计一个具有不同属性、方法和事件集合的控件,就需要自己构建该控件。

理解WPF中的自定义元素

  尽管可在任意WPF项目中编写自定义元素,但通常希望在专门的类库程序集(DLL)中放置自定义元素。这样,可在多个WPF应用程序之间共享自定义元素。

  为确保具有正确的程序集引用和名称空间导入,当在Visual Studio中创建应用程序时,应当选择Custom Control Library(WPF)项目类型。在类库中,可创建任意数量的控件。

  通常自定义控件的第一步是选择争取的基类进行继承,下图列出了创建自定义控件时一些常用的基类:

用于创建自定义元素的基类
名称 说明
FrameworkElement 当创建自定义元素时,这是常用的最低级的基类。通常,只希望重写OnRender()方法并使用System.Windows.Media.DrawingContext成头绘制内容,才会使用这种方法。FrameworkElement类为那些不打算与用户进行交互的元素提供了一些基本的属性和事件
Control **当从头开始创建控件时,这是最常用的起点。该类是所有用户交互小组讲的基类。**Control类添加了用于设置背景、前景、字体和内容对其方式的属性。控件类还为自身设置了Tab顺序(通过IsTabStop属性),并且引入了鼠标双击功能(通过MouseDoubleClick和PreviewMouseDoubleClick事件)。但最重要的是,Control类定义了Teamplate属性,为了得到武义县的灵活性,该属性允许使用自定义元素树替换其外观
ContentControl 这是能够显示单一内容的控件的基类。显示的内容可以是元素或结合使用模板的自定义对象(内容通过Content属性设置,并且可以通过ContentTemplate属性提供可选的模板)。许多控件都封装了特定的、类型在一定范围内的内容(例如,文本框中的文本字符串)。因为这些控件不支持所有元素,所以它们不是内容控件
UserControl 这是可使用设计视图进行配置的内容控件。尽管用户控件和普通的内容控件是不同的,但当希望在多个窗口中快速重用用户界面中的不变模块时(而不是创建真正的能在不同应用程序之间转移的独立控件),通常使用该基类
ItemsControl或Selector ItemsControl是封装项列表的控件的基类,当不支持选择,而Selector类是支持选择的控件的更具体的基类。创建自定义控件不经常使用这些类,因为ListBox、ListView以及TreeView控件的数据绑定特性提供了很大的灵活性
Panel 该类是具有布局逻辑控件的基类。布局控件能够包含多个子元素,并根据特定的布局语义安排这些子元素。通常,面板提供了用于设置子元素的附加属性,配置如何安排子元素
Decorator 封装其他元素的元素的基类,并且提供了一种图形效果或特定的功能。两个明显的例子是Border和Viewbox,其中Border控件在元素的周围绘制线条、Viewbox控件使用变换动态缩放其内容。其他装饰元素包括为普通控件提供熟悉边框和背景色的装饰类
特殊控件类 如果希望改进现有控件,可直接继承该控件,例如,可创建具有内置验证逻辑的TextBox控件。然而,在采取这一步之前,应当首先分析是否可通过事件处理代码或单独的组件达到同一目的。这两种方法都可使自定义逻辑和控件相分离,从而在其他控件中重用
元素和控件基类

  尽管可创建会控件的自定义元素,当在WPF中创建的大部分自定义元素都是控件——也就是说,它们能够接收焦点,并能与用户的按键操作和鼠标操作进行交互。所以在WPF开发领域,术语“自定义元素”和“自定义控件”有时互换使用。

构建基本的用户控件

接下来的示例中,首先创建一个基本的颜色拾取器

  可为颜色拾取器创建自定义对话框。但如果希望创建能集成进不同窗口的颜色拾取器,使用自定义控件是更好的选择。最简单的自定义控件类型是用户控件,当设计窗口或页面时通过用户控件可以使用相同的方式组装多个元素。因为仅通过直接组合现有控件并添加功能并不能实现颜色拾取器,所以用户控件看起来是更合理的选择。

定义依赖项属性

  创建颜色拾取器的第一步是为自定义控件库添加用户控件。当添加用户控件后,Visual Studio会创建XAML标记我文件和响应的包含初始化代码与事件处理代码的自定义类。这与创建新的窗口或页面是相同的——唯一的区别在于顶级容器是UserControl类:

1
2
3
4
5
6
7
public partial class ColorPicker : System.Windows.Controls.UserControl
{
public ColorPicker()
{
InitializeComponent();
}
}

  最简单的起点是设计用户控件对外界公开的公共接口。换句话说,就是设计控件使用者(使用控件的应用程序)使用的与颜色拾取器进行交互的属性、方法和事件。
  最基本的细节是 Color属性——毕竟,颜色拾取器不过是用于显示和选择颜色值的特定工为支持 WPF 特性,如数据绑定、样式以及动画,控件的可写属性几乎都是依赖项属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public partial class ColorPicker : UserControl
{
public ColorPicker()
{
InitializeComponent();
}

public Color Color
{
get { return (Color)GetValue(ColorProperty); }
set { SetValue(ColorProperty, value); }
}

public static readonly DependencyProperty ColorProperty =
DependencyProperty.Register("Color", typeof(Color), typeof(ColorPicker), new PropertyMetadata(Colors.Black, OnColorChanged));

private static void OnColorChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
Color newColor = (Color)e.NewValue;

ColorPicker colorPicker = (ColorPicker)d;
colorPicker.Red = newColor.R;
colorPicker.Green = newColor.G;
colorPicker.Blue = newColor.B;
}


public byte Red
{
get { return (byte)GetValue(RedProperty); }
set { SetValue(RedProperty, value); }
}

public static readonly DependencyProperty RedProperty =
DependencyProperty.Register("Red", typeof(byte), typeof(ColorPicker), new PropertyMetadata(OnColorRGBChanged));

public byte Green
{
get { return (byte)GetValue(GreenProperty); }
set { SetValue(GreenProperty, value); }
}

public static readonly DependencyProperty GreenProperty =
DependencyProperty.Register("Green", typeof(byte), typeof(ColorPicker), new PropertyMetadata(OnColorRGBChanged));


public byte Blue
{
get { return (byte)GetValue(BlueProperty); }
set { SetValue(BlueProperty, value); }
}

public static readonly DependencyProperty BlueProperty =
DependencyProperty.Register("Blue", typeof(byte), typeof(ColorPicker), new PropertyMetadata(OnColorRGBChanged));

private static void OnColorRGBChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
ColorPicker colorPicker = (ColorPicker)d;
Color color = colorPicker.Color;

if (e.Property == RedProperty)
color.R = (byte)e.NewValue;
else if (e.Property == GreenProperty)
color.G = (byte)e.NewValue;
else if (e.Property == BlueProperty)
color.B = (byte)e.NewValue;

colorPicker.Color = color;
}
}

  尽管很明显,但当各个属性试图改变其他属性时,**上面的代码不会引起一系列无休止的调用。因为 WPF 不允许重新进入属性变化回调函数。**例如,如果改变Color 属性,就会触发OnColorChanged( )方法。0nColorChanged( )方法会修改 Red、Green 以及 Blue 属性,从而触发OnColorRGBChanged( )回调方法三次(每个属性触发一次)。然而,OnColorRGBChanged( )方法不会再次触发 OnColorChanged( )方法。

定义路由事件

  您可能还希望添加路由事件,当发生一些事情时用于通知控件使用者。在颜色拾取器示例中,当颜色发生变化后,触发一个事件是很有用处的。尽管可将这个事件定义为普通的.NET事件,但使用路由事件可提供事件冒泡和隧道特性,从而可在更高层次的父元素(如包含窗口)中处理事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private static void OnColorChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
Color newColor = (Color)e.NewValue;
Color oldColor = (Color)e.OldValue;

ColorPicker colorPicker = (ColorPicker)d;
colorPicker.Red = newColor.R;
colorPicker.Green = newColor.G;
colorPicker.Blue = newColor.B;

// +++
RoutedPropertyChangedEventArgs<Color> args = new RoutedPropertyChangedEventArgs<Color>(oldColor, newColor);
args.RoutedEvent = ColorPicker.ColorChangedEvent;

colorPicker.RaiseEvent(args);
}

// 定义事件
public static readonly RoutedEvent ColorChangedEvent =
EventManager.RegisterRoutedEvent("ColorChanged", RoutingStrategy.Bubble, typeof(RoutedPropertyChangedEventHandler<Color>), typeof(ColorPicker));

// 定义事件后,需要创建标准的>NET事件封装器来公开事件。事件封装器可用于关联和删除事件监听程序
public event RoutedPropertyChangedEventHandler<Color> ColorChanged
{
add { AddHandler(ColorChangedEvent, value); }
remove
{
RemoveHandler(ColorChangedEvent, value);
}
}

  不一定要为事件签名创建新的委托,有时可重用已经存在的委托。两个有用的委托是ReoutedEventHandler(用于不带有额外信息的路由事件)RoutedPropertyChangedEventHandler(用于提供属性发生变化之后的旧值和新值的路由事件)。上例中使用的RoutedPropertyChangedEventHandler委托,是被类型参数化了的泛型委托。所以,可为任何属性数据类型使用该委托,而不会牺牲类型安全功能。

添加标记

  现在已经定义好用户控件的公有结构,需要做的所有工作就是创建控件外观的标记。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<UserControl
x:Class="CustomControlLibrary.ColorPicker"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:CustomControlLibrary"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
x:Name="colorPicker"
d:DesignWidth="200"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="auto" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="auto" />
</Grid.ColumnDefinitions>
<Slider
x:Name="sliderRed"
Maximum="255"
Minimum="0"
Value="{Binding ElementName=colorPicker, Path=Red}" />
<Slider
x:Name="sliderGreen"
Grid.Row="1"
Maximum="255"
Minimum="0"
Value="{Binding ElementName=colorPicker, Path=Green}" />
<Slider
x:Name="sliderBlue"
Grid.Row="2"
Maximum="255"
Minimum="0"
Value="{Binding ElementName=colorPicker, Path=Blue}" />

<Rectangle
Grid.RowSpan="3"
Grid.Column="1"
Width="50"
Stroke="Black"
StrokeThickness="1">
<Rectangle.Fill>
<SolidColorBrush Color="{Binding ElementName=colorPicker, Path=Color}" />
</Rectangle.Fill>
</Rectangle>
</Grid>
</UserControl>

  用于用户控件的标记和无外观控件的控件模板扮演相同的角色。如果希望使标记中的一细节是可配置的,可使用将它们连接到控件属性的绑定表达式。例如,目前Rectangle 元素的宽度被固定为 50个单位。然而,可使用数据绑定表达式从用户控件的依赖项属性中提取数值来代替这些细节。这样,控件使用者可通过修改属性来选择不同的宽度。同样,可使画颜色和宽度也是可变的。**然而,如果希望使控件具有真正的灵活性,最好创建无外观的控件,并在模板中定义标记,**如稍后所述。

推荐在UserControl中采用RelativeSource绑定依赖项属性

  在此演示的示例中,为顶级的 UserControl 控件指定了名称(colorPicker),从而可以直接编写绑定到自定义用户控件类中属性的数据绑定表达式。然而,这种技术导致了一个明显的问题。当在窗口(或页面)中创建用户控件的实例并为之指定新名称时,会发生什么情况呢?
  幸运的是,这种情况可以工作,不会出现问题,因为用户控件在包含它的窗口之前执行初始化。首先初始化用户控件,并连接它的数据绑定。接下来初始化窗口,并且在窗口标记中设置的名称被应用到用户控件。窗口中的数据绑定表达式和事件处理程序现在可使用在窗口中定义的名称访问用户控件,而且所有工作都如您所期望的那样进行。
  尽管这听来简单,但如果使用代码检查 UserControl.Name 属性,可能会注意到一些奇怪的问题。例如,如果在用户控件的某个事件处理程序中检查Name 属性,将看到在窗口中定义的新名称。类似地,如果没有在窗口标记中设置名称,用户控件会继续保留来自用户控件标记的名称。如果在窗口代码中检查Name属性,就会看到这个名称。
  虽然这些奇怪的事情并不表示存在问题,但更好的方法是避免在用户控件的标记中命名用户控件,并使用 Bimding.RelativeSource 属性查找元素树,直到找到 UserControl 父元素。下面是完成该工作的更长一些的语法:

1
2
3
4
5
<Slider
x:Name="sliderRed"
Maximum="255"
Minimum="0"
Value="{Binding Path=Red, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type UserControl}}}" />

使用控件

  

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<Window
x:Class="WpfApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:lib="clr-namespace:CustomControlLibrary;assembly=CustomControlLibrary"
xmlns:local="clr-namespace:WpfApp"
xmlns:r="clr-namespace:ResourceLibrary;assembly=ResourceLibrary"
Title="MainWindow"
Width="375"
Height="240">
<Grid>
<lib:ColorPicker ColorChanged="ColorPicker_ColorChanged" Color="Beige" />

<TextBlock
x:Name="lblColor"
HorizontalAlignment="Center"
VerticalAlignment="Bottom" />

</Grid>
</Window>
1
2
3
4
5
6
7
8
9
10
11
12
13
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}

private void ColorPicker_ColorChanged(object sender, RoutedPropertyChangedEventArgs<System.Windows.Media.Color> e)
{
if (lblColor != null)
lblColor.Text = "The new color is " + e.NewValue.ToString();
}
}

命令支持

  许多控件具有命令支持。可使用以下两种方法为自定义控件添加命令支持:

  • 添加将控件链接到特定命令的命令绑定。通过这种方式,控件可以响应命令,而且不需要借助于任何外部代码
  • 为命令创建新的ReoutedUICommand对象,作为自定义控件的静态字段。然后为这个命令对象添加命令绑定。这种方法可使自定义控件自动支持没有在基本命令类集合中定义的命令

  在颜色拾取器中为了支持Undo功能,需要使用成员字段跟踪以前选择的颜色:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public ColorPicker()
{
InitializeComponent();

SetUpCommands();
}

private void SetUpCommands()
{
// Set up command bindings.
CommandBinding binding = new CommandBinding(ApplicationCommands.Undo, UndoCommand_Executed, UndoCommand_CanExecute); ;
this.CommandBindings.Add(binding);
}

private void UndoCommand_Executed(object sender, ExecutedRoutedEventArgs e)
{
if (_previousColor != null)
this.Color = (Color)_previousColor;
}

private void UndoCommand_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute = _previousColor.HasValue;
}

更可靠的命令

  前面描述的技术是将命令链接到控件的相当合理的方法,但这不是在WPF元素和专业控件中使用的技术。这些元素使用更可靠的方法,并使用CommandManager.RegisterClassCommandBinding()方法关联静态的命令处理程序。

  上一个示例中样式的实现存在问题:**使用CommandBindings集合。这使得命令比较脆弱,因为客户可自由地修改CommandBindings集合。而使用RegisterClassCommandBinding()方法无法做到这一点。**WPF控件使用的就是这种方法。

  这种技术非常简单。不在实例构造函数中创建命令绑定,而必须在静态构造函数中创建命令绑定,使用如下所示的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static ColorPicker()
{
CommandManager.RegisterClassCommandBinding(
typeof(ColorPicker),
new CommandBinding(
ApplicationCommands.Undo,
UndoCommand_Executed,
UndoCommand_CanExecute
)
);
}

private static void UndoCommand_Executed(object sender, ExecutedRoutedEventArgs e)
{
ColorPicker colorPicker = (ColorPicker)sender;
colorPicker.Color = (Color)colorPicker._previousColor;
}

private static void UndoCommand_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
ColorPicker colorPicker = (ColorPicker)sender;
e.CanExecute = colorPicker._previousColor.HasValue;
}

  此外,这种技术不局限于命令。如果希望将事件处理逻辑硬编码到自定义控件,可通过EventManager.RegisterClassHandler()方法使用类事件处理程序。类事件处理程序总在实例事件处理程序之前调用,从而允许开发人员很容易地抑制事件。

深入分析用户控件

  用户控件提供了一种非常简单,但是有一定限制的创建自定义控件的方法。为理解其中的原因,深入分析用户控件的工作原理是很有帮助的。
  在后台,UserControl类的工作方式和其分类ContentControl非常类似。实际上,只有几个重要的区别:

  • UserControl 类改变了一些默认值。即该类将 IsTabStop 和 Focusable 属性设置为 false(从而在Tab 顺序中没有占据某个单独的位置),并将HorizontalAlignment和 VerticalAlignment 属性设置为 Stretch(而非 Left 或 Top),从而可以填充可用空间。
  • UserControl 类应用了一个新的控件模板,该模板由包含 ContentPresenter 元素的 Border元素组成。ContentPresenter元素包含了用标记添加的内容。
  • UserControl 类改变了路由事件的源。当事件从用户控件内的控件向用户控件外的元素冒泡或隧道路由时,事件源变为指向用户控件而不是原始元素。这提供了更好的封装性(例如,如果在包含颜色拾取器的布局容器中处理 UIElement,MouseLeftButtonDown 事件,当单击内部的 Rectangle 元素时将接收到事件。然而,这个事件的源不是 Rectangle 元素,而是包含 Rectangle 元素的 ColorPicker 对象。如果作为普通的内容控件创建相同的颜色拾取器,情况就不同了–您需要在控件中拦截事件、处理事件并且重新引发事件)。

  用户控件与其他类型的自定义控件之间最重要的区别是设计用户控件的方法。与所有控件样,用户控件有控件模板。然而,很少改变控件模板——反而,将作为自定义用户控件类的一部分提供标记,并且当创建了控件后,会使用InitializeComponent()方法处理这个标记。另一方面,无外观控件是没有标记的——需要的所有内容都在模板中。

简单的UserControl模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<ControlTemplate TargetType="{x:Type UserControl}">
<Border
Padding="{TemplateBinding Control.Padding}"
Background="{TemplateBinding Panel.Background}"
BorderBrush="{TemplateBinding Border.BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
SnapsToDevicePixels="True">
<ContentPresenter
HorizontalAlignment="{TemplateBinding Control.HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding Control.VerticalContentAlignment}"
Content="{TemplateBinding ContentControl.Content}"
ContentTemplate="{TemplateBinding ContentControl.ContentTemplate}"
SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}" />
</Border>
</ControlTemplate>

  从技术角度看,可改变用户控件的模板。实际上,只需要进行很少的调整,就可以将所有标记移到模板中。但确实没有理由采取该方法——如果希望得到更灵活的控件,使可视化外观和由自定义控件类定义的接口分开,创建无外观的自定义控件可能会更好一些。

创建无外观控件

  **用户控件的目标是提供增补控件模板的设计表面,提供一种定义控件的快速方法,代价是失去了将来的灵活性。**如果喜欢用户控件的功能,但需要修改其可视化外观,使用这种方法就有问题了。例如,设想希望使用相同的颜色拾取器,但希望使用不同的“皮肤”,将其更好地融合到已有的应用程序窗口中。可以通过样式来改变用户控件的某些方面,但该控件的一些部分是在内部锁定的,并硬编码到标记中。例如,无法将预览矩形移动到滑动条的左边。

  **解决方法是创建无外观控件——继承自控件基类,但没有设计表面的控件。**相反,这个控件将其标记放到默认模板中,可替换默认模板而不会影响控件逻辑。

修改颜色拾取器的代码

  将颜色拾取器改成无外观的,只需要修改类的声明,如下所示:

1
2
public partial class ColorPicker : System.Windows.Controls.Control
{}

  在这个示例中,ColorPicker类继承自Control类。继承自FrameworkElement 类是不合适的因为颜色拾取器允许与用户进行交互,而且其他高级的类不能准确地描述颜色拾取器的行为例如,颜色拾取器不允许在内部嵌套其他内容,所以继承自ContentControl类也是不合适的。

  ColorPicker类中的代码与用于用户控件的代码是相同的(除了必须删除构造函数中的ImitializeComponent()方法调用)。可使用相同的方法定义依赖项属性和路由事件。唯一的区别是需要通知 WPF,将为控件类提供新样式。该样式将提供新的控件模板(如果不执行该步骤,将继续使用在基类中定义的模板)。

为通知WPF正在提供新的样式,需要在自定义控件类的静态构造函数中调用DefaultStyleKeyProperty.OverrideMetadata(),该属性是为自定义控件定义默认央视的依赖项属性。需要的代码如下所示:

1
2
3
4
DefaultStyleKeyProperty.OverrideMetadata(
typeof(ColorPicker),
new FrameworkPropertyMetadata(typeof(ColorPicker))
);

  如果希望使用其他控件类的模板,可提供不同的类型,当几乎总是为每隔自定义控件创建特定的样式。

修改颜色拾取器的标记

  添加对OverrideMetadata()方法的调用后,只需要插入正确的样式。需要将样式房子啊名为Generic.xaml的资源字典中,该资源字典必须放在项目文件夹的Themes子文件夹中。这样,该样式就会被识别为自定义控件的默认样式。下面列出添加Generic.xaml文件的具体步骤:

  1. Solution Explorer中右击类库项目,并选择Add|New Folder菜单项
  2. 将新文件夹命名为Themes
  3. 右击Themes文件夹,并选择Add|New Item菜单项
  4. Add New Item对话框中选择Resource Dictionary File,输入名称Generic.xaml,并单击Add按钮

主题专用的样式和Generic.xaml文件

  您已经看到,ColorPicker从generic.xaml 文件获取默认的控件模板,generic.xaml 文件位于Themes 项目文件夹中。这个稍有些怪异的约定实际上是旧式WPF 功能的一部分:Windows 主题支持
  Windows 主题支持的初衷是使开发人员创建控件的自定义版本来匹配不同的 Windows 主题。Wimndows XP 计算机使用主题来控制 Windows 应用程序的总体颜色方案,Windows 主题支持在此类计算机上最有意义。Windows Vista 引入了 Aero 主题,该主题有效地取代了旧的主题选项,后续 Widows 版本尚未改变这种事态,因此人们现在普遍忽略了原本就不怎么常用的WPF 中的 Windows主题功能。
  不过,当今的 WPF 应用程序开发人员总是使用 generic.xam 文件来设置默认的控件样式generic.xaml 文件的名称(及其所在的 Themes 文件夹)被延用下来。

  通常,自定义控件库会包含几个控件。为了保持它们的样式相互独立以便编辑,Generic.xaml文件通常使用资源字典合并功能。下面的标记显示了Generic.xaml文件,该文件成ColorPicker.xaml资源字典中提取资源,该资源字典位于CustomControls控件库的Themes子文件夹中:

1
2
3
4
5
6
7
8
9
<ResourceDictionary 
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/CustomControlLibrary;component/Themes/ColorPicker.xaml" />
</ResourceDictionary.MergedDictionaries>

</ResourceDictionary>

  自定义的控件样式必须使用TargetType特性将自身自动关联到颜色拾取器。下面是ColorPicker.xaml文件中标记的基本结构:

1
2
3
4
5
6
7
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:CustomControlLibrary">
<Style TargetType="{x:Type local:ColorPicker}">
...
</Style>
</ResourceDictionary>

  可使用样式设置控件类中的任意属性(无论是继承自基类的属性还是新增属性)。但在此,样式最有用的任务是应用新模板,新模板定义了控件的默认可视化外观。
  很容易就能将普通标记(如颜色拾取器使用的标记)转换到控件模板中。但要注意以下几点:

  • 当创建链接到父控件类属性的绑定表达式时,不能使用ElementName属性。而需要使用RelativeSource属性指示希望绑定到父控件。如果单向绑定完全满足需要,通常可以使用轻量级的TeamplateBinding标记表达式,而不需要使用功能完备的数据绑定。
  • 不能在控件模板中关联事件处理程序。相反,需要为元素提供能够识别的名称,并在控件构造函数中通过代码为它们关联事件处理程序。
  • 除非希望关联事件处理程序或通过代码与它们进行交互,否则不要在控件模板中命名元素。当命名希望使用的元素时,使用“PART_元素名”的形式进行命名

遵循上面几点,可为颜色拾取器创建以下模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:CustomControlLibrary">
<Style TargetType="{x:Type local:ColorPicker}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:ColorPicker}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="auto" />
<RowDefinition Height="auto" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="auto" />
</Grid.ColumnDefinitions>
<Slider
Maximum="255"
Minimum="0"
Value="{Binding Red, RelativeSource={RelativeSource TemplatedParent}}" />
<Slider
Grid.Row="1"
Maximum="255"
Minimum="0"
Value="{Binding Green, RelativeSource={RelativeSource TemplatedParent}}" />
<Slider
Grid.Row="2"
Maximum="255"
Minimum="0"
Value="{Binding Blue, RelativeSource={RelativeSource TemplatedParent}}" />

<Rectangle
Grid.RowSpan="3"
Grid.Column="1"
Width="50"
Stroke="Black"
StrokeThickness="1">
<Rectangle.Fill>
<SolidColorBrush Color="{Binding Color, RelativeSource={RelativeSource TemplatedParent}}" />
</Rectangle.Fill>
</Rectangle>

</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

  用 TemplateBinding 扩展替换一些绑定表达式。其他一些绑定表达式仍使用 Binding 扩展,但将 RelativeSource 设置为指向模板的父元素(自定义控件)。**尽管TemplateBinding 和将 RelativeSource 属性设置为 TemplatedParent 值的 Binding 的作用相同从自定义控件的属性中提取数据——但是使用量级更轻的 TemplateBinding 总是合适的。**如果需要双向绑定(与滑动条一样)或绑定到继承自Freezable的类(如 SolidColorBrush 类)的属性TemplateBinding 就不能工作了。

精简控件模板

  现在,所有希望提供自定义模板的控件使用者都必须添加大量绑定表达式,以确保控制能够继续工作。这并不难,但很繁琐。另一种选择是,在控件自身的初始化代码中配置所有绑定表达式。这样,模板就不需要指定这些细节了。

  当为构成自定义控件的元素关联事件处理程序时使用的是相同的技术。通过代码关联事件处理程序,而不是在模板中使用事件特性

添加部件名称

  为了让这一系统能够工作,代码要能找到所需的元素。WPF控件通过名称定位它们需要的元素。所以,元素的名称就成自定义控件公有结构的一部分,而且需要恰当的描述性名称。根据约定,这些名称以PART_开头,后跟元素名称。元素的名称的首字母要大写,就像属性名称。对于需要的元素名称,PART_RedSlider是适合的选择,而PART_sldRed、PART_redSlider以及RedSlider等名称都不合适。

  下面的标记样式了如何通过删除三个滑动条的Value属性的绑定表达式,并为三个滑动条添加*PART_名称*,从而为通过代码设置绑定做好准备:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<Slider
x:Name="PART_RedSlider"
Maximum="255"
Minimum="0" />
<Slider
x:Name="PART_GreenSlider"
Grid.Row="1"
Maximum="255"
Minimum="0" />
<Slider
x:Name="PART_BlueSlider"
Grid.Row="2"
Maximum="255"
Minimum="0" />

<Rectangle
Grid.RowSpan="3"
Grid.Column="1"
Width="50"
Stroke="Black"
StrokeThickness="1">
<Rectangle.Fill>
<SolidColorBrush x:Name="PART_PreviewBrush" />
</Rectangle.Fill>
</Rectangle>

操作模板部件

  在初始化控件后,可连接绑定表达式,但有一种更好的方法。WPF有一个专用的OnApplyTemplate()方法,如果需要在模板中查找元素并关联事件处理程序或添加数据绑定表达式,应重写该方法。在该方法中,可以使用GetTemplateChild()方法(该方法继承自FrameworkElement)查找所需的元素。

  如果没有找到希望处理的元素,推荐的模式就不起作用。也可添加代码来检查该元素,如果元素存在,再检查类型是否正确;如果类型不正确,就引发异常(此处的想法是,不存在的元素代表有意丢失某个特定功能,但元素类型不正确代表错误)。

  下面的代码演示了如何在OnApplyTemplate()方法中连接其中一个滑动条的数据绑定表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public override void OnApplyTemplate()
{
base.OnApplyTemplate();

RangeBase redSlider = GetTemplateChild("PART_RedSlider") as RangeBase;
if (redSlider != null)
{
// Bind to the Red property in the control, using a two-way binding.
Binding binding = new Binding(nameof(Red));
binding.Source = this;
binding.Mode = BindingMode.TwoWay;
redSlider.SetBinding(RangeBase.ValueProperty, binding);
}

RangeBase greenSlider = GetTemplateChild("PART_GreenSlider") as RangeBase;
if (greenSlider != null)
{
// Bind to the Red property in the control, using a two-way binding.
Binding binding = new Binding(nameof(Green));
binding.Source = this;
binding.Mode = BindingMode.TwoWay;
greenSlider.SetBinding(RangeBase.ValueProperty, binding);
}

RangeBase blueSlider = GetTemplateChild("PART_BlueSlider") as RangeBase;
if (blueSlider != null)
{
// Bind to the Red property in the control, using a two-way binding.
Binding binding = new Binding(nameof(Blue));
binding.Source = this;
binding.Mode = BindingMode.TwoWay;
blueSlider.SetBinding(RangeBase.ValueProperty, binding);
}

SolidColorBrush brush = GetTemplateChild("PART_PreviewBrush") as SolidColorBrush;
if (brush != null)
{
Binding binding = new Binding(nameof(Color));
binding.Source = brush;
binding.Mode = BindingMode.OneWayToSource;
this.SetBinding(ColorPicker.ColorProperty, binding);
}

}

  注意,上面代码使用的是 System,Windows.Controls,Primitives.RangeBase 类(Slider 类继承自该类)而不是 Slider 类。因为 RangeBase 类提供了需要的最小功能——在本例中是指 Value 属性通过尽可能提高代码的通用性,控件使用者可获得更大自由。例如,现在可提供自定义模板,使用不同的派生自 RangeBase 类的控件代替颜色滑动条。

为查看这种设计变化的优点,需要创建一个使用颜色拾取器的控件,并提供一个新的控件模板

具有两个不同模板的颜色拾取器自定义控件

记录模板部件

  良好的设计指导原则建议为控件声明添加TemplatePart特性,以记录在控件模板中使用了那些部件名称,以及为每个部件使用了什么类型的控件。成技术角度该,这一步不是必需的,但该文档可为其他使用自定义类的用户提供帮助(还可通过允许构建自定义控件模板的设计工具(如Expression Blend)来进行检查)。

  下面是应当为ColorPicker控件类添加的TemplatePart特性:

1
2
3
4
5
6
7
8
[TemplatePart(Name = "PART_RedSlider", Type = typeof(RangeBase))]
[TemplatePart(Name = "PART_GreenSlider", Type = typeof(RangeBase))]
[TemplatePart(Name = "PART_BlueSlider", Type = typeof(RangeBase))]
[TemplatePart(Name = "PART_PreviewBrush", Type = typeof(SolidColorBrush))]
public partial class ColorPicker : System.Windows.Controls.Control
{
...
}

  每个控件都有默认样式。可调用控件类静态构造函数的DefaultStyleProperty.OverrideMetadata()方法来指示自定义控件应使用的默认样式。否则,自定义控件将简单地使用为基类控件定义的默认样式

1
Style style = Application.Current.FindResource(typeof(Button));

支持可视化状态

  ColorPicker控件是控件设计的极佳示例。因为其行为风格实话外观是精心分离的,所以其他设计人员可开发动态改变其外观的新模板。
  ColorPicker 控件如此简单的一个原因是它不涉及状态。换句话说,它不根据是否具有焦点、鼠标是否在它上面悬停、是否禁用等状态区分其可视化外观。接下来的FlipPanel 自定义控件有些不同。

  FlipPanel 控件背后的基本思想是,为驻留内容提供两个表面,但每次只有一个表面是可见的。为看到其他内容,需要在两个表面之间进行“翻转”。可通过控件模板定制翻转效果,但默认效果使用在前面和后面之间进行过渡的淡化效果。根据应用程序,可以使用 FlinPanel 控件把数据条目表单与一些有帮助的文档组合起来,以便为相同的数据提供一个简单或较复杂的视图,或在一个简单游戏中将问题和答案融合在一起。

  可通过代码执行翻转(通过设置名为IsFlipped的属性),也可使用一个便捷的按钮来翻转面板(除非控件使用者从模板中移除了该按钮)
  显然,控件模板需要指定两个独立部分:FlipPanel控件的前后内容区域。然而,还有一个细节–FlipPanel控件需要一种方法在两个状态之间进行切换:翻转过的状态与未翻转过的状态。可通过为模板添加触发器来完成该工作。当单击按钮时,可使用一个触发器隐藏前面的面板并显示第二个面板,而使用另一个触发器翻转这些更改。这两个触发器都可以使用您喜欢的任何动画。但通过使用可视化状态,可向控件使用者清晰地指明这两个状态是模板的必需部分。不是为适当的属性或事件编写触发器,控件使用者只需要填充适当的状态动画。如果使用Expression Blend,该任务甚至变得更简单。

开始编写FlipPanel类

  FlipPanel的基本骨架非常简单。包含用户可用单一元素(最有可能是包含各种元素的布局容器)填充的两个内容区域。从技术角度看,这意味着FlipPanel 控件不是真正的面板,因为不能使用布局逻辑组织一组子元素。然而,这不会造成问题,因为FlipPanel 控件的结构是清晰直观的。FlipPanel控件还包含一个翻转按钮,用户可使用该按钮在两个不同的内容区域之间进行切换。

  尽管可通过继承自ContentControlPanel等控件类来创建自定义控件,但是FlipPanl直接继承自Control基类。如果不需要特定控件类的功能,这是最好的起点。不应当继承自更简单的FrameworkworkElement类,除非希望创建不实用标准控件和模板基础架构的元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class FlipPanel : System.Windows.Controls.Control
{
static FlipPanel()
{
DefaultStyleKeyProperty.OverrideMetadata(
typeof(FlipPanel),
new FrameworkPropertyMetadata(typeof(FlipPanel))
);
}

public object FrontContent
{
get { return (object)GetValue(FrontContentProperty); }
set { SetValue(FrontContentProperty, value); }
}

public static readonly DependencyProperty FrontContentProperty =
DependencyProperty.Register(
"FrontContent",
typeof(object),
typeof(FlipPanel),
new PropertyMetadata(null)
);

public object BackContent
{
get { return (object)GetValue(BackContentProperty); }
set { SetValue(BackContentProperty, value); }
}

public static readonly DependencyProperty BackContentProperty = DependencyProperty.Register(
"BackContent",
typeof(object),
typeof(FlipPanel),
new PropertyMetadata(null)
);

public bool IsFlipped
{
get { return (bool)GetValue(IsFlippedProperty); }
set { SetValue(IsFlippedProperty, value); }
}

public static readonly DependencyProperty IsFlippedProperty = DependencyProperty.Register(
"IsFlipped",
typeof(bool),
typeof(FlipPanel),
new PropertyMetadata(false)
);

public CornerRadius CornerRadius
{
get { return (CornerRadius)GetValue(CornerRadiusProperty); }
set { SetValue(CornerRadiusProperty, value); }
}

public static readonly DependencyProperty CornerRadiusProperty =
DependencyProperty.Register(
"CornerRadius",
typeof(CornerRadius),
typeof(FlipPanel),
new PropertyMetadata(default)
);
}

选择部件和状态

  现在已经具备了基本结构,并且已经准备好确定将在控件模板中国使用的部件和状态了。显然,FlipPanel需要两个状态:

  • 正常状态。该故事板确保只有前面的内容是可见的,后面的内容被翻转、淡化或移除视图。
  • 翻转状态。该故事板确保只有后面的内容是可见的,前面的内容通过动画被移出视图。

  此外,需要两个部件:

  • FlipButton。这是一个按钮,当单击该按钮时,将视图成前面改到后面(或成后面改到前面)。FlipPanel控件通过处理该按钮的事件提供该服务
  • FlipButtonAlternate。这是一个可选元素,与FlipButton的工作方式相同。允许控件使用者在自定义模板中使用两种不同的方法。一种选择是使用在可翻转区域外的单个翻转按钮,另一种选择是在可翻转区域的面板两侧放置独立的翻转按钮。

为表明FlipPanel使用这些部件和状态的事实,应为自定义控件类应用TemplatePart特性,如下所示:

1
2
3
4
5
6
[TemplateVisualState(Name = "Normal", GroupName = "ViewStates")]
[TemplateVisualState(Name = "Flipped", GroupName = "ViewStates")]
[TemplatePart(Name = "FlipButton", Type = typeof(ToggleButton))]
[TemplatePart(Name = "FlipButtonAlternate", Type = typeof(ToggleButton))]
public class FlipPanel : System.Windows.Controls.Control
{}

  FlipButton 和 FlipButtonAlternate 部件都受到限制——每个部件只能是 ToggleButton 控件或ToggleButton 派生类的实例(ToggleButton 是可单击的按钮,能够处于两个状态中的某个状态。对于FlipPanel控件,ToggleButton 的状态对应于普通的前向视图或翻转的后向视图)。

  为确保最好、最灵活的模板支持,尽可能使用最通用的元素类型。例如,除非需要 ContentControl提供的某些属性或行为,使用FrameworkElement比使用ContentControl更好。

默认控件模板

  现在,可将这些内容投入到默认控件模板中。根元素是具有两行的Grid 面板,该面板包含内容区域(在顶行)和翻转按钮(在底行)。用两个相互重叠的 Border 元素填充内容区域,代表前面和后面的内容,但一次只显示前面或后面内容。

  为了填充前面和后面的内容区域,FlipPanel控件使用ContentPresenter 元素。该技术几乎和自定义按钮示例相同,只是需要两个ContentPresenter 元素,分别用于 FlipPanel 控件的前面和后面。FlipPanel控件还包含独立的 Border 元素来封装每个 ContentPresenter 元素。从而让控件使用者能通过设置FlipPanel的几个直接属性勾勒出可翻转内容区域(BorderBrushBorderThickness、Background 以及 CorerRadius),而不是强制性地手动添加边框。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:CustomControlLibrary">
<Style TargetType="{x:Type local:FlipPanel}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:FlipPanel}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>

<!-- This is the front content. -->
<Border
x:Name="FrontContent"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<ContentPresenter Content="{TemplateBinding FrontContent}" />
</Border>

<!-- This is the back content. -->
<Border
x:Name="BackContent"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<ContentPresenter Content="{TemplateBinding BackContent}" />
</Border>

<!-- This the flip button. -->
<ToggleButton
x:Name="FlipButton"
Grid.Row="1"
Width="19"
Height="19"
Margin="0,10,0,0"
RenderTransformOrigin="0.5,0.5">
<ToggleButton.Template>
<ControlTemplate>
<Grid>
<Ellipse Fill="AliceBlue" Stroke="#FFA9A9A9" />
<Path
HorizontalAlignment="Center"
VerticalAlignment="Center"
Data="M1,1.5 L4.5,5 8,1.5"
Stroke="#FF666666"
StrokeThickness="2" />
</Grid>
</ControlTemplate>
</ToggleButton.Template>
<ToggleButton.RenderTransform>
<RotateTransform x:Name="FlipButtonTransform" Angle="-90" />
</ToggleButton.RenderTransform>
</ToggleButton>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="ViewStates">
<VisualStateGroup.Transitions>
<!-- 统一设置相同的过渡时间 -->
<!--<VisualTransition GeneratedDuration="0:0:0.7" />-->
<!-- 单独设置切换的过渡时间 -->
<VisualTransition GeneratedDuration="0:0:0.7" To="Flipped">
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="FlipButtonTransform"
Storyboard.TargetProperty="Angle"
To="90"
Duration="0:0:0.2" />
</Storyboard>
</VisualTransition>
<VisualTransition GeneratedDuration="0:0:0.7" To="Normal">
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="FlipButtonTransform"
Storyboard.TargetProperty="Angle"
To="-90"
Duration="0:0:0.2" />
</Storyboard>
</VisualTransition>
</VisualStateGroup.Transitions>
<VisualState x:Name="Normal">
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="BackContent"
Storyboard.TargetProperty="Opacity"
To="0"
Duration="0" />
</Storyboard>
</VisualState>
<VisualState x:Name="Flipped">
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="FlipButtonTransform"
Storyboard.TargetProperty="Angle"
To="90"
Duration="0" />
<DoubleAnimation
Storyboard.TargetName="FrontContent"
Storyboard.TargetProperty="Opacity"
To="0"
Duration="0" />
</Storyboard>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

实例中的实现注意细节

翻转动画

  使用旋转动画前,需要先声明RotateTransform;这里的RotateTransform是通过x:Name直接命名的,如果不采用命名需要使用原始的写法:

1
2
3
4
5
<DoubleAnimation
Storyboard.TargetName="FlipButton"
Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)"
To="90"
Duration="0:0:0.2" />

定义状态动画

  状态动画是控件模板中最有趣的部分。它们是提供翻转行为的要素,它们还是为FlipPanel创建自定义模板的开发人员最有可能修改的细节。为定义状态组,必须在控件模板的跟元素中添加VisualStateManager.VisualStateGroups元素,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
 <ControlTemplate TargetType="{x:Type local:FlipPanel}">
<Grid>
<!-- VisualStateManager只能附加到FrameworkElement元素中,所以需要放在Grid中,不能直接放在ControlTemplate根下 -->
<VisualStateManager.VisualStateGroups>
<!-- 使用具有合适名称的VisualStateGroup,同一组的状态放在一起 -->
<VisualStateGroup x:Name="GroupName1">
<VisualState x:Name="StateName1"></VisualState>
<VisualState x:Name="StateName2"></VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>

  为给模板添加 VisualStateManager 元素,模板必须使用布局面板。布局面板包含控件的两个可视化对象和 VisualStateManager 元素(该元素不可见)。VisualStateManager 定义具有动画的故事板,控件在合适的时机使用动画改变其外观。

  每隔状态对应一个具有一个或多个动画的故事板。如果存在这些故事板,就会在适当的时机触发它们(如果不存在,控件将按正常方式降级,而不会引发错误)

这里将可视化状态持续时间设置为0,这意味着动画立即应用其效果。这看起来可能有些怪——毕竟,不是需要更平缓的改变从而能够注意到动画效果吗?

  实际上,该设计完全正确,因为可视化状态用于表示控件在适当状态时的外观。例如,当翻转面板处于翻转过的状态时,简单地显示其背面内容。翻转过程是在 FlipPanel 控件进入翻转状态前的过渡,而不是翻转状态本身的一部分(状态和过渡之间的这个区别是很重要的,因为有些控件确实具有在状态期间运行的动画。)

定义状态过渡

  过渡是成当前状态到新状态的动画。变换模型的优点之一是不需要为动画创建故事板。例如,如果添加如下标记,WPF会创建持续时间为0.7秒的动画以改变FlipPanel控件的透明度,从而创建希望的悦目的褪色效果:

1
2
3
4
5
6
7
8
9
10
11
<VisualStateGroup x:Name="ViewStates">
<VisualStateGroup.Transitions>
<VisualTransition GeneratedDuration="0:0:0.7"></VisualTransition>
</VisualStateGroup.Transitions>
<VisualState x:Name="Normal">
...
</VisualState>
<VisualState x:Name="Flipped">
...
</VisualState>
</VisualStateManager.VisualStateGroups>

  过渡会应用到状态组。当定义过渡时,必须将其添加到VisualStateGroup.Transitions集合。上面的示例使用最简单的过渡类型:默认过渡。默认过渡应用于该组中的所有状态变化.

  默认过渡是很方便的,但用于所有情况的解决方案不可能总是适合的。例如,您可能希望FlipPanel控件根据其进入的状态以不同的速度过渡。为实现该效果,需要定义多个过渡,并且需要设置 To 属性以指示何时应用过渡效果。例如,如果有以下过渡:

1
2
3
4
<VisualStateGroup.Transitions>
<VisualTransition To="Flipped" GeneratedDuration="0:0:0.5"/>
<VisualTransition To="Normal" GeneratedDuration="0:0:0.1"/>
</VisualStateGroup.Transitions>

  这个示例显示了当进入特定状态时应用的过渡,但还可使用From 属性创建当离开某个状态时应用的过渡,并且可结合使用 To 和 From 属性来创建更特殊的只有当在特定的两个状态之间移动时才会应用的过渡。当应用过渡时 WPF遍历过渡集合,在所有应用的过渡中查找最特殊的过渡,并只使用最特殊的那个过渡。

  为了进一步加以控制,可创建自定义过渡动画来替换 WPF 通常使用的自动生成的过渡。您可能会由于几个原因而创建自定义过渡。下面是一些例子:使用更复杂的动画控制动画的步长,使用动画缓动、连续运行几个动画或在运行动画时播放声音

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<VisualStateGroup.Transitions>
<VisualTransition GeneratedDuration="0:0:0.7" To="Flipped">
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="FlipButtonTransform"
Storyboard.TargetProperty="Angle"
To="90"
Duration="0:0:0.2" />
</Storyboard>
</VisualTransition>
<VisualTransition GeneratedDuration="0:0:0.7" To="Normal">
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="FlipButtonTransform"
Storyboard.TargetProperty="Angle"
To="-90"
Duration="0:0:0.2" />
</Storyboard>
</VisualTransition>
</VisualStateGroup.Transitions>

  当使用自定义过渡时,仍必须设置 VisualTransition.GeneratedDuration 属性以匹配动画的持续时间。如果没有设置该细节,VisualStateManager 就不能使用自定义过渡,而且它将立即应用新状态(使用的实际时间值对于您的自定义过渡仍无效果,因为它只应用于自动生成的动画)。

  但许多控件需要自定义过渡,而且编写自定义过渡是非常乏味的工作。仍需保持零长度的状态动画,这还会不可避免地在可视化状态和过渡之间复制一些细节。

关联元素

  现在,已经得到了一个相当好的控件模板,需要在 FlipPanel 控件中添加一些内容以使该模板工作。

  诀窍是使用OnApplyTemplate()方法,该方法还用于在ColorPicker控件中设置绑定。对于FlipPanel控件,OnApplyTemplate()方法用于为FlipButtonFlipButtonAlternate部件检索ToggleButton,并为每个部件关联事件处理程序,从而当用户单击以翻转控件时能够进行响应。最后,OnApplyTemplate()方法调用名为ChangeVisualState()的自定义方法,该方法确保控件的可视化外观和当前状态匹配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public override void OnApplyTemplate()
{
base.OnApplyTemplate();

// Wire up the ToggleButton.Click event.
ToggleButton? flipButton = GetTemplateChild("FlipButton") as ToggleButton;
if (flipButton != null) flipButton.Click += FlipButton_Click;

// Allow for two flip buttons if needed (one for each side of the panel).
ToggleButton? flipButtonAlternate = GetTemplateChild("FlipButtonAlternate") as ToggleButton;
if (flipButtonAlternate != null) flipButtonAlternate.Click += FlipButton_Click;

// Make sure the visuals match the current state.
this.ChangeVisualState(false);
}

private void FlipButton_Click(object sender, RoutedEventArgs e)
{
this.IsFlipped = !this.IsFlipped;
ChangeVisualState(true);
}

private void ChangeVisualState(bool useTransitions)
{
if (!IsFlipped)
{
VisualStateManager.GoToState(this, "Normal", useTransitions);
}
else
{
VisualStateManager.GoToState(this, "Flipped", useTransitions);
}
}

  当调用 GetTemplateChild()方法时,需要给出希望获取的元素的字符串名称。为避免可能的错误,可在控件中将该字符串声明为常量。然后在TemplatePart 特性中以及调用GetTemplateChild()方法时可以使用该常量。

  幸运的是,不需要手动触发状态动画。既不需要创建也不需要触发过渡动画。相反,为从一个状态改变到另一个状态,只需要调用静态方法 VisualStateManager.GoToState()。当调用该方法时,传递正在改变状态的控件对象的引用、新状态的名称以及确定是否显示过渡的 Boolean值。 如果是由用户引发的改变(例如,当用户单击 ToggleButon 按钮时),该值应当为true;如果是由属性设置引发的改变(例如,如果使用页面的标记设置IsFlipped 属性的初始值),该值为 false。

  处理控件支持的所有不同状态可能会变得很凌乱。为避免在整个控件代码中分散调用GoToState( )方法,大多数控件添加了与在 FlipPanel 控件中添加的 ChangeVisualState()类似的方法。该方法负责应用每个状态组中的正确状态。该方法中的代码使用if语句块(或 switch 语句)应用每个状态组的当前状态。该方法之所以可行,是因为它完全可以使用当前状态的名称调用GoToState()方法。在这种情况下,如果当前状态和请求的状态相同,那么什么也不会发生。

通常在以下为止调用ChangeVisualState()方法或与其等效的方法:

  • OnApplyTemplate()方法的结尾,在初始化控件之后
  • 当响应代表状态改变的事件时,例如鼠标移动或单击ToggleButton按钮
  • 当响应属性改变或通过代码触发时(例如,IsFlipped属性设置器调用ChangeVisualState()方法并且总是提供true,所以显示过渡动画。如果希望为控件使用者提供不显示过渡的机会,可添加Flip()方法,该方法接受与为ChangeVisualState()方法传递相同的Boolean参数)。

  正如上面介绍的,FlipPanel控件非常灵活。例如,可使用该控件并且不使用ToggleButon 按钮,通过代码进行翻转(可能是当用户单击不同的控件时)。也可在控件模板中包含一两个翻转按钮并且允许用户进行控制。

使用FlipPanel控件

  现在已经完成了 FlipPanel 控件的控件模板和代码,已经准备好在应用程序中使用该控件假定已添加了必需的程序集引用,然后可将XML前缀映射到包含自定义控件的名称空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<Window
x:Class="WpfApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:lib="clr-namespace:CustomControlLibrary;assembly=CustomControlLibrary"
xmlns:local="clr-namespace:WpfApp"
xmlns:r="clr-namespace:ResourceLibrary;assembly=ResourceLibrary"
Title="MainWindow"
Width="375"
Height="260">
<Grid>
<lib:FlipPanel
x:Name="panel"
Margin="10"
BorderBrush="DarkOrange"
BorderThickness="3"
CornerRadius="4">
<lib:FlipPanel.FrontContent>
<StackPanel Margin="6">
<TextBlock
Margin="3"
FontSize="16"
Foreground="DarkOrange"
TextWrapping="Wrap">
This is the front side of the FlipPanel.
</TextBlock>
<Button
Margin="3"
Padding="3"
Content="Button One" />
<Button
Margin="3"
Padding="3"
Content="Button Two" />
<Button
Margin="3"
Padding="3"
Content="Button Three" />
<Button
Margin="3"
Padding="3"
Content="Button Four" />
</StackPanel>
</lib:FlipPanel.FrontContent>
<lib:FlipPanel.BackContent>
<Grid Margin="6">
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock
Margin="3"
FontSize="16"
Foreground="DarkMagenta"
TextWrapping="Wrap">
This is the back side of the FlipPanel.
</TextBlock>
<Button
Grid.Row="1"
Margin="3"
Padding="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Click="cmdFlip_Click"
Content="Flip Back to Front" />
</Grid>
</lib:FlipPanel.BackContent>
</lib:FlipPanel>

</Grid>
</Window>

  当单击FlipPanel背面的按钮时,通过编程翻转面板:

1
2
3
4
private void cmdFlip_Click(object sender, RoutedEventArgs e)
{
panel.IsFlipped = !panel.IsFlipped;
}

使用不同的控件模板

  已经恰当设计好的自定义控件极其灵活。对于FlipPanel控件,可提供新模板来更改ToggleButton 按钮的外观和位置,并修改当在前后内容区域之间进行切换时应用的动画效果。

  在此,翻转按钮被放置到一个特殊的栏中,该栏位于前面的底部并且位于后面的顶部。当翻转面板时,它不是像一页纸那样翻转内容。相反,它缩小前面内容的同时在后面展开后面的内容。当反向翻转面板时,后面的内容从下面开始挤向后面,前面的内容从上面展开。为实现更精彩的效果,甚至还借助于BlurEfect 类模糊正在变形的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:CustomControlLibrary">
<Style TargetType="{x:Type local:FlipPanel}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:FlipPanel}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>

<!-- This is the front content. -->
<Border
x:Name="FrontContent"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<Border.RenderTransform>
<ScaleTransform x:Name="FrontContentTransform" />
</Border.RenderTransform>
<Border.Effect>
<BlurEffect x:Name="FrontContentEffect" Radius="0" />
</Border.Effect>

<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>

<ContentPresenter Content="{TemplateBinding FrontContent}" />
<Rectangle
Grid.Row="1"
Fill="LightSteelBlue"
Stretch="Fill" />
<ToggleButton
x:Name="FlipButton"
Grid.Row="1"
Margin="5"
Padding="15,0"
HorizontalAlignment="Right"
Content="^"
FontSize="12"
FontWeight="Bold" />
</Grid>

</Border>

<!-- This is the back content. -->
<Border
x:Name="BackContent"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<Border.RenderTransform>
<ScaleTransform x:Name="BackContentTransform" />
</Border.RenderTransform>
<Border.Effect>
<BlurEffect x:Name="BackContentEffect" Radius="0" />
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>

<ContentPresenter Content="{TemplateBinding BackContent}" />
<Rectangle
Grid.Row="1"
Fill="LightSteelBlue"
Stretch="Fill" />
<ToggleButton
x:Name="FlipButtonAlternate"
Grid.Row="1"
Margin="5"
Padding="15,0"
HorizontalAlignment="Right"
Content="^"
FontSize="12"
FontWeight="Bold" />
</Grid>
</Border>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="ViewStates">
<VisualStateGroup.Transitions>
<VisualTransition GeneratedDuration="0:0:0.7" To="Flipped">
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="FlipButton"
Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)"
To="90"
Duration="0:0:0.2" />
</Storyboard>
</VisualTransition>
<VisualTransition GeneratedDuration="0:0:0.7" To="Normal">
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="FlipButton"
Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)"
To="-90"
Duration="0:0:0.2" />
</Storyboard>
</VisualTransition>
</VisualStateGroup.Transitions>
<VisualState x:Name="Normal">
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="BackContent"
Storyboard.TargetProperty="Opacity"
To="0"
Duration="0" />
</Storyboard>
</VisualState>
<VisualState x:Name="Flipped">
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="FrontContentTransform"
Storyboard.TargetProperty="ScaleY"
To="0" />
<DoubleAnimation
Storyboard.TargetName="FrontContentEffect"
Storyboard.TargetProperty="Radius"
To="30" />
<DoubleAnimation
Storyboard.TargetName="BackContentTransform"
Storyboard.TargetProperty="ScaleY"
To="1" />
<DoubleAnimation
Storyboard.TargetName="BackContentEffect"
Storyboard.TargetProperty="Radius"
To="0" />
</Storyboard>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

创建自定义面板

  创建自定义面板是一种特殊但较常见的自定义控件开发子集。面板驻留一个或多个子元素,并且实现了特定的布局逻辑以恰当地安排其子元素。如果希望构建自己的可拖动的工具栏或可停靠的窗口系统,自定义面板是很重要的元素。当创建需要非标准特定布局的组合控件时,自定义面板通常是很有用的,例如花哨的停工具栏。

  现在,对于 WPF 提供的用于组织内容的基本类型的面板(如 StackPanel、DockPanel、WrapPanel、Canvas 以及 Grid)已经很熟悉了,也已经看到了一些使用它们自己自定义面板的 WPF 元素(如TabPanel、ToolBarOverflowPanel 以及 VirtualizingPanel)。可以在线査找更多有关自定义面板的示例。下面是一些值得研究的示例:

  • 允许拖动其子元素而不需要额外事件处理代码的自定义Canvas面板
  • 针对项列表实现了鱼眼效果和螺旋效果的两个面板
  • 使用基于帧的动画成一种布局变换到其他布局的面板

接下来的几节讲将介绍创建自定义面板,并且还将分析两个简单的示例——一个基本的Canvas面板副本,以及一个增强版本的WrapPanel面板

两步布局过程

  **每个面板都使用相同的设备:负责改变子元素尺寸和安排子元素的两步布局过程。第一个阶段是测量阶段(measure pass),在这一阶段面板决定其子元素希望具有多大的尺寸。第二个阶段是排列阶段(layoutpass),在这一阶段为每个控件指定边界。**这两个步骤是必需的,因为在决定如何分割可用空间时,面板需要考虑所有子元素的期望。

  可以通过重写 MeasureOveride()ArangeOveride()方法,为这两个步骤添加自己的逻辑,这两个方法是作为 WPF布局系统的一部分在FrameworkElement类中定义的。

MeasureOverride()方法

  第一步是首先使用 MeasureOverride()方法决定每个子元素希望多大的空间。然而,**即使是在 MeasureOverride()方法中,也不能为子元素提供无限空间。至少,也应当将子元素限制在能够适应面板可用空间的范围之内。此外,可能希望更严格地限制子元素。**例如,具有按比例分配尺寸的两行的 Grid 面板,会为子元素提供可用高度的一半。StackPanel 面板会为第一个元素提供所有可用空间,然后为第二个元素提供剩余的空间,等等。

  每个 MeasureOverride()方法的实现负贵遍历子元素集合,并调用每个子元素的 Measure()方法。当调用 Measure()方法时,需要提供边界框—— 决定每个子控件最大可用空间的 Size 对象。在 MeasureOverride()方法的最后,面板返回显示所有子元素所需的空间,并返回它们所期望的尺寸。

  下面是MeasureOverride()方法的基本结构,其中没有具体的尺寸细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected override Size MeasureOverride(Size constraint)
{
// Examine all the children.
foreach(UIElement element in base.InternalChildren)
{
// Ask each child how much space it would like, given the availableSize constraint.
Size availableSize = new Size(...);
element.Measure(availableSize);
// (You can now read element.DesiredSize to get the requested size.)
}

// Indicate how much space this panel requires.
// This will be used to set the DesiredSize property of the panel.
return new Size(...);
}

  Measure()方法不返回数值。在为每个子元素调用 Measure()方法之后,子元素的 DesiredSize属性提供了请求的尺寸。可以在为后续子元素执行计算时(以及决定面板需要的总空间时)使用这一信息。

  因为许多元素直到调用了 Measured()方法之后才会渲染它们自身,所以必须为每个子元素调用 Measure()方法,即使不希望限制子元素的尺寸或使用 DesiredSize 属性也同样如此。如果希望让所有子元素能够自由获得它们所希望的全部空间,可以传递在两个方向上的值都是Double.PositiveInfinity 的 Size 对象(ScrollViewer 是使用这种策略的一个元素,原因是它可以处理任意数量的内容)。然后子元素会返回其中所有内容所需要的空间。否则,子元素通常会返回其中内容需要的空间或可用空间——返回较小者。

  在测量过程的结尾,布局容器必须返回它所期望的尺寸。在简单的面板中,可以通过组合每个子元素的期望尺寸计算面板所期望的尺寸。

  不能为面板的期望尺寸简单地返回传递给 MeasureOverride()方法的限制范围。尽管这看起来是获取所有可用空间的好方法,但如果容器传递 Size 对象,而且 Size 对象的一个方向或两个方向上的数值是 Double.PositiveInfinity(这意味着“占用需要的所有控件空间”),这时就会出现麻烦。尽管对于尺寸限制范围来说,无限的尺寸是允许的,但是对于尺寸结果,无限的尺寸是不允许的,因为 WPF 不能计算出元素应当多大。另外,实际上不应当使用超出需要的更大空间。如果这样做的话,可能会导致额外的空白空间,并且布局面板之后的元素会在窗口中进一步下移

  为每个子元素调用的 Measure()方法和定义面板布局逻辑第步的 MeasureOverride()方法极其相似。实际上,Measure()方法会触发 MeasureOverride()方法。所以,如果在一个布局容器中放置另一个布局容器,当调用 Measure()方法时,将会得到布局容器及其所有子元素所需的总尺寸。

  通过两步执行测量过程(触发 MeasureOverride()方法的 Measure()方法)的一个原因是为了处理外边距。当调用 Measure()方法时,传递总的可用空间。当WPF 调用 MeasureOverride()方法时,考虑到外边距空间,会自动减少可用空间(除非传递无限的尺寸)。

ArrangeOverride()方法

  测量完所有元素后,就可以在可用的空间中排列元素了。布局系统调用面板的ArrangeOverride()方法,而面板为每个子元素调用Arrange()方法,以告诉子元素为它分配了多大的空间(Arange()方法会触发 ArrangeOvemide()方法,这与 Measure()方法会触发 MeasureOverride()方法非常类似)。

  当使用Measure()方法测量条目时,传递能够定义可用空间边界的Size对象。当使用Arrange()方法放置条目时,传递能够定义条目尺寸和位置的 System.Windows.Rect对象。这时,就像使用 Canvas 面板风格的X和Y坐标放置每个元素一样(坐标确定布局容器左上角与元素左上角之间的距离)。

下面是ArrangeOveride()方法的基本结构,其中没有给出具体的尺寸细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected override Size ArrangeOveride(Size arrangeSize)
{
// Examine all the children.
foreach(UIElement element in base.InternalChildren)
{
// Assign the child it's bounds.
Rect bounds = new Rect(...);
element.Arrange(bounds);
// (You can now element.ActualHeight and element.ActualWidth to find out the size it used...)
}

// Indicate how much space this panel occupies.
// This will be used to set the ActualHeight and ActualWidth properties of the panel
return arrangeSize;
}

  当排列元素时,不能传递无限尺寸。然而,可以通过传递来自DesiredSize属性的值,为元素提供它所期望的数值。也可以为元素提供比所需尺寸更大的空间。实际上,经常会出现这种情况。例如,垂直的 StackPanel 面板为其子元素提供所请求的高度,但是为子元素提供面板本身的整个宽度。同样,Grid 面板使用具有固定尺寸或按比例计算尺寸的行,这些行的尺寸可能大于其内部元素所期望的尺寸。即使已经在根据内容改变尺寸的容器中放置了元素,如果使用Height 和 Width 属性明确设置了元素的尺寸,那么仍可以扩展该元素。

  当使元素比所期望的尺寸更大时,就需要使用HorizontalAlignment和 VerticalAlignment 属元素内容被放在指定边界内部的某个位置

  因为 ArrangeOverride()方法总是接收定义的尺寸(而非无限的尺寸),所以为了设置面板的最终尺寸,可以返回传递的 Size对象。实际上,许多布局容器就是采用这一步骤来占据提供的所有空间(不能冒险占用其他控件可能需要的空间,因为除非有可用空间,否则布局系统的测量步骤一定不会为元素提供超出需要的空间)。

Canvas面板的副本

  理解这两个方法的最快捷方式是研究Canvas类的内部工作原理,Canvas是最简单的布局容器。为了创建自己的Canvas风格的面板,只需要简单地继承Panel类,并且添加MeasureOverride()ArrangeOverride()方法,如下所示:

1
2
3
4
public class CanvasClone : System.Windows.Controls.Panel
{
...
}

  Canvas面板在它们希望的为止放置子元素,并且为子元素设置它们希望的尺寸。所以,Canvas面板不需要计算如何分割可用空间。 这使得MeasureOverride()方法非常简单。为每个子元素提供无限的空间:

1
2
3
4
5
6
7
8
9
10
protected override Size MeasureOverride(Size availableSize)
{
// PositiveInfinity:正无穷大
Size size = new Size(double.PositiveInfinity, double.PositiveInfinity);
foreach (UIElement element in base.InternalChildren)
{
element.Measure(size);
}
return new Size();
}

  注意,MeasureOverride()方法返回空的Size对象。这意味着Canvas面板根本不请求任何空间,而是由您明确地为Canvas面板指定尺寸,或者将其放置到布局容器中进行拉伸以填充整个容器的可用空间。

  ArrangeOverride()方法包含的内容稍微多一些。为了确定每个元素的正确为止,Canvas面板使用附加属性(Left、Right、Top以及Bottom),附加属性是使用定义类中的来了哥哥辅助方法实现的:GetProperty()和SetProperty()

在此分析的Canvas面板副本简单一些——只使用Left和Top附加属性。下面是用于排列元素的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected override Size ArrangeOverride(Size finalSize)
{
foreach (UIElement element in base.InternalChildren)
{
double x = 0;
double y = 0;
double left = Canvas.GetLeft(element);
if (!Double.IsNaN(left))
{
x = left;
}
double top = Canvas.GetTop(element);
if (!Double.IsNaN(top))
{
y = top;
}
element.Arrange(new Rect(new Point(x, y), element.DesiredSize));
}

return finalSize;
}

更好的WrapPanel面板

  WrapPanel面本执行一个简单的功能,该功能有时十分有用。该面板逐个地布置其子元素,一旦当前行的宽度用完,就会切换到下一行。但有时您需要**采用一种方法来强制立即换行,以便在新行中启动某个特定控件。**尽管WrapPanel面板原本没有提供这一功能,但通过创建自定义控件可方便地添加该功能。只需要添加一个请求换行的附加属性即可。此后,面板中的子元素可使用该属性在适当位置换行。

  下面的代码清单显示了WrapBreakPanel类,该类添加了LineBreakBeforeProperty附加属性。当将该属性设置为true时,这个属性会导致在元素之前立即换行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
public class WrapBreakPanel : System.Windows.Controls.Panel
{
public static bool GetLineBreakBefore(DependencyObject obj)
{
return (bool)obj.GetValue(LineBreakBeforeProperty);
}

public static void SetLineBreakBefore(DependencyObject obj, bool value)
{
obj.SetValue(LineBreakBeforeProperty, value);
}

public static readonly DependencyProperty LineBreakBeforeProperty =
DependencyProperty.RegisterAttached(
"LineBreakBefore",
typeof(bool),
typeof(WrapBreakPanel),
new FrameworkPropertyMetadata(false)
{
// 指明该属性影响布局过程
AffectsArrange = true,
AffectsMeasure = true
}
);

/// <summary>
/// 测量阶段
/// </summary>
/// <param name="availableSize"></param>
/// <returns></returns>
protected override Size MeasureOverride(Size availableSize)
{
Size currentLineSize = new Size();
Size panelSize = new Size();

foreach (UIElement element in base.InternalChildren)
{
element.Measure(availableSize);
Size desiredSize = element.DesiredSize; // 所需尺寸

if (GetLineBreakBefore(element) ||
currentLineSize.Width + desiredSize.Width > availableSize.Width)
{
// 切换到新行(要么是因为元素请求了它,要么是因为空间用完了)。
panelSize.Width = Math.Max(currentLineSize.Width, panelSize.Width);
panelSize.Height += currentLineSize.Height;
currentLineSize = desiredSize;

// 如果元素太宽,无法使用线的最大宽度进行匹配,只需给它一条单独的线。
if (desiredSize.Width > availableSize.Width)
{
panelSize.Width = Math.Max(desiredSize.Width, panelSize.Width);
panelSize.Height += desiredSize.Height;
currentLineSize = new Size();
}
}
else
{
// 继续添加到当前行。
currentLineSize.Width += desiredSize.Width;

// 确保线条与其最高的元素一样高。
currentLineSize.Height = Math.Max(desiredSize.Height, currentLineSize.Height);
}
}

// 返回适合所有元素所需的尺寸。
// 通常,这是约束的宽度,高度基于元素的大小。但是,如果元素比面板的宽度宽,则所需的宽度将是该线的宽度
panelSize.Width = Math.Max(currentLineSize.Width, panelSize.Width);
panelSize.Height += currentLineSize.Height;
return panelSize;
}

protected override Size ArrangeOverride(Size finalSize)
{
Size currentLineSize = new Size(); // 当前行的尺寸
double accumulatedHeight = 0; // 累积的高度

foreach (UIElement element in base.InternalChildren)
{
Size desiredSize = element.DesiredSize; // 获取元素所需的尺寸

if (GetLineBreakBefore(element) ||
currentLineSize.Width + desiredSize.Width > finalSize.Width)
{
// 切换到新行(要么是因为元素请求了它,要么是因为空间用完了)
accumulatedHeight += currentLineSize.Height; // 累加当前行的高度
currentLineSize = new Size(desiredSize.Width, desiredSize.Height); // 重置当前行尺寸为新元素的尺寸

// 如果元素太宽,无法使用行的最大宽度进行匹配,只需给它一条单独的行
if (desiredSize.Width > finalSize.Width)
{
element.Arrange(new Rect(new Point(0, accumulatedHeight), desiredSize)); // 布置元素
accumulatedHeight += desiredSize.Height; // 累加元素的高度
currentLineSize = new Size(); // 重置当前行尺寸
}
else
{
element.Arrange(new Rect(new Point(0, accumulatedHeight), desiredSize)); // 布置元素
}
}
else
{
// 继续添加到当前行
element.Arrange(new Rect(new Point(currentLineSize.Width, accumulatedHeight), desiredSize)); // 布置元素
currentLineSize.Width += desiredSize.Width; // 增加当前行的宽度

// 确保行的高度与其最高的元素一样高
currentLineSize.Height = Math.Max(desiredSize.Height, currentLineSize.Height); // 设置当前行的高度为最大元素高度
}
}

// 返回面板的最终大小
accumulatedHeight += currentLineSize.Height; // 累加最后一行的高度
return new Size(finalSize.Width, accumulatedHeight); // 返回面板尺寸
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<Window
x:Class="WpfApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:lib="clr-namespace:CustomControlLibrary;assembly=CustomControlLibrary"
xmlns:local="clr-namespace:WpfApp"
xmlns:r="clr-namespace:ResourceLibrary;assembly=ResourceLibrary"
Title="MainWindow"
Width="375"
Height="260">
<StackPanel>
<StackPanel.Resources>
<Style TargetType="{x:Type Button}">
<Setter Property="Margin" Value="3" />
<Setter Property="Padding" Value="3" />
</Style>
</StackPanel.Resources>
<TextBlock Padding="5" Background="LightGray">
Content above the WQrapBreakPanel
</TextBlock>
<lib:WrapBreakPanel>
<Button>No Break Here</Button>
<Button>No Break Here</Button>
<Button>No Break Here</Button>
<Button>No Break Here</Button>
<Button lib:WrapBreakPanel.LineBreakBefore="True" FontWeight="Bold">Button with Break</Button>
<Button>No Break Here</Button>
<Button>No Break Here</Button>
<Button>No Break Here</Button>
<Button>No Break Here</Button>
</lib:WrapBreakPanel>
<TextBlock Padding="5" Background="LightGray">
Content below the WrapBreakPanel
</TextBlock>
</StackPanel>
</Window>

  唯一保留的细节是当执行布局逻辑时需要考虑该属性。WrapBreakPanel面板的布局逻辑以WrapPanel 面板的布局逻辑为基础。在测量阶段,元素按行排列,从而使面板能够计算需要的总空间。除非太大或LineBreakBefore属性被设置为tue,否则每个元素都被添加到当前行中。

自定义绘图元素

  前面已经开始分析 WPF元素的内部工作原理–允许每个元素插入到WPF布局系统的MeasureOverride()和 ArrangeOverride()方法中。本节将进一步深入分析和研究元素如何渲染它们自身。

  大多数 WPF 元素通过组合方式创建可视化外观。换句话说,典型的元素通过其他更基础的元素进行构建。在本章的整个内容中您已经看到了这种工作模式。例如,使用标记定义用户控件的组合元素,处理标记的方式与自定义窗口中的XAML 相同。使用控件模板为自定义控件定义可视化树。并且当创建自定义面板时,根本不必定义任何可视化细节。组合元素由控件使用者提供,并添加到 Children 集合中。

OnRender()方法

  为了执行自定义渲染,元素必须重写0nRender()方法,该方法继承自UIElement 基类OnRender()方法未必不需要替换组合——**一些控件使用OnRender()方法绘制可视化细节并使用组合在其上叠加其他元素。**Border和Panel类是两个例子,Border 类在 OnRender()方法中绘制边框,Panel类在 OnRender()方法中绘制背景。Border和 Panel类都支持子内容,并且这些子内容在自定义的绘图细节之上进行渲染。

  OnRender()方法接收一个 DrawingContext 对象,该对象为绘制内容提供了一套很有用的方法。第一次学习 DrawingContext 类的相关内容是在第14章,在该章中使用该类为 Visual 对象绘制内容。在 OnRender()方法中执行绘图的主要区别是不能显式地创建和关闭 DrawingContext对象。这是因为几个不同的 OnRender()方法可能使用相同的 DrawingContext对象。例如,派生的元素可以执行一些自定义绘图操作并调用基类中的 OnRender()方法来绘制其他内容。这种方法是可行的,因为当开始这一过程时,WPF会自动创建 DrawingContext 对象,并且当不再需要时关闭该对象。

  从技术角度看,OnRender()方法实际上没有将内容绘制到屏幕上,而是绘制到 DrawingContext对象上,然后 WPF 缓存这些信息。WPF 决定元素何时需要重新绘制并绘制使用 DrawingContext对象创建的内容。这是 WPF 保留模式图形系统的本质-- 由您定义内容,WPF 无缝地管理绘制和刷新过程。

关于 WPF 渲染,最令人惊奇的细节是实际上只需要使用很少的类。大多数类是通过其他更简单的类构建的,并且对于典型的控件,为了找到实际重写OnRender()方法的类,需要进入到控件元素树中非常深的层次。下面是一些重写了OnRender()方法的类:

  • TextBlock类。无论在何处放置文本,都有TextBlock对象使用OnRender()方法绘制文本
  • Image类。Image类重写OnRender()方法,使用*DrawingContext.DrawImage()*方法绘制图形内容
  • MediaElement类。如果正在使用该类播放视频文件,该类会重写OnRender()方法以绘制视频帧
  • 各种形状类。Shape基类重写了OnRender()方法,通过使用DrawingContext.DrawGeometry()方法,绘制在其内部存储的Geometry对象。根据Shape类的特定派生类,Geometry对象可以表示椭圆、矩形或更复杂的由直线和曲线构成的路径。许多元素使用形状绘制小的可视化细节
  • 各种修饰类。这些类(如ButtonChromeListBoxChrome)绘制通用控件的外侧外观,并在具体指定的内部放置内容。其他许多继承自Decorator的类,如Border类,都重写了OnRender()方法
  • 各种面板类。尽管面板的内容是由其子元素提供的,但是OnRender()方法绘制具有背景色(假设设置了background属性)的矩形

通常,OnRender()方法的实现看起来很简单。例如,下面是继承自Shape类的所有渲染代码:

1
2
3
4
5
6
7
8
protected override void OnRender(DrawingContext dc)
{
this.EnsureRenderedGeometry();
if (this._renderedGeometry != Geometry.Empty)
{
dc.DrawGeometry(this.Fill, this.GetPen(), this._renderedGeometry);
}
}

  请记住,重写 OnRender()方法不是渲染内容并且将其添加到用户界面的唯一方法。也可以创建 DrawingVisual 对象,并使用 AddVisualChild()方法为 UElement对象添加该可视化对象(并实现其他一些细节,正如在第 14 章中描述的那样)。然后可以调用 DrawingVisual,RenderOpen()方法为 DrawimgVisual 对象检索 DrawingContext对象,并使用返回的 DrawingContext 对象渲染DrawingVisual对象的内容。

  **在 WPF 中,一些元素使用这种策略在其他元素内容之上显示一些图形细节。**例如,在拖放指示器、错误指示器以及焦点框中可以看到这种情况。在所有这些情况中,DrawingVisua类允许元素在其他内容之上绘制内容,而不是在其他内容之下绘制内容。但对于大部分情况,是在专门的 OnRender()方法中进行渲染。

评估自定义绘图

  **当创建自定义元素时,可能会选择重写 OnRender()方法来绘制自定义内容。可在包含内容的元素(最常见的情况是继承自 Decorator的类)中重写 OnRender()方法,从而可以在内容周围添加图形装饰。也可以在没有任何嵌套内容的元素中重写OnRender()方法,从而可以绘制元素的整个可视化外观。**例如,可以创建绘制一些小的图形细节的自定义元素,然后可以通过组合,在其他类中使用自定义元素。WPF中的这方面示例是 TickBar 元素,该元素为 Slider 控件绘制刻度标记。TickBar 元素通过 Slider 控件的默认控件模板(该模板还包括一个 Border 和一个 Track元素,Track 元素又包含了两个 RepeatButton 控件和一个 Thumb 元素)嵌入到 Slider 控件的可视化树中。

  一个明显的问题是需要确定何时使用较低级的 OnRender()方法,以及何时使用其他类(例如,继承自 Shape 类的元素)的组合来绘制所需的内容。为了做出决定,需要评估所需图形的复杂程度以及希望提供的交互能力。

  例如,分析一下 ButtonChrome 类。在 ButtonChrome 类的 WPF 实现中,自定义的渲染代码考虑了各种属性,包括RenderDefaulted、RenderMouseOver 以及 RenderPressed。Button 类的默认控件模板在适当的时机使用触发器设置这些属性,就像在第17章中看到的那样。例如,当将鼠标移动到按钮上时,Button类使用触发器将 ButtonChrome.RenderMouseOver属性设置为tnue。

  无论何时改变 RenderDefaulted、RenderMouseOver或RenderPressed 属性,ButtonChrome类都会调用基本的InvalidateVisual()方法来指示当前外观不再有效。WPF然后调用ButtonChrome.OnRender()方法来获取新的图形表示。

  如果 ButtonChrome 类使用组合,这种行为就更难实现。使用合适的元素为 ButtonChrome类创建标准外观很容易,但是当按钮的状态发生变化时,需要做更多的工作来修改外观。需要动态改变构成 ButtonChrome类的嵌套元素,如果外观变化很大的话,就必须隐藏一个元素并在合适的位置显示另一个元素。
  大多数自定义元素不需要自定义渲染。但是当属性发生变化或执行特定操作时,需要渲染复杂的变化很大的可视化外观,此时使用自定义的渲染方法可能更加简单并且更便捷。

自定义绘图元素

  下面的代码定义了名为 CustomDrawElement 的元素,演示了一种简单的效果。该元素使用RadialGradientBrush 画刷绘制阴影背景。技巧是动态设置强调显示的渐变起点,使其跟随鼠标。从而当用户在控件上移动鼠标时,白色的发光中心点跟随鼠标移动。

  CustomDrawnElement 元索不需要包含任何子内容,所以它直接继承自FrameworkElement类。该元素只提供了一个可以设置的属性——渐变的背景色(前景色被硬编码为白色,尽管可以很容易地改变这一细节)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public class CustomDrawElement : FrameworkElement
{
public System.Windows.Media.Color BackgroundColor
{
get { return (Color)GetValue(BackgroundColorProperty); }
set { SetValue(BackgroundColorProperty, value); }
}

public static readonly DependencyProperty BackgroundColorProperty =
DependencyProperty.Register(
"BackgroundColor",
typeof(Color),
typeof(CustomDrawElement),
new FrameworkPropertyMetadata(Colors.Yellow)
{
// 标识WPF将自动调用OnRender()方法,通过调用InvalidateVisual()
AffectsRender = true
}
);

protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);

this.InvalidateVisual();
}

protected override void OnMouseLeave(MouseEventArgs e)
{
base.OnMouseLeave(e);

this.InvalidateVisual();
}

protected override void OnRender(DrawingContext drawingContext)
{
base.OnRender(drawingContext);

Rect bounds = new Rect(0, 0, base.ActualWidth, base.ActualHeight);
drawingContext.DrawRectangle(GetForegroundBrush(), null, bounds);
}

private Brush GetForegroundBrush()
{
if (!IsMouseOver)
{
return new SolidColorBrush(BackgroundColor);
}
else
{
RadialGradientBrush brush = new RadialGradientBrush(Colors.White, BackgroundColor);

// Get the position of the mouse in device-independent units, relative to the control itself.
Point absoluteGradientOrigin = Mouse.GetPosition(this);

// Convert the point coordinates to proportional (0 to 1) values.
Point relativeGradientOrigin = new Point(
absoluteGradientOrigin.X / base.ActualWidth,
absoluteGradientOrigin.Y / base.ActualHeight);

// Adjust the brush
brush.GradientOrigin = relativeGradientOrigin;
brush.Center = relativeGradientOrigin;

return brush;
}
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<Window
x:Class="WpfApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:lib="clr-namespace:CustomControlLibrary;assembly=CustomControlLibrary"
xmlns:local="clr-namespace:WpfApp"
xmlns:r="clr-namespace:ResourceLibrary;assembly=ResourceLibrary"
Title="MainWindow"
Width="375"
Height="260">
<Grid>
<lib:CustomDrawElement BackgroundColor="LightGreen" />
</Grid>
</Window>

  BackgroundColor依赖项属性使用FrameworkPropertyMetadata.AffectRender标志明确进行了标识。因此,无论何时改变了背景色,WPF都自动调用 OnRender()方法。然而,当鼠标移动到新的位置时,也需要确保调用 OnRender()方法。这是通过在合适的时间调用InvalidateVisual()方法实现的。

创建自定义装饰元素

  作为一条通用规则,切勿在控件中使用自定义绘图。如果在控件中使用自定义绘图,就违反了 WPF无外观控件的承诺。问题是一旦硬编码一些绘图逻辑,就会使控件可视化外观的一部分不能通过控件模板进行定制。

  更好的方法是设计单独的绘制自定义内容的元素(例如上一个示例中的 CustomDrawnElement类),然后在控件的默认模板内部使用自定义元素。很多WPF 控件使用这种方法,您在第17章中看到的 Button 控件,使用的就是这种方法。

在控件模板中,自定义绘图元素通常扮演两个角色

  • 它们绘制一些小的图形细节(例如滚动按钮上的箭头)
  • 它们在另一个元素的周围提供更加详细的背景或边框

  第二种方法需要自定义装饰元素。可以通过两个轻微的改动将CustomDrawElement 类转换成自定义绘图元素。首先,使该类继承自Decorator类:

1
2
3
4
public class CustomDrawDecorator : Decorator
{
...
}

  然后重写OnMeasure()方法,指定需要的尺寸。所有装饰元素都会考虑它们的子元素,增加装饰所需要的额外控件,然后返回组合之后的尺寸。CustomDrawDecorator类不需要任何额外的空间来绘制边框,相反,使用下面的代码简单地使自身和其内容具有相同的尺寸:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
public class CustomDrawDecorator : Decorator
{
protected override Size MeasureOverride(Size constraint)
{
UIElement child = this.Child;
if (child != null)
{
child.Measure(constraint);
return child.DesiredSize;
}
else
{
return new Size();
}
}

public System.Windows.Media.Color BackgroundColor
{
get { return (Color)GetValue(BackgroundColorProperty); }
set { SetValue(BackgroundColorProperty, value); }
}

public static readonly DependencyProperty BackgroundColorProperty =
DependencyProperty.Register(
"BackgroundColor",
typeof(Color),
typeof(CustomDrawDecorator),
new FrameworkPropertyMetadata(Colors.Yellow)
{
// 标识WPF将自动调用OnRender()方法,通过调用InvalidateVisual()
AffectsRender = true
}
);

protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);

this.InvalidateVisual();
}

protected override void OnMouseLeave(MouseEventArgs e)
{
base.OnMouseLeave(e);

this.InvalidateVisual();
}

protected override void OnRender(DrawingContext drawingContext)
{
base.OnRender(drawingContext);

Rect bounds = new Rect(0, 0, base.ActualWidth, base.ActualHeight);
drawingContext.DrawRectangle(GetForegroundBrush(), null, bounds);
}

private Brush GetForegroundBrush()
{
if (!IsMouseOver)
{
return new SolidColorBrush(BackgroundColor);
}
else
{
RadialGradientBrush brush = new RadialGradientBrush(Colors.White, BackgroundColor);

// Get the position of the mouse in device-independent units, relative to the control itself.
Point absoluteGradientOrigin = Mouse.GetPosition(this);

// Convert the point coordinates to proportional (0 to 1) values.
Point relativeGradientOrigin = new Point(
absoluteGradientOrigin.X / base.ActualWidth,
absoluteGradientOrigin.Y / base.ActualHeight);

// Adjust the brush
brush.GradientOrigin = relativeGradientOrigin;
brush.Center = relativeGradientOrigin;

return brush;
}
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<Window
x:Class="WpfApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:lib="clr-namespace:CustomControlLibrary;assembly=CustomControlLibrary"
xmlns:local="clr-namespace:WpfApp"
xmlns:r="clr-namespace:ResourceLibrary;assembly=ResourceLibrary"
Title="MainWindow"
Width="375"
Height="260">
<Window.Resources>
<ControlTemplate x:Key="ButtonWithCustomChrome" TargetType="{x:Type Button}">
<lib:CustomDrawDecorator BackgroundColor="LightGreen">
<ContentPresenter
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Content="{TemplateBinding ContentControl.Content}"
ContentTemplate="{TemplateBinding ContentControl.ContentTemplate}"
RecognizesAccessKey="True" />
</lib:CustomDrawDecorator>
</ControlTemplate>
</Window.Resources>
<Grid>
<Button
Width="100"
Height="40"
Template="{StaticResource ButtonWithCustomChrome}">
Button
</Button>
</Grid>
</Window>

  现在可以使用这个模板重新样式化按钮,使其具有新的外观。当然,为了使自定义装饰元索更加实用,当单击鼠标按钮时可能希望改变它的外观。使用修改装饰类属性的触发器可以完成该工作。之前在第17章中已经全面讨论了这一设计。