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也工作得很出色。

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

  • 需要向控件发出命令的,如响应点击事件,就用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自定义控件库] 自定义控件的代码如何与ControlTemplate交互的更多相关文章

  1. [WPF 自定义控件]自定义控件库系列文章

    Kino.Toolkit.Wpf Kino.Toolkit.Wpf是一组简单实用的WPF控件与工具,用于介绍自定义控件的入门.相关博客地址如下: 开始一个自定义控件库项目 介绍开始一个自定义控件库项目 ...

  2. [WPF 自定义控件]开始一个自定义控件库项目

    1. 目标 我实现了一个自定义控件库,并且打算用这个控件库作例子写一些博客.这个控件库主要目标是用于教学,希望通过这些博客初学者可以学会为自己或公司创建自定义控件,并且对WPF有更深入的了解. 控件库 ...

  3. [WPF自定义控件库] 关于ScrollViewer和滚动轮劫持(scroll-wheel-hijack)

    原文:[WPF自定义控件库] 关于ScrollViewer和滚动轮劫持(scroll-wheel-hijack) 1. 什么是滚动轮劫持# 这篇文章介绍一个很简单的继承自ScrollViewer的控件 ...

  4. [WPF自定义控件库]使用WindowChrome自定义RibbonWindow

    原文:[WPF自定义控件库]使用WindowChrome自定义RibbonWindow 1. 为什么要自定义RibbonWindow 自定义Window有可能是设计或功能上的要求,可以是非必要的,而自 ...

  5. [WPF自定义控件库] 让Form在加载后自动获得焦点

    原文:[WPF自定义控件库] 让Form在加载后自动获得焦点 1. 需求 加载后让第一个输入框或者焦点是个很基本的功能,典型的如"登录"对话框.一般来说"登录" ...

  6. WPF 如何创建自己的WPF自定义控件库

    在我们平时的项目中,我们经常需要一套自己的自定义控件库,这个特别是在Prism这种框架下面进行开发的时候,每个人都使用一套统一的控件,这样才不会每个人由于界面不统一而造成的整个软件系统千差万别,所以我 ...

  7. wpf 两个自定义控件

    wpf 两个自定义控件 一个是IP控件,一个滑动条.先看下效果图 IPControl 1.实际工作中有时需要设置IP信息,就想着做一个ip控件.效果没有window自带的好,需要通过tab切换.但也能 ...

  8. 二十五、【开源】EFW框架Winform前端开发之强大的自定义控件库

    回<[开源]EFW框架系列文章索引>        EFW框架源代码下载V1.2:http://pan.baidu.com/s/1hcnuA EFW框架实例源代码下载:http://pan ...

  9. WPF 精修篇 自定义控件

    原文:WPF 精修篇 自定义控件 自定义控件 因为没有办法对界面可视化编辑 所以用来很少 现在实现的是 自定义控件的 自定义属性 和自定义方法 用VS 创建自定义控件后 会自动创建 Themes 文件 ...

随机推荐

  1. 边看chromium的代码,边想骂人...

    这一年一直在看chromium for android的代码,边看边想骂,谷歌这帮人..一开始搞了个牛逼的架构,在安卓4.4上把以前android webkit团队的简单版替换掉了,结果发现性能大不如 ...

  2. xgboost调参

    The overall parameters have been divided into 3 categories by XGBoost authors: General Parameters: G ...

  3. C语言之基本算法34—分解质因数(方法一)

    //矩阵基础 /* ================================================================== 题目:输入一个正整数.将其分解为质因式,如:6 ...

  4. 线程相关函数(POSIX线程):

    创建单个线程 #include <pthread.h> // 若成功返回0,出错返回正的Exxx值 int pthread_create(pthread_t *tid, // 每个线程在进 ...

  5. OpenStack源码系列---neutron-server

    在看过了nova模块的源码之后,再去看OpenStack其它模块的源码会轻松很多,因为框架也是大同小异的.自四月份开通博客写了几篇文章后,真心觉得写篇技术文章如果要把前前后后牵扯到的其它技术内容都做介 ...

  6. linux内核模块依赖图

    Module                  Size  Used by btrfs                 880032  0 raid6_pq               97812  ...

  7. HBase协处理器同步二级索引到Solr

    一. 背景二. 什么是HBase的协处理器三. HBase协处理器同步数据到Solr四. 添加协处理器五. 测试六. 协处理器动态加载 一. 背景 在实际生产中,HBase往往不能满足多维度分析,我们 ...

  8. https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2

    https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2

  9. thinkphp 防sql注入

    $Model->where("id=%d and username='%s' and xx='%f'",array($id,$username,$xx))->selec ...

  10. react native 知识点总结(一)

    一.关于react native 版本的升级 参照文档:http://reactnative.cn/docs/0.45/upgrading.html react-native -v   查看当前版本 ...