WPF控件编程
WPF提供了一系列预定义组件以供UI开发人员使用。但软件开发人员仍常常需要自行编写满足特定要求的控件。本文就将以Spinner控件为例,讲解如何以派生方式完成自定义控件的编写。
一.动手前的思考
在着手开始编写控件之前,我们需要思考Spinner需要以怎样一种方式实现?MSDN建议使用三种控件实现方式:从UserControl类派生,从Control类派生以及从FrameworkElement类派生。
要正确地从这三种方式中作出选择,软件开发人员首先需要了解这些实现方法的特点。从UserControl类派生和WPF应用程序开发模型非常类似:控件仅仅由现有控件组成,并通过XAML描述。其支持样式和触发器。通过这种方式定义的自定义控件并不希望软件开发人员通过模板指定其外观。
从Control类派生则是大多数控件开发所使用的方式。与从UserControl类派生这一方法不同,其外观并不是由关联的XAML文件指定的,而常常由主题文件所指定。从该类派生的特点有:1) 可以通过ControlTemplate自定义控件的外观。2) 控件可以支持不同的主题。
而从FrameworkElement类派生则需要彻底抛弃使用控件元素组合的开发方式(无论是在Template中还是UserControl定义中)。生成基于FrameworkElement的组件有两种标准方法:直接呈现和自定义元素组合。
直接呈现是指重写FrameworkElement的OnRender方法,并提供显式定义组件视觉效果的DrawingContext操作,如Image类和Border类就是通过这种方法定义的。例如精简后的Border类的OnRender()函数如下所示:
protected override void OnRender(DrawingContext dc) { …… dc.DrawRoundedRectangle(…); …… }
另一种则是使用Visual类实例组合对象外观。如Track就是使用组合对象外观的实例。Track类提供了Thumb属性,并在其内部实现,如ArrangeOverride()函数中,都考虑了对该组成的使用。
从FrameworkElement中派生的优点有:1) 可以完成对控件外观的精确控制,而不仅仅是简单的元素组合。2) 通过定义自己的绘制逻辑定义控件的外观。
很显然,Spinner控件需要使用从Control类派生的方法,以提供对模板的支持。当然,这里并非是指Spinner控件直接从Control类派生,而是选择Control类的一个派生类作为Spinner的基类。这实际上与WPF中的控件类型组织特点有关。WPF中,代表各个控件的类型的继承层次按照控件特征以非常细致的方式划分,并在每个继承层次中仅添加对一个到两个特征的支持。就以Button类为例。该类型与Control类之间还存在着两层派生:ContentControl类以及ButtonBase类。这两个类型不仅仅分别提供了Content属性以及命令相关的属性,更重要的是,其内部提供了支持这些属性的默认实现。在这种情况下,软件开发人员仅仅在默认实现不再满足条件时才需要更改这些默认实现所提供的逻辑,从而大大减少了开发新控件所需要的时间。
正是由于这个原因,我们需要在编写一个控件之前仔细选择其所需要使用的基类。选择一个合适基类的标准就是该类型提供了最多的可重用功能,却没有提供过多的冗余功能。而基类的寻找也按照寻找相似控件,沿相似控件的继承层次由高到低逐个筛选两步。
在寻找相似控件的时候,软件开发人员需要简单地揣摩一下该控件的使用方法,以寻找具有相似功能的控件。一般情况下,Spinner需要拥有一个最大值,一个最小值,并拥有一个当前值。软件开发人员可以通过Spinner上的按钮调整当前值的大小,也可以通过输入框直接输入当前值的大小。这和滚动条控件非常相像,只不过滚动条的直接输入是通过Thumb完成的。然后我们需要反过来想想,是否ScrollBar提供了过多的Spinner所不需要或不支持的功能。显然ScrollBar所提供的ViewportSize、Orientation等都不是Spinner所需要的属性,因此其并不适合作为Spinner的基类。接下来我们可以依次考虑ScrollBar的各个基类,直到选中了一个较为适合的基类为止。就Spinner而言,RangeBase类就是一个较为合适的基类。
这里我们将遇到一个岔路口,那就是是否可以通过仅仅更改现有控件的模板这一方式满足用户的需求。如果可以,那么使用自定义模板则是更好的选择。
在确定需要从某个类派生之后,软件开发人员就应该检查该类所提供的各个依赖项属性所具有的默认值是否是一个合理的默认值。例如RangeBase类指定了Value属性的默认值为0,最小值为0而最大值为1。而对于Spinner而言,由于其常常需要操作整数,因此这些默认值都是不适合的。软件开发人员需要在类型的静态构造函数中对这些默认值进行重写:
RangeBase.ValueProperty.OverrideMetadata(typeof(Spinner), new FrameworkPropertyMetadata(10.0, OnValuePropertyChanged)); RangeBase.MaximumProperty.OverrideMetadata(typeof(Spinner), new FrameworkPropertyMetadata(20.0)); RangeBase.LargeChangeProperty.OverrideMetadata(typeof(Spinner), new FrameworkPropertyMetadata(1.0)); RangeBase.SmallChangeProperty.OverrideMetadata(typeof(Spinner), new FrameworkPropertyMetadata(1.0));
需要注意的是,OverrideMetadata()函数中所提供的属性默认值需要与属性的类型匹配。例如在为Maximum属性指定默认值时使用整型数值20,那么对OverrideMetadata()函数的调用将导致程序崩溃。
在更改属性的默认值时,软件开发人员需要考虑控件所应实际具有的意义。就以ComboBox和ListBox为例。在什么情况下应使用ComboBox,而什么情况下应使用ListBox呢?回答该问题的决定性因素就是这两个控件所具有的特征,进而导致的用户体验的区别。ComboBox可以通过下拉列表显示所有的可选项,并通过编辑框组成显示当前项。这种对数据的显示方式较ListBox占用了更小的空间,并突出显示了当前选中项。而相对于ComboBox,ListBox则在全面展示数据,尤其是关联型数据上较有优势。
同样的,Spinner也有自己存在的意义。Spinner的中文名称被称为微调控件。从名称上就可以看出,对Spinner的操作更多的是微小的调整。同时,Spinner所提供的输入框常常允许用户直接输入需要的数值,从而达到对数值精确的控制。也就是说,相对于ScrollBar等组成,其更注重于对值的精确指定。这也便是我在Spinner中添加精度控制属性的一个原因。当然,该部分内容我会在后面继续介绍。
在真正开始编写控件之前,我们还需要考虑的事情就是用户的使用方法。一般的用户输入都是通过鼠标和键盘来完成的,因此我们就将用户使用方法归结为鼠标和键盘两类。
先来看看鼠标。鼠标需要考虑的主要分为击键和滚轮两种操作。在鼠标左键点击增加及减少按钮时,数值需要随鼠标的击键而更改,并提供适当的外观反馈。在鼠标左键点击输入框时,光标需要移动到相应位置。而在鼠标右键点击输入框时,对文本进行操作的菜单需要被弹出。在鼠标滚轮滚动时,Value的值需要同时进行更改。
接下来是键盘。一般情况下,键盘操作常常与非字符输入键相关联。例如用户通过Tab等操作导航到控件之后,拥有输入框组成的控件将自动把其内容全部选中。而用户敲击Enter键则表示他同意当前数值。输入焦点应转移到下一个控件以便用户继续操作。同时对于范围类型控件而言,Up和Down表示小范围数值变化,而PageUp和PageDown则表示大范围数值变化。对于Spinner来说,微调是其主要功能,因此令小范围数值变化和大范围数值变化的值相等也是合情合理的。
最后,在开始编写控件之前,我们需要借鉴一下WPF中的基于同一基类的类似控件的实现。有关如何得到WPF源代码的方式,请查看“从Dispatcher.PushFrame()说起”一文。通过观察这些控件的实现,我们可以更好地了解基类所提供的扩展点以及这些扩展点的使用方法。
二.开始实现
在本节中,我们就将开始着手实现Spinner。右键点击项目文件,并在弹出菜单中选择“Add”->“New Item”。在弹出的对话框中选择“Custom Control(WPF)”并在名称输入框中输入“Spinner.cs”,如图所示:
在点击Add按钮决定添加控件以后,Visual Studio将为我们添加两个文件:Spinner.cs以及表示默认主题的Generic.xaml。
2.1 模板支持
通常情况下,我都会在主题文件中放置控件的一个简单模板实现。例如一开始,我在Generic.xaml中为Spinner定义了如下外观:
<Style TargetType="{x:Type local:Spinner}"> <Setter Property="Control.Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type local:Spinner}"> <Border Background="{TemplateBinding Background}" BorderThickness="0.5" BorderBrush="{TemplateBinding BorderBrush}"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="*"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition/> <ColumnDefinition Width="20"/> </Grid.ColumnDefinitions> <TextBox x:Name="PART_Input" Grid.Column="0" Grid.Row="0" Grid.RowSpan="2" Margin="0.5" BorderThickness="0" Background="{TemplateBinding Background}"/> <RepeatButton x:Name="PART_Decrease" Grid.Column="1" Grid.Row="0" Margin="0.5"/> <RepeatButton x:Name="PART_Increase" Grid.Column="1" Grid.Row="1" Margin="0.5"/> </Grid> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style>
虽然这并不是最终的控件外观,但是通过该控件模板,我们可以随时测试Spinner控件所包含的逻辑。
在该控件模板定义中,我们为几个组成提供了特殊的名称,如PART_Input。在模板定义中,以PART_开头的名称表示该名称所对应的组件是在控件内部使用的模板定义中必不可少的一部分。为该控件所提供的其它模板同样需要为这些名称提供相应的组成。
在自定义控件的实现中,软件开发人员需要通过FrameworkTemplate.FindName()函数从当前控件所使用模板的实例寻找具有特定名称的组成。使用该函数的前提条件则是控件的模板已经被施行。因此,调用该函数的最适合位置就是重载函数OnApplyTemplate()函数。如下面代码所示:
public override void OnApplyTemplate() { mInputTextBox = null; mDecreaseButton = null; mIncreaseButton = null; base.OnApplyTemplate(); if (Template != null) { mInputTextBox = Template.FindName("PART_Input", this) as TextBox; mDecreaseButton = Template.FindName("PART_Decrease", this) as RepeatButton; mIncreaseButton = Template.FindName("PART_Increase", this) as RepeatButton; } }
为了能让模板设计人员能够知道这些必须在模板定义中出现的名称以及这些名称所对应的控件类型,WPF提供了TemplatePart特性。该特性提供了两个属性Name及Type。Name用来标记模板定义中需要添加的组成名称,而Type则用来指明该名称所需要具有的类型。如下面代码所示:
[TemplatePart(Name="PART_Input", Type=typeof(TextBox)), TemplatePart(Name="PART_Decrease", Type=typeof(RepeatButton)), TemplatePart(Name="PART_Increase", Type=typeof(RepeatButton))]
在使用了该特性的情况下,模板设计人员可以直接通过该特性声明得知模板中所应具有的相应元素以及其类型。
2.2 功能实现
现在我们就需要考虑如何实现控件所对应的功能。控件与模板之间进行互动的方法主要分为两种:绑定和侦听模板组成所发出的事件。在实现自定义控件时,我们需要尽量使用绑定。但是对于特殊的处理逻辑,我们常常不能通过绑定完成相应功能。在这种情况下,软件开发人员就需要通过侦听模板组成所发出的事件这一方式。
现在就来想想Spinner所需要使用的操作方式:对按钮控件的输入可能更改当前值,同时在输入框中执行输入并回车同样可以确认当前值。对按钮的点击可以触发Click事件,更可以触发按钮控件所关联的命令,而在输入框中敲击回车键则只会触发Keydown事件。因此在每次施行模板之后,我们需要为特定组成添加这些处理逻辑:
private void Attach() { if (mDecreaseButton != null) mDecreaseButton.Command = mDecreaseCommand; if (mInputTextBox != null) { mInputTextBox.Text = Value.ToString(); mInputTextBox.InputBindings.Add(new KeyBinding(mIncreaseCommand, new KeyGesture(Key.Down))); mInputTextBox.PreviewKeyDown += PreviewTextBoxKeyDown; mInputTextBox.LostKeyboardFocus += TextBoxLostKeyboardFocus; } }
与之对应的是,在每次施行模板之前,我们则需要取消这些处理逻辑:
private void Detach() { if (mInputTextBox != null) { mInputTextBox.PreviewKeyDown -= PreviewTextBoxKeyDown; mInputTextBox.LostKeyboardFocus -= TextBoxLostKeyboardFocus; } }
这是因为添加的消息处理函数会对消息源生成一个引用。而取消该消息的侦听则会释放该引用。
这里,我们来看一下Attach()函数中所展示的互动方式。
首先是命令。在这里我们使用mDecreaseCommand为按钮指定命令。为什么使用命令,而不是路由事件?这取决于是否该用户行为是否需要被用户代码知晓。相对于路由事件,路由命令会在遇到相应的执行逻辑后不继续执行路由,从而对用户不可见。
为了支持这些命令,软件开发人员需要为Spinner设置CommandBinding以及InputBinding。CommandBinding为命令指定执行逻辑,而InputBinding则为命令指定触发命令的执行条件。这部分逻辑通常在Spinner的构造函数中完成:
public Spinner() { CommandBindings.Add(new CommandBinding(mIncreaseCommand, OnIncreaseCommand, CanExecuteIncreaseCommand)); InputBindings.Add(new KeyBinding(mIncreaseCommand, new KeyGesture(Key.Down))); }
接下来要考虑的则是使用命令之外的另一种处理逻辑,事件。在事件PreviewKeyDown中,我们需要判断用户按下的是否是回车键。如果是,那么用户的当前输入将会被验证,并根据用户输入的正确性决定对Value值的刷新。这里存在着几个需要写到的问题。首先就是为什么用Preview-事件。TextBox会在处理用户输入时将KeyDown事件的handled设置为true,如方向键,因此软件开发人员不能直接使用KeyDown事件,而是使用PreviewKeyDown事件。另一个则是InputBinding有效的时机。InputBinding是由KeyDown事件所驱动的。在KeyDown事件被TextBox处理之前,TextBox实例内设置的InputBinding将被处理;而在TextBox中,KeyDown事件的handled属性会在处理过程中被设置为true,从而使TextBox的各个祖先元素失去了处理InputBinding的机会。这也便是Attach()函数为TextBox类型成员mInputTextBox添加额外的InputBinding的原因:
private void Attach() { if (mInputTextBox != null) { mInputTextBox.InputBindings.Add(new KeyBinding(……)); } }
接下来,考虑到微调控件的每次调整可能并不是整数,因此我们还需要为Spinner提供一种控制显示精度的方法。这便是添加Precision属性的原因。该属性会通过double.ToString()函数控制当前值的格式化执行方式,以显示特定的精度:
private string GetValueString() { int precision = Precision < 0 ? 0 : Precision; string format = string.Format("F{0}", precision); return Value.ToString(format); }
2.3 更改主题
在实现了所有功能之后,我们就应该开始准备为控件指定主题。
首先要提及的就是如何为控件指定默认样式。为控件指定默认样式的方法主要是通过设置DefaultStyleKey属性完成的。完成该工作的最常见方法就是在静态构造函数中重写依赖项属性DefaultStyleKey的默认值:
1 FrameworkElement.DefaultStyleKeyProperty.OverrideMetadata(typeof(Spinner), new FrameworkPropertyMetadata(typeof(Spinner)));
接下来,我们就需要在默认主题文件Generic.xaml中添加Spinner的外观定义。该外观定义的部分代码如下:
<ControlTemplate x:Key="RepeatButtonTemplate" TargetType="{x:Type ButtonBase}"> <Border x:Name="Chrome" BorderThickness="0, 0, 1, "Background="Transparent" …> <ContentPresenter …/> </Border> </ControlTemplate> <Style TargetType="{x:Type local:Spinner}"> <ControlTemplate TargetType="{x:Type local:Spinner}"> <Border …> <TextBox x:Name="PART_Input" Grid.Column="0" Grid.Row="0" Grid.RowSpan="2" Background="{TemplateBinding Background}" …/> <RepeatButton x:Name="PART_Decrease" Grid.Column="1" Grid.Row="0" Template="{StaticResource RepeatButtonTemplate}" …> <Path x:Name="UpTriangle" StrokeThickness="1" Data="M 3,0 L 0,4 6,4 Z" …/> </RepeatButton> </Border> <ControlTemplate.Triggers> <DataTrigger Binding="{Binding IsMouseOver, ElementName=PART_Decrease}" …> <Setter TargetName="UpTriangle" Property="Stroke" Value="Blue"/> <Setter TargetName="UpTriangle" Property="Fill" Value="Blue"/> </DataTrigger> </ControlTemplate.Triggers> </ControlTemplate> </Style>
如果软件开发人员希望为不同的Windows主题提供不同的外观,那么他可以通过为特定主题提供特定外观或是侦听WM_THEMECHANGED事件完成。在需要为特定主题提供特定外观时,软件开发人员需要在项目的Themes文件夹下添加对应的主题文件,如Windows经典主题对应的就是Themes\Classic.xaml。而如果对主题更改的支持是通过侦听WM_THEMECHANGED事件完成的,那么对控件模板的更换则需要通过代码显式完成。
同时,软件开发人员还可以通过ThemeInfo特性指定主题文件所存在的位置。该特性拥有两个和主题相关的属性:GenericDictionaryLocation以及ThemeDictionaryLocation。这两个属性分别指定了与主题相关的通用资源所在的位置以及特定于主题的资源所在的位置。它们都接受类型为ResourceDictionaryLocation的枚举值。该枚举值中,None表示不使用主题,SourceAssembly表示主题存在于当前程序集中,而ExternalAssembly则表示主题字典存在于受主题影响的外部程序集中。
对于本例的示例控件Spinner而言,对ThemeInfo主题的使用如下:
[assembly: ThemeInfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)]