面向已经会使用 DoubleAnimation、Storyboard 和关键帧动画,准备把动画接入真实交互的读者。 推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,本文涉及的 API 在 .NET 9 与 .NET 10 中没有变化。 最后更新:2026-10-22
目录
动画对象的启动时间与缓动效果
动画的事件监听
动画的常规控制
触发器控制动画
视觉状态管理器控制动画
抽屉菜单与等待动画案例
写在前面 这份笔记解决什么问题 前面的笔记已经完成:
1 2 3 4 DoubleAnimation Storyboard 关键帧动画 缓动、路径、速率与累加
继续把动画放进界面时,又会遇到新的问题:
1 2 3 4 5 6 7 8 为什么动画要等一会儿才开始? 延迟和缓动为什么会同时出现? 怎样知道动画已经运行、暂停或结束? 暂停以后为什么不能重新 Begin? Stop 以后为什么控件还停在动画终点? 什么时候用触发器,什么时候用 ClockController? VisualStateManager 和普通 Storyboard 有什么区别? 抽屉菜单和等待动画怎样停止、重置、重复播放?
这篇笔记会用六个实验回答这些问题:
1 2 3 4 5 6 启动时间:决定什么时候开始 事件监听:读取时钟的进度与状态 常规控制:暂停、继续、跳转、停止 触发器:由属性或事件自动执行动画 视觉状态:把互斥外观组织成状态组 综合案例:把动画接入抽屉和等待反馈
图 1:从时间线参数逐步走到真实交互动画。
推荐学习顺序
先运行随附示例,依次操作六个页签。
第 1 章先比较“延迟播放”和“立即播放”。
第 2 章观察进度条和状态文字是否同步。
第 3 章依次点击开始、暂停、继续、跳转、停止。
第 4 章先悬停 Trigger,再点击 EventTrigger。
第 5 章切换三个视觉状态,观察哪些属性会恢复。
第 6 章反复开关抽屉、启停等待动画,检查资源释放。
随附示例位置 1 C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAnimationControlDemo
运行:
1 2 3 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAnimationControlDemo" dotnet build dotnet run
Visual Studio 中:
打开 WpfAnimationControlDemo.csproj。
确认启动项目是 WpfAnimationControlDemo。
按 F5。
六个页签依次操作按钮。
观察动画和右侧日志是否同步。
创建自己的跟做项目 1 2 3 4 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码" dotnet new wpf -n WpfAnimationControlPractice -o .\WpfAnimationControlPractice cd .\WpfAnimationControlPracticedotnet run
关闭默认窗口后,把目标框架设为:
1 <TargetFramework > net9.0-windows</TargetFramework >
如果已安装 .NET 10 SDK,也可以使用:
1 <TargetFramework > net10.0-windows</TargetFramework >
跟做完成后的结构 1 2 3 4 5 6 7 8 9 WpfAnimationControlPractice ├─ App.xaml ├─ App.xaml.cs ├─ MainWindow.xaml ├─ MainWindow.xaml.cs ├─ AnimationRules.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 WpfAnimationControlPractice.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 101 102 103 using System.Collections.ObjectModel;namespace WpfAnimationControlPractice.ViewModels ;public sealed class MainViewModel : ObservableObject { private string _delayStatus = "等待播放" ; private string _eventStatus = "等待启动" ; private string _eventState = "未创建时钟" ; private double _eventProgress; private string _controlStatus = "等待开始" ; private string _controlPosition = "0.00 / 2.40 秒" ; private double _controlProgress; private string _triggerStatus = "把鼠标移到卡片上" ; private string _stateStatus = "当前状态:Idle" ; private string _caseStatus = "抽屉关闭,等待动画未启动" ; public ObservableCollection<string > DelayLog { get ; } = new (); public ObservableCollection<string > EventLog { get ; } = new (); public ObservableCollection<string > ControlLog { get ; } = new (); public ObservableCollection<string > TriggerLog { get ; } = new (); public ObservableCollection<string > StateLog { get ; } = new (); public ObservableCollection<string > CaseLog { get ; } = new (); public string DelayStatus { get => _delayStatus; set => SetProperty(ref _delayStatus, value ); } public string EventStatus { get => _eventStatus; set => SetProperty(ref _eventStatus, value ); } public string EventState { get => _eventState; set => SetProperty(ref _eventState, value ); } public double EventProgress { get => _eventProgress; set => SetProperty(ref _eventProgress, value ); } public string ControlStatus { get => _controlStatus; set => SetProperty(ref _controlStatus, value ); } public string ControlPosition { get => _controlPosition; set => SetProperty(ref _controlPosition, value ); } public double ControlProgress { get => _controlProgress; set => SetProperty(ref _controlProgress, value ); } public string TriggerStatus { get => _triggerStatus; set => SetProperty(ref _triggerStatus, value ); } public string StateStatus { get => _stateStatus; set => SetProperty(ref _stateStatus, value ); } public string CaseStatus { get => _caseStatus; set => SetProperty(ref _caseStatus, value ); } public void AddDelay (string line ) => Add(DelayLog, line); public void AddEvent (string line ) => Add(EventLog, line); public void AddControl (string line ) => Add(ControlLog, line); public void AddTrigger (string line ) => Add(TriggerLog, line); public void AddState (string line ) => Add(StateLog, line); public void AddCase (string line ) => Add(CaseLog, line); private static void Add ( ObservableCollection<string > lines, string line ) { lines.Insert(0 , line); while (lines.Count > 30 ) { lines.RemoveAt(lines.Count - 1 ); } } }
先准备公共规则 新建 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 using System.Windows.Media.Animation;namespace WpfAnimationControlPractice ;public static class AnimationRules { public static double ClampProgress (double value ) { return Math.Clamp(value , 0 , 1 ); } public static string DescribeState (ClockState state ) { return state switch { ClockState.Active => "正在运行" , ClockState.Filling => "保持结尾值" , ClockState.Stopped => "已停止" , _ => "未知状态" }; } public static bool IsValidSpeedRatio (double ratio ) { return ratio > 0 && double .IsFinite(ratio); } public static string FormatPosition ( double totalSeconds, double progress ) { var normalized = ClampProgress(progress); return $"{totalSeconds * normalized:0.00 } / {totalSeconds:0.00 } 秒" ; } }
这里有一个容易误会的地方:
1 Pause 之后 CurrentState 仍然是 Active
ClockState 枚举只有 Active、Filling、Stopped。暂停状态由时钟控制器的状态和 CurrentTime 是否继续变化来体现,不要写成 ClockState.Paused。
1. 动画对象的启动时间与缓动效果 1.1 本节目标 完成本节后,你要能够:
使用 BeginTime 延迟动画启动。
区分“延迟”和“缓动”的作用范围。
给时间线添加缓动函数。
知道延迟动画在等待时不会提前改变目标值。
能解释为什么同样时长的动画看起来快慢不同。
1.2 BeginTime 和 EasingFunction 分别管什么
图 2:BeginTime 推迟开始,EasingFunction 改变开始后的进度。
先把两个概念分开:
1 2 BeginTime:动画什么时候开始 EasingFunction:动画开始后,进度怎样变化
例如:
1 2 3 BeginTime = 0.8 秒 Duration = 1.4 秒 EasingFunction = CubicEase(EaseOut)
时间顺序是:
1 2 3 4 5 0.0 秒:点击播放,创建时钟 0.0 到 0.8 秒:等待,目标值仍为 0 0.8 秒:动画开始 2.2 秒:动画结束 2.2 秒以后:HoldEnd 保持终点
所以动画的总观察时间不是只有 1.4 秒,而是:
1.3 为什么需要启动延迟 常见用途:
场景
延迟作用
提示条依次出现
让多个元素错开入场
抽屉打开后延迟淡入内容
先完成主要动作
用户输入停止后提示
做防抖式反馈
引导动画
先展示静态结构
延迟不是线程睡眠,也不会阻塞界面。BeginTime 只是时间线参数。
1.4 跟做:完成第 1 页 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 <Window.Resources > <SolidColorBrush x:Key ="InkBrush" Color ="#172033" /> <SolidColorBrush x:Key ="MutedBrush" Color ="#617089" /> <SolidColorBrush x:Key ="LineBrush" Color ="#DCE5F2" /> <Style TargetType ="TextBlock" > <Setter Property="Foreground" Value="{StaticResource InkBrush}" /> </Style > <Style x:Key ="PageTitle" TargetType ="TextBlock" > <Setter Property="FontSize" Value="22 " /> <Setter Property="FontWeight" Value="Bold" /> </Style > <Style x:Key ="SectionText" TargetType ="TextBlock" > <Setter Property="FontSize" Value="13 " /> <Setter Property="Foreground" Value="{StaticResource MutedBrush}" /> <Setter Property="LineHeight" Value="21 " /> <Setter Property="TextWrapping" Value="Wrap" /> </Style > <Style x:Key ="Card" TargetType ="Border" > <Setter Property="Background " Value="White" /> <Setter Property="BorderBrush" Value="{StaticResource LineBrush}" /> <Setter Property="BorderThickness" Value="1 " /> <Setter Property="CornerRadius" Value="10 " /> <Setter Property="Padding " Value="16 " /> </Style > <Style x:Key ="Stage" TargetType ="Border" > <Setter Property="Background " Value="#FBFCFE " /> <Setter Property="BorderBrush" Value="{StaticResource LineBrush}" /> <Setter Property="BorderThickness" Value="1 " /> <Setter Property="CornerRadius" Value="9 " /> </Style > <Style x:Key ="ActionButton" TargetType ="Button" > <Setter Property="Padding " Value="14 ,7 " /> <Setter Property="Margin " Value="0 ,0 ,9 ,8 " /> <Setter Property="Background " Value="#EAF1FF " /> <Setter Property="Foreground" Value="#173E7A " /> <Setter Property="BorderBrush" Value="#B9CBEA " /> <Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="Cursor " Value="Hand" /> </Style > <Style x:Key ="LogList" TargetType ="ListBox" > <Setter Property="Padding " Value="8 " /> <Setter Property="Background " Value="#0F172A " /> <Setter Property="Foreground" Value="#DCE7F8 " /> <Setter Property="BorderThickness" Value="0 " /> <Setter Property="FontFamily" Value="Consolas" /> <Setter Property="FontSize" Value="12 " /> </Style > <Style x:Key ="ProgressBarStyle" TargetType ="ProgressBar" > <Setter Property="Height " Value="8 " /> <Setter Property="Background " Value="#E5EBF5 " /> <Setter Property="Foreground" Value="#2456A6 " /> <Setter Property="BorderThickness" Value="0 " /> </Style > </Window.Resources >
再把第 1 个页签写成:
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 ="1 启动时间与缓动效果" > <Grid Margin ="22" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="420" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="先等,再走,还要走得自然" Style ="{StaticResource PageTitle}" /> <TextBlock Style ="{StaticResource SectionText}" Margin ="0,7,0,16" Text ="BeginTime 决定什么时候启动;缓动函数决定启动后怎样加速、减速。" /> <Border Style ="{StaticResource Card}" > <StackPanel > <TextBlock Text ="延迟 0.8 秒 + EaseOut" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Text ="{Binding DelayStatus}" Foreground ="#9B3030" FontWeight ="Bold" TextWrapping ="Wrap" /> </StackPanel > </Border > <WrapPanel Margin ="0,14,0,0" > <Button Style ="{StaticResource ActionButton}" Content ="播放" Click ="OnDelayPlayClick" /> <Button Style ="{StaticResource ActionButton}" Content ="立即开始" Click ="OnDelayImmediateClick" /> <Button Style ="{StaticResource ActionButton}" Content ="重置" Click ="OnDelayResetClick" /> </WrapPanel > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="180" /> </Grid.RowDefinitions > <Border Style ="{StaticResource Stage}" ClipToBounds ="True" > <Canvas Height ="420" > <Line X1 ="50" Y1 ="92" X2 ="740" Y2 ="92" Stroke ="#DCE5F2" StrokeThickness ="3" /> <Border x:Name ="DelayCard" Width ="138" Height ="70" Canvas.Left ="40" Canvas.Top ="140" Background ="#2456A6" CornerRadius ="12" > <Border.RenderTransform > <TranslateTransform x:Name ="DelayTransform" /> </Border.RenderTransform > <TextBlock Text ="DELAY" HorizontalAlignment ="Center" VerticalAlignment ="Center" Foreground ="White" FontWeight ="Bold" /> </Border > <Border Width ="138" Height ="70" Canvas.Left ="610" Canvas.Top ="140" BorderBrush ="#AFC2DF" BorderThickness ="1" CornerRadius ="12" > <TextBlock Text ="目标位置" HorizontalAlignment ="Center" VerticalAlignment ="Center" Foreground ="#617089" /> </Border > </Canvas > </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Style ="{StaticResource LogList}" ItemsSource ="{Binding DelayLog}" /> </Grid > </Grid > </TabItem >
1.5 跟做:创建延迟动画 在 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 private Storyboard? _delayStoryboard;private void BuildDelayAnimation (){ _delayStoryboard = new Storyboard { BeginTime = TimeSpan.FromSeconds(0.8 ) }; var animation = new DoubleAnimation { From = 0 , To = 520 , Duration = TimeSpan.FromSeconds(1.4 ), FillBehavior = FillBehavior.HoldEnd, EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }; Storyboard.SetTarget(animation, DelayTransform); Storyboard.SetTargetProperty( animation, new PropertyPath(TranslateTransform.XProperty)); _delayStoryboard.Children.Add(animation); }
在构造函数中调用:
1 2 3 4 5 6 7 8 public MainWindow (){ InitializeComponent(); ViewModel = new MainViewModel(); DataContext = ViewModel; BuildDelayAnimation(); }
1.6 跟做:播放、立即播放和重置 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 public void SimulateDelayPlay (){ _delayStoryboard?.Stop(this ); DelayTransform.X = 0 ; _delayStoryboard?.Begin(this , true ); ViewModel.DelayStatus = "已启动:等待 0.8 秒,再用 1.4 秒减速到达终点" ; ViewModel.AddDelay("Begin | BeginTime = 0.8s" ); ViewModel.AddDelay("Easing | CubicEase EaseOut" ); } public void SimulateDelayImmediate (){ _delayStoryboard?.Stop(this ); DelayTransform.X = 0 ; var animation = new DoubleAnimation { From = 0 , To = 520 , Duration = TimeSpan.FromSeconds(1.4 ), FillBehavior = FillBehavior.HoldEnd, EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }; DelayTransform.BeginAnimation( TranslateTransform.XProperty, animation); ViewModel.DelayStatus = "立即开始:BeginTime = 0,其他参数不变" ; ViewModel.AddDelay("Immediate | BeginTime = 0s" ); } public void SimulateDelayReset (){ _delayStoryboard?.Stop(this ); DelayTransform.BeginAnimation( TranslateTransform.XProperty, null ); DelayTransform.X = 0 ; ViewModel.DelayStatus = "已重置,卡片回到起点" ; ViewModel.AddDelay("Reset | X = 0" ); }
按钮事件:
1 2 3 4 5 6 7 8 private void OnDelayPlayClick (object sender, RoutedEventArgs e ) => SimulateDelayPlay(); private void OnDelayImmediateClick (object sender, RoutedEventArgs e ) => SimulateDelayImmediate(); private void OnDelayResetClick (object sender, RoutedEventArgs e ) => SimulateDelayReset();
1.7 运行截图
运行截图 1:卡片在延迟后向右移动,日志记录 BeginTime 与缓动模式。
1.8 跟做检查点
1.9 常见错误
现象
常见原因
修正方法
动画完全不开始
BeginTime 写成了 Duration
检查时间线参数
等待期间就移动
把延迟写进了关键帧
延迟放在外层时间线
延迟结束突然跳变
起点值没有固定
设置 From 或基础值
缓动看不出来
时长太短
增加到 1 秒以上观察
重复播放等待越来越长
每次创建新的延迟时钟
复用 Storyboard
立即播放仍等待
仍调用了延迟 Storyboard
使用独立的 BeginAnimation
1.10 小练习
把延迟改成 0.3 秒。
把缓动改成 BackEase。
用 BeginTime = 0 比较同一动画。
让目标位置改成 400。
在日志中记录开始时间、动画时长和结束时间。
2. 动画的事件监听 2.1 本节目标 完成本节后,你要能够:
订阅 CurrentTimeInvalidated。
订阅 CurrentStateInvalidated。
从 Clock 读取 CurrentTime、CurrentProgress、CurrentState。
区分进度事件和状态事件。
不在事件处理器中重复创建时钟。
2.2 两个事件分别报告什么
图 3:进度事件与状态事件的分工。
事件
何时触发
适合读取
CurrentTimeInvalidated
当前时间变化时
CurrentTime、CurrentProgress
CurrentStateInvalidated
时钟状态变化时
CurrentState
常见状态:
1 2 3 Active:时钟正在推进 Filling:时间线结束,但 FillBehavior 保持结尾值 Stopped:时钟已停止
暂停时,CurrentState 仍然是 Active,但 CurrentTime 不再推进。这是 WPF 动画时钟的重要特点。
2.3 为什么要监听事件 有些信息不能只靠最终属性值得到:
1 2 3 4 5 动画已经播放了多久? 当前进度是多少? 动画什么时候进入 Filling? 暂停后时间是否冻结? 停止后状态有没有变化?
监听时钟事件可以把这些信息实时显示出来。
2.4 跟做:完成第 2 页 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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 <TabItem Header ="2 动画的事件监听" > <Grid Margin ="22" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="420" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="时间线走到哪里,事件就报告什么" Style ="{StaticResource PageTitle}" /> <TextBlock Style ="{StaticResource SectionText}" Margin ="0,7,0,16" Text ="CurrentTimeInvalidated 适合读取进度;CurrentStateInvalidated 适合判断时钟状态。" /> <Border Style ="{StaticResource Card}" > <StackPanel > <TextBlock Text ="事件监听器" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Text ="{Binding EventStatus}" Foreground ="#9B3030" FontWeight ="Bold" TextWrapping ="Wrap" /> <TextBlock Margin ="0,10,0,0" Text ="{Binding EventState}" Foreground ="#617089" /> <ProgressBar Margin ="0,14,0,0" Style ="{StaticResource ProgressBarStyle}" Minimum ="0" Maximum ="1" Value ="{Binding EventProgress}" /> </StackPanel > </Border > <WrapPanel Margin ="0,14,0,0" > <Button Style ="{StaticResource ActionButton}" Content ="开始监听" Click ="OnEventStartClick" /> <Button Style ="{StaticResource ActionButton}" Content ="暂停" Click ="OnEventPauseClick" /> <Button Style ="{StaticResource ActionButton}" Content ="继续" Click ="OnEventResumeClick" /> <Button Style ="{StaticResource ActionButton}" Content ="停止" Click ="OnEventStopClick" /> </WrapPanel > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="180" /> </Grid.RowDefinitions > <Border Style ="{StaticResource Stage}" ClipToBounds ="True" > <Canvas Height ="420" > <Line X1 ="55" Y1 ="115" X2 ="740" Y2 ="115" Stroke ="#DCE5F2" StrokeThickness ="3" /> <Border x:Name ="EventCard" Width ="126" Height ="72" Canvas.Left ="40" Canvas.Top ="160" Background ="#176B4D" CornerRadius ="12" > <Border.RenderTransform > <TranslateTransform x:Name ="EventTransform" /> </Border.RenderTransform > <TextBlock Text ="监听进度" HorizontalAlignment ="Center" VerticalAlignment ="Center" Foreground ="White" FontWeight ="Bold" /> </Border > </Canvas > </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Style ="{StaticResource LogList}" ItemsSource ="{Binding EventLog}" /> </Grid > </Grid > </TabItem >
2.5 跟做:创建可控时钟 在 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 35 36 37 38 39 40 private AnimationClock? _eventClock;private ClockController? _eventController;public void SimulateEventStart (){ StopEventClock(); EventTransform.X = 0 ; ViewModel.EventProgress = 0 ; ViewModel.EventStatus = "正在监听动画时钟" ; ViewModel.EventState = "时钟已创建" ; var animation = new DoubleAnimation { From = 0 , To = 574 , Duration = TimeSpan.FromSeconds(3.2 ), FillBehavior = FillBehavior.HoldEnd, EasingFunction = new SineEase { EasingMode = EasingMode.EaseInOut } }; Storyboard.SetTarget(animation, EventTransform); Storyboard.SetTargetProperty( animation, new PropertyPath(TranslateTransform.XProperty)); var clock = animation.CreateClock(); clock.CurrentTimeInvalidated += OnEventCurrentTimeInvalidated; clock.CurrentStateInvalidated += OnEventCurrentStateInvalidated; EventTransform.ApplyAnimationClock( TranslateTransform.XProperty, clock); _eventClock = clock; _eventController = clock.Controller; _eventController.Begin(); }
这里不能直接使用 Storyboard.Begin,因为需要拿到同一个时钟的事件和控制器,所以使用:
1 2 3 CreateClock → ApplyAnimationClock → Controller.Begin
2.6 跟做:读取进度和状态 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 private void OnEventCurrentTimeInvalidated ( object ? sender, EventArgs e ){ if (sender is not Clock clock || clock.CurrentProgress is not { } progress) { return ; } ViewModel.EventProgress = AnimationRules.ClampProgress(progress); } private void OnEventCurrentStateInvalidated ( object ? sender, EventArgs e ){ if (sender is not Clock clock) { return ; } ViewModel.EventState = $"{clock.CurrentState} · {AnimationRules.DescribeState(clock.CurrentState)} " ; ViewModel.AddEvent( $"State | {clock.CurrentState} " ); }
CurrentProgress 可能是 null,例如时钟尚未开始或没有有效时长,因此先判断再使用。
2.7 跟做:暂停、继续和停止 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 public void SimulateEventPause (){ _eventController?.Pause(); ViewModel.EventStatus = "事件时钟已暂停" ; ViewModel.AddEvent("Pause | 当前时间冻结" ); } public void SimulateEventResume (){ _eventController?.Resume(); ViewModel.EventStatus = "事件时钟已继续" ; ViewModel.AddEvent("Resume | 从暂停位置继续" ); } public void SimulateEventStop (){ _eventController?.Stop(); ViewModel.EventStatus = "事件时钟已停止" ; ViewModel.AddEvent("Stop | 释放动画时钟" ); } private void StopEventClock (){ _eventController?.Stop(); _eventController = null ; _eventClock = null ; EventTransform.BeginAnimation( TranslateTransform.XProperty, null ); }
2.8 运行截图
运行截图 2:进度条显示约 54%,状态标签显示 Active。
2.9 跟做检查点
2.10 常见错误
现象
常见原因
修正方法
事件没有触发
没有保留时钟引用
保存 AnimationClock
进度一直为空
CurrentProgress 为 null
先判断再读取
状态文字写 Paused 编译失败
ClockState 没有 Paused
用控制器状态描述暂停
进度更新太频繁
每帧更新大量 UI
合并或降频显示
多次订阅后重复输出
重复创建时钟
开始前先停止旧时钟
停止后仍在更新
只停止动画,没有移除时钟
BeginAnimation(..., null)
2.11 小练习
把动画时长改成 5 秒。
在日志中记录 CurrentTime。
暂停时输出 CurrentTime,继续后再比较。
增加一个按钮,把进度条设为 50%。
观察 Filling 和 Stopped 的区别。
3. 动画的常规控制 3.1 本节目标 完成本节后,你要能够:
使用 Pause、Resume 控制时钟。
使用 Seek 跳转到指定时间。
使用 Stop 结束动画。
区分“停止时钟”和“重置属性”。
根据动画时长计算当前位置。
3.2 四种操作的关系
图 4:可控时钟的启动、暂停、继续、跳转与停止。
操作
作用
是否保留位置
Begin
启动时钟
从起点开始
Pause
冻结时钟
保留当前位置
Resume
继续时钟
从当前位置继续
Seek
跳到指定时间
直接改变当前位置
Stop
结束时钟
不保证属性自动回到基础值
如果动画总时长是 2.4 秒,进度是 0.5,当前位置就是:
3.3 Stop 为什么不等于 Reset 常见误解:
实际上:
1 2 Stop 结束的是时钟 控件属性可能仍然保留动画产生的当前值
所以重置通常要额外做:
1 2 3 4 ControlTransform.BeginAnimation( TranslateTransform.XProperty, null ); ControlTransform.X = 0 ;
3.4 跟做:完成第 3 页 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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 <TabItem Header ="3 动画的常规控制" > <Grid Margin ="22" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="420" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="把动画当作可控时钟" Style ="{StaticResource PageTitle}" /> <TextBlock Style ="{StaticResource SectionText}" Margin ="0,7,0,16" Text ="Begin 创建时钟,Pause 冻结,Resume 继续,Seek 跳转,Stop 结束。" /> <Border Style ="{StaticResource Card}" > <StackPanel > <TextBlock Text ="ClockController" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Text ="{Binding ControlStatus}" Foreground ="#9B3030" FontWeight ="Bold" TextWrapping ="Wrap" /> <TextBlock Margin ="0,10,0,0" Text ="{Binding ControlPosition}" Foreground ="#617089" /> <ProgressBar Margin ="0,14,0,0" Style ="{StaticResource ProgressBarStyle}" Minimum ="0" Maximum ="1" Value ="{Binding ControlProgress}" /> </StackPanel > </Border > <WrapPanel Margin ="0,14,0,0" > <Button Style ="{StaticResource ActionButton}" Content ="开始" Click ="OnControlStartClick" /> <Button Style ="{StaticResource ActionButton}" Content ="暂停" Click ="OnControlPauseClick" /> <Button Style ="{StaticResource ActionButton}" Content ="继续" Click ="OnControlResumeClick" /> <Button Style ="{StaticResource ActionButton}" Content ="跳到中点" Click ="OnControlSeekClick" /> <Button Style ="{StaticResource ActionButton}" Content ="停止" Click ="OnControlStopClick" /> </WrapPanel > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="180" /> </Grid.RowDefinitions > <Border Style ="{StaticResource Stage}" ClipToBounds ="True" > <Canvas Height ="420" > <Line X1 ="55" Y1 ="115" X2 ="740" Y2 ="115" Stroke ="#DCE5F2" StrokeThickness ="3" /> <Border x:Name ="ControlCard" Width ="126" Height ="72" Canvas.Left ="40" Canvas.Top ="160" Background ="#D97706" CornerRadius ="12" > <Border.RenderTransform > <TranslateTransform x:Name ="ControlTransform" /> </Border.RenderTransform > <TextBlock Text ="CLOCK" HorizontalAlignment ="Center" VerticalAlignment ="Center" Foreground ="White" FontWeight ="Bold" /> </Border > </Canvas > </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Style ="{StaticResource LogList}" ItemsSource ="{Binding ControlLog}" /> </Grid > </Grid > </TabItem >
3.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 private AnimationClock? _controlClock;private ClockController? _controlController;private const double ControlDurationSeconds = 2.4 ;public void SimulateControlStart (){ StopControlClock(); ControlTransform.X = 0 ; var animation = new DoubleAnimation { From = 0 , To = 574 , Duration = TimeSpan.FromSeconds(ControlDurationSeconds), FillBehavior = FillBehavior.HoldEnd, EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseInOut } }; Storyboard.SetTarget(animation, ControlTransform); Storyboard.SetTargetProperty( animation, new PropertyPath(TranslateTransform.XProperty)); var clock = animation.CreateClock(); clock.CurrentTimeInvalidated += OnControlCurrentTimeInvalidated; ControlTransform.ApplyAnimationClock( TranslateTransform.XProperty, clock); _controlClock = clock; _controlController = clock.Controller; _controlController.Begin(); ViewModel.ControlStatus = "开始:时钟从 0 秒播放到 2.4 秒" ; ViewModel.AddControl("Begin | Duration = 2.4s" ); }
3.6 跟做:暂停、继续、跳转和停止 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 public void SimulateControlPause (){ _controlController?.Pause(); ViewModel.ControlStatus = "暂停:时间冻结,位置保留" ; ViewModel.AddControl("Pause | 保留当前进度" ); } public void SimulateControlResume (){ _controlController?.Resume(); ViewModel.ControlStatus = "继续:从冻结位置向后播放" ; ViewModel.AddControl("Resume | 从当前进度继续" ); } public void SimulateControlSeek (){ _controlController?.Seek( TimeSpan.FromSeconds(1.2 ), TimeSeekOrigin.BeginTime); ViewModel.ControlStatus = "跳转:Seek 到 1.2 秒" ; ViewModel.AddControl("Seek | 1.2s / 2.4s" ); } public void SimulateControlStop (){ _controlController?.Stop(); ViewModel.ControlStatus = "停止:时钟结束,但基础值没有自动重置" ; ViewModel.AddControl("Stop | 需要另外重置 X" ); } private void StopControlClock (){ _controlController?.Stop(); _controlController = null ; _controlClock = null ; ControlTransform.BeginAnimation( TranslateTransform.XProperty, null ); }
3.7 跟做:把进度换算成时间 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private void OnControlCurrentTimeInvalidated ( object ? sender, EventArgs e ){ if (sender is not Clock clock || clock.CurrentProgress is not { } progress) { return ; } var value = AnimationRules.ClampProgress(progress); ViewModel.ControlProgress = value ; ViewModel.ControlPosition = AnimationRules.FormatPosition( ControlDurationSeconds, value ); }
FormatPosition 的计算:
3.8 运行截图
运行截图 3:暂停在 1.20 / 2.40 秒,进度条保持约一半。
3.9 跟做检查点
3.10 常见错误
现象
常见原因
修正方法
暂停后再开始从头播放
又调用了 Begin
暂停后使用 Resume
Seek 后没有更新
没有读取当前进度
订阅时间事件
Stop 后没有归零
把 Stop 当成了 Reset
额外恢复基础值
重复点击开始叠加
没有停止旧时钟
先 Stop 再 Begin
位置与进度不一致
属性值和动画时钟分开计算
统一由进度换算
暂停时状态文字显示 Paused
错误使用 ClockState
用控制器操作描述状态
3.11 小练习
把总时长改成 4 秒。
跳到 25% 和 75% 的位置。
暂停后点击 Seek,观察会发生什么。
在 Stop 后增加“重置”按钮。
在日志中记录 CurrentTime 和 CurrentProgress。
4. 触发器控制动画 4.1 本节目标 完成本节后,你要能够:
使用 Trigger 响应属性变化。
使用 EventTrigger 响应路由事件。
使用 MultiTrigger 组合多个条件。
理解 EnterActions 和 ExitActions。
知道触发器动画适合什么场景。
4.2 三种触发器
图 5:EventTrigger、Trigger 和 MultiTrigger 的适用范围。
触发器
触发条件
适合
EventTrigger
路由事件
点击、加载、鼠标事件
Trigger
单个属性条件
悬停、选中、状态变化
MultiTrigger
多个属性条件
同时满足多个条件的组合状态
4.3 Trigger 的进入和退出 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <Trigger Property ="IsMouseOver" Value ="True" > <Trigger.EnterActions > <BeginStoryboard > <Storyboard > <DoubleAnimation Storyboard.TargetProperty ="Opacity" To ="1" Duration ="0:0:0.22" /> </Storyboard > </BeginStoryboard > </Trigger.EnterActions > <Trigger.ExitActions > <BeginStoryboard > <Storyboard > <DoubleAnimation Storyboard.TargetProperty ="Opacity" To ="0.75" Duration ="0:0:0.22" /> </Storyboard > </BeginStoryboard > </Trigger.ExitActions > </Trigger >
执行过程:
1 2 鼠标进入 → EnterActions 鼠标离开 → ExitActions
如果只写进入动画,离开后不会自动反向恢复。
4.4 跟做:完成第 4 页 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 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 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 <TabItem Header ="4 触发器控制动画" > <Grid Margin ="22" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="420" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="让界面状态自己触发动画" Style ="{StaticResource PageTitle}" /> <TextBlock Style ="{StaticResource SectionText}" Margin ="0,7,0,16" Text ="EventTrigger 响应事件;Trigger 响应属性;MultiTrigger 组合多个条件。" /> <Border Style ="{StaticResource Card}" > <StackPanel > <TextBlock Text ="触发器对比" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Text ="{Binding TriggerStatus}" Foreground ="#9B3030" FontWeight ="Bold" TextWrapping ="Wrap" /> </StackPanel > </Border > <WrapPanel Margin ="0,14,0,0" > <Button Style ="{StaticResource ActionButton}" Content ="事件触发" Click ="OnTriggerEventClick" /> <Button Style ="{StaticResource ActionButton}" Content ="重置" Click ="OnTriggerResetClick" /> </WrapPanel > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="180" /> </Grid.RowDefinitions > <Border Style ="{StaticResource Stage}" ClipToBounds ="True" > <Grid > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="*" /> </Grid.RowDefinitions > <Border Margin ="40,34,40,18" Background ="#EAF1FF" BorderBrush ="#B9CBEA" BorderThickness ="1" CornerRadius ="12" > <Border.Style > <Style TargetType ="Border" > <Setter Property="Opacity " Value="0.75 " /> <Style.Triggers > <Trigger Property="IsMouseOver" Value="True"> <Trigger.EnterActions > <BeginStoryboard> <Storyboard> <DoubleAnimation Storyboard.TargetProperty ="Opacity " To ="1 " Duration="0 :0 :0.22 " /> </Storyboard> </BeginStoryboard> </Trigger.EnterActions> <Trigger.ExitActions> <BeginStoryboard> <Storyboard> <DoubleAnimation Storyboard.TargetProperty=" Opacity" To=" 0.75 " Duration=" 0 :0 :0.22 " /> </Storyboard> </BeginStoryboard> </Trigger.ExitActions> </Trigger> </Style.Triggers> </Style > </Border.Style > <TextBlock Text ="Trigger:鼠标进入" HorizontalAlignment ="Center" VerticalAlignment ="Center" FontSize ="19" FontWeight ="Bold" Foreground ="#2456A6" /> </Border > <Border x:Name ="EventTriggerCard" Grid.Row ="1" Margin ="40,18,40,34" Background ="#E8F6EF" BorderBrush ="#B9DDCB" BorderThickness ="1" CornerRadius ="12" > <Border.RenderTransform > <TranslateTransform x:Name ="TriggerTransform" /> </Border.RenderTransform > <TextBlock Text ="EventTrigger:按钮触发" HorizontalAlignment ="Center" VerticalAlignment ="Center" FontSize ="19" FontWeight ="Bold" Foreground ="#176B4D" /> </Border > </Grid > </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Style ="{StaticResource LogList}" ItemsSource ="{Binding TriggerLog}" /> </Grid > </Grid > </TabItem >
4.5 跟做:用代码执行 EventTrigger 动画 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 private Storyboard? _triggerStoryboard;private void BuildTriggerAnimation (){ _triggerStoryboard = new Storyboard(); var slide = new DoubleAnimation { From = -80 , To = 0 , Duration = TimeSpan.FromMilliseconds(420 ), EasingFunction = new BackEase { Amplitude = 0.2 , EasingMode = EasingMode.EaseOut } }; Storyboard.SetTarget(slide, TriggerTransform); Storyboard.SetTargetProperty( slide, new PropertyPath(TranslateTransform.XProperty)); var fade = new DoubleAnimation { From = 0 , To = 1 , Duration = TimeSpan.FromMilliseconds(320 ) }; Storyboard.SetTarget(fade, EventTriggerCard); Storyboard.SetTargetProperty( fade, new PropertyPath(UIElement.OpacityProperty)); _triggerStoryboard.Children.Add(slide); _triggerStoryboard.Children.Add(fade); }
1 2 3 4 5 6 7 8 9 10 public void SimulateTriggerEvent (){ _triggerStoryboard?.Stop(this ); TriggerTransform.X = 0 ; _triggerStoryboard?.Begin(this , true ); ViewModel.TriggerStatus = "EventTrigger 已执行:卡片从左侧滑入并淡入" ; ViewModel.AddTrigger("EventTrigger | Click 执行一次" ); }
4.6 跟做:MultiTrigger 示例 MultiTrigger 只有在所有条件都满足时才进入动作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <Style TargetType ="Button" > <Setter Property="Background " Value="#EAF1FF " /> <Style.Triggers > <MultiTrigger> <MultiTrigger.Conditions > <Condition Property="IsMouseOver" Value="True" /> <Condition Property="IsEnabled" Value="True" /> </MultiTrigger.Conditions > <Setter Property="Background " Value="#DCEBFF " /> <Setter Property="RenderTransform"> <Setter.Value > <ScaleTransform ScaleX="1.04 " ScaleY="1.04 " /> </Setter.Value > </Setter> </MultiTrigger> </Style.Triggers > </Style >
这里的条件含义:
如果按钮被禁用,即使鼠标悬停,也不会满足组合条件。
4.7 运行截图
运行截图 4:上方卡片演示 Trigger,下方卡片演示 EventTrigger。
4.8 跟做检查点
4.9 常见错误
现象
常见原因
修正方法
悬停动画只执行一次
只写了 EnterActions
补上 ExitActions
事件触发器不响应
事件名写错
检查 RoutedEvent
动画无法重复
没有重新开始 Storyboard
每次触发先停止旧动画
MultiTrigger 不生效
条件没有全部满足
检查每个属性值
触发器动画与代码控制冲突
两套动画同时改同一属性
统一入口
目标属性没有动画效果
属性不是依赖属性
检查目标属性类型
4.10 小练习
给 Trigger 增加宽度变化。
把 EventTrigger 改成鼠标双击事件。
给 MultiTrigger 增加 IsKeyboardFocused。
让事件动画执行两次。
比较触发器和 ClockController 的控制能力。
5. 视觉状态管理器控制动画 5.1 本节目标 完成本节后,你要能够:
创建 VisualStateGroup。
在一个状态组中定义多个互斥状态。
在 VisualState 中放 Storyboard。
使用 GoToState 或 GoToElementState 切换状态。
理解状态切换时的属性恢复。
5.2 状态组的结构
图 6:一个状态组中的多个互斥状态。
结构关系:
1 2 3 4 5 VisualStateManager └─ VisualStateGroup ├─ VisualState Normal ├─ VisualState Expanded └─ VisualState Disabled
同一状态组同一时刻只保留一个状态。状态切换时,WPF 会处理上一个状态留下的动画影响。
5.3 视觉状态和普通 Storyboard 的区别
普通 Storyboard
视觉状态
直接执行一组动画
描述某个状态的外观
需要自己管理多个动画冲突
状态组负责互斥切换
适合一次性动画
适合控件外观状态
代码通常调用 Begin
代码通常调用 GoToState
视觉状态不是“更高级的 Storyboard”,而是把多个 Storyboard 组织成状态模型。
5.4 跟做:完成第 5 页 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 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 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 <TabItem Header ="5 视觉状态管理器控制动画" > <Grid Margin ="22" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="420" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="一个控件,多套视觉状态" Style ="{StaticResource PageTitle}" /> <TextBlock Style ="{StaticResource SectionText}" Margin ="0,7,0,16" Text ="VisualStateManager 用状态组管理互斥外观。" /> <Border Style ="{StaticResource Card}" > <StackPanel > <TextBlock Text ="状态组:CommonStates" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Text ="{Binding StateStatus}" Foreground ="#9B3030" FontWeight ="Bold" /> </StackPanel > </Border > <WrapPanel Margin ="0,14,0,0" > <Button Style ="{StaticResource ActionButton}" Content ="Normal" Click ="OnStateNormalClick" /> <Button Style ="{StaticResource ActionButton}" Content ="Expanded" Click ="OnStateExpandedClick" /> <Button Style ="{StaticResource ActionButton}" Content ="Disabled" Click ="OnStateDisabledClick" /> </WrapPanel > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="180" /> </Grid.RowDefinitions > <Border Style ="{StaticResource Stage}" ClipToBounds ="True" > <Grid x:Name ="StateHost" Width ="410" Height ="280" HorizontalAlignment ="Center" VerticalAlignment ="Center" > <VisualStateManager.VisualStateGroups > <VisualStateGroup x:Name ="CommonStates" > <VisualState x:Name ="Normal" > <Storyboard > <DoubleAnimation Storyboard.TargetName ="StatePanel" Storyboard.TargetProperty ="Width" To ="300" Duration ="0:0:0.35" /> </Storyboard > </VisualState > <VisualState x:Name ="Expanded" > <Storyboard > <DoubleAnimation Storyboard.TargetName ="StatePanel" Storyboard.TargetProperty ="Width" To ="410" Duration ="0:0:0.45" > <DoubleAnimation.EasingFunction > <BackEase Amplitude ="0.25" EasingMode ="EaseOut" /> </DoubleAnimation.EasingFunction > </DoubleAnimation > </Storyboard > </VisualState > <VisualState x:Name ="Disabled" > <Storyboard > <DoubleAnimation Storyboard.TargetName ="StatePanel" Storyboard.TargetProperty ="Width" To ="260" Duration ="0:0:0.35" /> <DoubleAnimation Storyboard.TargetName ="StatePanel" Storyboard.TargetProperty ="Opacity" To ="0.55" Duration ="0:0:0.35" /> </Storyboard > </VisualState > </VisualStateGroup > </VisualStateManager.VisualStateGroups > <Border x:Name ="StatePanel" Width ="300" Height ="220" HorizontalAlignment ="Center" VerticalAlignment ="Center" Background ="#EAF1FF" BorderBrush ="#B9CBEA" BorderThickness ="1" CornerRadius ="18" > <TextBlock x:Name ="StateTitle" Text ="Normal" HorizontalAlignment ="Center" VerticalAlignment ="Center" FontSize ="24" FontWeight ="Bold" Foreground ="#2456A6" /> </Border > </Grid > </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Style ="{StaticResource LogList}" ItemsSource ="{Binding StateLog}" /> </Grid > </Grid > </TabItem >
5.5 跟做:切换状态 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public void SimulateState (string stateName ){ var changed = VisualStateManager.GoToElementState( StateHost, stateName, true ); if (!changed) { ViewModel.StateStatus = $"没有找到状态:{stateName} " ; return ; } StateTitle.Text = stateName; ViewModel.StateStatus = $"当前状态:{stateName} " ; ViewModel.AddState($"GoToState | {stateName} " ); }
按钮:
1 2 3 4 5 6 7 8 private void OnStateNormalClick (object sender, RoutedEventArgs e ) => SimulateState("Normal" ); private void OnStateExpandedClick (object sender, RoutedEventArgs e ) => SimulateState("Expanded" ); private void OnStateDisabledClick (object sender, RoutedEventArgs e ) => SimulateState("Disabled" );
为什么这里用 GoToElementState?
1 2 状态组定义在 StateHost 上 所以状态切换从 StateHost 开始查找
如果状态组定义在控件模板中,通常使用 GoToState。不要死记函数名,先看状态组属于哪个元素。
5.6 为什么切换到 Disabled 后透明度会恢复 假设状态顺序是:
Disabled 把透明度动画到 0.55,Normal 没有显式设置透明度。WPF 切换状态时会移除旧状态动画的影响,因此透明度会回到状态切换前的基础值。
这就是视觉状态适合控件外观的原因:
1 2 每个状态描述完整外观 切换时由 WPF 处理状态之间的差异
5.7 运行截图
运行截图 5:Expanded 状态宽度扩大,背景变为绿色。
5.8 跟做检查点
5.9 常见错误
现象
常见原因
修正方法
找不到状态
状态组不在查找范围
检查起始元素
状态名拼错
字符串与 x:Name 不一致
统一命名
状态切换没有动画
VisualState 没有 Storyboard
添加动画
动画不恢复
状态没有完整描述外观
检查基础值和状态值
目标名找不到
TargetName 写错
检查元素名称
把状态当成永久开关
状态组是互斥模型
用状态而不是布尔值
5.10 小练习
增加一个 Hover 状态。
让 Expanded 状态改变圆角。
让 Disabled 状态改变文字颜色。
用 ComboBox 选择状态。
比较视觉状态与普通触发器动画的差异。
6. 抽屉菜单与等待动画案例 6.1 本节目标 完成本节后,你要能够:
用位移和透明度制作抽屉菜单。
用遮罩表达内容层级。
用旋转和透明度制作等待动画。
让两个动画可以独立启停。
避免重复播放时叠加多个时钟。
6.2 案例结构
图 7:抽屉菜单和等待动画分别控制位移、遮罩、旋转与透明度。
抽屉菜单:
1 2 3 DrawerTransform.X:-190 → 0 DrawerOverlay.Opacity:0 → 0.35 关闭时反向执行
等待动画:
1 2 3 RotateTransform.Angle:0 → 360,永久重复 Spinner.Opacity:0.45 ↔ 1,自动反向 停止时移除动画时钟
6.3 跟做:完成第 6 页 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 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 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 <TabItem Header ="6 抽屉菜单与等待动画案例" > <Grid Margin ="22" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="420" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="把动画放进真实交互" Style ="{StaticResource PageTitle}" /> <TextBlock Style ="{StaticResource SectionText}" Margin ="0,7,0,16" Text ="抽屉关注位移与遮罩;等待动画关注旋转和透明度。" /> <Border Style ="{StaticResource Card}" > <StackPanel > <TextBlock Text ="综合案例" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" Text ="{Binding CaseStatus}" Foreground ="#9B3030" FontWeight ="Bold" TextWrapping ="Wrap" /> </StackPanel > </Border > <WrapPanel Margin ="0,14,0,0" > <Button Style ="{StaticResource ActionButton}" Content ="打开抽屉" Click ="OnDrawerOpenClick" /> <Button Style ="{StaticResource ActionButton}" Content ="关闭抽屉" Click ="OnDrawerCloseClick" /> <Button Style ="{StaticResource ActionButton}" Content ="开始等待" Click ="OnWaitingStartClick" /> <Button Style ="{StaticResource ActionButton}" Content ="停止等待" Click ="OnWaitingStopClick" /> </WrapPanel > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="180" /> </Grid.RowDefinitions > <Border Style ="{StaticResource Stage}" ClipToBounds ="True" > <Grid x:Name ="DrawerStage" Margin ="24" ClipToBounds ="True" > <Border Background ="#F8FAFE" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="12" /> <StackPanel Margin ="190,40,40,30" > <TextBlock Text ="内容区域" FontSize ="20" FontWeight ="Bold" /> <Border Margin ="0,26,0,0" Padding ="18" Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="10" > <StackPanel Orientation ="Horizontal" > <Grid x:Name ="WaitingSpinner" Width ="34" Height ="34" RenderTransformOrigin ="0.5,0.5" > <Grid.RenderTransform > <RotateTransform x:Name ="WaitingRotate" /> </Grid.RenderTransform > <Ellipse Stroke ="#DCE5F2" StrokeThickness ="5" /> <Path Data ="M 17,2 A 15,15 0 0 1 32,17" Stroke ="#2456A6" StrokeThickness ="5" /> </Grid > <TextBlock Margin ="14,0,0,0" VerticalAlignment ="Center" Text ="加载中..." /> </StackPanel > </Border > </StackPanel > <Border x:Name ="DrawerOverlay" Background ="#14213A" Opacity ="0" IsHitTestVisible ="False" /> <Border x:Name ="DrawerPanel" Width ="190" HorizontalAlignment ="Left" Background ="#172033" CornerRadius ="12,0,0,12" > <Border.RenderTransform > <TranslateTransform x:Name ="DrawerTransform" X ="-190" /> </Border.RenderTransform > <StackPanel Margin ="24,34,24,20" > <TextBlock Text ="菜单" FontSize ="22" FontWeight ="Bold" Foreground ="White" /> <TextBlock Margin ="0,16,0,0" Text ="概览" Foreground ="#DCE7F8" /> <TextBlock Margin ="0,16,0,0" Text ="动画示例" Foreground ="#DCE7F8" /> <TextBlock Margin ="0,16,0,0" Text ="设置" Foreground ="#DCE7F8" /> </StackPanel > </Border > </Grid > </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Style ="{StaticResource LogList}" ItemsSource ="{Binding CaseLog}" /> </Grid > </Grid > </TabItem >
6.4 跟做:打开抽屉 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 private const double DrawerWidth = 190 ;private Storyboard? _drawerStoryboard;private void BuildDrawerAnimation (){ _drawerStoryboard = new Storyboard(); var slide = new DoubleAnimation { To = 0 , Duration = TimeSpan.FromMilliseconds(360 ), EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }; Storyboard.SetTarget(slide, DrawerTransform); Storyboard.SetTargetProperty( slide, new PropertyPath(TranslateTransform.XProperty)); var overlay = new DoubleAnimation { To = 0.35 , Duration = TimeSpan.FromMilliseconds(300 ) }; Storyboard.SetTarget(overlay, DrawerOverlay); Storyboard.SetTargetProperty( overlay, new PropertyPath(UIElement.OpacityProperty)); _drawerStoryboard.Children.Add(slide); _drawerStoryboard.Children.Add(overlay); } public void SimulateDrawerOpen (){ _drawerStoryboard?.Stop(this ); _drawerStoryboard?.Begin(this , true ); ViewModel.CaseStatus = "抽屉已打开:菜单滑入,内容区域出现遮罩" ; ViewModel.AddCase("Drawer | Open" ); }
6.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 public void SimulateDrawerClose (){ _drawerStoryboard?.Stop(this ); var close = new Storyboard(); var slide = new DoubleAnimation { To = -DrawerWidth, Duration = TimeSpan.FromMilliseconds(280 ), EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn } }; Storyboard.SetTarget(slide, DrawerTransform); Storyboard.SetTargetProperty( slide, new PropertyPath(TranslateTransform.XProperty)); var fade = new DoubleAnimation { To = 0 , Duration = TimeSpan.FromMilliseconds(220 ) }; Storyboard.SetTarget(fade, DrawerOverlay); Storyboard.SetTargetProperty( fade, new PropertyPath(UIElement.OpacityProperty)); close.Children.Add(slide); close.Children.Add(fade); close.Begin(this , true ); ViewModel.CaseStatus = "抽屉正在关闭" ; ViewModel.AddCase("Drawer | Closing" ); }
6.6 跟做:等待动画 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 private Storyboard? _waitingStoryboard;private void BuildWaitingAnimation (){ _waitingStoryboard = new Storyboard(); var spin = new DoubleAnimation { From = 0 , To = 360 , Duration = TimeSpan.FromMilliseconds(900 ), RepeatBehavior = RepeatBehavior.Forever }; Storyboard.SetTarget(spin, WaitingRotate); Storyboard.SetTargetProperty( spin, new PropertyPath(RotateTransform.AngleProperty)); var breathe = new DoubleAnimation { From = 0.45 , To = 1 , Duration = TimeSpan.FromMilliseconds(650 ), AutoReverse = true , RepeatBehavior = RepeatBehavior.Forever }; Storyboard.SetTarget(breathe, WaitingSpinner); Storyboard.SetTargetProperty( breathe, new PropertyPath(UIElement.OpacityProperty)); _waitingStoryboard.Children.Add(spin); _waitingStoryboard.Children.Add(breathe); } public void SimulateWaitingStart (){ _waitingStoryboard?.Stop(this ); WaitingRotate.Angle = 0 ; _waitingStoryboard?.Begin(this , true ); ViewModel.CaseStatus = "等待动画正在运行:旋转 + 呼吸透明度" ; ViewModel.AddCase("Waiting | Start" ); } public void SimulateWaitingStop (){ _waitingStoryboard?.Stop(this ); WaitingRotate.Angle = 0 ; ViewModel.CaseStatus = "等待动画已停止" ; ViewModel.AddCase("Waiting | Stop" ); } private void OnWindowClosed ( object ? sender, EventArgs e ){ _waitingStoryboard?.Stop(this ); _drawerStoryboard?.Stop(this ); }
6.7 为什么要停止等待动画 等待动画使用 RepeatBehavior.Forever,如果不停止,会一直占用动画时钟:
1 2 3 4 页面关闭后仍在重复 重复点击开始产生多个时钟 旋转速度和透明度互相叠加 后台资源无法及时释放
正确做法:
1 2 3 开始前先停止旧动画 离开页面时停止动画 必要时移除动画时钟
6.8 运行截图
运行截图 6:抽屉滑入并出现遮罩,等待动画持续旋转。
6.9 跟做检查点
6.10 常见错误
现象
常见原因
修正方法
抽屉关闭后仍能点到菜单
只移动了视觉,没有处理命中测试
关闭后设置不可见或禁用
遮罩挡住所有点击
遮罩可命中测试
设置 IsHitTestVisible=False
等待动画越转越快
每次开始都创建新时钟
先停止旧 Storyboard
关闭后仍在运行
Forever 动画没有停止
页面退出时停止
抽屉关闭方向错误
只复用了打开动画
单独创建关闭动画
透明度没有恢复
停止后没有恢复基础值
重置属性
6.11 小练习
把抽屉宽度改成 240。
增加抽屉打开后的内容延迟淡入。
给等待动画增加文字呼吸效果。
在页面关闭事件中停止等待动画。
把遮罩透明度改成 0.5。
六个主题放在一起比较 它们分别解决什么问题
主题
解决问题
关键对象
启动时间与缓动
什么时候开始、怎样变化
BeginTime、EasingFunction
事件监听
动画进行到哪里
Clock、事件
常规控制
暂停、继续、跳转、停止
ClockController
触发器
由属性或事件自动执行
Trigger、EventTrigger
视觉状态
管理互斥外观
VisualStateManager
综合案例
把动画接入真实交互
Storyboard + 状态 + 资源清理
选择顺序 1 2 3 4 5 6 先确定动画何时开始 再确定是否需要监听进度 再确定是否需要暂停、继续或跳转 短小反馈优先考虑触发器 控件外观优先考虑视觉状态 复杂交互动画由代码统一管理时钟
完整项目结构 跟做项目可以整理成:
1 2 3 4 5 6 7 8 9 WpfAnimationControlPractice ├─ App.xaml ├─ App.xaml.cs ├─ MainWindow.xaml ├─ MainWindow.xaml.cs ├─ AnimationRules.cs └─ ViewModels ├─ ObservableObject.cs └─ MainViewModel.cs
随附完整示例:
1 2 3 4 5 6 7 8 9 WpfAnimationControlDemo ├─ SelfTestRunner.cs ├─ UiProbe.cs ├─ AnimationRules.cs ├─ Models │ └─ AppOptions.cs └─ ViewModels ├─ ObservableObject.cs └─ MainViewModel.cs
自动测试 1 2 3 4 $exe = "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAnimationControlDemo\bin\Debug\net9.0-windows\WpfAnimationControlDemo.exe" $process = Start-Process -FilePath $exe -ArgumentList "--self-test" -PassThru -Wait $process .ExitCodeGet-Content "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAnimationControlDemo\bin\Debug\net9.0-windows\self-test-results.txt" -Encoding UTF8
检查内容:
1 2 3 4 5 6 7 8 BeginTime 设置 缓动函数类型 控制动画时长 抽屉宽度 等待动画数量 进度裁剪 状态名称 重置、视觉状态、抽屉和等待动画
常见错误速查 启动时间与缓动
问题
优先检查
动画不开始
BeginTime 和 Duration 是否写反
延迟没有效果
是否直接调用了 BeginAnimation
缓动不明显
动画时长是否太短
重复播放叠加
是否停止旧时钟
等待期间跳变
基础值是否稳定
事件监听
问题
优先检查
事件不触发
时钟引用是否保留
进度为 null
是否判断 CurrentProgress
状态显示错误
是否误解 Pause 与 ClockState
重复输出
是否重复订阅事件
停止后仍更新
是否移除动画时钟
常规控制
问题
优先检查
暂停后从头开始
是否又调用了 Begin
Seek 无效
是否使用 ClockController
Stop 不归零
是否额外重置属性
多次点击叠加
是否先停止旧时钟
位置计算错误
总时长和进度是否分开
触发器
问题
优先检查
进入有动画、离开没恢复
是否缺少 ExitActions
EventTrigger 不响应
RoutedEvent 是否正确
MultiTrigger 不生效
条件是否全部满足
动画冲突
是否有代码同时修改属性
无法重复
是否停止旧 Storyboard
视觉状态
问题
优先检查
状态找不到
起始元素和状态组位置
动画不执行
VisualState 是否有 Storyboard
TargetName 无效
元素名是否正确
切换不恢复
基础值与状态值
状态切换混乱
是否把不同状态组混用
综合案例
问题
优先检查
抽屉打开后仍能点击底层
遮罩命中测试
等待动画越转越快
是否重复创建 Forever 动画
页面关闭后还在运行
是否在退出时停止动画
关闭方向不对
是否复用了打开动画
动画停止后位置不恢复
是否重置基础属性
综合练习 练习一:延迟入场 要求:
三张卡片依次延迟 0.2、0.4、0.6 秒。
每张卡片从右侧进入。
使用 CubicEase EaseOut。
进入完成后保持位置。
重置后全部回到起点。
练习二:动画状态面板 要求:
显示动画进度。
显示 CurrentState。
暂停后进度冻结。
继续后从原位置恢复。
停止后释放时钟。
练习三:触发器卡片 要求:
鼠标悬停改变透明度和阴影。
点击按钮触发一次滑入。
使用 MultiTrigger 组合悬停和可用状态。
重复点击不会叠加。
重置后恢复默认外观。
练习四:视觉状态按钮 要求:
定义 Normal、Hover、Pressed、Disabled 四个状态。
状态切换有平滑动画。
状态组只保留一个状态。
切换后日志记录状态名。
退出后没有残留动画。
练习五:抽屉与加载 要求:
抽屉滑入时遮罩同步淡入。
抽屉关闭时反向执行。
等待动画持续旋转。
停止等待时角度归零。
页面关闭时停止所有动画。
结语 这一篇的主线是“控制”:
1 2 3 4 5 6 BeginTime 控制什么时候开始 事件监听 控制你能看到什么 ClockController 控制时钟怎样运行 触发器 让界面状态自己驱动动画 视觉状态 管理互斥外观 综合案例 把控制放进真实交互
每次写动画,可以按下面顺序检查:
1 2 3 4 5 6 7 动画什么时候开始? 动画运行中需要显示进度吗? 需要暂停、继续或跳转吗? 这个反馈适合触发器还是代码控制? 这是控件外观状态吗? 重复播放时会不会叠加时钟? 停止后属性和资源是否恢复?
把这些问题回答清楚,动画就不只是“能动”,而是可以稳定地接入真实界面。