上一章介绍的ColorPicker控件,是控件设计的最好示例。因为其行为和可视化外观是精心分离的,所以其他设计人员可开发动态改变其外观的新模板。
ColorPicker控件如此简单的一个原因是不涉及状态。换句话说,不根据是否具有焦点、鼠标是否在它上面悬停、是否禁用等状态区分其可视化外观。接下来本章介绍的FlipPanel自定义控件有些不同。
FlipPanel控件背后的基本思想是,为驻留内容提供两个表面,但每次只有一个表面是可见的。为看到其他内容,需要在两个表面之间进行“翻转”。可通过控件模板定制翻转效果,但默认效果使用在前面和后面之间进行过渡的淡化效果。根据应用程序,可以使用FlipPanel控件把数据条目表单与一些由帮助的文档组合起来,以便为相同的数据提供一个简单或较复杂的试图,或在一个简单游戏中将问题和答案融合在一起。
可通过代码执行翻转(通过设置名为IsFlipped的属性),也可使用一个便捷的按钮来翻转面板(除非控件使用这从模板中移除了该按钮)。
显然,控件模板需要制定两个独立部分:FlipPanel控件的前后内容区域。然而,还有一个细节——FlipPanel控件需要一种方法在两个状态之间进行切换:翻转过的状态与未翻转过的状态。可通过为模板添加触发器完成该工作。当单击按钮是,可使用一个触发器隐藏前面的面板并显示第二个面板,而使用另一个触发器翻转这些更改。这两个触发器都可以使用喜欢的任何动画。但通过使用可视化状态,可向控件使用这清晰地指明这两个状态是模板的必须部分,不是为适当的属性或事件编写触发器,控件使用能管着只需要填充适当的状态动画。如果使用Expression Blend,该任务甚至变得更简单。
一、开始编写FlipPanel类
FlipPanel的基本骨架非常简单。包含用户可用单一元素(最有可能是包含各种元素的布局容器)填充的两个内容区域。从技术角度看,这意味着FlipPanel控件不是真正的面板,因为不能使用布局逻辑组织一组子元素。然而,这不会造成问题。因为FlipPanel控件的结构是清晰直观的。FlipPanel控件还包含一个翻转按钮,用户可使用该按钮在两个不同的内容区域之间进行切换。
尽管可通过继承自ContentControl或Panel等控件类来创建自定义控件,但是FlipPanel直接继承自Control基类。如果不需要特定控件类的功能,这是最好的起点。不应该当继承自更简单的FrameworkElement类,除非希望创建不使用标准控件和模板基础框架的元素:
public class FlipPanel:Control { }
首先为FlipPanel类创建属性。与WPF元素中的几乎所有属性一样,应使用依赖项属性。以下代码演示了FlipPanel如何定义FrontContent属性,该属性保持在前表面上显示的元素。
public static readonly DependencyProperty FrontContentProperty = DependencyProperty.Register("FrontContent", typeof(object), typeof(FlipPanel), null);
接着需要调用基类的GetValue()和SetValue()方法的常规.NET属性过程,以便修改依赖性属性。下面是FrontContent属性的实现过程:
/// <summary> /// 前面内容 /// </summary> public object FrontContent { get { return GetValue(FrontContentProperty); } set { SetValue(FrontContentProperty, value); } }
同理,还需要一个存储背面的依赖项属性。如下所示:
public static readonly DependencyProperty BackContentProperty = DependencyProperty.Register("BackContent", typeof(object), typeof(FlipPanel), null); /// <summary> /// 背面内容 /// </summary> public object BackContent { get { return GetValue(BackContentProperty); } set { SetValue(BackContentProperty, value); } }
还需要添加一个重要属性:IsFlipped。这个Boolean类型的属性持续跟踪FlipPanel控件的当前状态(面向前面还是面向后面),使控件使用者能够通过编程翻转状态:
public static readonly DependencyProperty IsFlippedProperty = DependencyProperty.Register("IsFlipped", typeof(bool), typeof(FlipPanel), null); /// <summary> /// 是否翻转 /// </summary> public bool IsFlipped { get { return (bool)GetValue(IsFlippedProperty); } set { SetValue(IsFlippedProperty, value); ChangeVisualState(true); } }
IsFlipped属性设置器调用自定义方法ChangeVisualState()。该方法确保更新显示以匹配当前的翻转状态。稍后介绍ChangeVisualState方法。
FlipPanel类不需要更多属性,因为它实际上从Control类继承了它所需要的几乎所有内容。一个例外是CornerRadius属性。尽管Control类包含了BorderBrush和BorderThickness属性,可以使用这些属性在FlipPanel控件上绘制边框,但缺少将方形边缘变成光滑曲线的CornerRadius属性,如Border元素所做的那样。在FlipPanel控件中实现类似的效果很容易,前提是添加CornerRadius依赖性属性并使用该属性配置FlipPanel控件的默认控件模板中的Border元素:
public static readonly DependencyProperty CornerRadiusProperty = DependencyProperty.Register("CornerRadius", typeof(CornerRadius), typeof(FlipPanel), null); /// <summary> /// 控件边框圆角 /// </summary> public CornerRadius CornerRadius { get { return (CornerRadius)GetValue(CornerRadiusProperty); } set { SetValue(CornerRadiusProperty, value); } }
还需要为FlipPanel控件添加一个应用默认模板的样式。将该样式放在generic.xaml资源字典中,正如在开发ColorPicker控件时所做的那样。下面是需要的基本骨架:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:CustomControls"> <Style TargetType="{x:Type local:FlipPanel}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="local:FlipPanel"> ... </ControlTemplate> </Setter.Value> </Setter> </Style> </ResourceDictionary>
还有最后一个细节。为通知控件从generic.xaml文件获取默认样式,需要在FlipPanel类的静态构造函数中调用DefaultStyleKeyProperty.OverrideMetadata()方法:
DefaultStyleKeyProperty.OverrideMetadata(typeof(FlipPanel), new FrameworkPropertyMetadata(typeof(FlipPanel)));
二、选择部件和状态
现在已经具备了基本结构,并且已经准备好确定将在控件模板中使用的部件和状态了。
显然,FlipPanel需要两个状态:
- 正常状态。该故事板确保只有前面的内容是可见的,后面的内容被翻转、淡化或移出试图。
- 翻转状态。该故事板确保只有后面的内容是可见的,前面的内容通过动画被移出试图。
此外,需要两个部件:
- FlipButton。这是一个按钮,当单击该按钮时,将试图从前面改到后面(或从后面改到前面)。FlipPanel控件通过处理该按钮的事件提供该服务。
- FlipButtonAlternate。这是一个可选元素,与FlipButton的工作方式相同。允许控件使用者在自定义模板中使用两种不同的方法。一种选择是使用在可翻转区域外的单个翻转按钮、另一种选择是在可翻转区域的面板两侧放置独立的翻转按钮。
还应当为前后内容区域添加部件。然而,FlipPanel克难攻坚不需要直接操作这些区域,只要模板包含在适当的时间隐藏和显示它们的动画即可(另一种选择是定义这些部件,从而可以明确地使用代码改变它们的可见性。这样一来,即使没有定义动画,通过隐藏一部分并显示另一部分,面板仍能在前后内容区域之间变化。为简单起见,FlipPanel没有采取这种选择)。
为表面FlipPanel使用这些部件和状态的事实,应为自定义控件类应用TemplatePart特性,如下所示:
[TemplatePart(Name = "FlipButton", Type = typeof(ToggleButton))] [TemplatePart(Name = "FlipButtonAlternate", Type = typeof(ToggleButton))] [TemplateVisualState(Name = "Normal", GroupName = "ViewStates")] [TemplateVisualState(Name = "Flipped", GroupName = "ViewStates")] public class FlipPanel : Control { }
三、默认控件模板
现在,可将这些内容投入到默认控件模板中。根元素是具有两行的Grid面板,该面板包含内容区域(在顶行)和翻转按钮(在底行)。用两个相互重叠的Border元素填充内容区域,代表前面和后面的内容,但一次只显示前面和后面的内容。
为了填充前面和后面的内容区域,FlipPanel控件使用ContentControl元素。该技术几乎和自定义按钮示例相同,只是需要两个ContentPresenter元素,分别用于FlipPanel控件的前面和后面。FlipPanel控件还包含独立的Border元素来封装每个ContentPresenter元素。从而让控件使用者能通过设置FlipPanel的几个直接属性勾勒出可翻转内容区域(BorderBrush、BorderThickness、Background以及CornerRadius),而不是强制性地手动添加边框。
下面是默认控件模板的基本骨架:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:CustomControls"> <Style TargetType="{x:Type local:FlipPanel}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type local:FlipPanel}"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"></RowDefinition> <RowDefinition Height="Auto"></RowDefinition> </Grid.RowDefinitions> <!-- This is the front content. --> <Border x:Name="FrontContent" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}" > <ContentPresenter Content="{TemplateBinding FrontContent}"> </ContentPresenter> </Border> <!-- This is the back content. --> <Border x:Name="BackContent" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}" > <ContentPresenter Content="{TemplateBinding BackContent}"> </ContentPresenter> </Border> <!-- This the flip button. --> <ToggleButton Grid.Row="1" x:Name="FlipButton" Margin="0,10,0,0" > </ToggleButton> </Grid> </ControlTemplate> </Setter.Value> </Setter> </Style> </ResourceDictionary>
当创建默认控件模板时,最好避免硬编码控件使用者可能希望定制的细节。相反,需要使用模板绑定表达式。在这个示例中,使用模板绑定表达式设置了几个属性:BorderBrush、BorderThickness、CornerRadius、Background、FrontContent以及BackContent。为设置这些属性的默认值(这样即使控件使用者没有设置它们,也仍然确保能得到正确的可视化外观),必须为控件的默认样式添加额外的设置器。
1、翻转按钮
在上面的示例中,显示的控件模板包含一个ToggleButton按钮。然而,该按钮使用ToggleButton的默认外观,这使得ToggleButton按钮看似普遍的按钮,完全具有传统的阴影背景。这对于FlipPanel控件是不合适的。
尽管可替换ToggleButton中的任何内容,但FlipPanel需要进一步。它需要去掉标准的背景并根据ToggleButton按钮的状态改变其内部元素的外观。
为创建这种效果,需要为ToggleButton设置自定义控件模板。该控件模板能够包含绘制所需箭头的形状元素。在该例中,ToggleButton是使用用于绘制圆的Ellipse元素和用于绘制箭头的Path元素绘制的,这两个元素都放在具有单个单元格的Grid面板中,以及需要一个改变箭头指向的RotateTransform对象:
<ToggleButton Grid.Row="1" x:Name="FlipButton" RenderTransformOrigin="0.5,0.5" Margin="0,10,0,0" Width="19" Height="19"> <ToggleButton.Template> <ControlTemplate> <Grid> <Ellipse Stroke="#FFA9A9A9" Fill="AliceBlue" /> <Path Data="M1,1.5L4.5,5 8,1.5" Stroke="#FF666666" StrokeThickness="2" HorizontalAlignment="Center" VerticalAlignment="Center"> </Path> </Grid> </ControlTemplate> </ToggleButton.Template> <ToggleButton.RenderTransform> <RotateTransform x:Name="FlipButtonTransform" Angle="-90"></RotateTransform> </ToggleButton.RenderTransform> </ToggleButton>
2、定义状态动画
状态动画是控件模板中最有趣的部分。它们是提供翻转行为的要素,它们还是为FlipPanel创建自定义模板的开发人员最有可能修改的细节。
为定义状态组,必须在控制模板的根元素中添加VisualStateManager.VisualStateGroups元素,如下所示:
<ControlTemplate TargetType="{x:Type local:FlipPanel}"> <Grid> <VisualStateManager.VisualStateGroups> ... </VisualStateManager.VisualStateGroups> </Grid> </ControlTemplate>
可在VisualStateGroups元素内部使用具有合适名称的VisualStateGroup元素创建状态组。在每个VisualStateGroup元素内部,为每个状态添加一个VisualState元素。对于FlipPanel面板,有一个包含两个可视化状态的组:
<VisualStateManager.VisualStateGroups> <VisualStateGroup x:Name="ViewStates"> <VisualState x:Name="Normal"> <Storyboard> ... </Storyboard> </VisualState> </VisualStateGroup> <VisualStateGroup x:Name="FocusStates"> <VisualState x:Name="Flipped"> <Storyboard> ... </Storyboard> </VisualState> </VisualStateGroup> </VisualStateManager.VisualStateGroups>
每个状态对应一个具有一个或多个动画的故事板。如果存在这些故事板,就会在适当的时机触发它们(如果不存在,控件将按正常方式降级,而不会引发错误)。
在默认控件模板中,动画使用简单的淡化效果从一个内容区域改变到另一个内容区域,并使用旋转变换翻转ToggleButton箭头使其指向另一个方向。下面是完成这两个任务的标记:
<VisualState x:Name="Normal"> <Storyboard> <DoubleAnimation Storyboard.TargetName="BackContent" Storyboard.TargetProperty="Opacity" To="0" Duration="0" ></DoubleAnimation> </Storyboard> </VisualState> <VisualState x:Name="Flipped"> <Storyboard> <DoubleAnimation Storyboard.TargetName="FlipButtonTransform" Storyboard.TargetProperty="Angle" To="90" Duration="0"> </DoubleAnimation> <DoubleAnimation Storyboard.TargetName="FrontContent" Storyboard.TargetProperty="Opacity" To="0" Duration="0"></DoubleAnimation> </Storyboard> </VisualState>
通过上面标记,发现可视化状态持续时间设置为0,这意味着动画立即应用其效果。这看起来可能有些怪——毕竟,不是需要更平缓的改变从而能够注意到动画效果吗?
时机上,该设计完成正确,因为可视化状态用于表示控件在适当状态时的外观。例如,当翻转面板处于翻转过的状态是,简单地显示其背面内容。翻转过程是在FlipPanel控件进入翻转状态前得过渡,而不是翻转状态本身的一部分。
3、定义状态过渡
过渡是从当前状态到新状态的动画。变换模型的优点之一是不需要为动画创建故事板。例如,如果添加如下标记,WPF会创建持续时间为0.7秒得动画以改变FlipPanel控件的透明度,从而创建所希望的悦目的褪色效果:
<VisualStateGroup x:Name="ViewStates"> <VisualStateGroup.Transitions> <VisualTransition GeneratedDuration="0:0:0.7"> </VisualTransition> </VisualStateGroup.Transitions> <VisualState x:Name="Normal"> ... </VisualState> </VisualStateGroup>
过渡会应用到状态组,当定义过渡时,必须将其添加到VisualStateGroup.Transitions集合。这个示例使用最简单的过渡类型:默认过渡。默认过渡应用于该组中的所有状态变化。
默认过渡是很方便的,但用于所有情况的解决方案不可能总是适合的。例如,可能希望FlipPanel控件根据其进入的状态以不同的速度过渡。为实现该效果,需要定义多个过渡,并且需要设置To属性以指示何时应用过渡效果。
例如,如果有以下过渡:
<VisualStateGroup.Transitions> <VisualTransition To="Flipped" GeneratedDuration="0:0:0.5"></VisualTransition> <VisualTransition To="Normal" GeneratedDuration="0:0:0.1"></VisualTransition> </VisualStateGroup.Transitions>
FlipPanel将在0.5秒得时间内切换到Flipped状态,并在0.1秒得时间内进入Normal状态。
这个示例显示了当进入特定状态时应用的过渡,但还可使用From属性创建当离开某个状态时应用的过渡,并且可结合使用To和From属性来创建更特殊的只有当在特定的两个状态之间移动时才会应用的过渡。当应用过渡时WPF遍历过渡集合,在所有应用的过渡中查找最特殊的过渡,并只使用最特殊的那个过渡。
为进一步加以控制,可创建自定义过渡动画来替换WPF通常使用的自动生成的过渡。可能会犹由于几个原因而创建自定义过渡。下面是一些例子:使用更复杂的动画控制动画的步长,使用动画缓动、连续运行几个动画或在运行动画时播放声音。
为定义自定义过渡,在VisualTransition元素中放置具有一个或多个动画的故事板。在FlipPanel示例中,可使用自定义过渡确保ToggleButton箭头更快递旋转自身,而淡化过程更缓慢:
<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"></DoubleAnimation> </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"></DoubleAnimation> </Storyboard> </VisualTransition> </VisualStateGroup.Transitions>
但许多控件需要自定义过渡,而且编写自定义过渡是非常乏味的工作。仍需保持零长度的状态动画,这还会不可避免地再可视化状态和过渡之间复制一些细节。
4、关联元素
通过上面的操作,已经创建了一个相当好的控件模板,需要在FlipPanel控件中添加一些内容以使该模板工作。
诀窍是使用OnApplyTemplate()方法,该方法还款用于在ColorPicker控件中设置绑定。对于FlipPanel控件,OnApplyTemplate()方法用于为FlipButton和FlipButtonAlternate部件检索ToggleButton,并为每个部件关联事件处理程序,从而当用户单击以翻转控件时能够进行响应。最后,OnApplyTemplate()方法调用名为ChangeVisualState()的自定义方法,该方法确保控件的可视化外观和其当前状态的相匹配:
public override void OnApplyTemplate() { base.OnApplyTemplate(); ToggleButton flipButton = base.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). // This is an optional design, as the control consumer may use template // that places the flip button outside of the panel sides, like the // default template does. ToggleButton flipButtonAlternate = base.GetTemplateChild("FlipButtonAlternate") as ToggleButton; if (flipButtonAlternate != null) flipButtonAlternate.Click += flipButton_Click; this.ChangeVisualState(false); }
下面是非常简单的允许用户单击ToggleButton按钮并翻转面板的事件处理程序:
private void flipButton_Click(object sender, RoutedEventArgs e) { this.IsFlipped = !this.IsFlipped; }
幸运的是,不需要手动触发状态动画。即不需要创建也不需要触发过渡动画。相反,为从一个状态改变到另一个状态,只需要调用静态方法VisualStateManager.GoToState()。当调用该方法时,传递正在改变状态的控件对象的引用、新状态的名称以及确定是否显示过渡的Boolean值。如果是由用户引发的改变(例如,当用户单击ToggleButton按钮时),该值应当为true;如果是由属性设置引发的改变(例如,如果使用页面的标记设置IsFlipped属性的初始值),该值为false。
处理控件支持的所有不同状态可能会变得凌乱。为避免在整个控件代码中分散调用GoToState()方法,大多数控件添加了与在FlipPanel控件中添加的ChangeVisualState()类似地方法。该方法负责应用每个状态组中的正确状态。该方法中的代码使用If语句块(或switch语句)应用每个状态组的当前状态。该方法之所以可行,是因为它完全可以使用当前状态的名称调用GoToState()方法。在这种情况下,如果当前状态和请求的状态相同,那么什么也不会发生。
下面是用于FlipPanel控件的ChangeVisualState()方法:
private void ChangeVisualState(bool useTransitions) { if (!this.IsFlipped) { VisualStateManager.GoToState(this, "Normal", useTransitions); } else { VisualStateManager.GoToState(this, "Flipped", useTransitions); } }
通常在以下位置调用ChangeVisualState()方法或其等效的方法:
- 在OnApplyTemplate()方法的结尾,在初始化控件之后。
- 在响应代表状态变化的事件时,例如鼠标移动或单击ToggleButton按钮。
- 当响应属性改变或通过代码触发方法时(例如,IsFlipped属性设置器调用ChangEVisualState()方法并且总是提供true,所以显示过渡动画。如果希望为控件使用者提供不显示过渡的机会,可添加Flip()方法,该方法接受与为ChangeVisualState()方法传递的相同的Boolean参数)。
正如上面介绍的,FlipPanel控件非常灵活。例如,可使用该控件并且不使用ToggleButton按钮,通过代码进行翻转(可能是当用户单击不同的控件时)。也可在控件模板中包含一两个翻转按钮,并且允许用户进行控制。
四、使用FlipPanel控件
使用FlipPanel控件相对简单。标记如下所示:
<Window x:Class="CustomControlsClient.FlipPanelTest" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="FlipPanelTest" Height="300" Width="300" xmlns:lib="clr-namespace:CustomControls;assembly=CustomControls" > <Grid x:Name="LayoutRoot" Background="White"> <lib:FlipPanel x:Name="panel" BorderBrush="DarkOrange" BorderThickness="3" IsFlipped="True" CornerRadius="4" Margin="10"> <lib:FlipPanel.FrontContent> <StackPanel Margin="6"> <TextBlock TextWrapping="Wrap" Margin="3" FontSize="16" Foreground="DarkOrange">This is the front side of the FlipPanel.</TextBlock> <Button Margin="3" Padding="3" Content="Button One"></Button> <Button Margin="3" Padding="3" Content="Button Two"></Button> <Button Margin="3" Padding="3" Content="Button Three"></Button> <Button Margin="3" Padding="3" Content="Button Four"></Button> </StackPanel> </lib:FlipPanel.FrontContent> <lib:FlipPanel.BackContent> <Grid Margin="6"> <Grid.RowDefinitions> <RowDefinition Height="Auto"></RowDefinition> <RowDefinition></RowDefinition> </Grid.RowDefinitions> <TextBlock TextWrapping="Wrap" Margin="3" FontSize="16" Foreground="DarkMagenta">This is the back side of the FlipPanel.</TextBlock> <Button Grid.Row="2" Margin="3" Padding="10" Content="Flip Back to Front" HorizontalAlignment="Center" VerticalAlignment="Center" Click="cmdFlip_Click"></Button> </Grid> </lib:FlipPanel.BackContent> </lib:FlipPanel> </Grid> </Window>
当单击FlipPanel背面的按钮时,通过编程翻转面板:
private void cmdFlip_Click(object sender, RoutedEventArgs e) { panel.IsFlipped = !panel.IsFlipped; }
本实例源码:FlipPanel.zip