WPF的一个优势在于,不必像其他用户界面框架那样要经常写自定义控件。如果你需要自定义一个已有控件的外观或者调整其表面的交互式行为,WPF提供各种各样的工具让你可以做到这些。在前面一些章节,我们已经看到了一些特色,如兼容性,内容模型,样式,模板,动画,以及集成的图像支持。这些可以让你广阔地定义已有控件而不用编写一个新的控件类型。
当然,自定义控件仍然占有地位。正如我们在第3章看到的,控件的角色是定义一个基本的行为。当你尽兴的定义一个按钮外观并为之添加动画的时候,它仍然保持其本质——可点击的能力。如果你需要的行为并没有被已有控件提供,同时不能通过将一些控件
放在一起的方式创建,这时你需要编写一个自定义的控件了。
如果你希望自己的控件是可复用的,你可能需要它具有和那些内嵌控件提供的一样的弹性,正如富文本,样式和模板所提供的支持。在这一章,我们将要看到如何利用内嵌控件同样强大的弹性,制作自定义控件。
9.1自定义控件基础
在写一个自定义控件之前,你需要问的第一个问题是,我真的需要一个自定义控件吗?一个写自定义控件的主要原因是为了用户界面技术专家可以修改控件的外观,但是正如我们在前些章看到的,内容模型和模板意味着这通常是不必要的。WPF提供了一个先进的按照规模的定制技术,你应该记住这些——当考虑写一个自定义控件时。
- 使用属性修改一个已有控件的外观
- 组合已有的一些控件
- 将内容嵌入已有控件
- 使用模板代替一个已有控件
- 创建一个自定义控件或其他自定义元素
这个顺序提供了渐增的能力级别,以每一级别上轻微的额外成效为交换。在这些简单的情形中,你可以通过设置属性来调整一个内嵌控件的行为和外观以满足你对的需求。下一步是将控件编译在一起,形成一个更强大的整体。你可以通过将内容嵌入另一个控件的方式,把这个合成物带入到下一步骤。你可以使用一个模板完全替换外观,正如第5章所描述的。一旦技术1-4都没有满足你的需求,编写一个自定义的元素就像自定义控件,看上去像是一个答案。
是否需要写一个新的可视化元素类型,一个重要的指示是,你是否计划添加一个新的API特性。即使在这种情形,你应该仔细的考虑要写哪一种自定义元素类型,控件并不是唯一的元素种类。通过写一个底层的组件,并集成到已知控件中的外观中,你可以得到更多的弹性。例如,大量的使WPF弹性化的元素,如布局类和Shapes,派生于FrameworkElement,实际上并不是控件——它们不是从Control基类派生的。
如果你确定一个自定义元素使最好的进行办法,你需要做完大量的设计步骤。首先,你必须挑选出基类。它会派生自FrameworkElement,Control,或WPF提供的其它基类型之一么?然后你必须定义API,决定你的组件哪个属性,事件和命令。最后,如果你的新元素提供了与内嵌组件相同的弹性,你需要注意元素和其模板间的接口。
9.2 选择一个基类
WPF提供了很多类,当创建一个自定义元素时,你可以从这些类中派生。图9-1显示了一组可能作为类——可能是合适的基类,并且说明了他们之间的继承关系。注意到,这决不是完整的继承关系图,只是简单的显示了一些你应该考虑的可能的基类。
无论你选择了哪一个基类,你的元素都会直接或间接地从FrameworkElement派生。这将提供routing事件,高级属性处理,动画,数据绑定,外观上的支持,样式,以及逻辑树的集成。
#派生于FrameworkElement并不是绝对的需要。第7章讨论了底层可视化图形API,虽然该章的示例派生自FrameworkElement,你也可以直接派生于Visual,当使用底层绘图API的时候。然而,如果你这么做了,你将会损失由FrameworkElement提供的全部服务。对派生于底层的元素,你只能在特别专业的环境使用到。
图9-1
直接派生自FrameworkElement,对于一个被设计为组合到其他元素的元素而言,是恰当的。例如,考虑一个绑定到数据源而且生成数据图表的元素。你可能使之派生自Control。尽管如此,未经加工的图形绘制的元素,通常协力于其他元素如TextBlock,从而为这个图形和其轴提供标签。因此,将graph分成图形绘制可能是有意义的,这将合并到一个外观中,包含着任意数量的不同控件。
#将一个控件放在另一个控件的模板内部是可行的。但是一旦你发现纯粹是在写一个自定义控件,并放在另一个控件的模板内,你可能需要回顾一下你选择的基类了。
如果你写一个表现自定义外观逻辑的元素,你应该派生于Panel,从而与内嵌外观元素保持一致。
如果你写一个包着另一个元素的元素——在某些方面的增强,要考虑派生于Decorator。很多内嵌元素都是派生于Decorator。例如,Border,在元素的外面添加了一个边框;还有Viewbox,可以自动伸缩被其包着的元素,填充有效的空间。如果你希望提供一种包装器,在内容外添加功能,要考虑派生于Decorator。
如果你的元素提供了行为,或支持用户交互动作——不能使用内嵌组件,这时派生于Control就是恰当的了,以直接或间接的方式。例如,如果你想制作一个交互式的图表组件,用户可以在上面点击图表中的数据项来检查它们,或者放大,这可以被典型地写为一个控件(同时可能要使用你先前写的表现底层图像的元素)。
Control提供了很多派生类,增强了基础控件的功能性。如果你写了一个控件,提供了空白区域,用户可以在上面防止一些内容(如一个标题),你应该派生于ContentControl,这个基类提供了支持内容模型的控件。如果你的控件支持在头标题以及主区域(如分页TabPage)的中的内容,要考虑派生于HeaderedContentControl。
如果你需要表现多个子元素,首先要考虑ListBox和数据绑定以及数据样式的联合是否满足你的需求。数据绑定和样式支持WPF的ListBox处理宽范围的场景,这些都是Win32和Windows Forms的ListBox所不适合的。一旦你需要额外的功能——内嵌的列表控件并不支持,你应该考虑派生于你的自定义元素类型,如Selector或其基类,如ItemControl。ItemControl对包含了列表项的控件提供了基本支持,包括可选的数据绑定功能。Selector增强了跟踪当前一个或一组选项的功能。
9.3自定义功能
一旦你挑选好一个基类,你将要为你的控件设计一个API。大部分WPF元素提供属性暴露了多数功能,事件,命令,因为他们从框架中获取广泛的支持,以及易于使用XAML。WPF框架对routed event和命令提供了自动支持,它的依赖属性系统提供了数据半岛和动画支持。当然,你也可以写方法——对于某一种功能,方法是最好的途径。(例如,ListBox有一个ScrollIntoView方法,保证了一个特定的项目是可见的。这时从代码中能够做的方便的事情。)但是,我更喜欢在合理的地方使用属性,事件以及命令。
9.3.1属性
.NET类型系统提供了一个标准的方式为一个对象定义属性。它指定了一个协定:提供了get和set的方法访问器,但是对于这些的实现,以及属性值的存储方式,都留给了开发者。在WPF中,元素通常使用依赖属性系统。.NET提供了代表性的样式属性访问器,但是这些仅仅是对依赖属性(DP)的包装,增加了便利。
DP系统添加了大量的特色——并不有标准.NET属性提供。例如,DP从父元素中继承了它的值。这与OO意义上的继承不同,DP是从其基类继承其特征(虽然DP也支持OO意义的继承性)。属性值的继承性是一个更动态的特征,允许在一个单元素上设置属性,以及自动传播到它的所有子元素。例如,所有的元素有一个Cursor属性用来控制鼠标指针。这个属性使用了值的继承性,意味着一旦你在元素上看到Cursor,所有的子元素将自动得到同样的Cursor属性值。(如果你使用Windows Forms,你将熟悉这个概念,这里任意的元素都具有相同的特征。)
DP在别处也自动获取它们的值。DP支持数据绑定和样式,它们提供一个定义默认值的机制。动画系统也依赖于DP,它使用了DP结构来即时的调整属性值。
通过实现你的元素属性,如DP,你不仅可以自动得到这些特征,而且DP系统还为你管理着值的存储。你不必为定义任何字段实例来存储属性值。
#存储管理器看起来是件小事情,毕竟,为类添加一个字段是多么的困难?尽管如此,这种特征能提供令人惊讶的有意义的内存存储。
简单的继承于Control,你的元素可以支持多于40个属性(加上任何附属属性)来改变复杂性,其中大部分看起来都具有一个默认值对于大多数对象而言。如果每个元素都有自己的一组字段存储这些值,每个元素将占用数百字节。一个复杂的用户界面可能需要成千的字节(即使UI有一个相当简单的结构,可视化树可以显著地增加元素的数量。)
多数元素的大部分属性或者继承与它们的父类或者设置为它们的默认值,然后使用元素按字段存储这些值,这将浪费成百上千的内存。更加高级的存储方式暴露了这样的事实:多数未设置的属性是有效的。而且随着内存的便宜,在CPU中移入移出数据是昂贵的。CPU可以比数据转换主内存, 更快的执行代码。只有内存缓存可以相当快的跟上处理器,而且大多数现代化的处理器典型地只有成百上千字节的缓存。甚至高端系统仅有几个兆字节的缓存。保存成百上千字节能够显著的提高性能。
最后,DP系统跟踪了值的改变。这意味着一旦任何感兴趣的部分想知道一个属性值何时改变,它能使用DP系统注册通知。(数据绑定取决于次。)我们不需要写任何特殊的代码使之发生。DP系统管理者我们属性值的存储,因此它知道何时属性改变。
任何你创建的WPF自定义元素,将会自动地支持DP的一切,因为FrameworkElement间接派生于DependencyObject基类。为了在我们的自定义元素上定义一个新的属性,我们必须在元素的静态构造函数中创建一个新的DependencyObject对象。作为惯例,我们暴露了对象属性,通过在我们的类中,按一个公有的静态字段排序,正如示例9-1所示。
示例9-1
自定义控件定义了一个单独的名为Foo的DP,类型为Brush。当注册一个属性时,传递Brushes.Green这个默认值在PropertyMetadata对象中。
#你可能想知道为什么WPF发明了新类型来表现属性和关联元数据,当反射API已经提供了PropertyInfo类以及一个扩展机制以自定义属性的形式。不幸的是,反射API不能提供WPF需要的弹性和性能的联合。这是为什么在DP元数据和反射之间有交叠的原因。
示例9-1还提供了一个标准的.NET属性——成对的get和set。这些不是确实需要。你可以使用公有的继承自DependencyObject的GetValue和SetValue方法访问属性,如下:
myControl.SetValue(MuCustomControl.FooProperty, Brushes.Red);
尽管如此,在大多数.NET语言中,使用正规的CLR属性将是很容易的,因此你通常要提供一个恰当的包装,如示例9-1。正如你看到的,访问器简单的使用继承自DependencyObject基类的GetValueBase和SetValueBase方法。这些方法被特殊的定义用来被属性访问器调用。
示例9-2显示了在xaml中如何使用自定义属性。(这里假设命名空间包含了这个被关联到XML命名空间前缀local的控件。参见附录A获取更多关于.NET命名空间和XML命名空间之间关系的信息)
示例9-2
注意到因为我们的属性是Brush,我们可以使用同样的文字速记格式,用来表示我们在第7章看到的笔刷。示例9-2用此来创建一个垂直渐变的笔刷。
9.3.1.1附属属性
如果你希望定义一个附属属性,一种是将其应用到元素而不是定义你使用DP系统注册的元素,通过一个不同的调用:RegisterAttached。正如示例9-3显示,这个方法的调用方式与Register方法一样。
示例9-3
注意到,访问器看上去不太一样。.NET并未定义一个标准的暴露属性的方式,这些属性由一个类型定义,但是可以应用到另一种类型。XAML和WPF承认示例9-3中的约定语法,其中我们定义了一对静态方法GetPropname和SetPropname。这些方法都是将目标对象传递给要应用到的属性。
示例9-4展示了如何在xaml中应有一个自定义附属属性到一个Button元素。
示例9-4
9.3.1.2 值改变的通知机制
你不能总是使用方法访问器设置属性,例如,数据绑定和动画使用了DP系统直接修改属性值。如果你需要知道属性值什么时候改变,你应该依赖于被调用的访问器,因为它们不会经常改变。取代的,你应该注册无效的通知,在属性注册期间,通过传递一个回调到PropertyMetadata。这将以同样的方式工作在正常属性和附属属性上。示例9-5显示了对示例9-3的改动,从而当属性改变时收到通知。
示例9-5
无论何时属性被改动,处理改动的函数都将被调用,不论是在示例9-3调用静态的SetIsBar方法来改变,还是直接使用DP系统在代码中改变。
9.3.2事件
让我们看一下第三章中routed事件的处理。如果你希望为内容定义自定义事件,将它们实现为routed事件就是有意义的。不仅使你的元素与其它WPF元素保持一致,而且你可以恰当地利用同样的bubbing和tunnel路由策略。
创建自定义的路由事件有点像创建自定义属性。你简单的创建它们在类的静态构造函数中。方便起见,你还可以添加一个.NET样式对底层的路由事件处理进行包装。这些技术被示范在示例9-6中。
示例9-6
示例9-6显示了定义一对事件:一个PreviewBar(对应tunneling)事件和一个Bar(对应bubbling)事件。这样就提供给.NET事件成员以便利——推迟到基类中的AddHandler和RemoveHandler方法。
这个事例还提供了OnBar方法来激发事件。这将激发preview事件,而且如果没有标记为已处理,将会继续激发主要的Bar事件。RaiseEvent方法由使用routed事件的基类提供,会调用。注意到正如标准的CLR事件,由异步RaiseEvent激发的routed事件,将会顺序的调用事件句柄,直到全部执行完毕才会返回。
9.3.2.1附属属性
正如一些属性可以被附属到类型上——而不是直接定义的类型,事件也是这样。不同于依赖属性,routed事件不需要以一种不同的方式注册来作为附属事件工作。例如,你可以为示例9-6定义的MyCustomControl.Bar事件附属一个句柄到一个Button上,如示例9-7所示。
示例9-7
这个示例提及的MyCustomControl是一个事件句柄方法,将会被调用,当这个按钮上的Bar事件被激活的时候。当然,这个按钮并不知道Bar事件,因此我们需要写一些代码来激活事件,如示例9-8。
示例9-8
附属事件支持你将你自己的事件引进到UI树中,而不需要担心树中的元素是否知道这些事件。
9.3.3命令
我们在第3章看到了WPF的RoutedCommand类表示用户的一个特定的动作,这个动作可能被任意数量的不同输入所调用。一个自定义控件有两种办法想和命令系统进行交互:可能定义新的命令类型;或者处理定义在别处的命令。
示例9-9显示了如何注册一个自定义命令。
示例9-9
代表性的,你想要制作自己的控件处理任意自定义命令。你可能还向处理一个已有的命令。例如,你可能希望响应一些由CommandLibrary提供的标准命令。在第三章,我们看到通过添加一个CommandBinding到你的自定义控件的CommandBindings集合,可以达到这个目的。然而,对于自定义控件,这通常不是一个恰当的技术。通常你想要你的控件的所有实例都按照同样的方式响应命令,而且你可能为每一个实例设立命令绑定,最好是注册一个类的处理器。这使你在静态构造函数中一次性设立一个命令处理联合,这将会为你的自定义元素的所有实例工作。示例9-10显示了如何去做。
示例9-10
注意到,处理器必须是静态的方法。当你的静态构造函数执行时,还没有一个自定义控件的实例。除此之外,处理器将会代表所有实例注册一次,因此将其放在一个实例方法中是没有意义的。当一个命令被调用时,处理器将传递一个引用到目标元素作为它的第一个参数。
实例9-11显示了在xaml中配置一个Button,当点击的时候会调用这个自定义命令。
9.4模板
对一个自定义元素最后的设计考虑是,它是如何连接其可视化的。如果一个元素直接从FrameworkElement中派生,这将会适当的生成它自己的可视化。(第7章描述了如何创建一个图形外观。)尤其是,如果你创建了一个元素,是为了提供一个特定的可视化表现,该元素应该完全控制这个可视化是如何管理的,一旦你编写了一个控件,通常你不会将一个图形硬编码到里面。
记住,一个控件的工作是提供行为。可视化是由控件模板提供的。这种可视化是由控件模板提供的。一个控件可能提供一组默认的可视化,而应允许这些可视化被替换,为了提供像内迁控件一样的弹性。(第五章描述了如何使用模板替换一个控件的可视化)符合这种方法的控件,这里可视化从控件中分离出来,通常引用到一个没有外观的控件。所有内迁到WPF的控件都是没有外观的。
当然,控件完全独立于其可视化是不可能的。任何控件将对模板必须满足的需求施加影响,如果控件操作正确。这些需求的程度随控件不同而不同。例如,Button有一个相当简单的需求——仅仅需要一个占位符放置标题或内容。Slider控件需要更广泛的需求:可视化必须提供两个按钮(增加和减少),“Thumb”,以及运行时Thumb上的一个跟踪。此外,它还需要能够响应点击和拖动在这些元素的任意一个,以及能够定位这个Thumb。
在任意控件类型和样式或模板之间有一个隐式的约定。这个控件允许它的外观通过替换可视化树的方式进行自定义,但是这棵树必须轮流提供代表这棵树的某些特征。这个约定的本性依赖于这个控件,内嵌控件使用一些不同的样式,紧紧依赖于它们的可视化结构。下面的部分描述了很多将控件与其模板联系在一起的方式
示例9-11
9.4.1属性别名
控件和模板间最松散的约定形式是控件简单的定义了公有属性,以及允许模板来决定哪一个属性在别名中可见。(参见第5章获取更多属性别名的信息。)这个控件并不关心
在控件中是什么。
这里有一个单行的约定:控件提供属性和命令,不需要返回值。尽管如此,如果必要的话,这样的控件仍能响应用户输入。事件路由允许事件从可视化向上冒泡到控件。控件能够处理这些事件而不需要知道任何关于可视化本性的信息。
为了支持这个模型,你所要做的是,使用本章先前描述的依赖属性机制,来实现这些属性。示例9-11显示了一个自定义控件,并且定义了一个单独的名为Foo的依赖属性,Brush类型。
依赖属性支持这个控件的用户在模板中提及,正如示例9-12所示。
示例9-12
所有的依赖属性自动支持属性别名。这种情形下的“约定”是由一组你的控件提供的依赖属性暗示的。
9.4.2占位符
一些控件希望在模板中找到一个特定的占位符元素。这将要么采取该元素指定类型的形式,或者可以是一个元素标记了一个特定的属性。
控件通过派生于ContentControl支持内容模板,使用元素类型的方法。它们希望在模板中找到一个ContentPresenter元素。这是一个特殊意图的元素,它的工作是在其他内容中担当一个占位符。
#实际上,这是一个松散的强迫性的约定。如果模板中没有ContentPresenter,ContentControl通常不会申诉。控件并不绝对依赖于表现的内容,为了放在那里起作用。或者你能到达另一个极端,以及放一些ContentPresenter在你的模板中,可以使子内容多次出现。
你不需要做任何特殊的事情来支持ContentPresenter的使用,只要你派生于ContentControl,它可以很好的工作。控件的用户能够编写一个模板,正如示例9-13所示。
示例9-13
9.4.3通过属性指定占位符
一些控件寻找用一个特定属性标记的元素。例如,派生于ItemsControl的控件,如ListBox和MenuItem,希望模板包括一个带有Panel.IsItemsHost属性设为true的元素。这标志了Panel将要扮演控件数据项目的宿主。ItemCOntrol使用附属属性取代占位符的原因是,允许你决定使用什么类型的Panel,作为数据项的宿主。(ItemControl还支持ItemsPresenter占位符元素的使用。这将使用于当样式不希望利用特定的panel类型的时候以及想要使用无论控件的默认panel是什么的时候)
为了实现使用此技术的控件,你需要定义一个自定义附属依赖属性,将其应用到占位符。这是一个Boolean属性。示例9-14注册了这样一个附属属性,并定义了通常的访问器功能。
示例9-14
注意到示例9-14为PropertyMetaData提供了一个PropertyInvalidatedCallBack。这指示了一个可以在任意时间调用的方法,这个附属属性可以在任意元素上被设置或修改。在这种方法中,我们的控件将发现哪个元素被设置为占位符,示例9-15显示了这个方法。
示例9-15
这个示例开始于检测属性被应用到派生于FrameworkElement的对象。记住我们希望这个属性会被应用到一个特定的控件模板内的UI元素,因此如果被应用到别的元素而不是FrameworkElement,我们这么做就得不到什么有用的东西。
其次,我们通过GetIsMyPlaceholder访问器方法检测了属性值,该方法是我们在示例9-14为附属属性定义的。这将是些微单独的,如果有人显示的设置这个属性为false,但是如果确实是这样,我们干脆不应该把元素作为占位符。
如果这个属性设置为true,我们继续获取目标元素的TemplatedParent属性。因为元素作为控件的模板一部分,这将返回可视化所属于的控件。(如果这个元素不是控件的成员,那么返回null。既然这个属性仅仅对模板中的元素有意义,如果没有模板化的父一级,我们就做不了任何事情。)我们还检查了父一级是一个控件类型的一个实例,而且忽略了属性,如果被应用到一个模板中的元素,在某种其它类型的控件模板中。
示例9-16显示了如何在一个控件模板中使用属性,来表明是哪一个元素在占位符中。
示例9-16
一些控件希望有一种模板,提供一组详细明确的元素,来履行特定的角色在控件的标签中。例如,HorizontalSlider控件希望模板包含表示可拖动thumb的元素,这个可点击的跟踪,在thumb的任意一边,等等。模板需要指出哪一个元素是哪一个。这可以通过使用上述显示的技术,定义多个附属属性来实现。
当你写一个使用了占位符的控件时,你可能选择不执行这个约定。例如,如果模板的任意部分不见了,slider控件不会抱怨。一旦你只提供了要寻找的一些元素,这可以工作而不用抱怨。
9.5默认可视化
虽然为控件提供一个自定义外观的能力是有用的,开发者应该能够使用一个控件而不用必须提供自定义可视化。这个控件应该正好工作,当以它最直接的方式使用时。这意味着控件应该提供一组默认的值。
这些默认的可视化存储在组件的二进制资源中,使用的源文件为theme"generic.xaml。如果你在Visual Studio 2005中创建了一个WPF 控件库的工程,这将自动添加这个文件到你的工程中,并且设置它的Build Action为作为资源内嵌。(参见第6章获取更多关于如何在组件中编译xaml资源的信息)
在theme"generic.xaml文件中,定义一个样式,携有TargetType指定你的控件。这个样式应该通过一个ControlTemplate标签设置Template属性,为你的控件定义了默认可视化,正如示例9-17显示的。参见第5章获取更多关于如何定义一个提供了模板的样式的信息。
示例9-17
为了确定你的控件获取了默认的主体,你需要让依赖属性系统知道样式所在。如果你不这么做,你只能为你选择的基类获得默认值。示例9-18显示了如何这么做。
示例9-18
注意到Visual Studio 2005为你自动生成这段代码,当你添加一个新的自定义控件到你的控件库的工程中。
9.6我们进行到哪里了?
只有当任何内嵌控件都没有提供你需要的底层行为时,你将要写一个自定义控件。当你写一个自定义控件,你将要使用到依赖属性系统,来提供支持数据绑定和动画的属性。你将使用routed事件结构来暴露事件。如果你想写一个没有外观的控件,允许其可视化能被替换——如内嵌控件,你必须考虑你的控件和模板之间如何进行交互。你还将要为一个提供了一组默认可视化的模板提供一个默认值。