【WPF】从零到一:C# 控件编写与自定义完全指南

为什么你需要自定义控件

用 WPF 做过界面的人,大概率都经历过这个瞬间:

系统自带的 ButtonTextBox 丑得没法看,DataGrid 性能又差,产品经理还要求「这个进度条要变成环形的」「这个开关要有动画」。

于是你开始到处找第三方 UI 库(MahApps、HandyControl、MaterialDesign……),引入一堆依赖,结果样式打架、版本冲突、体积膨胀。

其实,WPF 最强大的能力之一,恰恰就是「外观与行为分离」的控件模型。一旦你理解了它,自己写一个符合产品需求、轻量可控的控件,通常只需要几十行代码。

这篇文章会把 WPF 控件自定义这件事讲透:从理论到三种实现方式,再到可直接运行的完整代码。

WPF 控件模型:先理解「无外观控件」

WPF 和 WinForms 最大的不同在于:控件的「行为」(逻辑)和「外观」(模板)是解耦的

一个 Button 本质上就是一个「可以点击、有 Command 行为」的逻辑对象,它长什么样子完全由 ControlTemplate 决定。所以你才能把一个按钮渲染成圆形、图标、甚至一整张卡片——行为不变,只换皮肤。

这带来三个直接好处:

  1. 换肤零成本:换一套 Style/Template,整个应用外观全变,逻辑代码一行不用改。
  2. 数据驱动:通过 DependencyProperty,控件属性可被绑定、样式、动画、触发器直接驱动。
  3. 高度复用:写一次,多处使用,团队共享。

想写好自定义控件,先储备下面四个核心概念。

核心概念储备

1. DependencyProperty(依赖属性)

普通 CLR 属性只有「存值」功能。依赖属性额外支持:

  • 数据绑定({Binding}
  • 样式设置(Setter
  • 动画(Storyboard 驱动)
  • 属性值继承与默认值

标准写法:

public bool IsOn
{
    get => (bool)GetValue(IsOnProperty);
    set => SetValue(IsOnProperty, value);
}

public static readonly DependencyProperty IsOnProperty =
    DependencyProperty.Register(
        nameof(IsOn),          // 属性名
        typeof(bool),          // 属性类型
        typeof(ToggleSwitch),  // 所属控件类型
        new PropertyMetadata(false, OnIsOnChanged)); // 默认值 + 回调

记住这条铁律:依赖属性的 CLR 包装器里,只能有 GetValue/SetValue,不要加任何逻辑。否则绑定、动画等路径会绕过你的逻辑。

2. RoutedEvent(路由事件)

WPF 事件可以沿可视化树「冒泡」(Bubble)或「隧道」(Tunnel)传播。自定义事件用 EventManager.RegisterRoutedEvent 注册,父容器能统一监听子控件的同类事件。

public static readonly RoutedEvent ToggledEvent =
    EventManager.RegisterRoutedEvent(
        nameof(Toggled),             // 事件名
        RoutingStrategy.Bubble,      // 冒泡策略
        typeof(RoutedEventHandler),  // 委托类型
        typeof(ToggleSwitch));       // 所属控件

public event RoutedEventHandler Toggled
{
    add => AddHandler(ToggledEvent, value);
    remove => RemoveHandler(ToggledEvent, value);
}

3. ControlTemplate 与 TemplateBinding

ControlTemplate 定义控件的完整视觉树;TemplateBinding 则把模板内部的元素属性「接到」控件的依赖属性上——外观跟随数据走。

4. Themes/Generic.xaml

自定义控件(Custom Control)的默认模板必须放在 Themes/Generic.xaml 里,并配合 ThemeInfo 特性。这是 WPF 查找默认样式约定俗成的位置。


方式一:用 Style + Template 改造现有控件(零代码)

如果只是想让现有控件「变好看」,完全不需要写新控件。直接覆写它的 StyleTemplate

下面把一个普通 Button 改成圆角、带悬停和按压动画的现代按钮:

<Style x:Key="ModernButton" TargetType="Button">
    <Setter Property="Foreground" Value="White"/>
    <Setter Property="FontSize" Value="14"/>
    <Setter Property="Cursor" Value="Hand"/>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="Button">
                <Border x:Name="bd"
                        Background="#3B82F6"
                        CornerRadius="8"
                        Padding="16,8">
                    <ContentPresenter HorizontalAlignment="Center"
                                      VerticalAlignment="Center"/>
                </Border>
                <ControlTemplate.Triggers>
                    <Trigger Property="IsMouseOver" Value="True">
                        <Setter TargetName="bd" Property="Background" Value="#2563EB"/>
                    </Trigger>
                    <Trigger Property="IsPressed" Value="True">
                        <Setter TargetName="bd" Property="RenderTransform">
                            <Setter.Value>
                                <ScaleTransform ScaleX="0.96" ScaleY="0.96"/>
                            </Setter.Value>
                        </Setter>
                    </Trigger>
                    <Trigger Property="IsEnabled" Value="False">
                        <Setter TargetName="bd" Property="Opacity" Value="0.5"/>
                    </Trigger>
                </ControlTemplate.Triggers>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

适用场景:改外观、加动画、统一风格。缺点是无法新增「行为」,比如给按钮加个「长按」逻辑就做不到了。


方式二:UserControl 复合控件(组合现有控件)

当你要「把几个现成控件打包成一个新控件」时,用 UserControl 最省事。典型场景:信息卡片、搜索框(图标+输入框+按钮)、表单行等。

下面做一个 InfoCard 信息卡片,暴露 TitleDescription 两个依赖属性:

InfoCard.xaml

<UserControl x:Class="WpfControlLibrary.Controls.InfoCard"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Border Background="White"
            CornerRadius="8"
            Padding="16"
            BorderBrush="#E5E7EB"
            BorderThickness="1">
        <StackPanel>
            <TextBlock Text="{Binding Title, RelativeSource={RelativeSource AncestorType=UserControl}}"
                       FontSize="18" FontWeight="Bold"/>
            <TextBlock Text="{Binding Description, RelativeSource={RelativeSource AncestorType=UserControl}}"
                       TextWrapping="Wrap"
                       Foreground="#6B7280"
                       Margin="0,8,0,0"/>
        </StackPanel>
    </Border>
</UserControl>

InfoCard.xaml.cs

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

namespace WpfControlLibrary.Controls
{
    public partial class InfoCard : UserControl
    {
        public InfoCard() => InitializeComponent();

        public string Title
        {
            get => (string)GetValue(TitleProperty);
            set => SetValue(TitleProperty, value);
        }

        public static readonly DependencyProperty TitleProperty =
            DependencyProperty.Register(nameof(Title), typeof(string),
                typeof(InfoCard), new PropertyMetadata(string.Empty));

        public string Description
        {
            get => (string)GetValue(DescriptionProperty);
            set => SetValue(DescriptionProperty, value);
        }

        public static readonly DependencyProperty DescriptionProperty =
            DependencyProperty.Register(nameof(Description), typeof(string),
                typeof(InfoCard), new PropertyMetadata(string.Empty));
    }
}

使用:

<local:InfoCard Title="订单已发货"
                Description="包裹预计 3 天内送达,请注意查收短信。"/>

适用场景:组合复用、快速搭建。缺点:UserControl 本质是把内容写死在自己的 XAML 里,无法像原生控件那样被外部 Style/Template 彻底换肤,灵活性有限。


方式三:Custom Control 自定义控件(重点)

这是 WPF 控件自定义的「正统」方式:继承 Control,行为写在代码里,外观写在 ControlTemplate 里,两者通过 TemplateBindingTemplatePart 协作。换肤彻底、复用最强、可被 Style 覆盖,是打造可分发控件库的首选。

3.1 目标:写一个带开关动画的 ToggleSwitch

需求拆解:

  • 一个 IsOn 依赖属性,控制开关状态。
  • 点击切换状态,并触发一个 Toggled 路由事件。
  • 开/关两种视觉状态,带滑动动画。

3.2 代码实现

ToggleSwitch.cs

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

namespace WpfControlLibrary.Controls
{
    public class ToggleSwitch : Control
    {
        static ToggleSwitch()
        {
            // 关键:声明默认样式从 Themes/Generic.xaml 加载
            DefaultStyleKeyProperty.OverrideMetadata(
                typeof(ToggleSwitch),
                new FrameworkPropertyMetadata(typeof(ToggleSwitch)));
        }

        public bool IsOn
        {
            get => (bool)GetValue(IsOnProperty);
            set => SetValue(IsOnProperty, value);
        }

        public static readonly DependencyProperty IsOnProperty =
            DependencyProperty.Register(nameof(IsOn), typeof(bool), typeof(ToggleSwitch),
                new PropertyMetadata(false, OnIsOnChanged));

        public static readonly RoutedEvent ToggledEvent =
            EventManager.RegisterRoutedEvent(nameof(Toggled), RoutingStrategy.Bubble,
                typeof(RoutedEventHandler), typeof(ToggleSwitch));

        public event RoutedEventHandler Toggled
        {
            add => AddHandler(ToggledEvent, value);
            remove => RemoveHandler(ToggledEvent, value);
        }

        // 点击切换状态
        protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
        {
            base.OnMouseLeftButtonDown(e);
            IsOn = !IsOn;
        }

        private static void OnIsOnChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var toggle = (ToggleSwitch)d;
            // 状态变化时,对外抛出 Toggled 事件
            toggle.RaiseEvent(new RoutedEventArgs(ToggledEvent));
        }
    }
}

Themes/Generic.xaml

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

    <Style TargetType="{x:Type local:ToggleSwitch}">
        <Setter Property="Width" Value="52"/>
        <Setter Property="Height" Value="28"/>
        <Setter Property="Cursor" Value="Hand"/>
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:ToggleSwitch}">
                    <Grid>
                        <!-- 轨道 -->
                        <Border x:Name="Track" Background="#C8C8CC" CornerRadius="14"/>
                        <!-- 滑块 -->
                        <Ellipse x:Name="Thumb"
                                 Width="22" Height="22" Fill="White"
                                 HorizontalAlignment="Left" Margin="3,0"/>
                    </Grid>
                    <ControlTemplate.Triggers>
                        <!-- 开启状态:轨道变绿、滑块靠右 -->
                        <Trigger Property="IsOn" Value="True">
                            <Setter TargetName="Track" Property="Background" Value="#34C759"/>
                            <Setter TargetName="Thumb" Property="HorizontalAlignment" Value="Right"/>
                        </Trigger>
                        <Trigger Property="IsEnabled" Value="False">
                            <Setter Property="Opacity" Value="0.5"/>
                        </Trigger>
                    </ControlTemplate.Triggers>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

AssemblyInfo.cs(关键配置)

自定义控件库必须在程序集级声明主题资源位置:

[assembly: ThemeInfo(
    ResourceDictionaryLocation.None,            // 非主题资源
    ResourceDictionaryLocation.SourceAssembly)]  // 主题资源(Generic.xaml 所在程序集)

3.3 使用与监听事件

<local:ToggleSwitch IsOn="True" Toggled="ToggleSwitch_Toggled"/>
private void ToggleSwitch_Toggled(object sender, RoutedEventArgs e)
{
    var sw = (ToggleSwitch)sender;
    MessageBox.Show($"开关状态:{sw.IsOn}");
}

3.4 进阶:用 VisualStateManager 加滑动动画

上面的 Trigger 切换是「瞬间跳变」。想要平滑滑动,用 VisualStateManager 定义状态 + 过渡动画:

<ControlTemplate TargetType="{x:Type local:ToggleSwitch}">
    <Grid>
        <VisualStateManager.VisualStateGroups>
            <VisualStateGroup x:Name="SwitchStates">
                <VisualState x:Name="Off">
                    <Storyboard>
                        <ColorAnimation Storyboard.TargetName="Track"
                                        Storyboard.TargetProperty="Background.Color"
                                        To="#C8C8CC" Duration="0:0:0.2"/>
                        <ThicknessAnimation Storyboard.TargetName="Thumb"
                                            Storyboard.TargetProperty="Margin"
                                            To="3,0,27,0" Duration="0:0:0.2"/>
                    </Storyboard>
                </VisualState>
                <VisualState x:Name="On">
                    <Storyboard>
                        <ColorAnimation Storyboard.TargetName="Track"
                                        Storyboard.TargetProperty="Background.Color"
                                        To="#34C759" Duration="0:0:0.2"/>
                        <ThicknessAnimation Storyboard.TargetName="Thumb"
                                            Storyboard.TargetProperty="Margin"
                                            To="27,0,3,0" Duration="0:0:0.2"/>
                    </Storyboard>
                </VisualState>
            </VisualStateGroup>
        </VisualStateManager.VisualStateGroups>
        <Border x:Name="Track" Background="#C8C8CC" CornerRadius="14"/>
        <Ellipse x:Name="Thumb" Width="22" Height="22" Fill="White"
                 HorizontalAlignment="Left" Margin="3,0"/>
    </Grid>
</ControlTemplate>

然后在 OnIsOnChanged 回调里切换状态:

private static void OnIsOnChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    var toggle = (ToggleSwitch)d;
    VisualStateManager.GoToState(toggle, (bool)e.NewValue ? "On" : "Off", true);
    toggle.RaiseEvent(new RoutedEventArgs(ToggledEvent));
}

三种方式对比与选型

方式 换肤能力 新增行为 开发成本 适用场景
Style/Template 改造 最低 纯美化现有控件
UserControl 弱(写死) 打包组合多个控件
Custom Control 最强 较高 打造可分发、可换肤的控件库

选型建议:只是改样式用方式一;快速拼装用方式二;要做成团队/开源控件库,或被外部反复换肤,用方式三。


常见坑与最佳实践

  1. 依赖属性包装器别写逻辑:绑定和动画会直接走 GetValue/SetValue,绕过 CLR 包装器。逻辑放 PropertyChangedCallback 里。
  2. DefaultStyleKey 别忘了Custom Control 必须在静态构造函数里 OverrideMetadata,否则找不到默认样式,控件渲染成空白。
  3. ThemeInfo 特性必须配:丢了它,Generic.xaml 不会被打进主题资源,控件同样空白。
  4. TemplatePart 记得加:用 [TemplatePart(Name = "...", Type = ...)] 标注模板关键元素,既方便 OnApplyTemplateGetTemplateChild,也是给使用者(含设计器)的契约。
  5. OnApplyTemplate 里找模板元素:不要在构造函数里 GetTemplateChild(此时模板尚未应用),要重写 OnApplyTemplate
  6. 别在模板里硬编码颜色:优先用依赖属性暴露(如 TrackBrushThumbBrush),让使用者能 Setter 覆盖,保持「无外观」优势。

总结

WPF 的控件自定义,本质是理解一句话:

行为放代码,外观放模板,数据用依赖属性连接两者。

掌握「三个方式 + 四个概念」,你就拿到了 WPF 界面开发的「自由权」:

  1. 三个方式:Style/Template 改造、UserControl 组合、Custom Control 自建。
  2. 四个概念:DependencyProperty、RoutedEvent、ControlTemplate、Generic.xaml。

文中 ToggleSwitchInfoCard 的代码都是完整可运行的,直接复制进一个类库项目即可使用。

如果这篇文章对你有帮助,欢迎点赞、收藏、评论;你在自定义控件时还踩过哪些坑?评论区聊聊,我会尽量回复。


(本文代码基于 .NET 6/8 WPF 编写,兼容 .NET Framework 4.6+ 的 WPF 项目。)

Logo

一站式 AI 云服务平台

更多推荐