[WPF自定義控制元件庫] 自定義控制元件的程式碼如何與ControlTemplate互動
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