WPF 进阶特性详解:依赖属性、附加属性、Transform、Effect 与路由事件
大家在学习 WPF 的时候前期最容易接触到的是控件、布局和数据绑定但真正把这些能力串起来的其实是 WPF 自己的一整套机制。 比如为什么有些属性能绑定、有些属性能做动画、为什么Grid.Row能写在Button上、为什么一个按钮点击后父级也能收到事件这些问题的答案都藏在 WPF 的“底层特性”里。目录一、WPF 依赖属性1. 什么是依赖属性2. 依赖属性和普通属性有什么区别3. 自定义依赖属性的 3 个步骤4. 实战给自定义控件定义依赖属性5. 依赖属性的回调函数二、WPF 附加属性1. 什么是附加属性2. 附加属性怎么定义3. 实战让 PasswordBox 支持绑定三、WPF Transform 转换1. Transform 是什么2. 四种常见变换2.1 RotateTransform 旋转2.2 ScaleTransform 缩放2.3 SkewTransform 倾斜2.4 TranslateTransform 平移3. 实战TransformGroup 做图片查看器四、WPF Effect 特效1. Effect 是什么2. DropShadowEffect 阴影效果3. BlurEffect 模糊效果五、WPF 路由事件1. 什么是路由事件2. 隧道事件和冒泡事件怎么理解3. 自定义路由事件实战六、总结这篇文章结合实战案例系统梳理 5 个非常重要的知识点依赖属性附加属性Transform 转换Effect 特效路由事件如果你正在做自定义控件、MVVM 绑定、交互动画或者面试里经常被问到 WPF 原理这篇内容基本都绕不开。一、WPF 依赖属性1. 什么是依赖属性依赖属性DependencyProperty是 WPF 属性系统的核心。它不是普通的 .NET 属性包装私有字段而是一种由 WPF 属性系统统一管理的属性机制。它最大的价值在于一个属性的值不再只来自字段本身而是可以同时受到本地值、样式、动画、数据绑定、资源、默认值等多种来源的影响。也正因为如此依赖属性天然支持这些 WPF 高频能力数据绑定样式与模板动画默认值属性变更回调值继承2. 依赖属性和普通属性有什么区别对比项普通 .NET 属性WPF 依赖属性存储方式一般存储在私有字段中由 WPF 属性系统统一管理是否支持绑定不支持支持是否支持动画不支持支持是否支持样式不支持支持变更通知需要手动写可以通过回调处理使用场景普通业务类WPF 控件、可视化对象普通属性写法如下private int length; public int Length { get { return length; } set { length value; } }依赖属性写法如下public int MyProperty { get { return (int)GetValue(MyPropertyProperty); } set { SetValue(MyPropertyProperty, value); } } public static readonly DependencyProperty MyPropertyProperty DependencyProperty.Register( MyProperty, typeof(int), typeof(OwnerClass), new PropertyMetadata(0));3. 自定义依赖属性的 3 个步骤定义一个依赖属性一般分成 3 步定义DependencyProperty静态字段使用DependencyProperty.Register()完成注册用普通属性外壳包装GetValue()和SetValue()其中最关键的一句就是public static readonly DependencyProperty MyPropertyProperty DependencyProperty.Register( MyProperty, typeof(int), typeof(OwnerClass), new PropertyMetadata(0));这 4 个参数分别表示属性名属性类型所属类型元数据默认值、回调函数等4. 实战给自定义控件定义依赖属性下面我们定义一个Widget用户控件用来显示图标、标题和数值。前端 XAMLUserControl x:ClassDemo.Widget xmlnshttp://schemas.microsoft.com/winfx/2006/xaml/presentation xmlns:xhttp://schemas.microsoft.com/winfx/2006/xaml x:Nameroot FontSize30 Foreground#666666 BorderBrush#8CDDCD Border BorderBrush{Binding ElementNameroot, PathBorderBrush} Border.Style Style TargetTypeBorder Setter PropertyPadding Value10/ Setter PropertyBackground ValueWhite/ Setter PropertyBorderThickness Value0 3 0 0/ Setter PropertyMargin Value5/ Style.Triggers Trigger PropertyIsMouseOver ValueTrue Setter PropertyBackground Value#F7F9F9/ /Trigger /Style.Triggers /Style /Border.Style Grid Grid.ColumnDefinitions ColumnDefinition/ ColumnDefinition WidthAuto/ /Grid.ColumnDefinitions Grid.RowDefinitions RowDefinition/ RowDefinition/ /Grid.RowDefinitions TextBlock Grid.Row0 Grid.Column0 Text{Binding Value} Foreground{Binding ElementNameroot, PathForeground} FontSize{Binding ElementNameroot, PathFontSize} / TextBlock Grid.Row1 Grid.Column0 Text{Binding Title} Foreground{Binding ElementNameroot, PathForeground} FontSize14 / TextBlock Grid.Row0 Grid.Column1 Grid.RowSpan2 Text{Binding Icon} Foreground{Binding ElementNameroot, PathBorderBrush} FontSize26 VerticalAlignmentCenter/ /Grid /Border /UserControl后台代码public partial class Widget : UserControl { public Widget() { InitializeComponent(); DataContext this; } public string Icon { get { return (string)GetValue(IconProperty); } set { SetValue(IconProperty, value); } } public static readonly DependencyProperty IconProperty DependencyProperty.Register( Icon, typeof(string), typeof(Widget), new PropertyMetadata()); public string Title { get { return (string)GetValue(TitleProperty); } set { SetValue(TitleProperty, value); } } public static readonly DependencyProperty TitleProperty DependencyProperty.Register( Title, typeof(string), typeof(Widget), new PropertyMetadata(请输入标题)); public string Value { get { return (string)GetValue(ValueProperty); } set { SetValue(ValueProperty, value); } } public static readonly DependencyProperty ValueProperty DependencyProperty.Register( Value, typeof(string), typeof(Widget), new PropertyMetadata(内容)); }使用时就可以像普通控件一样直接写StackPanel OrientationHorizontal local:Widget Icon Title本年度销售总额 Value38452.21 Width215 Height100/ local:Widget Icon Title系统访问量 Value9985 Foreground#415767 BorderBrush#87BEE4 Width225 Height110/ /StackPanel这就是依赖属性最实用的地方自定义控件既能对外暴露属性又天然支持绑定、样式和后续扩展。5. 依赖属性的回调函数很多时候我们不只是想“存一个值”而是想在值变化后立即执行逻辑这时候就要用到PropertyChangedCallback。例如public static readonly DependencyProperty CountProperty DependencyProperty.Register( Count, typeof(int), typeof(TrayControl), new PropertyMetadata(0, OnCountPropertyChanged)); private static void OnCountPropertyChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var control d as TrayControl; control?.Initialize(); }当Count发生变化时就会自动调用OnCountPropertyChanged。 这类写法非常适合做控件刷新界面重绘数据校验联动其它属性如果你还需要默认值、强制值修正等能力也可以把这些逻辑写到PropertyMetadata中。二、WPF 附加属性1. 什么是附加属性附加属性Attached Property可以理解成某个属性本来不属于这个控件但被另一个类型“附加”到了它身上。最经典的例子就是Grid Button Grid.Row0 Content按钮1/ Button Grid.Row1 Content按钮2/ /Grid这里的Row并不是Button自己定义的属性而是Grid定义出来附加给子元素使用的属性也就是Grid.Row。所以附加属性特别适合这种场景父容器给子元素打标签控件间建立额外关系给原本不支持某种能力的控件补功能2. 附加属性怎么定义Visual Studio 中输入propa按两次Tab就能生成模板。标准写法如下public static int GetMyProperty(DependencyObject obj) { return (int)obj.GetValue(MyPropertyProperty); } public static void SetMyProperty(DependencyObject obj, int value) { obj.SetValue(MyPropertyProperty, value); } public static readonly DependencyProperty MyPropertyProperty DependencyProperty.RegisterAttached( MyProperty, typeof(int), typeof(OwnerClass), new PropertyMetadata(0));和依赖属性相比附加属性最大的区别是使用RegisterAttached()注册通过GetXxx()/SetXxx()访问3. 实战让 PasswordBox 支持绑定PasswordBox的Password并不是依赖属性所以不能像TextBox.Text一样直接绑定。 这也是 WPF 初学者经常会踩的坑。解决思路就是写一个PasswordBoxHelper通过附加属性给PasswordBox搭一座“桥”。先准备一个支持通知的基类public class ObservableObject : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; public void RaisePropertyChanged([CallerMemberName] string propertyName ) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }再定义实体类public class Person : ObservableObject { private string userName; public string UserName { get { return userName; } set { userName value; RaisePropertyChanged(); } } private string password; public string Password { get { return password; } set { password value; RaisePropertyChanged(); } } }核心桥接代码如下public class PasswordBoxHelper { public static string GetPassword(DependencyObject obj) { return (string)obj.GetValue(PasswordProperty); } public static void SetPassword(DependencyObject obj, string value) { obj.SetValue(PasswordProperty, value); } public static readonly DependencyProperty PasswordProperty DependencyProperty.RegisterAttached( Password, typeof(string), typeof(PasswordBoxHelper), new PropertyMetadata(, OnPasswordPropertyChanged)); private static void OnPasswordPropertyChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is PasswordBox passwordBox) { passwordBox.PasswordChanged - PasswordBox_PasswordChanged; passwordBox.PasswordChanged PasswordBox_PasswordChanged; } } private static void PasswordBox_PasswordChanged(object sender, RoutedEventArgs e) { if (sender is PasswordBox passwordBox) { SetPassword(passwordBox, passwordBox.Password); } } } 然后在 XAML 中这样使用 StackPanel Margin80 TextBox Text{Binding Person.UserName, UpdateSourceTriggerPropertyChanged} Width200 Height25/ PasswordBox local:PasswordBoxHelper.Password{Binding Person.Password, ModeTwoWay, UpdateSourceTriggerPropertyChanged} Width200 Height25/ /StackPanel这样就实现了PasswordBox.Password - PasswordBoxHelper.Password - ViewModel.Password这个案例也是附加属性最经典的面试题之一。三、WPF Transform 转换1. Transform 是什么Transform是 WPF 中负责二维图形变换的抽象基类常见子类有 4 个RotateTransform旋转ScaleTransform缩放SkewTransform倾斜TranslateTransform平移如果多个变换要同时使用就交给TransformGroup组合处理。2. 四种常见变换2.1 RotateTransform 旋转Button ContentRotateTransform Button.RenderTransform RotateTransform Angle45 CenterX50 CenterY12.5/ /Button.RenderTransform /Button常用属性Angle旋转角度CenterX旋转中心 X 坐标CenterY旋转中心 Y 坐标2.2 ScaleTransform 缩放Button ContentScaleTransform Button.RenderTransform ScaleTransform ScaleX1.5 ScaleY1.5 CenterX50 CenterY12.5/ /Button.RenderTransform /Button常用属性ScaleXScaleYCenterXCenterY2.3 SkewTransform 倾斜Border Width120 Height120 BackgroundLightBlue Border.RenderTransform SkewTransform AngleX20 AngleY10 CenterX60 CenterY60/ /Border.RenderTransform /Border常用属性AngleXAngleYCenterXCenterY2.4 TranslateTransform 平移Border Width120 Height120 BackgroundLightGreen Border.RenderTransform TranslateTransform X80 Y30/ /Border.RenderTransform /Border常用属性XY3. 实战TransformGroup 做图片查看器如果我们想同时支持图片拖拽和平滑缩放就不能只靠单一变换而是要把ScaleTransform和TranslateTransform组合起来。XAMLCanvas x:Namecanvas BackgroundTransparent MouseWheelcanvas_MouseWheel MouseMovecanvas_MouseMove MouseLeftButtonDowncanvas_MouseLeftButtonDown MouseLeftButtonUpcanvas_MouseLeftButtonUp Image x:Nameimage Source/Images/mm.jpg/ /Canvas后台代码private bool isMouseDown false; private Point mousePoint new Point(0, 0); private TranslateTransform translateTransform new TranslateTransform(); private ScaleTransform scaleTransform new ScaleTransform(); private TransformGroup group new TransformGroup(); public MainWindow() { InitializeComponent(); Loaded (s, e) { group.Children.Add(scaleTransform); group.Children.Add(translateTransform); image.RenderTransform group; var scale Math.Min( canvas.ActualWidth / image.ActualWidth, canvas.ActualHeight / image.ActualHeight); scaleTransform.ScaleX scale; scaleTransform.ScaleY scale; translateTransform.X (canvas.ActualWidth - image.ActualWidth * scale) / 2; }; } private void canvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { isMouseDown true; mousePoint e.GetPosition(canvas); } private void canvas_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { isMouseDown false; } private void canvas_MouseMove(object sender, MouseEventArgs e) { var position e.GetPosition(canvas); if (isMouseDown) { translateTransform.X position.X - mousePoint.X; translateTransform.Y position.Y - mousePoint.Y; mousePoint position; } } private void canvas_MouseWheel(object sender, MouseWheelEventArgs e) { var delta e.Delta * 0.001; var position e.GetPosition(canvas); if (scaleTransform.ScaleX delta 0.1) return; Point inversePoint group.Inverse.Transform(position); scaleTransform.ScaleX delta; scaleTransform.ScaleY delta; translateTransform.X -(inversePoint.X * scaleTransform.ScaleX - position.X); translateTransform.Y -(inversePoint.Y * scaleTransform.ScaleY - position.Y); }这个案例里最值得记住的一点是缩放不是简单改倍率还要同步修正平移量这样才能做到“以鼠标所在位置为中心缩放”。四、WPF Effect 特效1. Effect 是什么Effect是 WPF 里的特效基类常见的两个子类是DropShadowEffect阴影特效BlurEffect模糊特效2. DropShadowEffect 阴影效果给按钮加阴影非常简单Button Content按钮1 Width100 Height50 Button.Effect DropShadowEffect ShadowDepth10 BlurRadius20 ColorGray Direction-45 Opacity1/ /Button.Effect /Button常用属性如下属性作用Color阴影颜色ShadowDepth阴影偏移距离Direction阴影方向BlurRadius模糊半径Opacity透明度在实际项目中这个特效非常适合做卡片悬浮感按钮立体感弹窗层级区分3. BlurEffect 模糊效果模糊效果常用于毛玻璃背景聚焦突出鼠标交互反馈一个简单示例Ellipse Width120 Height120 FillSkyBlue Ellipse.Effect BlurEffect Radius8/ /Ellipse.Effect /Ellipse如果想做动态模糊可以在代码里修改Radiusprivate void grid_MouseMove(object sender, MouseEventArgs e) { Point mousePoint e.GetPosition(this); Point centerPoint new Point(ActualWidth / 2, ActualHeight / 2); double distance Math.Sqrt( Math.Pow(mousePoint.X - centerPoint.X, 2) Math.Pow(mousePoint.Y - centerPoint.Y, 2)); effect.Radius distance / 5; }这样鼠标越远模糊越明显交互感会非常直观。五、WPF 路由事件1. 什么是路由事件WPF 的界面本质上是一棵元素树。 比如下面这段结构Window Border Canvas Button/ Button/ /Canvas /Border /Window当按钮触发事件时这个事件并不一定只在按钮自己身上结束而是可能沿着整棵树传播。 这种“会沿元素树传播”的事件就叫做路由事件。WPF 中常见的路由策略有 3 种Tunnel隧道事件从根节点到事件源常见前缀是PreviewBubble冒泡事件从事件源往父级一路传播Direct直接事件只在事件源本身触发2. 隧道事件和冒泡事件怎么理解如果点击按钮隧道事件路线Window - Border - Canvas - Button冒泡事件路线Button - Canvas - Border - Window看一个隧道事件示例Window PreviewMouseUpWindow_PreviewMouseUp Border PreviewMouseUpBorder_PreviewMouseUp Canvas PreviewMouseUpCanvas_PreviewMouseUp Button PreviewMouseUpButton_PreviewMouseUp Content确定/ /Canvas /Border /Window如果点击按钮输出顺序会是Window对象的隧道事件PreviewMouseUp被触发 Border对象的隧道事件PreviewMouseUp被触发 Canvas对象的隧道事件PreviewMouseUp被触发 Button确定按钮的隧道事件PreviewMouseUp被触发再看冒泡事件Window MouseUpWindow_MouseUp Border MouseUpBorder_MouseUp BackgroundTransparent Canvas MouseUpCanvas_MouseUp BackgroundTransparent Button MouseUpButton_MouseUp Content确定/ /Canvas /Border /Window这里有一个很容易忽略的细节 像Canvas、Border这种控件如果没有背景色哪怕是透明色也可能收不到鼠标事件。3. 自定义路由事件实战除了使用系统自带的路由事件我们也可以注册自己的路由事件。比如给Widget自定义一个“销售完成事件”public static readonly RoutedEvent CompletedEvent EventManager.RegisterRoutedEvent( CompletedEvent, RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(Widget)); public event RoutedEventHandler Completed { add { AddHandler(CompletedEvent, value); } remove { RemoveHandler(CompletedEvent, value); } } private void RaiseRoutedEvent() { RoutedEventArgs args new RoutedEventArgs(CompletedEvent, this); RaiseEvent(args); }再结合依赖属性回调做业务判断public double Target { get { return (double)GetValue(TargetProperty); } set { SetValue(TargetProperty, value); } } public static readonly DependencyProperty TargetProperty DependencyProperty.Register( Target, typeof(double), typeof(Widget), new PropertyMetadata(0.0)); public double Value { get { return (double)GetValue(ValueProperty); } set { SetValue(ValueProperty, value); } } public static readonly DependencyProperty ValueProperty DependencyProperty.Register( Value, typeof(double), typeof(Widget), new PropertyMetadata(0.0, OnValuePropertyChanged)); private static void OnValuePropertyChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is Widget control e.NewValue is double value) { if (value control.Target control.Target ! 0) { control.RaiseRoutedEvent(); } } }前端直接订阅local:Widget Value{Binding ElementNameslider, PathValue} Target1000000 Title第四季度华南市场总销售额统计 CompletedWidget_Completed/后台处理private void Widget_Completed(object sender, RoutedEventArgs e) { Widget widget sender as Widget; listBox.Items.Insert(0, $完成目标销售额{widget.Value}); }这个例子把两个知识点串起来了用依赖属性回调监听值变化用路由事件向外广播业务完成状态在实际项目里这种设计非常适合做自定义控件事件通知业务状态上报父容器统一监听子控件行为六、总结WPF 的很多“高级能力”并不是孤立存在的而是互相配合的依赖属性负责让属性具备绑定、样式、动画和回调能力附加属性负责把一个类型的能力扩展到另一个控件身上Transform 负责视觉变换Effect 负责视觉特效路由事件负责事件传播和控件通信如果你只是写界面可能觉得这些内容有点底层但只要一旦开始做自定义控件、复杂交互、MVVM 架构这些知识几乎都会变成必修课。最后给大家一个学习建议先掌握依赖属性和附加属性因为这两个是 WPF 属性系统的核心。再练习 Transform 和 Effect把界面“做出来”。最后重点理解路由事件把控件之间的通信“串起来”。当你把这 5 个知识点真正吃透之后WPF 里的很多“黑魔法”其实就不神秘了。课后作业简单的后台绑定数据需要注意绑定数据时对属性的定义与数据匹配问题运行展示具体代码XAML:Window x:ClassWpfApp8.MainWindow xmlnshttp://schemas.microsoft.com/winfx/2006/xaml/presentation xmlns:xhttp://schemas.microsoft.com/winfx/2006/xaml xmlns:dhttp://schemas.microsoft.com/expression/blend/2008 xmlns:mchttp://schemas.openxmlformats.org/markup-compatibility/2006 xmlns:localclr-namespace:WpfApp8 mc:Ignorabled TitleMainWindow Height450 Width900 Grid Grid.RowDefinitions RowDefinition HeightAuto/ RowDefinition Height*/ /Grid.RowDefinitions TextBlock Text{Binding Model.TimeText} BackgroundSkyBlue FontSize24 ForegroundWhite Margin0 0 0 20/ StackPanel Grid.Row1 OrientationHorizontal Border BackgroundCadetBlue CornerRadius4 Margin5 Width150 Height200 StackPanel Margin20 TextBlock Text⏱ FontSize20 ForegroundWhite/ TextBlock Text汇总 FontSize18 ForegroundWhite Margin0 10 0 20/ TextBlock Text{Binding Model.TotalTasks} FontSize48 ForegroundWhite FontWeightBold/ /StackPanel /Border Border BackgroundLightGreen CornerRadius4 Margin5 Width150 Height200 StackPanel Margin20 TextBlock Text⏱ FontSize20 ForegroundWhite/ TextBlock Text已完成 FontSize18 ForegroundWhite Margin0 10 0 20/ TextBlock Text{Binding Model.CompletedTasks} FontSize48 ForegroundWhite FontWeightBold/ /StackPanel /Border Border BackgroundBlueViolet CornerRadius4 Margin5 Width150 Height200 StackPanel Margin20 TextBlock Text FontSize20 ForegroundWhite/ TextBlock Text完成比例 FontSize18 ForegroundWhite Margin0 10 0 20/ TextBlock Text{Binding Model.CompletionRate} FontSize48 ForegroundWhite FontWeightBold/ /StackPanel /Border Border BackgroundOrange CornerRadius4 Margin5 Width150 Height200 StackPanel Margin20 TextBlock Text FontSize20 ForegroundWhite/ TextBlock Text备忘录 FontSize18 ForegroundWhite Margin0 10 0 20/ TextBlock Text{Binding Model.MemoCount} FontSize48 ForegroundWhite FontWeightBold/ /StackPanel /Border /StackPanel /Grid /WindowModel.csusing System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; namespace WpfApp8 { public class Model { private string _timeText; private int _totalTasks; private int _completedTasks; private string _completionRate; private int _memoCount; public string TimeText { get _timeText; set _timeText value; } public int TotalTasks { get _totalTasks; set _totalTasks value; } public int CompletedTasks { get _completedTasks; set _completedTasks value; } public string CompletionRate { get _completionRate; set _completionRate value; } public int MemoCount { get _memoCount; set _memoCount value; } } }TaskView.csusing System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using System.ComponentModel; using System.Runtime.CompilerServices; namespace WpfApp8 { public class TaskView { public Model Model { get; set; } public TaskView() { Model new Model(); Model.TimeText $你好{DateTime.Now:yyyy年M月d日dddd}; Model.TotalTasks 27; Model.CompletedTasks 24; Model.CompletionRate 89%; Model.MemoCount 13; } } }MainWindow.csusing System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Data; using System.Windows.Documents; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Navigation; using System.Windows.Shapes; namespace WpfApp8 { /// summary /// MainWindow.xaml 的交互逻辑 /// /summary public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); this.DataContext new TaskView(); } } }