【WPF】从零到一:C# 控件编写与自定义完全指南(附完整代码)
【WPF】从零到一:C# 控件编写与自定义完全指南
为什么你需要自定义控件
用 WPF 做过界面的人,大概率都经历过这个瞬间:
系统自带的
Button、TextBox丑得没法看,DataGrid性能又差,产品经理还要求「这个进度条要变成环形的」「这个开关要有动画」。
于是你开始到处找第三方 UI 库(MahApps、HandyControl、MaterialDesign……),引入一堆依赖,结果样式打架、版本冲突、体积膨胀。
其实,WPF 最强大的能力之一,恰恰就是「外观与行为分离」的控件模型。一旦你理解了它,自己写一个符合产品需求、轻量可控的控件,通常只需要几十行代码。
这篇文章会把 WPF 控件自定义这件事讲透:从理论到三种实现方式,再到可直接运行的完整代码。
WPF 控件模型:先理解「无外观控件」
WPF 和 WinForms 最大的不同在于:控件的「行为」(逻辑)和「外观」(模板)是解耦的。
一个 Button 本质上就是一个「可以点击、有 Command 行为」的逻辑对象,它长什么样子完全由 ControlTemplate 决定。所以你才能把一个按钮渲染成圆形、图标、甚至一整张卡片——行为不变,只换皮肤。
这带来三个直接好处:
- 换肤零成本:换一套
Style/Template,整个应用外观全变,逻辑代码一行不用改。 - 数据驱动:通过
DependencyProperty,控件属性可被绑定、样式、动画、触发器直接驱动。 - 高度复用:写一次,多处使用,团队共享。
想写好自定义控件,先储备下面四个核心概念。
核心概念储备
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 改造现有控件(零代码)
如果只是想让现有控件「变好看」,完全不需要写新控件。直接覆写它的 Style 和 Template。
下面把一个普通 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 信息卡片,暴露 Title 和 Description 两个依赖属性:
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 里,两者通过 TemplateBinding 和 TemplatePart 协作。换肤彻底、复用最强、可被 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 | 最强 | 有 | 较高 | 打造可分发、可换肤的控件库 |
选型建议:只是改样式用方式一;快速拼装用方式二;要做成团队/开源控件库,或被外部反复换肤,用方式三。
常见坑与最佳实践
- 依赖属性包装器别写逻辑:绑定和动画会直接走
GetValue/SetValue,绕过 CLR 包装器。逻辑放PropertyChangedCallback里。 - DefaultStyleKey 别忘了:
Custom Control必须在静态构造函数里OverrideMetadata,否则找不到默认样式,控件渲染成空白。 - ThemeInfo 特性必须配:丢了它,
Generic.xaml不会被打进主题资源,控件同样空白。 TemplatePart记得加:用[TemplatePart(Name = "...", Type = ...)]标注模板关键元素,既方便OnApplyTemplate里GetTemplateChild,也是给使用者(含设计器)的契约。OnApplyTemplate里找模板元素:不要在构造函数里GetTemplateChild(此时模板尚未应用),要重写OnApplyTemplate。- 别在模板里硬编码颜色:优先用依赖属性暴露(如
TrackBrush、ThumbBrush),让使用者能Setter覆盖,保持「无外观」优势。
总结
WPF 的控件自定义,本质是理解一句话:
行为放代码,外观放模板,数据用依赖属性连接两者。
掌握「三个方式 + 四个概念」,你就拿到了 WPF 界面开发的「自由权」:
- 三个方式:Style/Template 改造、UserControl 组合、Custom Control 自建。
- 四个概念:DependencyProperty、RoutedEvent、ControlTemplate、Generic.xaml。
文中 ToggleSwitch 和 InfoCard 的代码都是完整可运行的,直接复制进一个类库项目即可使用。
如果这篇文章对你有帮助,欢迎点赞、收藏、评论;你在自定义控件时还踩过哪些坑?评论区聊聊,我会尽量回复。
(本文代码基于 .NET 6/8 WPF 编写,兼容 .NET Framework 4.6+ 的 WPF 项目。)
更多推荐



所有评论(0)