面向刚开始学习 WPF、已经会创建窗口和放置按钮,但还不熟悉路由事件、行为封装与动画时间线的读者。 推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,本文涉及的 API 在 .NET 9 与 .NET 10 中没有变化。 最后更新:2026-10-22
目录
自定义路由事件的定义
自定义路由事件执行过程跟踪
行为对象的定义
行为对象的使用
WPF动画与分类
动画的基本定义与使用
写在前面 这份笔记要解决什么问题 前面的笔记已经讲过 WPF 的基础事件、输入事件、数据绑定和依赖属性。继续往下学习时,很容易遇到下面这些疑问:
1 2 3 4 5 6 7 8 9 普通 C# event 和路由事件到底差在哪里? 为什么自定义事件要注册一个 RoutedEvent 标识? 一个事件从子元素出发,为什么父元素也能收到? 自定义控件怎样把业务消息放进事件参数? 行为对象和普通事件处理器有什么区别? 为什么行为分离后,按钮还应该继续正常工作? WPF 动画为什么只能作用于依赖属性? Storyboard.Begin 的第二个参数为什么常常要传 true? 动画停止后,属性值为什么会回到原来的值?
这篇笔记不会只给出一段“看起来能运行”的代码。每一章都会按照下面的顺序推进:
1 2 3 4 先做出一个最小结果 再观察运行过程 然后解释关键 API 最后补充常见错误和练习
学完后,你应该能独立完成一条完整的交互链:
1 2 3 4 自定义控件发出事件 外层元素接收并处理事件 行为对象封装可复用交互 动画根据状态改变界面属性
图 1:事件负责通知,行为负责封装交互,动画负责随时间改变属性。
推荐学习顺序
先运行随附示例,依次点开六个页签,观察每一页的最终效果。
第 1、2 章连着学。先定义事件,再观察它在元素树中的执行路径。
第 3、4 章连着学。先写行为类,再在窗口中控制它的附加和分离。
第 5 章先分清动画类型,不急着写复杂效果。
第 6 章只做一个 Opacity 动画,把开始、暂停、继续、停止全部走通。
每完成一章,至少亲手复现一次,再完成章末小练习。
随附示例位置 完整示例位于:
1 C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfCustomEventBehaviorAnimationDemo
在 PowerShell 中运行:
1 2 3 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfCustomEventBehaviorAnimationDemo" dotnet build dotnet run
如果使用 Visual Studio:
打开 WpfCustomEventBehaviorAnimationDemo.csproj。
确认启动项目是 WpfCustomEventBehaviorAnimationDemo。
按 F5 启动。
依次点开六个页签,每页至少操作一次。
观察界面下方日志中的事件顺序和状态变化。
建立自己的跟做项目 建议不要直接在随附示例上改。先新建一个练习项目:
1 2 3 4 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码" dotnet new wpf -n WpfCustomEventBehaviorAnimationPractice -o .\WpfCustomEventBehaviorAnimationPractice cd .\WpfCustomEventBehaviorAnimationPracticedotnet run
看到默认窗口后关闭程序。接下来打开 WpfCustomEventBehaviorAnimationPractice.csproj,确认目标框架:
1 <TargetFramework > net9.0-windows</TargetFramework >
如果电脑已经安装 .NET 10 SDK,也可以改为:
1 <TargetFramework > net10.0-windows</TargetFramework >
本文代码在两个目标框架下都可以使用。
跟做项目的最终结构 跟做完成后,项目结构应当接近下面这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 WpfCustomEventBehaviorAnimationPractice ├─ App.xaml ├─ App.xaml.cs ├─ MainWindow.xaml ├─ MainWindow.xaml.cs ├─ AnimationRules.cs ├─ Controls │ ├─ StatusRoutedEventArgs.cs │ └─ StatusButton.cs ├─ Behaviors │ ├─ BehaviorBase.cs │ └─ ClickPulseBehavior.cs └─ ViewModels ├─ ObservableObject.cs └─ MainViewModel.cs
先不要一次性把所有文件都写完。下面会按章节逐步加入,这样每一步都能单独运行和检查。
先准备一个简单 ViewModel 界面中的状态文字和日志会越来越多。为了不让代码全部挤在窗口后台,先建立两个简单文件。
新建 ViewModels\ObservableObject.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 using System.ComponentModel;using System.Runtime.CompilerServices;namespace WpfCustomEventBehaviorAnimationPractice.ViewModels ;public abstract class ObservableObject : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; protected bool SetProperty <T >( ref T field, T value , [CallerMemberName] string ? propertyName = null ) { if (EqualityComparer<T>.Default.Equals(field, value )) { return false ; } field = value ; PropertyChanged?.Invoke( this , new PropertyChangedEventArgs(propertyName)); return true ; } }
新建 ViewModels\MainViewModel.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 using System.Collections.ObjectModel;namespace WpfCustomEventBehaviorAnimationPractice.ViewModels ;public sealed class MainViewModel : ObservableObject { private string _eventStatus = "等待触发自定义事件" ; private string _routeStatus = "尚未运行路径跟踪" ; private string _behaviorStatus = "等待附加行为" ; private string _behaviorToggleText = "附加行为" ; private bool _isBehaviorAttached; private string _animationStatus = "等待动画" ; private string _animationClass = "DoubleAnimation" ; private double _animationProgress; public ObservableCollection<string > EventLog { get ; } = new (); public ObservableCollection<string > RouteLog { get ; } = new (); public ObservableCollection<string > BehaviorLog { get ; } = new (); public ObservableCollection<string > AnimationLog { get ; } = new (); public string EventStatus { get => _eventStatus; set => SetProperty(ref _eventStatus, value ); } public string RouteStatus { get => _routeStatus; set => SetProperty(ref _routeStatus, value ); } public string BehaviorStatus { get => _behaviorStatus; set => SetProperty(ref _behaviorStatus, value ); } public string BehaviorToggleText { get => _behaviorToggleText; set => SetProperty(ref _behaviorToggleText, value ); } public bool IsBehaviorAttached { get => _isBehaviorAttached; set => SetProperty(ref _isBehaviorAttached, value ); } public string AnimationStatus { get => _animationStatus; set => SetProperty(ref _animationStatus, value ); } public string AnimationClass { get => _animationClass; set => SetProperty(ref _animationClass, value ); } public double AnimationProgress { get => _animationProgress; set => SetProperty(ref _animationProgress, value ); } public void AddEvent (string line ) { EventLog.Insert(0 , line); Trim(EventLog); } public void AddRoute (string line ) { RouteLog.Insert(0 , line); Trim(RouteLog); } public void AddBehavior (string line ) { BehaviorLog.Insert(0 , line); Trim(BehaviorLog); } public void AddAnimation (string line ) { AnimationLog.Insert(0 , line); Trim(AnimationLog); } private static void Trim (ObservableCollection<string > lines ) { while (lines.Count > 30 ) { lines.RemoveAt(lines.Count - 1 ); } } }
这里的日志使用 Insert(0, line),所以界面中最新一条会显示在最上面。观察执行顺序时,要注意界面显示顺序和实际发生顺序是相反的。
1. 自定义路由事件的定义 1.1 本节目标 完成本节后,你要能够:
解释普通 CLR 事件与路由事件的区别。
使用 EventManager.RegisterRoutedEvent 注册自定义事件。
使用 CLR 事件包装 AddHandler 和 RemoveHandler。
使用 RaiseEvent 触发事件。
在自定义事件参数中携带一条业务消息。
1.2 先理解:普通事件不会自动沿元素树传播 普通的 C# 事件通常是“谁订阅,谁收到”:
1 button.Click += OnButtonClick;
如果某个控件内部定义了一个普通事件:
1 public event Action<string >? StatusChanged;
那么只有直接订阅这个事件的对象才能收到通知。父级 Border、Grid 或 Window 不会因为位于元素树外层而自动收到它。
路由事件不同。它有一个全局注册的事件标识,并且可以按照 RoutingStrategy 沿元素树传播。
WPF 常见路由策略:
策略
传播方向
常见例子
Direct
只通知事件源本身
某些只属于单个元素的事件
Bubble
从事件源向祖先传播
Button.Click
Tunnel
从祖先向事件源传播
PreviewMouseDown
本节先实现最常用的 Bubble。
1.3 自定义路由事件由三部分组成
图 2:事件标识、CLR 包装和触发入口必须一起工作。
很多初学者只写了其中一个部分,于是出现下面几种情况:
1 2 3 只写 RoutedEvent 标识:XAML 里无法像普通事件一样订阅。 只写 CLR event:事件不会自动获得路由传播能力。 只注册不 RaiseEvent:事件永远没有触发入口。
完整定义要同时完成三件事:
1 2 3 1. 注册 RoutedEvent 2. 写 CLR event 包装 3. 在合适的位置 RaiseEvent
1.4 跟做:创建自定义事件参数 在项目中新建 Controls 文件夹,再新建 StatusRoutedEventArgs.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 using System.Windows;namespace WpfCustomEventBehaviorAnimationPractice.Controls ;public sealed class StatusRoutedEventArgs : RoutedEventArgs { public StatusRoutedEventArgs ( RoutedEvent routedEvent, string message ) : base (routedEvent ) { Message = message; } public StatusRoutedEventArgs ( RoutedEvent routedEvent, object source, string message ) : base (routedEvent, source ) { Message = message; } public string Message { get ; } } public delegate void StatusRoutedEventHandler ( object sender, StatusRoutedEventArgs e ) ;
这里有两个关键点。
第一,StatusRoutedEventArgs 继承自 RoutedEventArgs。WPF 的路由系统需要这种事件参数类型。
第二,自定义字段 Message 用来携带业务数据。实际项目中还可以加入商品编号、操作结果、旧值和新值等数据。
1.5 跟做:创建自定义按钮 新建 Controls\StatusButton.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 using System.Windows;using System.Windows.Controls;namespace WpfCustomEventBehaviorAnimationPractice.Controls ;public sealed class StatusButton : Button { public static readonly RoutedEvent StatusChangedEvent = EventManager.RegisterRoutedEvent( nameof (StatusChanged), RoutingStrategy.Bubble, typeof (StatusRoutedEventHandler), typeof (StatusButton)); public event StatusRoutedEventHandler StatusChanged { add => AddHandler(StatusChangedEvent, value ); remove => RemoveHandler(StatusChangedEvent, value ); } protected override void OnClick () { base .OnClick(); RaiseEvent( new StatusRoutedEventArgs( StatusChangedEvent, this , $"StatusButton 已点击:{DateTime.Now:HH:mm:ss.fff} " )); } }
1.6 逐行解释注册代码 注册代码是:
1 2 3 4 5 6 public static readonly RoutedEvent StatusChangedEvent = EventManager.RegisterRoutedEvent( nameof (StatusChanged), RoutingStrategy.Bubble, typeof (StatusRoutedEventHandler), typeof (StatusButton));
四个参数分别表示:
参数
本例的值
作用
事件名称
StatusChanged
注册到 WPF 事件系统中的名称
路由策略
RoutingStrategy.Bubble
从事件源向祖先传播
处理器类型
typeof(StatusRoutedEventHandler)
必须与事件参数类型匹配
所有者类型
typeof(StatusButton)
声明这个事件的控件类型
这里最容易出错的是处理器类型和事件参数类型不匹配。例如注册时写的是 StatusRoutedEventHandler,但 RaiseEvent 传入的却是普通的 RoutedEventArgs,运行时就会报错。
事件标识通常声明为:
1 public static readonly RoutedEvent
它代表“事件类型本身”,而不是某一次触发产生的数据。每次点击产生的数据放在 StatusRoutedEventArgs 中。
1.7 逐行解释 CLR 包装 CLR 包装是:
1 2 3 4 5 public event StatusRoutedEventHandler StatusChanged{ add => AddHandler(StatusChangedEvent, value ); remove => RemoveHandler(StatusChangedEvent, value ); }
它看起来像普通 C# 事件,但内部并没有自己保存一组委托,而是把订阅和取消订阅交给 WPF 路由系统。
这样做的好处是:
XAML 可以使用 StatusChanged="处理器名称"。
C# 可以使用 button.StatusChanged += Handler。
订阅关系仍然进入 WPF 的路由事件系统。
父级元素可以通过 AddHandler 监听同一个事件。
1.8 逐行解释触发入口 OnClick 中先调用:
这样保留 Button 原本的点击行为。随后:
1 2 3 4 5 RaiseEvent( new StatusRoutedEventArgs( StatusChangedEvent, this , $"StatusButton 已点击:{DateTime.Now:HH:mm:ss.fff} " ));
这一步才是真正把事件送入路由系统。RaiseEvent 的参数是一个已经创建好的 RoutedEventArgs。
可以把整个过程理解成:
1 2 3 注册:告诉 WPF 有这样一种事件 包装:让 XAML 和 C# 能正常订阅 触发:某次操作真正产生一个事件实例
1.9 跟做:第 1 页 XAML 先把 MainWindow.xaml 改成六个页签的骨架。为了后面继续添加内容,可以先写成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 <Window x:Class ="WpfCustomEventBehaviorAnimationPractice.MainWindow" xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls ="clr-namespace:WpfCustomEventBehaviorAnimationPractice.Controls" Title ="WPF 自定义事件、行为与动画练习" Width ="1280" Height ="840" WindowStartupLocation ="CenterScreen" Background ="#F3F6FB" FontFamily ="Microsoft YaHei UI" > <TabControl x:Name ="TabHost" Margin ="20" > <TabItem Header ="1 自定义路由事件定义" > <Grid Margin ="24" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="430" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="一个事件,三层定义" FontSize ="22" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Foreground ="#617089" TextWrapping ="Wrap" Text ="事件标识、CLR 包装和触发入口必须一起工作。" /> <TextBlock Margin ="0,18,0,0" Foreground ="#174EA6" FontWeight ="Bold" TextWrapping ="Wrap" Text ="{Binding EventStatus}" /> </StackPanel > <Border Grid.Column ="1" Padding ="24" Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="10" > <StackPanel HorizontalAlignment ="Center" VerticalAlignment ="Center" > <TextBlock Text ="自定义控件事件" FontSize ="20" FontWeight ="Bold" HorizontalAlignment ="Center" /> <controls:StatusButton x:Name ="CustomStatusButton" Margin ="0,18,0,0" Padding ="20,10" Content ="点击触发 StatusChanged" StatusChanged ="OnStatusButtonStatusChanged" /> <TextBlock Margin ="0,16,0,0" Text ="{Binding EventStatus}" Foreground ="#617089" TextWrapping ="Wrap" TextAlignment ="Center" /> </StackPanel > </Border > </Grid > </TabItem > <TabItem Header ="2 路由事件执行过程跟踪" > </TabItem > <TabItem Header ="3 行为对象的定义" > </TabItem > <TabItem Header ="4 行为对象的使用" > </TabItem > <TabItem Header ="5 WPF 动画与分类" > </TabItem > <TabItem Header ="6 动画的基本定义与使用" > </TabItem > </TabControl > </Window >
此时只完成了第 1 页,其他页签暂时留空是正常的。
1.10 跟做:窗口后台代码 把 MainWindow.xaml.cs 改成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 using System.Windows;using WpfCustomEventBehaviorAnimationPractice.Controls;using WpfCustomEventBehaviorAnimationPractice.ViewModels;namespace WpfCustomEventBehaviorAnimationPractice ;public partial class MainWindow : Window { public MainWindow () { InitializeComponent(); ViewModel = new MainViewModel(); DataContext = ViewModel; } public MainViewModel ViewModel { get ; } private void OnStatusButtonStatusChanged ( object sender, StatusRoutedEventArgs e ) { ViewModel.EventStatus = e.Message; ViewModel.AddEvent( $"StatusChanged | Source={GetName(e.Source)} | Message={e.Message} " ); } private static string GetName (object ? value ) { return value is FrameworkElement element ? element.Name : value ?.GetType().Name ?? "未知" ; } }
按 F5 运行,点击“点击触发 StatusChanged”。
1.11 运行截图
运行截图 1:按钮触发自定义事件,事件参数中的消息被显示在页面上。
1.12 跟做检查点 完成本节后,逐项检查:
1.13 常见错误
现象
常见原因
修正方法
XAML 报找不到 StatusChanged
只注册了 RoutedEvent,没有 CLR 包装
添加 public event StatusRoutedEventHandler
点击后没有日志
OnClick 中没有 RaiseEvent
检查触发入口
运行时提示事件参数不匹配
注册的处理器类型与传入参数不一致
统一使用 StatusRoutedEventHandler
事件名称重复注册
每次创建按钮都调用注册方法
注册代码放在 static readonly 字段中
sender 和 Source 分不清
把“订阅者”和“事件源”混为一谈
下一章用三层元素专门观察
1.14 小练习
给 StatusRoutedEventArgs 增加 ClickCount 属性。
每次点击时让 ClickCount 加一,并把数字显示在页面上。
把事件消息改成“第 N 次点击,状态已更新”。
故意把注册参数中的处理器类型改成 RoutedEventHandler,运行并记录错误信息。
改完后恢复正确类型,确认程序重新正常工作。
2. 自定义路由事件执行过程跟踪 2.1 本节目标 完成本节后,你要能够:
让自定义冒泡事件从内层元素传到外层元素。
区分 sender、Source 和 OriginalSource。
观察 Handled 对传播的影响。
在每一层记录事件到达顺序。
理解界面日志顺序与真实执行顺序可能相反。
2.2 先看传播方向
图 3:同一个事件对象从触发源开始,沿祖先链向外传播。
本节使用三层 Border:
1 2 3 RouteOuter └─ RouteMiddle └─ RouteInner
从 RouteInner 触发冒泡事件时,默认执行顺序是:
1 2 3 RouteInner RouteMiddle RouteOuter
如果某一层把 e.Handled 设置为 true,后面的祖先默认不再收到这个事件。
2.3 跟做:完成第 2 页 XAML 把第 2 个 TabItem 改为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 <TabItem Header ="2 路由事件执行过程跟踪" > <Grid Margin ="24" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="420" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="事件怎样穿过元素树" FontSize ="22" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Foreground ="#617089" TextWrapping ="Wrap" Text ="记录每一层收到的 sender、Source、OriginalSource 和 Handled。" /> <Border Margin ="0,18,0,0" Padding ="16" Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" > <StackPanel > <TextBlock Text ="跟踪结果" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Text ="{Binding RouteStatus}" TextWrapping ="Wrap" Foreground ="#33415C" /> </StackPanel > </Border > <StackPanel Margin ="0,14,0,0" Orientation ="Horizontal" > <Button Padding ="15,8" Content ="运行路径跟踪" Click ="OnRunRouteTraceClick" /> <Button Margin ="10,0,0,0" Padding ="15,8" Content ="清空日志" Click ="OnClearRouteLogClick" /> </StackPanel > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="210" /> </Grid.RowDefinitions > <Border x:Name ="RouteOuter" Padding ="30" Background ="#F7F9FD" BorderBrush ="#B9CDEE" BorderThickness ="1" CornerRadius ="10" > <Border x:Name ="RouteMiddle" Padding ="30" Background ="#EEF4FF" BorderBrush ="#B9CDEE" BorderThickness ="1" CornerRadius ="8" > <Border x:Name ="RouteInner" Padding ="28" HorizontalAlignment ="Center" VerticalAlignment ="Center" Background ="#DDEBFF" BorderBrush ="#8FB4E8" BorderThickness ="1" CornerRadius ="8" > <StackPanel > <TextBlock Text ="自定义事件目标" FontSize ="18" FontWeight ="Bold" Foreground ="#174EA6" HorizontalAlignment ="Center" /> <TextBlock Margin ="0,6,0,0" Text ="RouteInner" Foreground ="#617089" HorizontalAlignment ="Center" /> </StackPanel > </Border > </Border > </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Padding ="8" Background ="#0F172A" Foreground ="#DCE7F8" BorderThickness ="0" FontFamily ="Consolas" FontSize ="12" ItemsSource ="{Binding RouteLog}" /> </Grid > </Grid > </TabItem >
2.4 跟做:注册三层处理器 在 MainWindow 中增加字段:
1 private readonly List<string > _routeSequence = new ();
在构造函数中,等 InitializeComponent() 执行完之后,再注册:
1 2 3 4 5 6 7 8 9 10 11 RouteOuter.AddHandler( StatusButton.StatusChangedEvent, new StatusRoutedEventHandler(OnRouteOuterStatusChanged)); RouteMiddle.AddHandler( StatusButton.StatusChangedEvent, new StatusRoutedEventHandler(OnRouteMiddleStatusChanged)); RouteInner.AddHandler( StatusButton.StatusChangedEvent, new StatusRoutedEventHandler(OnRouteInnerStatusChanged));
为什么用 AddHandler,而不继续用 +=?
因为 StatusButton.StatusChanged 这个 CLR 包装只声明在 StatusButton 类型上。外层 Border 不是 StatusButton,不能直接写:
1 RouteOuter.StatusChanged += ...
所有 UIElement 都提供 AddHandler,因此外层元素可以用事件标识订阅同一个路由事件。
2.5 跟做:触发并记录路径 增加下面几个方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 public IReadOnlyList<string > RouteSequence => _routeSequence;public void SimulateRouteTrace (){ _routeSequence.Clear(); ViewModel.RouteLog.Clear(); var args = new StatusRoutedEventArgs( StatusButton.StatusChangedEvent, RouteInner, "路径跟踪:从 RouteInner 触发" ); RouteInner.RaiseEvent(args); ViewModel.RouteStatus = "冒泡路径完成:Inner → Middle → Outer" ; } private void OnRunRouteTraceClick (object sender, RoutedEventArgs e ){ SimulateRouteTrace(); } private void OnClearRouteLogClick (object sender, RoutedEventArgs e ){ _routeSequence.Clear(); ViewModel.RouteLog.Clear(); ViewModel.RouteStatus = "日志已清空" ; } private void OnRouteInnerStatusChanged ( object sender, StatusRoutedEventArgs e ) => RecordRoute("RouteInner" , sender, e); private void OnRouteMiddleStatusChanged ( object sender, StatusRoutedEventArgs e ) => RecordRoute("RouteMiddle" , sender, e); private void OnRouteOuterStatusChanged ( object sender, StatusRoutedEventArgs e ) => RecordRoute("RouteOuter" , sender, e); private void RecordRoute ( string stage, object sender, StatusRoutedEventArgs e ){ _routeSequence.Add(stage); ViewModel.AddRoute( $"{stage} | sender={GetName(sender)} | " + $"Source={GetName(e.Source)} | " + $"OriginalSource={GetName(e.OriginalSource)} | " + $"Handled={e.Handled} " ); }
运行后点击“运行路径跟踪”。
2.6 运行截图
运行截图 2:日志中最上面是最新记录,所以界面看起来是 Outer、Middle、Inner;真实执行顺序是 Inner、Middle、Outer。
2.7 三个对象要分清 sendersender 是“当前处理器挂在哪里”。
本例中:
1 2 3 OnRouteInnerStatusChanged 的 sender 是 RouteInner OnRouteMiddleStatusChanged 的 sender 是 RouteMiddle OnRouteOuterStatusChanged 的 sender 是 RouteOuter
SourceSource 表示当前这次路由事件的事件源。
本例在创建参数时显式传入:
1 2 3 4 new StatusRoutedEventArgs( StatusButton.StatusChangedEvent, RouteInner, "..." );
因此三层看到的 Source 都是 RouteInner。
OriginalSourceOriginalSource 表示最初触发事件的元素。在鼠标等输入事件中,它通常是最深的命中元素。本例没有改变来源,所以它也是 RouteInner。
可以先用一句话记忆:
1 2 3 sender 看处理器挂在哪里 Source 看事件当前来自哪里 OriginalSource 看最初由谁产生
2.8 Handled 到底是什么 Handled 是一个状态标记,不是“事件对象被删除”。
当某一层执行:
默认情况下,后续祖先的普通 AddHandler 不再执行。但通过下面这种写法,仍然可以观察已经被处理的事件:
1 2 3 4 RouteOuter.AddHandler( StatusButton.StatusChangedEvent, new StatusRoutedEventHandler(OnRouteOuterStatusChanged), handledEventsToo: true );
第三个参数适合做全局监控、调试日志或特殊拦截,不建议无脑全部开启,否则容易重复处理业务逻辑。
可以做一个实验:
在 OnRouteMiddleStatusChanged 中设置 e.Handled = true。
重新运行路径跟踪。
默认情况下只会记录 Inner 和 Middle。
把 Outer 的注册改成 handledEventsToo: true。
再次运行,Outer 也会记录,并能看到 Handled=True。
2.9 自定义 Tunnel 事件要单独注册 WPF 中常见的 PreviewMouseDown 和 MouseDown 是一对相关事件,但这不代表你注册一个冒泡事件后,系统会自动生成对应的隧道事件。
如果确实需要自定义隧道事件,应另外注册一个事件标识,例如:
1 2 3 4 5 6 public static readonly RoutedEvent PreviewStatusChangedEvent = EventManager.RegisterRoutedEvent( "PreviewStatusChanged" , RoutingStrategy.Tunnel, typeof (StatusRoutedEventHandler), typeof (StatusButton));
然后在合适的入口依次触发隧道事件和冒泡事件。初学阶段先把一个 Bubble 事件理解透,比同时写两个事件更有效。
2.10 跟做检查点
2.11 常见错误
现象
常见原因
修正方法
只有 Inner 收到事件
注册的是 Direct,或事件没有冒泡
检查 RoutingStrategy
外层完全收不到
使用了错误的 RoutedEvent 标识
所有层统一使用 StatusChangedEvent
顺序看起来反了
日志用 Insert(0, ...) 插入到最前面
另存真实顺序列表
Source 全是外层元素
误把 sender 当成了 Source
分开记录两个值
开启 handledEventsToo 后重复执行
同一处理器被注册多次
在窗口初始化时只注册一次
自定义隧道事件没有触发
只注册了隧道事件,没有对应的触发入口
单独调用 RaiseEvent
2.12 小练习
在 OnRouteMiddleStatusChanged 中把 e.Handled 设置为 true,记录日志变化。
给每一层增加 DateTime.Now:HH:mm:ss.fff,观察毫秒级顺序。
在 RouteOuter 上注册 handledEventsToo: true,比较普通注册和特殊注册。
增加第 4 层 RouteTop,确认传播路径自动变成四层。
画一张自己的元素树,并标出 sender、Source、OriginalSource。
3. 行为对象的定义 3.1 本节目标 完成本节后,你要能够:
说明行为对象适合封装什么逻辑。
编写一个带 Attach 和 Detach 生命周期的基础类。
在附加时订阅事件,在分离时取消订阅。
避免一个行为实例同时附加到多个元素。
让按钮不写点击处理器,也能获得可复用交互。
3.2 为什么需要行为 假设有五个按钮都需要“点击时缩小再回弹”。如果每个页面都写一遍:
1 button.Click += OnPulseClick;
再在每个后台类中复制动画代码,会产生三个问题:
1 2 3 交互代码重复 订阅和取消订阅容易漏掉 按钮、窗口和动画细节耦合在一起
行为对象把这段交互封装成一个独立对象:
1 2 3 目标控件只负责被附加 行为负责订阅事件、执行交互、释放订阅 窗口负责决定何时 Attach 和 Detach
行为不是 ViewModel。ViewModel 主要保存业务状态和业务命令;行为主要封装界面交互。行为也不是样式触发器。触发器通常由属性状态驱动,而行为可以直接订阅事件、管理附加资源或与输入设备交互。
3.3 生命周期比动画更重要
图 4:行为的关键不是“能挂上去”,而是附加和分离都能正确执行。
一个可靠的行为至少要考虑:
1 2 3 4 5 现在附加到了哪个元素? 附加时订阅了哪些事件? 分离时怎样取消这些订阅? 分离后目标元素还能不能正常使用? 同一个行为实例能不能附加到第二个元素?
随附示例没有依赖第三方行为库,而是手写一个教学版基类,这样每个环节都看得见。
3.4 跟做:创建行为基类 新建 Behaviors\BehaviorBase.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 using System.Windows;namespace WpfCustomEventBehaviorAnimationPractice.Behaviors ;public abstract class BehaviorBase <T > where T : FrameworkElement { private T? _associatedObject; protected T AssociatedObject => _associatedObject ?? throw new InvalidOperationException("行为还没有附加到元素。" ); public bool IsAttached => _associatedObject is not null ; public void Attach (T element ) { if (ReferenceEquals(_associatedObject, element)) { return ; } if (_associatedObject is not null ) { throw new InvalidOperationException( "一个行为实例不能同时附加到两个元素。" ); } _associatedObject = element; OnAttached(); } public void Detach () { if (_associatedObject is null ) { return ; } OnDetaching(); _associatedObject = null ; } protected virtual void OnAttached () { } protected virtual void OnDetaching () { } }
几个设计点:
AssociatedObject 是行为当前服务的控件。
IsAttached 让外部可以查询状态。
重复附加到同一个元素时直接返回,避免重复订阅。
试图附加到第二个元素时立即报错,防止状态混乱。
子类只重写 OnAttached 和 OnDetaching。
3.5 跟做:创建点击回弹行为 新建 Behaviors\ClickPulseBehavior.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 using System.Windows;using System.Windows.Controls;using System.Windows.Input;using System.Windows.Media;using System.Windows.Media.Animation;namespace WpfCustomEventBehaviorAnimationPractice.Behaviors ;public sealed class ClickPulseBehavior : BehaviorBase <Button >{ public event Action<string >? Log; protected override void OnAttached () { AssociatedObject.Click += OnClick; AssociatedObject.MouseEnter += OnMouseEnter; EnsureScaleTransform(); Log?.Invoke("行为已附加:订阅 Click 和 MouseEnter" ); } protected override void OnDetaching () { AssociatedObject.Click -= OnClick; AssociatedObject.MouseEnter -= OnMouseEnter; Log?.Invoke("行为已分离:事件订阅已经移除" ); } private void OnClick (object sender, RoutedEventArgs e ) { Log?.Invoke("行为收到 Click:开始播放缩放动画" ); if (AssociatedObject.RenderTransform is not ScaleTransform transform) { return ; } var animation = new DoubleAnimation { From = 1 , To = 0.88 , Duration = TimeSpan.FromMilliseconds(90 ), AutoReverse = true , EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } }; transform.BeginAnimation( ScaleTransform.ScaleXProperty, animation); transform.BeginAnimation( ScaleTransform.ScaleYProperty, animation); } private void OnMouseEnter (object sender, MouseEventArgs e ) { Log?.Invoke("行为收到 MouseEnter:可以扩展悬停提示" ); } private void EnsureScaleTransform () { if (AssociatedObject.RenderTransform is ScaleTransform && AssociatedObject.RenderTransformOrigin == new Point(0.5 , 0.5 )) { return ; } AssociatedObject.RenderTransformOrigin = new Point(0.5 , 0.5 ); AssociatedObject.RenderTransform = new ScaleTransform(1 , 1 ); } }
这里暂时把 DoubleAnimation 当成行为内部的一个小工具。第 6 章会系统解释它。
RenderTransformOrigin 设置为 0.5, 0.5,表示以按钮中心为缩放原点。如果保持默认的左上角原点,按钮会向一侧缩,看起来像位置发生了跳动。
3.6 跟做:完成第 3 页 XAML 把第 3 个 TabItem 改为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 <TabItem Header ="3 行为对象的定义" > <Grid Margin ="24" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="430" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="把可复用交互抽成对象" FontSize ="22" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Foreground ="#617089" TextWrapping ="Wrap" Text ="行为对象封装订阅、交互和释放过程,按钮本身不需要知道内部细节。" /> <Border Margin ="0,18,0,0" Padding ="16" Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" > <StackPanel > <TextBlock Text ="行为生命周期" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Foreground ="#33415C" TextWrapping ="Wrap" Text ="Attach:订阅事件并初始化。Detach:取消订阅并释放状态。" /> <TextBlock Margin ="0,12,0,0" Text ="{Binding BehaviorStatus}" Foreground ="#174EA6" FontWeight ="Bold" TextWrapping ="Wrap" /> </StackPanel > </Border > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="210" /> </Grid.RowDefinitions > <Border Padding ="24" Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="10" > <StackPanel HorizontalAlignment ="Center" VerticalAlignment ="Center" > <TextBlock Text ="行为附加目标" FontSize ="20" FontWeight ="Bold" HorizontalAlignment ="Center" /> <Button x:Name ="BehaviorTargetButton" Margin ="0,18,0,0" Padding ="22,12" Content ="点击观察行为" HorizontalAlignment ="Center" > <Button.RenderTransform > <ScaleTransform x:Name ="BehaviorTargetScale" ScaleX ="1" ScaleY ="1" /> </Button.RenderTransform > </Button > <TextBlock Margin ="0,16,0,0" MaxWidth ="500" Text ="这个按钮固定附加了一个 ClickPulseBehavior。点击它观察回弹,并查看下方日志。" Foreground ="#617089" TextWrapping ="Wrap" TextAlignment ="Center" /> </StackPanel > </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Padding ="8" Background ="#0F172A" Foreground ="#DCE7F8" BorderThickness ="0" FontFamily ="Consolas" FontSize ="12" ItemsSource ="{Binding BehaviorLog}" /> </Grid > </Grid > </TabItem >
3.7 跟做:固定附加行为 在 MainWindow 中增加字段:
1 private readonly ClickPulseBehavior _definitionBehavior = new ();
在构造函数末尾加入:
1 2 3 4 5 _definitionBehavior.Log += message => ViewModel.AddBehavior(message); _definitionBehavior.Attach(BehaviorTargetButton); ViewModel.BehaviorStatus = "行为已附加,点击按钮可观察回弹" ;
注意顺序:InitializeComponent() 必须先执行,BehaviorTargetButton 才会被创建。
运行程序,打开第 3 页:
鼠标移入按钮,日志出现 MouseEnter。
点击按钮,按钮轻微缩小再恢复。
日志出现“行为收到 Click”。
页面没有任何按钮点击事件处理器,交互仍然生效。
3.8 运行截图
运行截图 3:行为固定附加到第 3 页按钮,附加日志直接显示在页面中。
3.9 为什么一个行为实例不能附加两个元素 假设写成:
1 2 3 var behavior = new ClickPulseBehavior();behavior.Attach(buttonA); behavior.Attach(buttonB);
如果允许这样做,AssociatedObject 只能保存其中一个按钮。后续分离时,也很难判断应该从哪个按钮取消订阅。
更安全的规则是:
1 2 一个行为实例对应一个目标元素 多个元素需要多个行为实例
第 4 页会再创建一个 _usageBehavior,专门演示动态附加和分离。
3.10 跟做检查点
3.11 常见错误
现象
常见原因
修正方法
构造时找不到按钮
InitializeComponent 尚未执行
在它之后附加行为
点击没有回弹
RenderTransform 不是 ScaleTransform
在附加时创建或检查变换
缩放从左上角开始
没有设置 RenderTransformOrigin
设置为 0.5, 0.5
分离后仍然响应
OnDetaching 没有取消订阅
成对写 += 和 -=
一个实例附加两个按钮
试图复用同一个行为实例
每个目标使用独立实例
重复点击后事件执行多次
重复调用 Attach 或重复订阅
检查 IsAttached 和订阅位置
3.12 小练习
把缩放终点从 0.88 改成 0.95,比较手感。
把动画时长从 90ms 改成 180ms,观察差异。
增加一个 MouseLeave 日志,但不要改变按钮状态。
给行为增加 PulseScale 属性,让不同按钮可以使用不同缩放值。
思考:如果按钮原本已经有 ScaleTransform,EnsureScaleTransform 会不会把它覆盖掉?
4. 行为对象的使用 4.1 本节目标 完成本节后,你要能够:
在运行时附加行为。
在运行时分离行为。
观察附加、使用、分离三个阶段的日志。
保证分离后目标控件仍然正常工作。
理解“行为不再执行”和“按钮失效”不是一回事。
4.2 从“定义”走向“控制”
图 5:目标按钮、行为对象和宿主窗口各自只负责一件事。
第 3 页展示的是固定附加:
第 4 页要展示可控生命周期:
1 2 3 4 5 6 7 点击“附加行为” 行为订阅事件 用户点击目标按钮 行为响应 点击“分离行为” 行为取消订阅 目标按钮仍然可以正常点击
4.3 跟做:完成第 4 页 XAML 把第 4 个 TabItem 改为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 <TabItem Header ="4 行为对象的使用" > <Grid Margin ="24" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="430" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="附加、使用、分离" FontSize ="22" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Foreground ="#617089" TextWrapping ="Wrap" Text ="附加后行为接管事件,分离后应完全恢复原状态。" /> <Border Margin ="0,18,0,0" Padding ="16" Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" > <StackPanel > <TextBlock Text ="附加状态" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Text ="{Binding BehaviorStatus}" Foreground ="#33415C" TextWrapping ="Wrap" /> <Button Margin ="0,14,0,0" Padding ="15,8" HorizontalAlignment ="Left" Content ="{Binding BehaviorToggleText}" Click ="OnToggleBehaviorClick" /> </StackPanel > </Border > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="210" /> </Grid.RowDefinitions > <Border Padding ="24" Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="10" > <StackPanel HorizontalAlignment ="Center" VerticalAlignment ="Center" > <TextBlock Text ="可附加行为的目标" FontSize ="20" FontWeight ="Bold" HorizontalAlignment ="Center" /> <Button x:Name ="AttachTargetButton" Margin ="0,18,0,0" Padding ="22,12" Content ="点击测试行为" HorizontalAlignment ="Center" > <Button.RenderTransform > <ScaleTransform x:Name ="UsageScaleTransform" ScaleX ="1" ScaleY ="1" /> </Button.RenderTransform > </Button > <TextBlock Margin ="0,16,0,0" MaxWidth ="500" Text ="分离后按钮仍然可用,但行为动画不再执行。" Foreground ="#617089" TextWrapping ="Wrap" TextAlignment ="Center" /> </StackPanel > </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Padding ="8" Background ="#0F172A" Foreground ="#DCE7F8" BorderThickness ="0" FontFamily ="Consolas" FontSize ="12" ItemsSource ="{Binding BehaviorLog}" /> </Grid > </Grid > </TabItem >
4.4 跟做:增加第二个行为实例 在窗口字段区域增加:
1 private readonly ClickPulseBehavior _usageBehavior = new ();
构造函数中继续加入:
1 2 _usageBehavior.Log += message => ViewModel.AddBehavior(message);
注意不要把 _definitionBehavior 直接附加到 AttachTargetButton。第 3 页和第 4 页需要两个独立实例。
4.5 跟做:切换附加状态 增加方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public void SimulateBehaviorToggle (){ if (_usageBehavior.IsAttached) { _usageBehavior.Detach(); ViewModel.IsBehaviorAttached = false ; ViewModel.BehaviorToggleText = "附加行为" ; ViewModel.BehaviorStatus = "行为已分离,事件订阅已移除" ; return ; } _usageBehavior.Attach(AttachTargetButton); ViewModel.IsBehaviorAttached = true ; ViewModel.BehaviorToggleText = "分离行为" ; ViewModel.BehaviorStatus = "行为已附加,点击按钮可观察回弹" ; } private void OnToggleBehaviorClick (object sender, RoutedEventArgs e ){ SimulateBehaviorToggle(); }
运行程序,按下面顺序操作:
打开第 4 页。
点击“附加行为”。
日志出现“行为已附加:订阅 Click 和 MouseEnter”。
点击“点击测试行为”,按钮回弹,日志出现 Click。
点击“分离行为”。
日志出现“行为已分离:事件订阅已经移除”。
再次点击目标按钮,按钮仍然能响应点击,但不再回弹,也没有行为日志。
4.6 运行截图
运行截图 4:行为实例可以动态附加和分离,日志记录完整生命周期。
4.7 分离后为什么按钮还能用 行为只是额外订阅了按钮的事件。取消订阅后:
1 2 3 按钮自身的 Click 事件仍然存在 按钮的命令、样式、键盘响应仍然存在 只是 ClickPulseBehavior 不再收到通知
这正是行为应该达到的效果。分离不是禁用控件,而是移除某一段可复用交互。
4.8 生命周期代码的执行顺序 Attach:
1 2 3 4 5 保存 AssociatedObject 调用 OnAttached 订阅 Click 和 MouseEnter 创建或确认 RenderTransform 写日志
Detach:
1 2 3 4 调用 OnDetaching 取消 Click 和 MouseEnter 订阅 写日志 清空 AssociatedObject
在简单示例中,这个顺序已经足够。生产项目中如果 OnAttached 可能抛出异常,可以用 try/finally 做回滚,避免行为停在“附加了一半”的状态。
4.9 行为、触发器、命令怎么选
需求
更适合的机制
原因
属性变化后改变另一个属性
触发器
适合声明式状态映射
点击按钮执行保存命令
ICommand
业务动作清楚,可测试
鼠标悬停时让控件轻微移动
行为
交互细节可复用
输入框自动选择全部文本
行为
与具体控件事件紧密相关
多个页面复用拖拽逻辑
行为
封装订阅、状态和释放
保存订单数据
ViewModel 服务
行为不应承担业务持久化
行为不是用来替代所有后台代码的。它最适合封装“界面交互机制”,而不是保存订单、访问数据库或处理复杂业务规则。
4.10 跟做检查点
4.11 常见错误
现象
常见原因
修正方法
第 3 页按钮没有行为
行为只附加到了第 4 页按钮
为第 3 页创建独立实例
第 4 页按钮无法回弹
行为还没有附加
先点击“附加行为”
分离后日志仍增加
OnDetaching 漏掉取消订阅
检查 -=
反复切换后一次点击出现多条日志
每次附加都重复订阅
Attach 前检查状态,确保成对订阅
行为实例被两个按钮共用
没有遵守一对一规则
每个目标创建独立行为实例
页面切换后状态文字混乱
多页共用同一个 ViewModel 属性
拆分状态属性或明确共享规则
4.12 小练习
增加“重置行为”按钮,先分离再附加,并清空日志。
给行为增加 PulseScale 和 PulseDuration 属性。
第 4 页增加两个按钮,分别附加两个不同的行为实例。
在日志中记录目标按钮的 Name。
把 Detach 改成先取消订阅再写日志,观察顺序差异。
5. WPF动画与分类 5.1 本节目标 完成本节后,你要能够:
说明 WPF 动画为什么依赖依赖属性。
区分补间动画、关键帧动画、路径动画和无限循环。
认识常见的动画类型。
根据目标属性选择合适的动画类。
理解 Duration、AutoReverse、FillBehavior 和 EasingFunction。
5.2 动画不是循环修改属性 初学者有时会写一个循环:
1 2 3 4 5 for (var value = 0.0 ; value <= 1.0 ; value += 0.01 ){ card.Opacity = value ; Thread.Sleep(16 ); }
这段代码有几个问题:
1 2 3 4 它会阻塞 UI 线程 时间间隔不精确 窗口无法顺畅重绘 难以暂停、继续和停止
WPF 动画由动画时钟驱动。你描述“从什么值变到什么值、持续多久、怎样缓动”,动画系统负责在时间线上计算属性值。
5.3 为什么目标必须是依赖属性 WPF 动画系统需要能够:
1 2 3 4 5 保存属性的基础值 临时叠加动画值 在多个动画之间计算优先级 暂停、继续、停止和移除动画时钟 通知绑定和渲染系统
依赖属性提供了这套机制。普通的自动属性只保存一个字段,没有动画时钟和值优先级,因此不能直接作为 DoubleAnimation 的目标。
常见可动画属性:
1 2 3 4 5 6 7 8 9 UIElement.Opacity FrameworkElement.Width FrameworkElement.Height Canvas.Left Canvas.Top RotateTransform.Angle ScaleTransform.ScaleX TranslateTransform.X Brush.Opacity
5.4 常见分类方式
图 6:这里的分类用于学习和选型,不是要求死记的官方唯一分类表。
按时间模型分类
类型
特点
常见类
补间动画
给出起点、终点或增量,中间值自动计算
DoubleAnimation
关键帧动画
在多个时间点定义值
DoubleAnimationUsingKeyFrames
路径动画
目标值沿几何路径变化
DoubleAnimationUsingPath
无限循环
使用重复行为持续播放
任意时间线配合 RepeatBehavior.Forever
按目标属性类型分类
目标属性类型
动画类
double
DoubleAnimation
Color
ColorAnimation
Point
PointAnimation
Thickness
ThicknessAnimation
Rect
RectAnimation
按组织方式分类
组织方式
用途
属性动画
只操作一个属性
Storyboard
组合多个动画,统一开始、暂停和停止
并行时间线
多个动画同时进行
顺序时间线
通过 BeginTime 错开动画
5.5 补间动画的 From、To、By DoubleAnimation 最常用:
1 2 3 4 5 6 var animation = new DoubleAnimation{ From = 0.35 , To = 1 , Duration = TimeSpan.FromMilliseconds(900 ) };
三个值的含义:
1 2 3 From:起点 To:终点 By:相对当前值增加多少
常见组合:
写法
效果
From + To
从固定起点到固定终点
只写 To
从当前值到终点
只写 By
从当前值增加指定量
From + By
从起点增加指定量
不要同时把 To 和 By 当成互相独立的两个终点。通常选择其中一种表达方式,避免自己把目标值算混。
5.6 Duration、BeginTime、AutoReverse 1 2 3 4 5 6 7 8 var animation = new DoubleAnimation{ From = 1 , To = 0.88 , Duration = TimeSpan.FromMilliseconds(90 ), BeginTime = TimeSpan.FromMilliseconds(50 ), AutoReverse = true };
属性
作用
Duration
单程持续时间
BeginTime
延迟多久开始
AutoReverse
到达终点后是否反向播放
RepeatBehavior
重复次数或无限循环
SpeedRatio
播放速度倍率
当 AutoReverse=true 时,总活动时间通常要按“去程 + 回程”理解。一个 Duration=90ms 的回弹动画,完整来回大约需要 180ms。
5.7 EasingFunction 让速度更自然 线性动画从起点到终点速度不变,机械感较强。缓动函数可以改变速度曲线:
1 2 3 4 EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseInOut }
常见模式:
模式
感觉
EaseIn
开始慢,后面快
EaseOut
开始快,后面慢
EaseInOut
两端慢,中间快
按钮回弹通常适合 EaseOut,页面淡入通常适合 EaseInOut。
5.8 FillBehavior:结束后保留还是恢复 FillBehavior 决定动画活动期结束后怎样处理属性值:
值
效果
HoldEnd
保持终点值
Stop
动画结束后回到基础值
示例:
1 2 3 4 5 6 7 var fadeIn = new DoubleAnimation{ From = 0 , To = 1 , Duration = TimeSpan.FromMilliseconds(500 ), FillBehavior = FillBehavior.HoldEnd };
这里使用 HoldEnd,动画结束后控件保持不透明。
如果一个动画只是短暂提示,希望结束后恢复原样,可以考虑 FillBehavior.Stop,或者在完成后主动调用 Stop 并设置基础值。
5.9 动画与本地值的关系 假设控件本身写的是:
1 2 <Border x:Name ="Card" Opacity ="0.8" />
然后启动一个 Opacity 动画。动画播放期间,屏幕上看到的是动画计算出的值,而不是本地值 0.8。动画停止或移除后,属性会重新显示基础值。
这解释了一个常见现象:
1 2 3 动画正在播放时设置 card.Opacity = 1 界面看起来没有变化 动画停止后,属性才回到 1
动画值有更高的计算优先级。要立即恢复,需要停止或移除动画时钟。
5.10 跟做:完成第 5 页 XAML 把第 5 个 TabItem 改为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 <TabItem Header ="5 WPF 动画与分类" > <Grid Margin ="24" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="430" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="动画的分类方式" FontSize ="22" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Foreground ="#617089" TextWrapping ="Wrap" Text ="先分清补间、关键帧、路径和无限循环,再选择具体动画类。" /> <Border Margin ="0,18,0,0" Padding ="16" Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" > <StackPanel > <TextBlock Text ="当前选择" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Text ="{Binding AnimationClass}" FontSize ="20" FontWeight ="Bold" Foreground ="#174EA6" /> <TextBlock Margin ="0,8,0,0" Text ="{Binding AnimationStatus}" Foreground ="#33415C" TextWrapping ="Wrap" /> </StackPanel > </Border > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="210" /> </Grid.RowDefinitions > <Border Padding ="24" Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="10" > <StackPanel > <TextBlock Text ="动画类型" FontWeight ="Bold" /> <WrapPanel Margin ="0,12,0,0" > <Button Margin ="0,0,10,0" Padding ="15,8" Content ="From / To" Click ="OnSelectFromToClick" /> <Button Margin ="0,0,10,0" Padding ="15,8" Content ="关键帧" Click ="OnSelectKeyFrameClick" /> <Button Margin ="0,0,10,0" Padding ="15,8" Content ="路径" Click ="OnSelectPathClick" /> <Button Padding ="15,8" Content ="无限循环" Click ="OnSelectForeverClick" /> </WrapPanel > <TextBlock Margin ="0,18,0,0" Text ="选型提示" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" TextWrapping ="Wrap" Foreground ="#617089" Text ="补间:只给起点和终点。关键帧:多个时间点。路径:沿几何路径。无限循环:没有自然结束点。" /> </StackPanel > </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Padding ="8" Background ="#0F172A" Foreground ="#DCE7F8" BorderThickness ="0" FontFamily ="Consolas" FontSize ="12" ItemsSource ="{Binding AnimationLog}" /> </Grid > </Grid > </TabItem >
5.11 跟做:增加动画分类规则 在项目根目录新建 AnimationRules.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 namespace WpfCustomEventBehaviorAnimationPractice ;public enum AnimationCategory{ FromTo, KeyFrame, Path, Forever } public static class AnimationRules { public static string DescribeCategory (AnimationCategory category ) { return category switch { AnimationCategory.FromTo => "From / To / By" , AnimationCategory.KeyFrame => "关键帧" , AnimationCategory.Path => "路径" , AnimationCategory.Forever => "无限循环" , _ => "未知" }; } public static bool IsSupportedDuration (TimeSpan duration ) { return duration >= TimeSpan.FromMilliseconds(100 ) && duration <= TimeSpan.FromSeconds(10 ); } public static double ClampProgress (double progress ) { return Math.Clamp(progress, 0 , 1 ); } }
这里的 100ms 到 10s 只是示例约束,不是 WPF 的限制。实际项目中可根据交互类型制定规则。例如按钮反馈适合几十到几百毫秒,页面转场可以更长。
5.12 跟做:切换分类状态 增加四个事件处理器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 private void OnSelectFromToClick (object sender, RoutedEventArgs e ){ ViewModel.AnimationClass = AnimationRules.DescribeCategory(AnimationCategory.FromTo); ViewModel.AnimationStatus = "补间动画:只定义起点和终点,中间值由动画系统计算" ; ViewModel.AddAnimation("分类 | From / To / By" ); } private void OnSelectKeyFrameClick (object sender, RoutedEventArgs e ){ ViewModel.AnimationClass = AnimationRules.DescribeCategory(AnimationCategory.KeyFrame); ViewModel.AnimationStatus = "关键帧动画:在多个时间点定义值,适合复杂节奏" ; ViewModel.AddAnimation("分类 | 关键帧" ); } private void OnSelectPathClick (object sender, RoutedEventArgs e ){ ViewModel.AnimationClass = AnimationRules.DescribeCategory(AnimationCategory.Path); ViewModel.AnimationStatus = "路径动画:目标值沿几何路径变化" ; ViewModel.AddAnimation("分类 | 路径" ); } private void OnSelectForeverClick (object sender, RoutedEventArgs e ){ ViewModel.AnimationClass = AnimationRules.DescribeCategory(AnimationCategory.Forever); ViewModel.AnimationStatus = "无限循环:必须明确暂停、停止或移除的时机" ; ViewModel.AddAnimation("分类 | 无限循环" ); }
5.13 运行截图
运行截图 5:点击四个按钮,观察分类名称、说明和日志同步变化。
5.14 动画性能和布局 优先考虑不会触发布局的动画:
1 2 Opacity RenderTransform 中的 ScaleTransform、RotateTransform、TranslateTransform
谨慎使用会频繁触发布局的动画:
1 2 3 4 5 Width Height Margin Padding FontSize
例如移动一个元素时,直接连续修改 Margin 会让布局系统反复计算。使用 TranslateTransform 通常更合适,因为它主要影响渲染变换。
5.15 跟做检查点
5.16 常见错误
现象
常见原因
修正方法
动画没有效果
目标不是依赖属性
检查目标属性
动画结束后突然跳回
FillBehavior 为 Stop
改为 HoldEnd 或保留动画时钟
动画太快看不清
时长过短
初学调试时先使用 500ms 以上
无限循环无法停止
没有保存可控时钟
用 Storyboard 统一管理
位移动画导致界面卡顿
修改布局属性
改用 TranslateTransform
缩放中心不对
没有设置变换原点
设置 RenderTransformOrigin
5.17 小练习
写一个 Opacity 从 0 到 1 的动画,持续 600ms。
写一个 ScaleTransform 从 1 到 1.08 再回到 1 的动画。
使用 BeginTime 让第二个动画延迟 200ms。
把同一个动画的 EasingMode 改成三种模式,比较手感。
使用 FillBehavior.Stop 和 HoldEnd 各运行一次,观察最终值。
6. 动画的基本定义与使用 6.1 本节目标 完成本节后,你要能够:
创建 DoubleAnimation。
使用 Storyboard 指定目标和目标属性。
开始、暂停、继续和停止动画。
处理 Completed 和 CurrentTimeInvalidated。
解释 Begin(this, true) 中 true 的作用。
避免动画时钟和基础值互相干扰。
6.2 一条动画时间线
图 7:动画时钟由 Begin 创建,Pause、Resume 和 Stop 改变它的状态。
本节做一个最简单的可见效果:让卡片的不透明度在 0.35 和 1 之间往返。
选择 Opacity 的原因是:
1 2 3 4 它是依赖属性 它不会触发布局 效果明显,容易观察 便于把注意力放在时间线上
6.3 跟做:完成第 6 页 XAML 把第 6 个 TabItem 改为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 <TabItem Header ="6 动画的基本定义与使用" > <Grid Margin ="24" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="420" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="让属性真正动起来" FontSize ="22" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Foreground ="#617089" TextWrapping ="Wrap" Text ="动画由时间线计算属性值。开始、暂停、继续、停止和完成要分别处理。" /> <Border Margin ="0,18,0,0" Padding ="16" Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="8" > <StackPanel > <TextBlock Text ="动画状态" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Text ="{Binding AnimationStatus}" Foreground ="#174EA6" FontWeight ="Bold" TextWrapping ="Wrap" /> <TextBlock Margin ="0,10,0,0" Text ="{Binding AnimationClass}" Foreground ="#617089" /> </StackPanel > </Border > <WrapPanel Margin ="0,14,0,0" > <Button Margin ="0,0,10,10" Padding ="15,8" Content ="开始" Click ="OnStartAnimationClick" /> <Button Margin ="0,0,10,10" Padding ="15,8" Content ="暂停" Click ="OnPauseAnimationClick" /> <Button Margin ="0,0,10,10" Padding ="15,8" Content ="继续" Click ="OnResumeAnimationClick" /> <Button Margin ="0,0,10,10" Padding ="15,8" Content ="停止" Click ="OnStopAnimationClick" /> </WrapPanel > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="210" /> </Grid.RowDefinitions > <Border Padding ="20" Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="10" > <Canvas x:Name ="AnimationCanvas" Height ="420" Background ="#FBFCFE" ClipToBounds ="True" > <Border x:Name ="AnimatedCard" Width ="170" Height ="110" Canvas.Left ="60" Canvas.Top ="150" Background ="#2456A6" CornerRadius ="12" > <StackPanel HorizontalAlignment ="Center" VerticalAlignment ="Center" > <TextBlock Text ="动画卡片" FontWeight ="Bold" Foreground ="White" HorizontalAlignment ="Center" /> <TextBlock Margin ="0,5,0,0" Text ="{Binding AnimationProgress, StringFormat={}{0:N2}}" Foreground ="#D9E7FF" HorizontalAlignment ="Center" /> </StackPanel > </Border > </Canvas > </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Padding ="8" Background ="#0F172A" Foreground ="#DCE7F8" BorderThickness ="0" FontFamily ="Consolas" FontSize ="12" ItemsSource ="{Binding AnimationLog}" /> </Grid > </Grid > </TabItem >
6.4 跟做:增加动画字段 在 MainWindow 字段区域增加:
1 2 private Storyboard? _animationStoryboard;private bool _isPaused;
_animationStoryboard 保存当前可控的动画时钟。_isPaused 用来防止在没有暂停时执行“继续”。
6.5 跟做:创建动画 增加 BuildAnimation 方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 private void BuildAnimation (){ var opacity = new DoubleAnimation { From = 0.35 , To = 1 , Duration = TimeSpan.FromMilliseconds(900 ), AutoReverse = true , EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseInOut } }; Storyboard.SetTarget(opacity, AnimatedCard); Storyboard.SetTargetProperty( opacity, new PropertyPath(UIElement.OpacityProperty)); _animationStoryboard = new Storyboard(); _animationStoryboard.Children.Add(opacity); _animationStoryboard.Completed += OnAnimationCompleted; opacity.CurrentTimeInvalidated += (_, _) => { ViewModel.AnimationProgress = AnimationRules.ClampProgress(AnimatedCard.Opacity); }; }
在构造函数中调用:
6.6 为什么需要 Storyboard 单独一个 DoubleAnimation 可以直接调用:
1 AnimatedCard.BeginAnimation(UIElement.OpacityProperty, opacity);
但如果需要:
1 2 3 4 同时播放多个属性动画 统一暂停、继续、停止 监听整个动画组的 Completed 控制多个目标
使用 Storyboard 会更清楚。Storyboard 负责组织时间线,动画对象负责描述某个属性的变化。
6.7 SetTarget 和 SetTargetProperty 1 Storyboard.SetTarget(opacity, AnimatedCard);
意思是:这个动画作用于 AnimatedCard。
1 2 3 Storyboard.SetTargetProperty( opacity, new PropertyPath(UIElement.OpacityProperty));
意思是:动画修改 AnimatedCard 的 Opacity 依赖属性。
如果目标属性写错,常见结果是:
1 2 3 运行时报找不到属性 动画没有任何视觉效果 动画修改了错误的元素
初学阶段优先使用依赖属性字段,例如 UIElement.OpacityProperty,而不是手写容易拼错的字符串路径。
6.8 跟做:开始、暂停、继续、停止 增加四个公开方法和对应按钮处理器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 public void SimulateAnimationStart (){ _animationStoryboard?.Stop(this ); _animationStoryboard?.Begin(this , true ); _isPaused = false ; ViewModel.AnimationStatus = "动画正在播放" ; ViewModel.AnimationClass = "DoubleAnimation + Storyboard" ; ViewModel.AnimationProgress = 0.5 ; ViewModel.AddAnimation("Begin | 动画时钟开始" ); } public void SimulateAnimationPause (){ if (_animationStoryboard is null ) { return ; } _animationStoryboard.Pause(this ); _isPaused = true ; ViewModel.AnimationStatus = "动画已暂停" ; ViewModel.AddAnimation("Pause | 保留当前进度" ); } public void SimulateAnimationResume (){ if (_animationStoryboard is null || !_isPaused) { return ; } _animationStoryboard.Resume(this ); _isPaused = false ; ViewModel.AnimationStatus = "动画已继续" ; ViewModel.AddAnimation("Resume | 从暂停位置继续" ); } public void SimulateAnimationStop (){ if (_animationStoryboard is null ) { return ; } _animationStoryboard.Stop(this ); _isPaused = false ; AnimatedCard.Opacity = 1 ; ViewModel.AnimationStatus = "动画已停止" ; ViewModel.AnimationProgress = 0 ; ViewModel.AddAnimation("Stop | 恢复静态值" ); } private void OnStartAnimationClick (object sender, RoutedEventArgs e ){ SimulateAnimationStart(); } private void OnPauseAnimationClick (object sender, RoutedEventArgs e ){ SimulateAnimationPause(); } private void OnResumeAnimationClick (object sender, RoutedEventArgs e ){ SimulateAnimationResume(); } private void OnStopAnimationClick (object sender, RoutedEventArgs e ){ SimulateAnimationStop(); }
6.9 为什么 Begin 的第二个参数要传 true 代码是:
1 _animationStoryboard?.Begin(this , true );
第二个参数表示是否创建可控时钟。传 true 后,才可以对这个 Storyboard 调用:
如果传 false,动画可能仍然能播放,但后续暂停和停止行为不符合预期。需要交互控制时,记得使用可控版本。
6.10 Completed 与 CurrentTimeInvalidated Completed 在动画活动期结束时触发:
1 2 3 4 5 6 private void OnAnimationCompleted (object ? sender, EventArgs e ){ ViewModel.AnimationStatus = "动画已完成" ; ViewModel.AnimationProgress = 1 ; ViewModel.AddAnimation("Completed | 动画时钟结束" ); }
CurrentTimeInvalidated 在动画时间推进时触发:
1 2 3 4 5 opacity.CurrentTimeInvalidated += (_, _) => { ViewModel.AnimationProgress = AnimationRules.ClampProgress(AnimatedCard.Opacity); };
这个事件触发很频繁,适合读取进度、刷新简单显示,不适合做数据库访问、复杂文件操作或大量对象创建。
6.11 运行截图
运行截图 6:卡片透明度随时间变化,状态文字和日志同步记录开始、暂停、继续和停止。
6.12 建议的跟做顺序 第一次运行时不要急着连点。按下面顺序操作:
点击“开始”,观察卡片从半透明变为不透明。
在播放过程中点击“暂停”,观察画面停住。
点击“继续”,观察动画从暂停位置接着播放。
等待自然结束,观察 Completed 日志。
再次点击“开始”,在播放中点击“停止”。
观察卡片恢复静态值,日志出现 Stop。
连续点击两次“开始”,观察先停止旧时钟再创建新时钟的行为。
6.13 跟做检查点
6.14 常见错误
现象
常见原因
修正方法
动画完全不动
目标属性不是依赖属性
检查 SetTargetProperty
能播放但不能暂停
Begin 没有创建可控时钟
使用 Begin(this, true)
停止后值不恢复
基础值被修改或 FillBehavior 设置不当
明确基础值和 FillBehavior
Completed 不触发
无限循环或动画被提前停止
检查 RepeatBehavior 和时钟状态
进度文字不更新
没有处理 CurrentTimeInvalidated
在回调中更新简单状态
动画卡顿
在进度回调中做重活
只做轻量更新
点击多次后动画叠加
每次点击都创建新时钟
开始前先 Stop 旧时钟
6.15 小练习
把 Duration 改成 1500ms,观察开始、暂停和继续的差异。
把 AutoReverse 改成 false,比较结束状态。
把 FillBehavior 改为 Stop,观察动画结束后的值。
增加一个 ScaleTransform 动画,让卡片在淡入时轻微放大。
增加“重置”按钮,停止动画并恢复 Opacity=1。
把 CurrentTimeInvalidated 中的进度改成百分比显示。
把六章串成一个完整程序 完整窗口后台结构 六章都跟做完成后,MainWindow 的字段和方法可以整理成下面的结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 using System.Windows;using System.Windows.Controls;using System.Windows.Media;using System.Windows.Media.Animation;using WpfCustomEventBehaviorAnimationPractice.Behaviors;using WpfCustomEventBehaviorAnimationPractice.Controls;using WpfCustomEventBehaviorAnimationPractice.ViewModels;namespace WpfCustomEventBehaviorAnimationPractice ;public partial class MainWindow : Window { private readonly ClickPulseBehavior _definitionBehavior = new (); private readonly ClickPulseBehavior _usageBehavior = new (); private readonly List<string > _routeSequence = new (); private Storyboard? _animationStoryboard; private bool _isPaused; public MainWindow () { InitializeComponent(); ViewModel = new MainViewModel(); DataContext = ViewModel; _definitionBehavior.Log += OnBehaviorLog; _usageBehavior.Log += OnBehaviorLog; _definitionBehavior.Attach(BehaviorTargetButton); ViewModel.BehaviorStatus = "行为已附加,点击按钮可观察回弹" ; RouteOuter.AddHandler( StatusButton.StatusChangedEvent, new StatusRoutedEventHandler(OnRouteOuterStatusChanged)); RouteMiddle.AddHandler( StatusButton.StatusChangedEvent, new StatusRoutedEventHandler(OnRouteMiddleStatusChanged)); RouteInner.AddHandler( StatusButton.StatusChangedEvent, new StatusRoutedEventHandler(OnRouteInnerStatusChanged)); BuildAnimation(); } public MainViewModel ViewModel { get ; } public IReadOnlyList<string > RouteSequence => _routeSequence; private void OnBehaviorLog (string message ) { ViewModel.AddBehavior(message); } }
其余事件处理器可以按照前面的章节逐个加入。不要把所有逻辑都塞进构造函数,按功能拆成方法更易读。
最终构建和运行 在练习项目目录执行:
构建成功时应看到:
随附示例还包含自动测试和截图功能。测试命令如下:
1 2 3 4 $exe = "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfCustomEventBehaviorAnimationDemo\bin\Debug\net9.0-windows\WpfCustomEventBehaviorAnimationDemo.exe" $process = Start-Process -FilePath $exe -ArgumentList "--self-test" -PassThru -Wait $process .ExitCodeGet-Content "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfCustomEventBehaviorAnimationDemo\bin\Debug\net9.0-windows\self-test-results.txt" -Encoding UTF8
当前示例的检查项包括:
1 2 3 4 5 6 7 8 9 10 11 事件已经注册 事件所有者类型正确 冒泡顺序为 Inner、Middle、Outer 行为可以附加 行为可以分离 动画时长规则有效 动画分类映射正确 动画可以开始 动画可以暂停 动画可以继续 动画可以停止
常见错误速查 自定义路由事件
问题
优先检查
XAML 找不到事件
是否有 CLR 事件包装
运行时提示处理器类型错误
注册的委托类型是否和 EventArgs 匹配
点击后没有反应
RaiseEvent 是否执行
外层收不到事件
路由策略是否为 Bubble
注册时抛出重复事件错误
注册代码是否被反复执行
路由传播
问题
优先检查
只到某一层就停止
是否有处理器设置了 Handled=true
顺序看起来相反
日志是否用 Insert(0, ...)
sender 与预期不同
sender 表示处理器所在元素
Source 与预期不同
创建 EventArgs 时传入了哪个 source
想观察已处理事件
使用 handledEventsToo: true
行为对象
问题
优先检查
附加后没有效果
OnAttached 是否订阅了正确事件
分离后仍然响应
OnDetaching 是否取消订阅
点击一次触发多次
是否重复附加或重复订阅
一个行为影响两个按钮
是否共用了同一个行为实例
缩放位置不对
是否设置 RenderTransformOrigin
按钮失效
行为不应禁用控件,检查是否误改 IsEnabled
动画
问题
优先检查
动画不动
目标属性是否是依赖属性
暂停无效
Begin 是否使用可控时钟
动画结束后跳回
FillBehavior 和基础值
停止后不恢复
是否还有别的动画时钟在起作用
进度更新太频繁
CurrentTimeInvalidated 中是否有重活
位移动画卡顿
是否修改了布局属性
综合练习 练习一:自定义“购物车数量变化”事件 要求:
创建 QuantityChangedRoutedEventArgs,携带旧数量和新数量。
创建一个自定义数量按钮。
点击后触发冒泡事件。
在外层 Border 接收事件并显示“数量从 A 变为 B”。
在中间层设置一次 Handled=true,观察外层是否还能收到。
验收标准:
1 2 3 4 事件参数能区分旧值和新值 外层能收到冒泡事件 Handled 行为符合预期 日志能说明事件源和处理器位置
练习二:做一个可复用的悬停高亮行为 要求:
行为附加到 Border。
MouseEnter 时把边框颜色变深。
MouseLeave 时恢复原颜色。
分离行为后不再改变颜色。
记录原始颜色,避免分离后留下错误状态。
验收标准:
1 2 3 同一行为不能同时附加到两个 Border 附加和分离各执行一次 鼠标进入和离开都能恢复状态
练习三:做一个加载完成动画 要求:
点击“开始加载”,显示一个半透明遮罩。
使用 DoubleAnimation 让遮罩淡入。
使用 Storyboard 管理动画。
点击“取消”时停止动画并恢复静态值。
动画完成后更新状态文字。
验收标准:
1 2 3 开始、暂停、继续、停止都能操作 Completed 只记录一次 停止后界面恢复可用
练习四:把事件、行为和动画串起来 要求:
自定义按钮触发 StatusChanged。
外层面板接收事件并更新业务状态。
行为监听按钮点击,播放一次轻微缩放。
状态变化后,另一个卡片执行淡入动画。
所有操作都写入同一个日志面板。
建议的日志格式:
例如:
1 2 3 10:21:03.118 | StatusChanged | CustomStatusButton | Message=已保存 10:21:03.120 | Behavior | CustomStatusButton | Click 10:21:03.125 | Animation | ResultCard | Begin
验收标准:
1 2 3 4 能从日志还原一次完整操作 事件不会被重复处理 行为分离后不再响应 动画停止后属性恢复
结语 这一篇的六个主题看起来分散,实际上都在处理同一件事:界面中的状态怎样被可靠地通知、封装和表现。
可以把它们压缩成一条主线:
1 2 3 4 5 6 RoutedEvent 负责“发生了什么” 事件参数负责“带上了哪些数据” 路由系统负责“谁还能收到” 行为对象负责“这类交互怎样复用” 依赖属性负责“动画可以修改什么” Storyboard 负责“多个动画怎样统一控制”
初学阶段最容易犯的错误,是只追求“效果出来了”,却忽略生命周期和状态恢复。真正可靠的 WPF 交互需要同时回答:
1 2 3 4 5 事件订阅了吗? 分离时取消了吗? 动画时钟还能控制吗? 停止后基础值正确吗? 日志能解释发生了什么吗?
每做完一个功能,都可以用这五个问题检查一次。等你能稳定回答这些问题,再学习更复杂的控件模板、MVVM 命令、可视化状态和自定义控件,就不会只停留在“复制一段能跑的代码”的层面。