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