自定义控件开发
大约 8 分钟约 2314 字
自定义控件开发
简介
WPF 提供了强大的自定义控件能力,当内置控件无法满足需求时,可以创建 UserControl(用户控件)或 CustomControl(自定义控件)。UserControl 适合组合现有控件,CustomControl 适合从零构建支持模板和主题的控件。掌握依赖属性、路由事件和控件模板,可以构建专业级可复用组件。
特点
UserControl 用户控件
创建用户控件
<!-- NumericSpinner.xaml -->
<UserControl x:Class="MyApp.Controls.NumericSpinner"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="ValueText"
Grid.Column="0"
VerticalContentAlignment="Center"
TextAlignment="Right"
PreviewTextInput="ValueText_PreviewTextInput"/>
<StackPanel Grid.Column="1" Orientation="Vertical">
<RepeatButton Content="▲" FontSize="8" Click="UpButton_Click"
Interval="200"/>
<RepeatButton Content="▼" FontSize="8" Click="DownButton_Click"
Interval="200"/>
</StackPanel>
</Grid>
</UserControl>// NumericSpinner.xaml.cs
public partial class NumericSpinner : UserControl
{
// 依赖属性
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(nameof(Value), typeof(double), typeof(NumericSpinner),
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnValueChanged));
public static readonly DependencyProperty MinimumProperty =
DependencyProperty.Register(nameof(Minimum), typeof(double), typeof(NumericSpinner),
new PropertyMetadata(double.MinValue));
public static readonly DependencyProperty MaximumProperty =
DependencyProperty.Register(nameof(Maximum), typeof(double), typeof(NumericSpinner),
new PropertyMetadata(double.MaxValue));
public static readonly DependencyProperty StepProperty =
DependencyProperty.Register(nameof(Step), typeof(double), typeof(NumericSpinner),
new PropertyMetadata(1.0));
public static readonly RoutedEvent ValueChangedEvent =
EventManager.RegisterRoutedEvent(nameof(ValueChanged), RoutingStrategy.Bubble,
typeof(RoutedPropertyChangedEventHandler<double>), typeof(NumericSpinner));
public double Value
{
get => (double)GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
public double Minimum { get => (double)GetValue(MinimumProperty); set => SetValue(MinimumProperty, value); }
public double Maximum { get => (double)GetValue(MaximumProperty); set => SetValue(MaximumProperty, value); }
public double Step { get => (double)GetValue(StepProperty); set => SetValue(StepProperty, value); }
public event RoutedPropertyChangedEventHandler<double> ValueChanged
{
add => AddHandler(ValueChangedEvent, value);
remove => RemoveHandler(ValueChangedEvent, value);
}
public NumericSpinner()
{
InitializeComponent();
}
private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var spinner = (NumericSpinner)d;
spinner.ValueText.Text = ((double)e.NewValue).ToString("F2");
spinner.RaiseEvent(new RoutedPropertyChangedEventArgs<double>(
ValueChangedEvent, (double)e.OldValue, (double)e.NewValue));
}
private void UpButton_Click(object sender, RoutedEventArgs e)
{
var newValue = Math.Min(Value + Step, Maximum);
Value = newValue;
}
private void DownButton_Click(object sender, RoutedEventArgs e)
{
var newValue = Math.Max(Value - Step, Minimum);
Value = newValue;
}
private void ValueText_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
e.Handled = !decimal.TryParse(e.Text, out _);
}
}使用用户控件
<!-- 使用 -->
<StackPanel>
<!-- 基本使用 -->
<local:NumericSpinner Value="{Binding Quantity}"
Minimum="0" Maximum="999"
Step="1"/>
<!-- 带事件处理 -->
<local:NumericSpinner Value="{Binding Price}"
Minimum="0" Maximum="99999"
Step="0.01"
ValueChanged="PriceSpinner_ValueChanged"/>
</StackPanel>CustomControl 自定义控件
创建支持模板的控件
/// <summary>
/// RatingControl — 评分控件
/// </summary>
[TemplatePart(Name = "PART_StarsPanel", Type = typeof(StackPanel))]
public class RatingControl : Control
{
static RatingControl()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(RatingControl),
new FrameworkPropertyMetadata(typeof(RatingControl)));
}
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(nameof(Value), typeof(int), typeof(RatingControl),
new FrameworkPropertyMetadata(0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public static readonly DependencyProperty MaxRatingProperty =
DependencyProperty.Register(nameof(MaxRating), typeof(int), typeof(RatingControl),
new PropertyMetadata(5));
public static readonly DependencyProperty StarFillProperty =
DependencyProperty.Register(nameof(StarFill), typeof(Brush), typeof(RatingControl),
new PropertyMetadata(Brushes.Gold));
public int Value
{
get => (int)GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
public int MaxRating
{
get => (int)GetValue(MaxRatingProperty);
set => SetValue(MaxRatingProperty, value);
}
public Brush StarFill
{
get => (Brush)GetValue(StarFillProperty);
set => SetValue(StarFillProperty, value);
}
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
if (GetTemplateChild("PART_StarsPanel") is StackPanel panel)
{
for (int i = 0; i < MaxRating; i++)
{
var star = new ToggleButton
{
Content = "★",
FontSize = 20,
Tag = i + 1
};
star.Checked += (s, e) => Value = (int)((ToggleButton)s!).Tag;
panel.Children.Add(star);
}
UpdateStars();
}
}
private void UpdateStars()
{
if (GetTemplateChild("PART_StarsPanel") is StackPanel panel)
{
for (int i = 0; i < panel.Children.Count; i++)
{
if (panel.Children[i] is ToggleButton star)
{
star.IsChecked = i < Value;
star.Foreground = i < Value ? StarFill : Brushes.Gray;
}
}
}
}
}<!-- Themes/Generic.xaml — 默认样式 -->
<Style TargetType="{x:Type local:RatingControl}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:RatingControl}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<StackPanel x:Name="PART_StarsPanel" Orientation="Horizontal"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>附加行为(Attached Behavior)
输入行为封装
/// <summary>
/// 附加行为 — 数字输入限制
/// </summary>
public static class InputBehavior
{
// 只允许数字输入
public static readonly DependencyProperty IsNumericOnlyProperty =
DependencyProperty.RegisterAttached("IsNumericOnly", typeof(bool),
typeof(InputBehavior), new PropertyMetadata(false, OnIsNumericOnlyChanged));
public static bool GetIsNumericOnly(DependencyObject obj)
=> (bool)obj.GetValue(IsNumericOnlyProperty);
public static void SetIsNumericOnly(DependencyObject obj, bool value)
=> obj.SetValue(IsNumericOnlyProperty, value);
private static void OnIsNumericOnlyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is TextBox textBox)
{
if ((bool)e.NewValue)
textBox.PreviewTextInput += NumericOnly_PreviewTextInput;
else
textBox.PreviewTextInput -= NumericOnly_PreviewTextInput;
}
}
private static void NumericOnly_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
e.Handled = !double.TryParse(e.Text, out _);
}
// 输入长度限制
public static readonly DependencyProperty MaxLengthBehaviorProperty =
DependencyProperty.RegisterAttached("MaxLengthBehavior", typeof(int),
typeof(InputBehavior), new PropertyMetadata(0, OnMaxLengthChanged));
public static int GetMaxLengthBehavior(DependencyObject obj)
=> (int)obj.GetValue(MaxLengthBehaviorProperty);
public static void SetMaxLengthBehavior(DependencyObject obj, int value)
=> obj.SetValue(MaxLengthBehaviorProperty, value);
private static void OnMaxLengthChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is TextBox textBox && e.NewValue is int maxLength && maxLength > 0)
{
textBox.TextChanged += (_, _) =>
{
if (textBox.Text.Length > maxLength)
textBox.Text = textBox.Text.Substring(0, maxLength);
};
}
}
// 选中全部文本
public static readonly DependencyProperty SelectAllOnFocusProperty =
DependencyProperty.RegisterAttached("SelectAllOnFocus", typeof(bool),
typeof(InputBehavior), new PropertyMetadata(false, OnSelectAllOnFocusChanged));
public static bool GetSelectAllOnFocus(DependencyObject obj)
=> (bool)obj.GetValue(SelectAllOnFocusProperty);
public static void SetSelectAllOnFocus(DependencyObject obj, bool value)
=> obj.SetValue(SelectAllOnFocusProperty, value);
private static void OnSelectAllOnFocusChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is TextBox textBox && (bool)e.NewValue)
{
textBox.GotFocus += (_, _) => textBox.SelectAll();
}
}
}<!-- 使用附加行为 -->
<TextBox local:InputBehavior.IsNumericOnly="True"
local:InputBehavior.MaxLengthBehavior="10"
local:InputBehavior.SelectAllOnFocus="True"
Text="{Binding Quantity}"/>拖拽行为封装
/// <summary>
/// 拖拽排序附加行为
/// </summary>
public static class DragDropBehavior
{
public static readonly DependencyProperty IsDragSourceProperty =
DependencyProperty.RegisterAttached("IsDragSource", typeof(bool),
typeof(DragDropBehavior), new PropertyMetadata(false, OnIsDragSourceChanged));
public static bool GetIsDragSource(DependencyObject obj)
=> (bool)obj.GetValue(IsDragSourceProperty);
public static void SetIsDragSource(DependencyObject obj, bool value)
=> obj.SetValue(IsDragSourceProperty, value);
public static readonly DependencyProperty IsDropTargetProperty =
DependencyProperty.RegisterAttached("IsDropTarget", typeof(bool),
typeof(DragDropBehavior), new PropertyMetadata(false, OnIsDropTargetChanged));
public static bool GetIsDropTarget(DependencyObject obj)
=> (bool)obj.GetValue(IsDropTargetProperty);
public static void SetIsDropTarget(DependencyObject obj, bool value)
=> obj.SetValue(IsDropTargetProperty, value);
public static readonly DependencyProperty DragDropTemplateProperty =
DependencyProperty.RegisterAttached("DragDropTemplate", typeof(DataTemplate),
typeof(DragDropBehavior), new PropertyMetadata(null));
public static DataTemplate GetDragDropTemplate(DependencyObject obj)
=> (DataTemplate)obj.GetValue(DragDropTemplateProperty);
public static void SetDragDropTemplate(DependencyObject obj, DataTemplate value)
=> obj.SetValue(DragDropTemplateProperty, value);
private static void OnIsDragSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is ListBox listBox && (bool)e.NewValue)
{
listBox.PreviewMouseLeftButtonDown += ListBox_PreviewMouseLeftButtonDown;
listBox.DragEnter += (s, args) =>
{
if (args.Data.GetDataPresent(DataFormats.FileDrop))
args.Effects = DragDropEffects.Copy;
};
}
}
private static void OnIsDropTargetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is ListBox listBox && (bool)e.NewValue)
{
listBox.AllowDrop = true;
listBox.Drop += ListBox_Drop;
}
}
private static void ListBox_Drop(object sender, DragEventArgs e)
{
if (sender is ListBox listBox && e.Data.GetDataPresent(DataFormats.Serializable))
{
var data = e.Data.GetData(DataFormats.Serializable);
// 处理拖放逻辑
}
}
private static Point _dragStartPoint;
private static void ListBox_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
_dragStartPoint = e.GetPosition(null);
}
}依赖属性进阶
属性元数据和回调
/// <summary>
/// 依赖属性高级用法
/// </summary>
public class WatermarkTextBox : TextBox
{
public static readonly DependencyProperty WatermarkProperty =
DependencyProperty.Register(nameof(Watermark), typeof(string), typeof(WatermarkTextBox),
new PropertyMetadata(string.Empty));
public static readonly DependencyProperty WatermarkForegroundProperty =
DependencyProperty.Register(nameof(WatermarkForeground), typeof(Brush), typeof(WatermarkTextBox),
new PropertyMetadata(Brushes.Gray));
public string Watermark
{
get => (string)GetValue(WatermarkProperty);
set => SetValue(WatermarkProperty, value);
}
public Brush WatermarkForeground
{
get => (Brush)GetValue(WatermarkForegroundProperty);
set => SetValue(WatermarkForegroundProperty, value);
}
static WatermarkTextBox()
{
TextProperty.OverrideMetadata(typeof(WatermarkTextBox),
new FrameworkPropertyMetadata(string.Empty, OnTextChanged));
}
private static void OnTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var box = (WatermarkTextBox)d;
box.UpdateWatermarkVisibility();
}
private void UpdateWatermarkVisibility()
{
// 根据 Text 是否为空显示/隐藏 Watermark
}
}优点
缺点
总结
自定义控件选择:组合现有控件用 UserControl(简单快速),专业级可复用控件用 CustomControl(支持模板和主题)。核心技术:依赖属性(支持绑定)、路由事件(支持冒泡)、控件模板(支持样式替换)。建议先用 UserControl 满足需求,复杂场景再升级 CustomControl。
关键知识点
- 先分清主题属于界面层、ViewModel 层、线程模型还是设备接入层。
- WPF 文章真正的价值在于把 UI、数据、命令、线程和资源关系讲清楚。
- 上位机场景里,稳定性和异常恢复常常比界面花哨更重要。
- WPF 主题往往要同时理解依赖属性、绑定、可视树和命令系统。
项目落地视角
- 优先明确 DataContext、绑定路径、命令源和线程切换位置。
- 涉及设备时,补齐超时、重连、日志、告警和资源释放策略。
- 复杂控件最好提供最小可运行页面,便于后续复用和排障。
- 优先确认 DataContext、绑定路径、命令触发点和资源引用来源。
常见误区
- 把大量逻辑堆进 code-behind,导致页面难测难维护。
- 在后台线程直接操作 UI,或忽略 Dispatcher 切换。
- 只验证正常路径,不验证设备掉线、权限缺失和资源泄漏。
- 在 code-behind 塞太多状态与业务逻辑。
进阶路线
- 继续向 MVVM 工具链、控件可复用性、性能优化和视觉树诊断深入。
- 把主题放回真实设备流程,思考启动、连接、采集、显示、告警和恢复。
- 沉淀成控件库、通信层和诊断工具,提高整套客户端的复用度。
- 继续补齐控件库、主题系统、诊断工具和启动性能优化。
适用场景
- 当你准备把《自定义控件开发》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合桌面业务系统、监控看板、工控上位机和设备交互界面。
- 当系统需要同时处理复杂 UI、后台任务和硬件通信时,这类主题尤为关键。
落地建议
- 优先明确 View、ViewModel、服务层和设备层边界,避免代码隐藏过重。
- 涉及实时刷新时,提前设计 UI 线程切换、节流和资源释放。
- 对硬件通信、日志、告警和异常恢复建立标准流程。
排错清单
- 先检查 DataContext、绑定路径、INotifyPropertyChanged 和命令状态是否正常。
- 排查 Dispatcher 调用、死锁、后台线程直接操作 UI 的问题。
- 出现内存增长时,优先检查事件订阅、图像资源和窗口生命周期。
复盘问题
- 如果把《自定义控件开发》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《自定义控件开发》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《自定义控件开发》最大的收益和代价分别是什么?
