前言

WPF (Windows Presentation Foundation) 提供了强大的 UI 框架,允许开发者创建美观且功能丰富的应用程序。WPF 的核心优势之一在于其高度的 可定制性。除了使用内置控件外,WPF 还允许我们创建完全自定义的控件,以满足特定的 UI 需求。

本教程将带你深入了解 WPF 自定义控件的开发,涵盖从基础概念到高级技巧,并提供详细的代码示例和解释,助你掌握自定义控件的精髓。

目录

  1. 为什么要自定义控件?

  2. 自定义控件的基础概念

    • 2.1. 依赖属性 (Dependency Properties)

    • 2.2. 路由事件 (Routed Events)

    • 2.3. 控件模板 (Control Templates)

    • 2.4. 样式和主题 (Styles and Themes)

    • 2.5. 内容呈现 (Content Presentation)

  3. 自定义控件的类型

    • 3.1. 用户控件 (User Controls)

    • 3.2. 模板化控件 (Templated Controls / Custom Controls)

    • 3.3. 元素控件 (Element Controls / FrameworkElement 直接派生)

  4. 实战教程:创建一个自定义评分控件 (Templated Control)

    • 4.1. 创建自定义控件库项目

    • 4.2. 定义依赖属性 (评分值、星星数量)

    • 4.3. 定义路由事件 (评分值改变事件)

    • 4.4. 设计控件模板 (XAML 结构和样式)

    • 4.5. 实现控件逻辑 (C# 代码,属性更改处理,事件触发)

    • 4.6. 应用样式和主题

    • 4.7. 在应用程序中使用自定义控件

  5. 高级自定义控件技术

    • 5.1. 命令 (Commands)

    • 5.2. 自定义控件中的数据绑定

    • 5.3. 自定义布局 (MeasureOverride, ArrangeOverride)

    • 5.4. 控件的可访问性 (Accessibility)

    • 5.5. 性能优化

  6. 总结


1. 为什么要自定义控件?

WPF 提供了丰富的内置控件,但在以下情况下,你可能需要创建自定义控件:

  • 满足特定 UI 需求: 内置控件无法完全满足你的应用程序的独特界面设计或交互逻辑。

  • 封装可重用 UI 组件: 你需要创建可以在应用程序中多次使用的、具有特定功能和外观的 UI 组件。

  • 提升代码可维护性: 将复杂的 UI 逻辑封装在自定义控件中,可以提高代码的可读性和可维护性。

  • 创建独特的品牌风格: 自定义控件可以帮助你打造与众不同的应用程序界面,体现独特的品牌风格。

2. 自定义控件的基础概念

在开始创建自定义控件之前,我们需要理解 WPF 中与控件开发密切相关的几个核心概念。

2.1. 依赖属性 (Dependency Properties)

依赖属性是 WPF 属性系统中的核心概念,它增强了 CLR 属性的功能,并为 WPF 控件提供了强大的特性,例如:

  • 样式设置 (Styling): 允许通过样式 (Styles) 和模板 (Templates) 设置属性值。

  • 数据绑定 (Data Binding): 支持将属性值绑定到数据源,实现 UI 与数据的同步。

  • 属性值继承 (Property Value Inheritance): 允许子元素继承父元素的属性值。

  • 动画 (Animation): 支持对属性值进行动画操作。

  • 属性值更改通知 (Property Change Notification): 提供属性值更改时的回调机制。

  • 验证 (Validation): 可以在属性值设置时进行验证。

  • 强制值 (Coercion): 可以强制属性值在一定范围内。

  • 默认值 (Default Value): 可以为属性设置默认值。

如何定义依赖属性:

使用 DependencyProperty.Register() 方法在控件类中注册依赖属性。

C#

public static readonly DependencyProperty ValueProperty =
    DependencyProperty.Register(
        "Value", // 属性名称
        typeof(int), // 属性类型
        typeof(MyCustomControl), // 属性所属的控件类型
        new FrameworkPropertyMetadata(
            0, // 默认值
            FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender, // 元数据选项
            new PropertyChangedCallback(OnValueChanged) // 属性更改回调
        ),
        new ValidateValueCallback(ValidateValue) // 属性值验证回调 (可选)
    );

public int Value
{
    get { return (int)GetValue(ValueProperty); }
    set { SetValue(ValueProperty, value); }
}

private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    MyCustomControl control = (MyCustomControl)d;
    int newValue = (int)e.NewValue;
    int oldValue = (int)e.OldValue;
    // 在这里处理属性值更改的逻辑
    control.OnValueChanged(oldValue, newValue);
}

private static bool ValidateValue(object value)
{
    int v = (int)value;
    return v >= 0 && v <= 100; // 验证值是否在 0-100 范围内
}

代码解释:

  • DependencyProperty.Register(...): 静态方法,用于注册依赖属性。

    • "Value": 依赖属性的名称,通常与 CLR 属性名相同。

    • typeof(int): 依赖属性的类型。

    • typeof(MyCustomControl): 拥有该依赖属性的控件类型。

    • FrameworkPropertyMetadata: 属性元数据,用于设置属性的行为特性。

      • 0: 属性的默认值。

      • FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender: 指定属性更改会影响控件的布局 (Measure) 和渲染 (Render)。

      • new PropertyChangedCallback(OnValueChanged): 指定属性值更改时的回调方法 OnValueChanged

    • new ValidateValueCallback(ValidateValue): (可选) 指定属性值验证回调方法 ValidateValue

  • public int Value { get; set; }: CLR 属性包装器,用于简化对依赖属性的访问。在 get 和 set 访问器中,分别调用 GetValue()SetValue() 方法来操作依赖属性。

  • OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e): 属性值更改回调方法,当 ValueProperty 的值发生变化时,该方法会被调用。

  • ValidateValue(object value): 属性值验证回调方法,在设置 ValueProperty 的值之前,该方法会被调用进行值验证。

2.2. 路由事件 (Routed Events)

路由事件是 WPF 事件系统中的核心概念,它允许事件在元素树中 "路由",即事件可以从触发元素沿着元素树向上 (冒泡路由) 或向下 (隧道路由) 传播,或者直接在触发元素上处理 (直接路由)。

路由事件的主要优势在于:

  • 事件共享: 多个元素可以监听同一个路由事件。

  • 事件处理集中化: 可以在元素树的较高层级处理来自子元素的事件。

  • 控件组合: 路由事件使得组合和定制控件的行为更加灵活。

路由事件的类型:

  • 冒泡路由 (Bubbling Routing): 事件从触发元素开始,沿着元素树向上冒泡,依次触发父元素、祖父元素...直到根元素的事件处理程序。这是最常见的路由类型。

  • 隧道路由 (Tunneling Routing): 事件从元素树的根元素开始,沿着元素树向下隧道,依次触发祖父元素、父元素...直到触发元素的事件处理程序。隧道路由事件通常以 "Preview" 开头,例如 PreviewMouseDown

  • 直接路由 (Direct Routing): 事件只在触发元素自身上触发,不进行路由传播。CLR 事件通常是直接路由事件。

如何定义路由事件:

使用 EventManager.RegisterRoutedEvent() 方法在控件类中注册路由事件。

C#

public static readonly RoutedEvent ValueChangedEvent = EventManager.RegisterRoutedEvent(
    "ValueChanged", // 事件名称
    RoutingStrategy.Bubble, // 路由策略 (冒泡)
    typeof(RoutedPropertyChangedEventHandler<int>), // 事件处理程序类型
    typeof(MyCustomControl) // 事件所属的控件类型
);

public event RoutedPropertyChangedEventHandler<int> ValueChanged
{
    add { AddHandler(ValueChangedEvent, value); }
    remove { RemoveHandler(ValueChangedEvent, value); }
}

private void OnValueChanged(int oldValue, int newValue)
{
    RoutedPropertyChangedEventArgs<int> args = new RoutedPropertyChangedEventArgs<int>(oldValue, newValue);
    args.RoutedEvent = ValueChangedEvent;
    RaiseEvent(args); // 触发路由事件
}

代码解释:

  • EventManager.RegisterRoutedEvent(...): 静态方法,用于注册路由事件。

    • "ValueChanged": 路由事件的名称,通常与 CLR 事件名相同。

    • RoutingStrategy.Bubble: 路由策略,这里使用冒泡路由。

    • typeof(RoutedPropertyChangedEventHandler<int>): 路由事件处理程序的委托类型,RoutedPropertyChangedEventHandler<int> 是 WPF 预定义的委托,用于处理属性值更改的路由事件,<int> 指定了事件参数的类型。

    • typeof(MyCustomControl): 拥有该路由事件的控件类型。

  • public event RoutedPropertyChangedEventHandler<int> ValueChanged { add; remove; }: CLR 事件包装器,用于简化路由事件的添加和移除处理程序。

  • OnValueChanged(int oldValue, int newValue): 触发路由事件的方法。

    • RoutedPropertyChangedEventArgs<int> args = new RoutedPropertyChangedEventArgs<int>(oldValue, newValue);: 创建路由事件参数对象,包含旧值和新值。

    • args.RoutedEvent = ValueChangedEvent;: 设置事件参数的路由事件类型。

    • RaiseEvent(args);: 触发路由事件,开始路由传播。

2.3. 控件模板 (Control Templates)

控件模板 (ControlTemplate) 是 WPF 中用于 定义控件视觉结构 的核心机制。它决定了控件在屏幕上如何呈现,包括控件由哪些元素组成,以及这些元素如何布局和样式化。

  • 完全自定义外观: ControlTemplate 允许你 完全替换控件默认的视觉树 (Visual Tree)。你可以使用 XAML 自由地组合各种 WPF 元素 (例如 Border, TextBlock, Path, Shape, 以及其他控件),构建出完全自定义的控件外观。

  • 模板绑定 (TemplateBinding): ControlTemplate 中可以使用 TemplateBinding 表达式,将模板内部元素的属性 绑定到控件自身的依赖属性。这使得模板内部的元素可以响应控件属性的变化,例如 Background, Foreground, BorderBrush, IsEnabled, IsMouseOver 等。

  • 触发器 (Triggers): ControlTemplate 可以包含触发器 (Trigger, DataTrigger, EventTrigger 等),用于根据控件的状态 (例如 IsMouseOver, IsPressed, IsFocused, IsExpanded 等) 或数据变化,动态地改变模板内部元素的属性,实现动态的视觉效果。

如何定义控件模板:

Style 或控件资源中,设置 Template 属性的值为 ControlTemplate 对象。

XML

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

代码解释:

  • <Style TargetType="{x:Type MyCustomControl}">: 定义针对 MyCustomControl 类型的样式。

  • <Setter Property="Template">: 设置 Template 属性,指定控件模板。

  • <Setter.Value>: Template 属性的值是一个 ControlTemplate 对象。

  • <ControlTemplate TargetType="{x:Type MyCustomControl}">: 定义 MyCustomControl 的控件模板。

    • <Border...>: 模板的根元素,使用 Border 作为容器。

    • Background="{TemplateBinding Background}", BorderBrush="{TemplateBinding BorderBrush}", BorderThickness="{TemplateBinding BorderThickness}": 使用 TemplateBindingBorder 的属性绑定到 MyCustomControl 自身的同名属性,实现样式联动。

    • <TextBlock Text="{TemplateBinding Text}" />: 在 Border 内部添加一个 TextBlock,并将其 Text 属性绑定到 MyCustomControlText 属性。

2.4. 样式和主题 (Styles and Themes)

样式 (Styles) 和主题 (Themes) 是 WPF 中用于 控制控件外观 的重要机制。

  • 样式 (Styles): 用于 封装一组属性值,并将其应用于一个或多个控件。样式可以定义在资源字典 (ResourceDictionary) 中,并在控件或其父元素上引用。样式可以极大地简化控件外观的统一管理和修改。

  • 主题 (Themes): 是一组样式的集合,用于 定义应用程序的整体视觉风格。WPF 提供了默认的主题 (例如 Classic, Luna, Royale),你也可以创建自定义主题。主题允许应用程序在不同视觉风格之间切换。

样式和主题的关系:

  • 主题是由多个样式组成的。

  • 样式可以定义在主题中,也可以独立存在。

  • 样式可以覆盖主题中的样式设置。

如何定义样式:

ResourceDictionary 中定义 Style 对象,并设置 TargetType 属性指定样式应用的控件类型。

XML

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Style TargetType="{x:Type Button}">
        <Setter Property="Background" Value="LightBlue"/>
        <Setter Property="Foreground" Value="Black"/>
        <Setter Property="FontFamily" Value="微软雅黑"/>
        <Setter Property="FontSize" Value="14"/>
        <Setter Property="Padding" Value="5,3"/>
    </Style>

    <Style TargetType="{x:Type MyCustomControl}">
        <Setter Property="Background" Value="White"/>
        <Setter Property="BorderBrush" Value="Gray"/>
        <Setter Property="BorderThickness" Value="1"/>
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type MyCustomControl}">
                    </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

代码解释:

  • <ResourceDictionary>: 资源字典的根元素。

  • <Style TargetType="{x:Type Button}">: 定义针对 Button 类型的样式。

    • <Setter Property="Background" Value="LightBlue"/>,...: 使用 Setter 元素设置 Button 的各种属性值。

  • <Style TargetType="{x:Type MyCustomControl}">: 定义针对 MyCustomControl 类型的样式,并设置了 ControlTemplate。  

如何应用样式:

  • 隐式样式 (Implicit Style): 当样式没有 x:Key 属性时,它会成为隐式样式。隐式样式会 自动应用于 资源字典作用域内 所有指定类型的控件

  • 显式样式 (Explicit Style): 当样式定义了 x:Key 属性时,它会成为显式样式。显式样式需要 通过 Style="{StaticResource 样式Key}" 在控件上显式引用 才能应用。

2.5. 内容呈现 (Content Presentation)

WPF 控件通常可以显示内容,例如文本、图像、其他控件等。WPF 提供了几种机制来处理控件的内容呈现:

  • ContentPropertyAttribute: 用于指定控件的 默认内容属性。例如,Button 的默认内容属性是 ContentLabel 的默认内容属性也是 Content

  • ContentPresenter: 是一个控件,用于 在控件模板中呈现控件的 Content 属性ContentPresenter 会根据控件的 ContentTemplateContentStringFormat 属性来呈现内容。  

  • ContentControl: 是一个基类,许多 WPF 控件 (例如 Button, Label, GroupBox, ScrollViewer 等) 都派生自 ContentControlContentControl 提供了 Content, ContentTemplate, ContentStringFormat 等属性,用于管理和呈现内容。

  • ItemsControl: 是一个基类,用于显示 集合数据ItemsControl 提供了 ItemsSource, ItemTemplate, ItemsPanel 等属性,用于管理和呈现集合数据。ListBox, ComboBox, TreeView, DataGrid 等控件都派生自 ItemsControl

3. 自定义控件的类型

WPF 中创建自定义控件主要有以下三种类型,复杂度递增:

3.1. 用户控件 (User Controls)

  • 最简单: 用户控件是最简单的自定义控件类型。它本质上是一个 由现有 WPF 控件组合而成的复合控件

  • 可视化设计: 用户控件通常使用 可视化设计器 (例如 Visual Studio 的设计视图) 创建,通过拖拽和组合现有控件来构建用户界面。

  • 代码分离: 用户控件的代码 (C# 代码) 和界面 (XAML 代码) 通常 分离在不同的文件中,易于维护和管理。

  • 适用场景: 适用于 封装简单的 UI 组合,例如,一个包含标签和文本框的输入框组,一个自定义工具栏等。  

创建用户控件的步骤:

  1. 在项目中添加 "用户控件 (WPF)" 项。

  2. 在用户控件的 XAML 文件中,使用现有 WPF 控件组合构建用户界面。

  3. 在用户控件的代码文件中,编写 C# 代码实现逻辑,例如处理事件、访问控件属性等。

  4. 在其他 WPF 窗口或控件中使用用户控件,就像使用普通控件一样。

示例:创建一个简单的计数器用户控件  

  1. 创建用户控件项目: 在 WPF 项目中,右键点击项目 -> "添加" -> "新建文件夹",命名为 "CustomControls"。 然后在 "CustomControls" 文件夹上右键点击 -> "添加" -> "用户控件 (WPF)...",命名为 "CounterControl.xaml"。

  2. 设计用户界面 (CounterControl.xaml):

    XML

    <UserControl x:Class="WpfApp.CustomControls.CounterControl"
                 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                 xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
                 xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
                 mc:Ignorable="d"
                 d:DesignHeight="450" d:DesignWidth="800">
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
            <Button x:Name="btnDecrease" Content="-" Click="btnDecrease_Click"/>
            <TextBlock Grid.Column="1" x:Name="txtCount" Text="0" TextAlignment="Center" VerticalAlignment="Center"/>
            <Button Grid.Column="2" x:Name="btnIncrease" Content="+" Click="btnIncrease_Click"/>
        </Grid>
    </UserControl>
    
  3. 编写代码逻辑 (CounterControl.xaml.cs):

    C#

    using System.Windows;
    using System.Windows.Controls;
    
    namespace WpfApp.CustomControls
    {
        public partial class CounterControl: UserControl
        {
            private int _count = 0;
    
            public CounterControl()
            {
                InitializeComponent();
                UpdateCountText();
            }
    
            private void btnIncrease_Click(object sender, RoutedEventArgs e)
            {
                _count++;
                UpdateCountText();
            }
    
            private void btnDecrease_Click(object sender, RoutedEventArgs e)
            {
                if (_count > 0)
                {
                    _count--;
                    UpdateCountText();
                }
            }
    
            private void UpdateCountText()
            {
                txtCount.Text = _count.ToString();
            }
    
            public int Count
            {
                get { return _count; }
                set
                {
                    _count = value;
                    UpdateCountText();
                }
            }
        }
    }
    
  4. 在窗口中使用用户控件 (MainWindow.xaml):

    XML

    <Window x:Class="WpfApp.MainWindow"
            xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
            xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
            xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
            xmlns:local="clr-namespace:WpfApp"
            xmlns:customControls="clr-namespace:WpfApp.CustomControls"  mc:Ignorable="d"
            Title="MainWindow" Height="450" Width="800">
        <Grid>
            <customControls:CounterControl HorizontalAlignment="Center" VerticalAlignment="Center" x:Name="myCounter"/>
            <Button Content="获取计数器值" HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,50" Click="Button_Click"/>
        </Grid>
    </Window>
    
  5. 在窗口代码中访问用户控件属性 (MainWindow.xaml.cs):

    C#

    using System.Windows;
    
    namespace WpfApp
    {
        public partial class MainWindow: Window
        {
            public MainWindow()
            {
                InitializeComponent();
            }
    
            private void Button_Click(object sender, RoutedEventArgs e)
            {
                MessageBox.Show($"计数器值: {myCounter.Count}");
            }
        }
    }
    

3.2. 模板化控件 (Templated Controls / Custom Controls)

  • 更灵活: 模板化控件 (也常被称为 Custom Controls) 提供了比用户控件 更大的灵活性和定制性

  • 完全自定义外观: 通过 自定义 ControlTemplate,你可以完全重新定义控件的视觉结构,使其外观与内置控件完全不同。

  • 逻辑与外观分离: 模板化控件 严格遵循逻辑与外观分离的原则。控件的逻辑代码 (C# 代码) 专注于处理控件的行为和数据,而控件的外观则完全由 ControlTemplate (XAML 代码) 定义。这使得控件的样式和主题化更加容易。

  • 适用场景: 适用于 创建需要高度定制外观和行为的复杂控件,例如,自定义进度条、滑块、日历控件、图表控件等。

创建模板化控件的步骤:

  1. 创建 WPF 自定义控件库项目 (推荐): 为了更好地组织和重用自定义控件,通常建议创建一个独立的 WPF 自定义控件库项目。

  2. 在自定义控件库项目中添加 "自定义控件 (WPF)" 项。

  3. 定义依赖属性和路由事件 (在控件的代码文件中)。

  4. 在 "Themes" 文件夹下的 "Generic.xaml" 文件中,定义控件的默认样式和 ControlTemplate

  5. 在应用程序中使用自定义控件库中的控件。

实战教程:创建一个自定义评分控件 (Templated Control) (将在下一节详细展开)

3.3. 元素控件 (Element Controls / FrameworkElement 直接派生)

  • 最底层,最复杂: 元素控件是 最底层的自定义控件类型。它直接从 FrameworkElement 或其子类 (例如 Panel, Shape) 派生。

  • 完全控制渲染: 元素控件需要 完全自定义控件的渲染逻辑,包括如何绘制自身、如何处理布局、如何响应用户输入等。

  • 性能优化: 对于需要 极致性能和高度定制渲染 的场景,例如游戏 UI、高性能图表控件等,元素控件可能是最佳选择。

  • 复杂性高: 元素控件开发难度较高,需要深入理解 WPF 的渲染管道和布局系统。

创建元素控件的步骤:

  1. 创建 WPF 项目或自定义控件库项目。

  2. 创建一个 C# 类,从 FrameworkElement 或其子类派生。

  3. 重写 MeasureOverride()ArrangeOverride() 方法,实现自定义布局逻辑。

  4. 重写 OnRender() 方法,实现自定义渲染逻辑 (使用 DrawingContext 进行绘制)。

  5. 处理用户输入事件 (例如 MouseDown, MouseMove, KeyDown 等)。

  6. 定义依赖属性和路由事件 (可选,但通常需要)。  

  7. 在 XAML 中使用自定义元素控件。

元素控件示例 (简单的自定义圆形控件):

C#

using System.Windows;
using System.Windows.Media;

namespace WpfApp.CustomControls
{
    public class CircleControl: FrameworkElement
    {
        #region 依赖属性

        public static readonly DependencyProperty FillProperty =
            DependencyProperty.Register("Fill", typeof(Brush), typeof(CircleControl),
                new FrameworkPropertyMetadata(Brushes.Red, FrameworkPropertyMetadataOptions.AffectsRender));

        public Brush Fill
        {
            get { return (Brush)GetValue(FillProperty); }
            set { SetValue(FillProperty, value); }
        }

        #endregion

        protected override Size MeasureOverride(Size availableSize)
        {
            // 返回控件的期望大小,这里简单地返回 50x50
            return new Size(50, 50);
        }

        protected override Size ArrangeOverride(Size finalSize)
        {
            // 返回控件的最终大小,这里直接使用 finalSize
            return finalSize;
        }

        protected override void OnRender(DrawingContext drawingContext)
        {
            base.OnRender(drawingContext);

            // 获取控件的渲染区域
            Rect rect = new Rect(0, 0, ActualWidth, ActualHeight);

            // 计算圆心和半径
            Point center = rect.GetCenter();
            double radius = System.Math.Min(rect.Width, rect.Height) / 2;

            // 绘制圆形
            drawingContext.DrawEllipse(Fill, null, center, radius, radius);
        }
    }
}

在 XAML 中使用元素控件:

XML

<Window...
        xmlns:customControls="clr-namespace:WpfApp.CustomControls"...>
    <Grid>
        <customControls:CircleControl Fill="Blue" Width="100" Height="100" HorizontalAlignment="Center" VerticalAlignment="Center"/>
    </Grid>
</Window>

4. 实战教程:创建一个自定义评分控件 (Templated Control)

本节将通过一个详细的步骤,创建一个自定义的星级评分控件 (RatingControl),演示如何开发模板化控件。

4.1. 创建自定义控件库项目

  1. 打开 Visual Studio,创建新的 WPF 项目。 选择 "WPF 自定义控件库 (.NET Framework)" 模板,命名为 "RatingControlLibrary"。

  2. 项目结构: 创建完成后,项目结构应该包含:

    • Themes 文件夹: 用于存放控件的默认样式和模板 (Generic.xaml)。

    • CustomControl1.cs (或你自定义的控件类名).

  3. 重命名控件类和 Generic.xaml 文件: 将 "CustomControl1.cs" 重命名为 "RatingControl.cs",并将 "Generic.xaml" 中的 <Style TargetType="{x:Type local:CustomControl1}"> 修改为 <Style TargetType="{x:Type local:RatingControl}"> (将 local:CustomControl1 替换为你的控件命名空间和类名)。

4.2. 定义依赖属性 (评分值、星星数量)

打开 RatingControl.cs 文件,添加以下代码来定义依赖属性:

C#

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

namespace RatingControlLibrary
{
    public class RatingControl: Control
    {
        static RatingControl()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(RatingControl), new FrameworkPropertyMetadata(typeof(RatingControl)));
        }

        #region 依赖属性

        // 评分值
        public static readonly DependencyProperty ValueProperty =
            DependencyProperty.Register(
                "Value", typeof(int), typeof(RatingControl),
                new FrameworkPropertyMetadata(
                    0, // 默认值
                    FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender,
                    new PropertyChangedCallback(OnValueChanged), // 属性更改回调
                    new CoerceValueCallback(CoerceValue) // 强制值回调
                ),
                new ValidateValueCallback(ValidateValue) // 属性值验证回调
            );

        public int Value
        {
            get { return (int)GetValue(ValueProperty); }
            set { SetValue(ValueProperty, value); }
        }

        private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            RatingControl control = (RatingControl)d;
            int newValue = (int)e.NewValue;
            control.UpdateVisualState(); // 更新视觉状态
            control.OnValueChanged(newValue); // 触发 CLR 事件
        }

        private static object CoerceValue(DependencyObject d, object baseValue)
        {
            RatingControl control = (RatingControl)d;
            int value = (int)baseValue;
            if (value < 0) return 0;
            if (value > control.StarCount) return control.StarCount;
            return value;
        }

        private static bool ValidateValue(object value)
        {
            return (int)value >= 0;
        }

        // 星星数量
        public static readonly DependencyProperty StarCountProperty =
            DependencyProperty.Register(
                "StarCount", typeof(int), typeof(RatingControl),
                new FrameworkPropertyMetadata(5, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender, null, new CoerceValueCallback(CoerceStarCount)), new ValidateValueCallback(ValidateStarCount));

        public int StarCount
        {
            get { return (int)GetValue(StarCountProperty); }
            set { SetValue(StarCountProperty, value); }
        }

        private static object CoerceStarCount(DependencyObject d, object baseValue)
        {
            return System.Math.Max(1, (int)baseValue); // 星星数量最小为 1
        }

        private static bool ValidateStarCount(object value)
        {
            return (int)value > 0; // 星星数量必须大于 0
        }

        #endregion

        #region 路由事件

        public static readonly RoutedEvent ValueChangedRoutedEvent =
            EventManager.RegisterRoutedEvent(
                "ValueChangedRouted", RoutingStrategy.Bubble,
                typeof(RoutedPropertyChangedEventHandler<int>), typeof(RatingControl));

        public event RoutedPropertyChangedEventHandler<int> ValueChangedRouted
        {
            add { AddHandler(ValueChangedRoutedEvent, value); }
            remove { RemoveHandler(ValueChangedRoutedEvent, value); }
        }

        protected virtual void OnValueChanged(int newValue)
        {
            RoutedPropertyChangedEventArgs<int> args = new RoutedPropertyChangedEventArgs<int>(Value, newValue, ValueChangedRoutedEvent);
            RaiseEvent(args); // 触发路由事件
        }

        #endregion

        #region 视觉状态

        private void UpdateVisualState()
        {
            VisualStateManager.GoToState(this, "Value" + Value, true); // 根据 Value 属性切换视觉状态
        }

        #endregion
    }
}

代码解释:

  • static RatingControl() {... }: 静态构造函数,用于设置控件的默认样式键 (DefaultStyleKeyProperty),这是模板化控件的必要步骤。

  • ValueProperty: 评分值依赖属性。

    • CoerceValueCallback(CoerceValue): 强制值回调,确保 Value 值在 0 到 StarCount 之间。

    • ValidateValueCallback(ValidateValue): 验证值回调,确保 Value 值非负。

    • PropertyChangedCallback(OnValueChanged): 属性更改回调,当 Value 值改变时,调用 UpdateVisualState() 更新视觉状态,并触发 CLR 事件 ValueChanged

  • StarCountProperty: 星星数量依赖属性。

    • CoerceValueCallback(CoerceStarCount): 强制值回调,确保 StarCount 值最小为 1。

    • ValidateValueCallback(ValidateStarCount): 验证值回调,确保 StarCount 值大于 0。

  • ValueChangedRoutedEvent: 评分值更改路由事件,当 Value 值改变时触发。

  • OnValueChanged(int newValue): 触发 ValueChangedRoutedEvent 路由事件的方法。

  • UpdateVisualState(): 根据 Value 属性值,使用 VisualStateManager.GoToState() 切换控件的视觉状态 (将在控件模板中定义视觉状态)。

4.3. 定义路由事件 (评分值改变事件)

在上面的 RatingControl.cs 代码中,我们已经定义了路由事件 ValueChangedRoutedEvent 和 CLR 事件 ValueChanged

4.4. 设计控件模板 (XAML 结构和样式)

打开 "Themes/Generic.xaml" 文件,修改 ControlTemplateStyle 的内容如下:

XML

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:RatingControlLibrary">
    <Style TargetType="{x:Type local:RatingControl}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:RatingControl}">
                    <Grid>
                        <VisualStateManager.VisualStateGroups>
                            <VisualStateGroup Name="ValueStates">
                                <VisualState Name="Value0">
                                    <Storyboard>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star1" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarEmptyBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star2" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarEmptyBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star3" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarEmptyBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star4" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarEmptyBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star5" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarEmptyBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                    </Storyboard>
                                </VisualState>
                                <VisualState Name="Value1">
                                    <Storyboard>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star1" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarFilledBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star2" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarEmptyBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star3" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarEmptyBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star4" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarEmptyBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star5" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarEmptyBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                    </Storyboard>
                                </VisualState>
                                <VisualState Name="Value5">
                                    <Storyboard>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star1" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarFilledBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star2" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarFilledBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star3" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarFilledBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star4" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarFilledBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                        <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star5" Storyboard.TargetProperty="Fill">
                                            <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarFilledBrush}"/>
                                        </ObjectAnimationUsingKeyFrames>
                                    </Storyboard>
                                </VisualState>
                            </VisualStateGroup>
                        </VisualStateManager.VisualStateGroups>
                        <StackPanel Orientation="Horizontal">
                            <Path Name="Star1" Style="{StaticResource StarStyle}"/>
                            <Path Name="Star2" Style="{StaticResource StarStyle}"/>
                            <Path Name="Star3" Style="{StaticResource StarStyle}"/>
                            <Path Name="Star4" Style="{StaticResource StarStyle}"/>
                            <Path Name="Star5" Style="{StaticResource StarStyle}"/>
                        </StackPanel>
                    </Grid>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>

    <Style x:Key="StarStyle" TargetType="{x:Type Path}">
        <Setter Property="Data" Value="M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z"/>
        <Setter Property="Fill" Value="{StaticResource StarEmptyBrush}"/>
        <Setter Property="Stroke" Value="Black"/>
        <Setter Property="StrokeThickness" Value="0"/>
        <Setter Property="Width" Value="24"/>
        <Setter Property="Height" Value="24"/>
        <Setter Property="Margin" Value="2"/>
    </Style>

    <SolidColorBrush x:Key="StarFilledBrush" Color="Gold"/>
    <SolidColorBrush x:Key="StarEmptyBrush" Color="LightGray"/>
</ResourceDictionary>

代码解释:

  • <Style TargetType="{x:Type local:RatingControl}">: 定义 RatingControl 的默认样式。

  • <ControlTemplate TargetType="{x:Type local:RatingControl}">: 定义 RatingControl 的控件模板。

    • <Grid>: 模板的根元素,使用 Grid 作为容器。

    • <VisualStateManager.VisualStateGroups>: 定义视觉状态组 ValueStates,用于管理不同评分值下的视觉状态。

      • <VisualState Name="Value0">, <VisualState Name="Value1">,..., <VisualState Name="Value5">: 定义了评分值从 0 到 5 的视觉状态。

        • <Storyboard>: 每个视觉状态都包含一个 Storyboard,用于定义状态切换时的动画效果 (这里使用 ObjectAnimationUsingKeyFrames 快速切换星星的填充颜色)。

        • <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Star1" Storyboard.TargetProperty="Fill">: 针对名为 "Star1" 的 Path 元素,设置其 Fill 属性的动画。

        • <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource StarEmptyBrush}"/>: 在 0 时刻,将 "Star1" 的 Fill 属性设置为 StarEmptyBrush (空星画刷)。

        • 根据不同的视觉状态 (Value0, Value1,..., Value5),设置不同数量星星的填充颜色为 StarFilledBrush (填充星画刷) 或 StarEmptyBrush

    • <StackPanel Orientation="Horizontal">: 使用 StackPanel 水平排列星星。

      • <Path Name="Star1" Style="{StaticResource StarStyle}"/>,..., <Path Name="Star5" Style="{StaticResource StarStyle}"/>: 创建 5 个 Path 元素作为星星,并应用 StarStyle 样式。

  • <Style x:Key="StarStyle" TargetType="{x:Type Path}">: 定义星星的样式 StarStyle

    • <Setter Property="Data" Value="...">: 设置星星的形状数据 (五角星 PathGeometry)。

    • <Setter Property="Fill" Value="{StaticResource StarEmptyBrush}"/>: 设置星星的默认填充颜色为空星画刷。

    • <Setter Property="Stroke" Value="Black"/>,...: 设置星星的其他样式属性 (边框、大小、间距等)。

  • <SolidColorBrush x:Key="StarFilledBrush" Color="Gold"/>, <SolidColorBrush x:Key="StarEmptyBrush" Color="LightGray"/>: 定义填充星和空星的画刷资源。

4.5. 实现控件逻辑 (C# 代码,属性更改处理,事件触发)

RatingControl.cs 文件中,我们已经实现了大部分控件逻辑,包括依赖属性的定义、属性更改回调、强制值和验证、路由事件触发等。

关键代码回顾:

  • OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e): 在 ValueProperty 的属性更改回调中,调用 control.UpdateVisualState() 更新视觉状态,并调用 control.OnValueChanged(newValue) 触发 CLR 事件 ValueChanged

  • UpdateVisualState(): 根据 Value 属性值,使用 VisualStateManager.GoToState(this, "Value" + Value, true) 切换控件的视觉状态,从而改变星星的填充颜色,实现评分的视觉效果。

4.6. 应用样式和主题

模板化控件的样式和主题化非常灵活。

  • 默认样式: 在 "Themes/Generic.xaml" 中定义的 Style 是控件的 默认样式。当控件没有显式设置样式时,会应用默认样式。

  • 自定义样式: 可以在应用程序的资源字典 (App.xaml, 窗口资源, 控件资源) 中 重写或扩展控件的样式。可以修改默认样式中的 Setter 值,或者完全替换 ControlTemplate,实现自定义的外观。

  • 主题切换: 通过 替换应用程序的资源字典,可以实现主题切换。例如,可以创建不同的资源字典文件 (例如 "Themes/LightTheme.xaml", "Themes/DarkTheme.xaml"),分别定义不同主题的样式,然后在应用程序启动时或运行时动态加载不同的资源字典,实现主题切换效果。

4.7. 在应用程序中使用自定义控件

  1. 添加对自定义控件库项目的引用: 在你的 WPF 应用程序项目中,右键点击 "引用" -> "添加引用" -> "项目",选择 "RatingControlLibrary" 项目,点击 "确定"。

  2. 在 XAML 中使用自定义控件: 在需要使用 RatingControl 的 XAML 文件中,添加命名空间声明,并使用 <local:RatingControl> 标签来使用自定义控件。

    XML

    <Window...
            xmlns:local="clr-namespace:RatingControlLibrary;assembly=RatingControlLibrary"...>
        <Grid>
            <local:RatingControl Value="3" StarCount="7" HorizontalAlignment="Center" VerticalAlignment="Center" ValueChangedRouted="RatingControl_ValueChangedRouted"/>
        </Grid>
    </Window>
    
  3. 处理路由事件 (可选): 如果需要响应 RatingControl 的路由事件 ValueChangedRoutedEvent,可以在窗口或父控件的代码文件中添加事件处理程序。

    C#

    private void RatingControl_ValueChangedRouted(object sender, RoutedPropertyChangedEventArgs<int> e)
    {
        MessageBox.Show($"评分值已更改: 旧值 = {e.OldValue}, 新值 = {e.NewValue}");
    }
    

5. 高级自定义控件技术

5.1. 命令 (Commands)

命令 (Commands) 是一种用于 解耦 UI 交互和业务逻辑 的机制。在自定义控件中,可以使用命令来处理用户操作 (例如按钮点击、菜单选择等),并将操作委托给命令执行器 (通常是 ViewModel) 处理。

  • ICommand 接口: WPF 命令系统基于 ICommand 接口,该接口定义了 Execute() (执行命令) 和 CanExecute() (判断命令是否可执行) 方法。

  • RoutedCommandDelegateCommand: WPF 提供了 RoutedCommandDelegateCommand 等命令实现。RoutedCommand 是路由命令,可以沿着元素树传播;DelegateCommand 是委托命令,可以将命令执行逻辑委托给委托方法。

  • CommandBinding: 用于将命令与控件的特定操作 (例如按钮点击) 关联起来。

  • CommandParameter: 用于传递命令参数。

5.2. 自定义控件中的数据绑定

自定义控件可以充分利用 WPF 的数据绑定机制,实现 UI 与数据的双向同步。

  • 绑定到依赖属性: 自定义控件的依赖属性可以作为数据绑定的目标属性。

  • Binding 表达式: 可以使用 Binding 表达式在 XAML 中将控件属性绑定到数据源 (例如 ViewModel 的属性)。

  • INotifyPropertyChanged 接口: 如果数据源对象实现了 INotifyPropertyChanged 接口,当数据源属性值发生变化时,会自动通知 UI 更新。

5.3. 自定义布局 (MeasureOverride, ArrangeOverride)

对于需要 完全自定义布局行为 的复杂控件 (例如自定义布局面板、图表控件等),可能需要重写 FrameworkElementMeasureOverride()ArrangeOverride() 方法。

  • MeasureOverride(Size availableSize): 用于测量控件的期望大小。控件应该根据 availableSize (可用空间) 计算并返回自身的期望大小 (DesiredSize)。

  • ArrangeOverride(Size finalSize): 用于排列控件的子元素。控件应该根据 finalSize (最终分配的大小) 和子元素的 DesiredSize,确定子元素的位置和大小,并返回自身的最终大小 (ArrangeBounds)。

5.4. 控件的可访问性 (Accessibility)

创建自定义控件时,需要考虑控件的可访问性,确保残障人士也能正常使用你的应用程序。

  • UI 自动化 (UI Automation): WPF 提供了 UI 自动化框架,用于辅助技术 (例如屏幕阅读器) 访问和操作 UI 元素。

  • AutomationProperties: 可以使用 AutomationProperties 类的附加属性 (例如 AutomationProperties.Name, AutomationProperties.HelpText, AutomationProperties.ControlType 等) 为自定义控件提供可访问性信息。

  • 键盘导航 (Keyboard Navigation): 确保自定义控件支持键盘导航,用户可以使用 Tab 键、方向键等进行操作。

  • 高对比度主题 (High Contrast Themes): 测试自定义控件在高对比度主题下的显示效果,确保控件在不同主题下都具有良好的可读性。

5.5. 性能优化

自定义控件的性能优化非常重要,特别是对于复杂控件或大量使用的控件。

  • 减少视觉复杂度: 尽量简化控件的视觉结构,减少 XAML 元素的数量。

  • 优化渲染逻辑: 在 OnRender() 方法中,避免进行复杂的计算或资源加载操作。

  • 使用 DrawingVisual: 对于复杂的自定义图形渲染,可以使用 DrawingVisual 来提高性能。

  • 虚拟化 (Virtualization): 对于显示大量数据的列表控件 (例如自定义 ListBox, DataGrid),可以使用虚拟化技术 (例如 VirtualizingStackPanel) 来提高性能。

  • 延迟加载 (Deferred Loading): 对于不必要的资源 (例如大型图像、复杂模板),可以使用延迟加载技术,只在需要时才加载。

6. 总结

本教程深入介绍了 WPF 自定义控件的开发,涵盖了从基础概念到高级技巧的各个方面。通过学习本教程,你应该能够:

  • 理解 WPF 自定义控件的必要性和优势。

  • 掌握自定义控件开发的基础概念,例如依赖属性、路由事件、控件模板、样式和主题、内容呈现等。

  • 了解不同类型的自定义控件 (用户控件、模板化控件、元素控件) 的特点和适用场景。

  • 通过实战教程,学会创建模板化控件,并应用样式和主题。

  • 了解高级自定义控件技术,例如命令、数据绑定、自定义布局、可访问性和性能优化。

自定义控件开发是 WPF 开发中的重要技能,掌握它可以让你创建出更加强大、灵活和美观的 WPF 应用程序。希望本教程能够帮助你入门并精通 WPF 自定义控件开发!


后续学习方向:

  • 深入研究 WPF 样式和主题系统。

  • 学习更多高级控件模板技巧,例如使用 ItemsPresenter, ScrollViewer, AdornerDecorator 等。

  • 探索 WPF 的布局系统,学习如何创建自定义布局面板。

  • 研究 WPF 的渲染管道,学习如何进行高性能自定义渲染。

  • 阅读更多关于 WPF 自定义控件开发的书籍、文章和示例代码。

祝你在 WPF 自定义控件开发的道路上取得成功!