[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 ControlTemplate

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章