1. 程式人生 > >[WPF自定義控制元件]從ContentControl開始入門自定義控制元件

[WPF自定義控制元件]從ContentControl開始入門自定義控制元件

1. 前言

我去年寫過一個在UWP自定義控制元件的系列部落格,大部分的經驗都可以用在WPF中(只有一點小區別)。這篇文章的目的是快速入門自定義控制元件的開發,所以儘量精簡了篇幅,更深入的概念在以後介紹各控制元件的文章中實際運用到才介紹。

ContentControl是WPF中最基礎的一種控制元件,Window、Button、ScrollViewer、Label、ListBoxItem等都繼承自ContentControl。而且ContentControl的結構十分簡單,很適合用來入門自定義控制元件。

這篇文章通過自定義一個ContentControl來介紹自定義控制元件的一些基礎概念,包括自定義控制元件的基本步驟及其組成。

2. 什麼是自定義控制元件

在開始之前首先要了解什麼是自定義控制元件以及為什麼要用自定義控制元件。

在WPF要建立自己的控制元件(Control),通常可以使用自定義控制元件(CustomControl)或使用者控制元件(UserControl),兩者最大的區別是前者可以通過ControlTemplate對控制元件的外觀靈活地進行定製。如在下面的例子中,通過ControlTemplate將Button改成一個圓形按鈕:

<Button Content="Orginal" Margin="0,0,20,0"/>
<Button Content="Custom">
    <Button.Template>
        <ControlTemplate TargetType="Button">
            <Grid>
                <Ellipse  Stroke="DarkOrange" StrokeThickness="3" Fill="LightPink"/>
                <ContentPresenter Margin="10,20" Foreground="White"/>
            </Grid>
        </ControlTemplate>
    </Button.Template>
</Button>

控制元件庫中通常使用自定義控制元件而不是使用者控制元件。

3. 建立自定義控制元件

ContentControl最簡單的派生類應該是HeaderedContentControl了吧,這篇文章會建立一個模仿HeaderedContentControl的MyHeaderedContentControl,它繼承自ContentControl並添加了一些細節。

在“新增新項”對話方塊選擇“自定義控制元件(WPF)”,名稱改為"MyHeaderedContentControl.cs"(用My-做字首是十分差勁的命名方式,但只要一看到這種命名就明白這是個測試用的東西,不會和正規程式碼搞錯,所以我習慣了測試用程式碼就這樣命名。),點選“新增”後VisualStudio會自動建立兩個檔案:MyHeaderedContentControl.cs和Themes/Generic.xaml。

編譯通過後在XAML上新增MyHeaderedContentControl的名稱空間即可使用這個控制元件:

<Window x:Class="CustomControlDemo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:CustomControlDemo">
    <Grid>
        <local:MyHeaderedContentControl Content="I am a new control" />
    </Grid>
</Window>

在新增新項時,小心不要和“Windows Forms”裡的“自定義控制元件”搞混。

4. 自定義控制元件的組成

自定義控制元件通常由程式碼和DefaultStyle兩部分組成,它們分別位於VisualStudio建立的MyHeaderedContentControl.cs和Themes/Generic.xaml兩個檔案中。

4.1 程式碼

public class MyHeaderedContentControl: Control
{
    static MyCustomControl()
    {
        DefaultStyleKeyProperty.OverrideMetadata(typeof(MyHeaderedContentControl), new FrameworkPropertyMetadata(typeof(MyHeaderedContentControl)));
    }
}

控制元件程式碼負責定義控制元件的結構和行為。MyHeaderedContentControl.cs的程式碼如上所示,只包含一個靜態建構函式及一句 DefaultStyleKeyProperty.OverrideMetadata。DefaultStyleKey是用於查詢控制元件樣式的鍵,沒有這句程式碼控制元件就找不到預設樣式。

4.2 DefaultStyle

<Style TargetType="{x:Type local:MyHeaderedContentControl}">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type local:MyHeaderedContentControl}">
                <Border Background="{TemplateBinding Background}"
                        BorderBrush="{TemplateBinding BorderBrush}"
                        BorderThickness="{TemplateBinding BorderThickness}">
                </Border>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

在第一次建立控制元件後VisualStudio會自動建立Themes/Generic.xaml,並且插入上面的XAML。這段XAML即MyCustomControl的DefaultStyle,它負責定義控制元件的外觀及屬性的預設值。注意其中兩個TargetType="{x:Type local:MyHeaderedContentControl}",第一個用於匹配MyHeaderedContentControl.cs中的DefaultStyleKey,第二個確定ControlTemplete針對的控制元件型別,兩個都不可以移除。Style的內容是一組Setter的集合,除了Template外,還可以新增其它的Setter指定控制元件的各屬性預設值。

注意,不可以為這個Style設定x:Key。

5. 在DefaultStyle上實現ContentControl的基礎部分

接下來將MyHeaderedContentControl的父類修改為ContentControl。

如果只看常用屬性的話,ContentControl的定義可以簡化為以下程式碼:

[ContentProperty("Content")]
public class ContentControl : Control
{
    public static readonly DependencyProperty ContentProperty;
    public static readonly DependencyProperty ContentTemplateProperty;

    public object Content { get; set; }
    public DataTemplate ContentTemplate { get; set; }

    protected virtual void OnContentChanged(object oldContent, object newContent);
    protected virtual void OnContentTemplateChanged(DataTemplate oldContentTemplate, DataTemplate newContentTemplate);
}

對應的DefaultStyle可以如下實現:

<Style TargetType="{x:Type local:MyHeaderedContentControl}">
    <Setter Property="IsTabStop"
            Value="False" />
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="local:MyHeaderedContentControl">
                <ContentPresenter ContentTemplate="{TemplateBinding ContentTemplate}"
                                  Margin="{TemplateBinding Padding}"
                                  HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                                  VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

DefaultStyle的內容也不多,簡單講解一下。

ContentPresenter
ContentPresenter用於顯示內容,預設繫結到ContentControl的Content屬性。基本上所有ContentControl中都包含一個ContentPresenter。ContentPresenter直接從FrameworkElement派生。

TemplateBinding
用於單向繫結ControlTemplate所在控制元件的功能屬性,例如Margin="{TemplateBinding Padding}"幾乎等效於Margin="{Binding Margin,RelativeSource={RelativeSource Mode=TemplatedParent},Mode=OneWay}",相當於一種簡化的寫法。但它們之間有如下不同:

  • TemplateBinding只能用在ControlTemplate中。
  • TemplateBinding的源和目標屬性都必須是依賴屬性。
  • TemplateBinding不能使用TypeConverter,所以源屬性和目標屬性必須為相同的資料型別。

通常在ContentPresenter上使用TemplateBinding的屬性不會太多,因為很大一部分Control的屬性的值都可繼承,即預設使用VisualTree上父節點所設定的屬性值,譬如字型屬性(如FontSize、FontFamily)、DataContext等。

除了可繼承值的屬性,需要適當地將ControlTemplate中的元素屬性繫結到所屬控制元件的屬性,例如Margin="{TemplateBinding Padding}",這樣可以方便控制元件的使用者通過屬性調整UI。

IsTabStop

瞭解IsTabStop的作用有助於處理好自定義控制元件的焦點。

<GroupBox>
    <TextBox />
</GroupBox>
<GroupBox>
    <TextBox />
</GroupBox>

在上面這個UI中,在第一個TextBox獲得焦點時按下Tab後第二個TextBox將獲得焦點,這很自然。但如果換成下面這段XAML:

<ContentControl>
    <TextBox />
</ContentControl>
<ContentControl>
    <TextBox />
</ContentControl>

結果就如上面截圖顯示,第二個TextBox沒有獲得焦點,焦點被包含它的ContentControl獲取了,要再按一次 Tab TextBox才能獲得焦點。這是由於ContentControl的IsTabStop屬性預設為True。IsTabStop指示是否將某個控制元件包含在 Tab 導航中,Tab的導航順序是用深度優先演算法搜尋VisualTree上的Control,所以ContentControl優先獲得了焦點。如果ContentControl作為一個容器的話(如GroupBox)IsTabStop屬性都應該設定為False。

通過Setter改變預設值
通常從父控制元件繼承而來的屬性很少在建構函式中設定預設值,而是在DefaultStyle的Setter中設定預設值。MyHeaderedContentControl為了將IsTabStop改為False而在Style添加了Property="IsTabStop"的Setter。

6. 新增Header和HeaderTemplate依賴屬性

現在模仿HeaderedContentControl為MyHeaderedContentControl新增Header和HeaderTemplate屬性。

在自定義控制元件中新增屬性時應儘量使用依賴屬性(有些只讀屬性可以使用CLR屬性),因為只有依賴屬性才可以作為Binding的Target。WPF中建立依賴屬性可以做到很複雜,而再簡單也要好幾行程式碼。在自定義控制元件中建立依賴屬性通常包含以下幾部分:

  1. 註冊依賴屬性並生成依賴屬性識別符號。依賴屬性識別符號為一個public static readonly DependencyProperty欄位。依賴屬性識別符號的名稱必須為“屬性名+Property”。在PropertyMetadata中指定屬性預設值。

  2. 實現屬性包裝器。為屬性提供 CLR get 和 set 訪問器,在Getter和Setter中分別呼叫GetValue和SetValue,除此之外Getter和Setter中不應該有其它任何自定義程式碼。

  3. 需要監視屬性值變更。在PropertyMetadata中定義一個PropertyChangedCallback方法,因為這個方法是靜態的,可以再實現一個同名的例項方法(可以參考ContentControl的OnContentChanged方法)。

/// <summary>
/// 獲取或設定Header的值
/// </summary>  
public object Header
{
    get => (object)GetValue(HeaderProperty);
    set => SetValue(HeaderProperty, value);
}

/// <summary>
/// 標識 Header 依賴屬性。
/// </summary>
public static readonly DependencyProperty HeaderProperty =
    DependencyProperty.Register(nameof(Header), typeof(object), typeof(MyHeaderedContentControl), new PropertyMetadata(default(object), OnHeaderChanged));

private static void OnHeaderChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
{
    var oldValue = (object)args.OldValue;
    var newValue = (object)args.NewValue;
    if (oldValue == newValue)
        return;

    var target = obj as MyHeaderedContentControl;
    target?.OnHeaderChanged(oldValue, newValue);
}

/// <summary>
/// Header 屬性更改時呼叫此方法。
/// </summary>
/// <param name="oldValue">Header 屬性的舊值。</param>
/// <param name="newValue">Header 屬性的新值。</param>
protected virtual void OnHeaderChanged(object oldValue, object newValue)
{
}

上面程式碼為MyHeaderedContentControl添加了Header屬性(HeaderTemplate的程式碼大同小異就不寫出來了)。請注意我使用object型別,在WPF中Content、Header、Title這類屬性最好是object型別,這樣不僅可以使用文字,還可以是UIElement如圖片或其他控制元件。protected virtual void OnHeaderChanged(object oldValue, object newValue)目前只是個空函式,但為了派生類著想不要吝嗇這一行程式碼。

依賴屬性的預設值可以在註冊依賴屬性時在PropertyMetadata中設定,通常為屬性型別的預設值,也可以在DefaultStyle的Setter中設定,不推薦在建構函式中設定。

依賴屬性的定義程式碼比較複雜,我一直都是用程式碼段生成,可以參考我另一篇部落格為附加屬性和依賴屬性自定義程式碼段(相容UWP和WPF)。

新增依賴屬性後再更新控制元件模板,這個控制元件就基本完成了。

<ControlTemplate TargetType="local:MyHeaderedContentControl">
    <Border Background="{TemplateBinding Background}"
            BorderBrush="{TemplateBinding BorderBrush}"
            BorderThickness="{TemplateBinding BorderThickness}">
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>
            <ContentPresenter Content="{TemplateBinding Header}"
                              ContentTemplate="{TemplateBinding HeaderTemplate}" />
            <ContentPresenter Grid.Row="1"
                              ContentTemplate="{TemplateBinding ContentTemplate}"
                              Margin="{TemplateBinding Padding}"
                              HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                              VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
        </Grid>
    </Border>
</ControlTemplate>

7. 結語

雖然儘量精簡,但結果這篇文章仍是太長,而且很多關鍵的技術仍未介紹到。

更深入的內容會在後續文章中逐漸介紹,敬請期待。

8. 參考

控制元件自定義
Silverlight 控制元件自定義
Customizing the Appearance of an Existing Control by Using a ControlTempl