目录

WPF-UserControl 之 Templated Control(一个demo)

这是一个数字输入控件,包含一个显示数字的文本框,以及左右两个“+”和“-”的按钮

核心知识点:

  1. 逻辑与外观分离:C# 负责加减逻辑,XAML 负责长什么样。
  2. Themes/Generic.xaml:这是 WPF 约定俗成的“藏经阁”,自定义控件的默认皮肤必须放在这里。
  3. PART_ 命名约定:代码需要操作模板里的按钮,所以我们要给按钮起约定的名字。

第一步:创建项目与文件结构

  1. 新建一个 WPF 项目(假设叫 WpfCustomControlDemo)。
  2. 在项目中新建一个文件夹叫 Themes(必须叫这个名字,不能错)。
  3. Themes 里新建一个 资源字典 (Resource Dictionary),命名为 Generic.xaml(必须叫这个名字)。
  4. 在根目录新建一个类文件 NumberBox.cs

文件结构应该是这样的:

WpfCustomControlDemo
│  MainWindow.xaml
│  NumberBox.cs          <-- 逻辑代码
└─Themes
      Generic.xaml       <-- 外观代码 (Style & Template)

第二步:编写 C# 逻辑 (NumberBox.cs)

这里我们只关心属性和交互逻辑,不写任何界面代码。

using System.Windows;
using System.Windows.Controls;

namespace WpfCustomControlDemo
{
    // 1. 继承自 Control,这是自定义控件的基石
    // TemplatePart 特性是为了告诉使用者:如果你要改皮肤,记得由于这两个名字的部件,否则功能会失效
    [TemplatePart(Name = "PART_DecreaseButton", Type = typeof(Button))]
    [TemplatePart(Name = "PART_IncreaseButton", Type = typeof(Button))]
    public class NumberBox : Control
    {
        // 2. 静态构造函数:告诉 WPF,去 Generic.xaml 里找这个控件的默认样式
        static NumberBox()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(NumberBox), new FrameworkPropertyMetadata(typeof(NumberBox)));
        }

        // 3. 定义依赖属性 (Dependency Property) - Value
        public int Value
        {
            get { return (int)GetValue(ValueProperty); }
            set { SetValue(ValueProperty, value); }
        }

        public static readonly DependencyProperty ValueProperty =
            DependencyProperty.Register("Value", typeof(int), typeof(NumberBox), new PropertyMetadata(0));


        // 4. 重写 OnApplyTemplate
        // 当 WPF 把 XAML 的皮肤应用到这个类上时,会调用这个方法。
        // 我们就在这里通过名字找到那个按钮,并加上点击事件。
        public override void OnApplyTemplate()
        {
            base.OnApplyTemplate();

            // 在 Template 中查找名为 "PART_DecreaseButton" 的按钮
            var btnDecrease = GetTemplateChild("PART_DecreaseButton") as Button;
            if (btnDecrease != null)
            {
                // 先解绑防止内存泄漏(虽然在简单场景不明显,但这是好习惯)
                btnDecrease.Click -= BtnDecrease_Click;
                btnDecrease.Click += BtnDecrease_Click;
            }

            var btnIncrease = GetTemplateChild("PART_IncreaseButton") as Button;
            if (btnIncrease != null)
            {
                btnIncrease.Click -= BtnIncrease_Click;
                btnIncrease.Click += BtnIncrease_Click;
            }
        }

        // 业务逻辑:减
        private void BtnDecrease_Click(object sender, RoutedEventArgs e)
        {
            Value--;
        }

        // 业务逻辑:加
        private void BtnIncrease_Click(object sender, RoutedEventArgs e)
        {
            Value++;
        }
    }
}

第三步:编写默认外观 (Themes/Generic.xaml)

这里我们决定它长什么样。注意看我们是如何把 C# 里的属性绑定的。

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:local="clr-namespace:WpfCustomControlDemo">

    <!-- 定义 NumberBox 的默认样式 -->
    <Style TargetType="{x:Type local:NumberBox}">
        <!-- 默认属性值 -->
        <Setter Property="Background" Value="White"/>
        <Setter Property="BorderBrush" Value="Gray"/>
        <Setter Property="BorderThickness" Value="1"/>
        <Setter Property="FontSize" Value="14"/>
        <Setter Property="HorizontalAlignment" Value="Center"/>
        <Setter Property="VerticalAlignment" Value="Center"/>
        
        <!-- 核心:Template (控件模板) -->
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:NumberBox}">
                    <!-- 外边框,绑定控件本身的 BorderBrush 等属性 -->
                    <Border Background="{TemplateBinding Background}"
                            BorderBrush="{TemplateBinding BorderBrush}"
                            BorderThickness="{TemplateBinding BorderThickness}"
                            CornerRadius="4">
                        
                        <Grid>
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="30"/>
                                <ColumnDefinition Width="60"/>
                                <ColumnDefinition Width="30"/>
                            </Grid.ColumnDefinitions>

                            <!-- 减号按钮:注意 x:Name 必须和 C# 里的 PART_ 名称一致 -->
                            <Button x:Name="PART_DecreaseButton" 
                                    Grid.Column="0" 
                                    Content="-"
                                    Background="LightGray"
                                    BorderThickness="0"/>

                            <!-- 显示数字:内容绑定到 Value 属性 -->
                            <TextBlock Grid.Column="1"
                                       Text="{Binding Value, RelativeSource={RelativeSource TemplatedParent}}"
                                       HorizontalAlignment="Center"
                                       VerticalAlignment="Center"
                                       FontSize="{TemplateBinding FontSize}"/>

                            <!-- 加号按钮 -->
                            <Button x:Name="PART_IncreaseButton" 
                                    Grid.Column="2" 
                                    Content="+"
                                    Background="LightGray"
                                    BorderThickness="0"/>
                        </Grid>
                    </Border>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

注意:如果你的项目没有自动生成 AssemblyInfo.cs 中的 ThemeInfo 特性,可能会导致样式加载失败。但在现代 .NET 项目(.NET 6/8+)中,这通常是默认配置好的。如果样式不显示,检查项目属性或 AssemblyInfo。

第四步:在界面中使用 (MainWindow.xaml)

现在你可以像使用原生 TextBox 一样使用你的 NumberBox 了。

<Window x:Class="WpfCustomControlDemo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfCustomControlDemo"
        Title="自定义控件Demo" Height="200" Width="300">
    
    <StackPanel VerticalAlignment="Center">
        
        <TextBlock Text="下面的控件是我们手搓的 Templated Control:" 
                   HorizontalAlignment="Center"/>

        <!-- 使用控件,甚至可以覆盖默认属性 -->
        <local:NumberBox Value="10" 
                         BorderBrush="Red" 
                         BorderThickness="2" />

    </StackPanel>
</Window>

这里的关键点(为了验证你的理论)

  1. 没有 .xaml .xaml.cs 的耦合
    UserControl 是两个文件绑死的。而这个 NumberBox.cs 只是一个纯类。你可以把 Themes/Generic.xaml 里的 Style 删掉,程序不会报错,只是控件会变成透明(因为没有渲染树)。
  2. 可换肤性
    你完全可以在 MainWindow.xaml<Window.Resources> 里重新写一个 Style,TargetType 设为 NumberBox,把 Template 改成一个圆形,把 Button 换成图标。这时候,C# 里的加减逻辑不需要改动一行代码,这就是“第二类控件”的强大之处。
  3. 它不是从点画出来的
    你看 Generic.xaml 里面,我们依然使用了 Grid, Button, TextBlock。我们只是在编排现有的元素。这就是它和“第三类”(OnRender)的区别。

总结

这个 Demo 完美展示了 WPF 自定义控件的标准开发范式

  • 定义的 NumberBox 是一个“只有灵魂(逻辑+属性),没有肉体(界面)”的类。
  • Generic.xaml 赋予了它默认的肉体。