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,內容為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