欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

[WPF自定义控件库] 自定义控件的代码如何与ControlTemplate交互

程序员文章站 2022-04-09 08:41:09
1. 前言 WPF有一个灵活的UI框架,用户可以轻松地使用代码控制控件的外观。例设我需要一个控件在鼠标进入的时候背景变成蓝色,我可以用下面这段代码实现: 但一般没人会这么做,因为这样做代码和UI过于耦合,难以扩展。正确的做法应该是使用代码告诉ControlTemplate去改变外观,或者控制Cont ......

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,内容为triggereventtrigger的集合,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也工作得很出色。

[WPF自定义控件库] 自定义控件的代码如何与ControlTemplate交互

[WPF自定义控件库] 自定义控件的代码如何与ControlTemplate交互

如果某个功能三种方案都可以实现,我的选择原则是这样:

  • 需要向控件发出命令的,如响应点击事件,就用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