[WPF自定义控件库] 自定义控件的代码如何与ControlTemplate交互
1. 前言
wpf有一个灵活的ui框架,用户可以轻松地使用代码控制控件的外观。例设我需要一个控件在鼠标进入的时候背景变成蓝色,我可以用下面这段代码实现:
protected override void onmouseenter(mouseeventargs e) { base.onmouseenter(e); background = new solidcolorbrush(colors.blue); }
但一般没人会这么做,因为这样做代码和ui过于耦合,难以扩展。正确的做法应该是使用代码告诉controltemplate去改变外观,或者控制controltemplate中可用的元素进入某个状态。
这篇文章介绍自定义控件的代码如何和controltemplate交互,涉及的知识包括relativesource、trigger、templatepart和visualstate。
2. 简单的expander
本文使用一个简单的expander介绍ui和controltemplate交互的几种技术,它的代码如下:
public class myexpander : headeredcontentcontrol { public myexpander() { defaultstylekey = typeof(myexpander); } public bool isexpanded { get => (bool)getvalue(isexpandedproperty); set => setvalue(isexpandedproperty, value); } public static readonly dependencyproperty isexpandedproperty = dependencyproperty.register(nameof(isexpanded), typeof(bool), typeof(myexpander), new propertymetadata(default(bool), onisexpandedchanged)); private static void onisexpandedchanged(dependencyobject obj, dependencypropertychangedeventargs args) { var oldvalue = (bool)args.oldvalue; var newvalue = (bool)args.newvalue; if (oldvalue == newvalue) return; var target = obj as myexpander; target?.onisexpandedchanged(oldvalue, newvalue); } protected virtual void onisexpandedchanged(bool oldvalue, bool newvalue) { if (newvalue) onexpanded(); else oncollapsed(); } protected virtual void oncollapsed() { } protected virtual void onexpanded() { } }
<style targettype="{x:type local:myexpander}"> <setter property="horizontalcontentalignment" value="stretch" /> <setter property="template"> <setter.value> <controltemplate targettype="{x:type local:myexpander}"> <border background="{templatebinding background}" borderbrush="{templatebinding borderbrush}" borderthickness="{templatebinding borderthickness}"> <stackpanel> <togglebutton x:name="expandertogglebutton" content="{templatebinding header}" ischecked="{binding isexpanded,relativesource={relativesource mode=templatedparent},mode=twoway}" /> <contentpresenter grid.row="1" x:name="contentpresenter" horizontalalignment="{templatebinding horizontalcontentalignment}" verticalalignment="{templatebinding verticalcontentalignment}" visibility="collapsed" /> </stackpanel> </border> </controltemplate> </setter.value> </setter> </style>
myexpander是一个headeredcontentcontrol,它包含一个isexpanded用于指示当前是展开还是折叠。controltemplate中包含expandertogglebutton及contentpresenter两个元素。
3. 使用relativesource
之前已经介绍过templatebinding,通常controltemplate中元素都通过templatebinding获取控件的属性值。但需要双向绑定的话,就是relativesource出场的时候了。
relativesource有几种模式,分别是:
- findancestor,引用数据绑定元素的父链中的上级。 这可用于绑定到特定类型的上级或其子类。
- previousdata,允许在当前显示的数据项列表中绑定上一个数据项(不是包含数据项的控件)。
- self,引用正在其上设置绑定的元素,并允许你将该元素的一个属性绑定到同一元素的其他属性上。
- templatedparent,引用应用了模板的元素,其中此模板中存在数据绑定元素。。
controltemplate中主要使用relativesource mode=templatedparent
的binding,它相当于templatebinding的双向绑定版本。,主要是为了可以和控件本身进行双向绑定。expandertogglebutton.ischecked使用这种绑定与expander的isexpanded关联,当expander.ischecked为true时expandertogglebutton处于选中的状态。
ischecked="{binding isexpanded,relativesource={relativesource mode=templatedparent},mode=twoway}"
接下来分别用几种技术实现expander.ischecked为true时显示contentpresenter。
4. 使用trigger
<controltemplate targettype="{x:type local:expanderusingtrigger}"> <border background="{templatebinding background}"> ...... </border> <controltemplate.triggers> <trigger property="isexpanded" value="true"> <setter property="visibility" targetname="contentpresenter" value="visible" /> </trigger> </controltemplate.triggers> </controltemplate>
可以为controltemplate添加triggers,内容为trigger或eventtrigger的集合,triggers通过响应属性值变更或事件更改控件的外观。
大部分情况下trigger简单好用,但滥用或错误使用将使controltemplate的各个状态之间变得很混乱。例如当可以影响外观的属性超过一定数量,并且这些属性可以组成不同的组合,trigger将要处理无数种情况。
5. 使用templatepart
templatepart(部件)是指controltemplate中的命名元素(如上面xaml中的“headerelement”)。控件逻辑预期这些部分存在于controltemplate中,控件在加载controltemplate后会调用onapplytemplate,可以在这个函数中调用protected dependencyobject gettemplatechild(string childname)
获取模板中指定名字的部件。
[templatepart(name =contentpresentername,type =typeof(uielement))] public class expanderusingpart : myexpander { private const string contentpresentername = "contentpresenter"; protected uielement contentpresenter { get; private set; } public override void onapplytemplate() { base.onapplytemplate(); contentpresenter = gettemplatechild(contentpresentername) as uielement; updatecontentpresenter(); } protected override void onisexpandedchanged(bool oldvalue, bool newvalue) { base.onisexpandedchanged(oldvalue, newvalue); updatecontentpresenter(); } private void updatecontentpresenter() { if (contentpresenter == null) return; contentpresenter.visibility = isexpanded ? visibility.visible : visibility.collapsed; } }
上面的代码实现了获取contentpresenter并根据isexpanded 的值将它显示或隐藏。由于template可能多次加载,或者不能正确获取templatepart,所以使用templatepart前应该先判断是否为空;如果要订阅templatepart的事件,应该先取消订阅。
注意:不要在loaded事件中尝试调用gettemplatechild,因为loaded的时候onapplytemplate不一定已经被调用,而且loaded更容易被多次触发。
templatepartattribute协定
有时,为了表明控件期待在controltemplate存在某个特定部件,防止编辑controltemplate的开发人员删除它,控件上会添加添加templatepartattribute协定。上面代码中即包含这个协定:
[templatepart(name =contentpresentername,type =typeof(uielement))]
这段代码的意思是期待在controltemplate中存在名称为 "contentpresentername",类型为uielement的部件。
templatepartattribute在uwp中的作用好像被弱化了,不止在uwp原生控件中见不到templatepartattribute,甚至在blend中“部件”窗口也消失了。可能uwp更加建议使用visualstate。
使用templatepart需要遵循以下原则:
- 尽可能减少templarepartattribute协定。
- 在使用templatepart之前检查其是否为null。
- 如果controltemplate没有遵循templatepartattribute协定也不应该抛出异常,缺少部分功能可以接受,但要确保程序不会报错。
6. 使用visualstate
visualstate 指定控件处于特定状态时的外观。控件的代码使用visualstatemanager.gotostate(control control, string statename,bool usetransitions)
指定控件处于何种visualstate,控件的controltemplate中根节点使用visualstatemanager.visualstategroups
附加属性,并在其中确定各个visualstate的外观。
[templatevisualstate(name = stateexpanded, groupname = groupexpansion)] [templatevisualstate(name = statecollapsed, groupname = groupexpansion)] public class expanderusingstate : myexpander { public const string groupexpansion = "expansionstates"; public const string stateexpanded = "expanded"; public const string statecollapsed = "collapsed"; public expanderusingstate() { defaultstylekey = typeof(expanderusingstate); } protected override void onisexpandedchanged(bool oldvalue, bool newvalue) { base.onisexpandedchanged(oldvalue, newvalue); updatevisualstates(true); } public override void onapplytemplate() { base.onapplytemplate(); updatevisualstates(false); } protected virtual void updatevisualstates(bool usetransitions) { visualstatemanager.gotostate(this, isexpanded ? stateexpanded : statecollapsed, usetransitions); } }
<controltemplate targettype="{x:type local:expanderusingstate}"> <border background="{templatebinding background}" borderbrush="{templatebinding borderbrush}" borderthickness="{templatebinding borderthickness}"> <visualstatemanager.visualstategroups> <visualstategroup x:name="expansionstates"> <visualstate x:name="expanded"> <storyboard> <objectanimationusingkeyframes storyboard.targetproperty="(uielement.visibility)" storyboard.targetname="contentpresenter"> <discreteobjectkeyframe keytime="0" value="{x:static visibility.visible}" /> </objectanimationusingkeyframes> </storyboard> </visualstate> <visualstate x:name="collapsed" /> </visualstategroup> </visualstatemanager.visualstategroups> ...... </border> </controltemplate>
上面的代码演示了如何通过控件的isexpanded 属性进入不同的visualstate。expansionstates是visualstategroup,它包含expanded和collapsed两个互斥的状态,控件使用visualstatemanager.gotostate(control control, string statename,bool usetransitions)
更新visualstate。usetransitions这个参数指示是否使用 visualtransition 进行状态过渡,简单来说即是visualstate之间切换时用不用visualtransition里面定义的动画。请注意我在onapplytemplate()
中使用了 updatevisualstates(false)
,这是因为这时候控件还没在ui上呈现,这时候使用动画毫无意义。
使用visualstate的最佳实践
使用属性控制状态,并创建一个方法帮助状态间的转换。如上面的updatevisualstates(bool usetransitions)
。当属性值改变或其它有可能影响visualstate的事件发生都可以调用这个方法,由它统一管理控件的visualstate。注意一个控件应该最多只有几种visualstategroup,有限的状态才容易管理。
templatevisualstateattribute协定
自定义控件可以使用templatevisualstateattribute协定声明它的visualstate,用于通知控件的使用者有这些visualstate可用。这很好用,尤其是对于复杂的控件来说。上面代码也包含了这个协定:
[templatevisualstate(name = stateexpanded, groupname = groupexpansion)] [templatevisualstate(name = statecollapsed, groupname = groupexpansion)]
templatevisualstateattribute是可选的,而且就算控件声明了这些visualstate,controltemplate也可以不包含它们中的任何一个,并且不会引发异常。
7. trigger、templatepart及visualstate之间的选择
正如expander所示,trigger、templatepart及visualstate都可以实现类似的功能,像这种三种方式都可以实现同一个功能的情况很常见。
在过去版本的blend中,编辑controltemplate可以看到“状态(states)”、“触发器(triggers)”、“部件(parts)”三个面板,现在“部件”面板已经消失了,而“触发器”从silverlight开始就不再支持,以后也应该不会回归(xaml standard在github上有这方面的讨论(add triggers, datatrigger, eventtrigger,___) [and-or] visualstate · issue #195 · microsoft-xaml-standard · github[https://github.com/microsoft/xaml-standard/issues/195])。现在看起来是visualstate的胜利,其实在silverlight和uwp中templatepart仍是个十分常用的技术,而在wpf中trigger也工作得很出色。
如果某个功能三种方案都可以实现,我的选择原则是这样:
- 需要向控件发出命令的,如响应点击事件,就用templatepart;
- 简单的ui,如隐藏/显示某个元素就用trigger;
- 如果要有动画,并且代码量和使用trigger的话,我会选择用visualstate;
几乎所有wpf的原生控件都提供了visualstate支持,例如button虽然使用buttonchrome实现外观,但同时也可以使用visualstate定义外观。有时做自定义控件的时候要考虑为常用的visualstate提供支持。
8. 结语
visualstate是个比较复杂的话题,可以通过我的另一篇文章理解controltemplate中的visualtransition更深入地理解它的用法(虽然是uwp的内容,但对wpf也同样适用)。
即使不自定义控件,学会使用controltemplate也是一件好事,下面给出一些有用的参考链接。
9. 参考
创建具有可自定义外观的控件 microsoft docs
通过创建 controltemplate 自定义现有控件的外观 microsoft docs
control customization microsoft docs
controltemplate class (system_windows_controls) microsoft docs
推荐阅读
-
[WPF自定义控件库] 模仿UWP的ProgressRing
-
如何在双向绑定的Image控件上绘制自定义标记(wpf)
-
[WPF自定义控件库]使用TextBlockHighlightSource强化高亮的功能,以及使用TypeConverter简化调用
-
[WPF自定义控件库]好用的VisualTreeExtensions
-
[WPF自定义控件库]了解WPF的布局过程,并利用Measure为Expander添加动画
-
[WPF自定义控件库] 自定义控件的代码如何与ControlTemplate交互
-
VS下WPF自定义控件的基本步骤和基本代码实现
-
[WPF自定义控件库]以Button为例谈谈如何模仿Aero2主题
-
[WPF自定义控件库]简单的表单布局控件
-
[WPF自定义控件库]使用WindowChrome的问题