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 添加, 内容为 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;
- }
- }
上面的代码实现了获取 HeaderElement 并为它订阅鼠标点击事件. 由于 Template 可能多次加载, 或者不能正确获取 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[]). 现在看起来是 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
来源: https://www.cnblogs.com/dino623/p/interact_with_ControlTemplate.html