1. 程式人生 > >[WPF自定義控制元件庫] 自定義控制元件的程式碼如何與ControlTemplate互動

[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