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">
                <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">
        <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中创建依赖属性可以做到很复杂,而再简单也要好几行代码。在自定义控件中创建依赖属性通常包含以下几部分:

    注册依赖属性并生成依赖属性标识符。依赖属性标识符为一个public static readonly DependencyProperty字段。依赖属性标识符的名称必须为“属性名+Property”。在PropertyMetadata中指定属性默认值。

    实现属性包装器。为属性提供 CLR get 和 set 访问器,在Getter和Setter中分别调用GetValue和SetValue,除此之外Getter和Setter中不应该有其它任何自定义代码。

    需要监视属性值变更。在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.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