面向已经会使用 DoubleAnimation、Storyboard 和关键帧动画,准备把动画接入真实交互的读者。
推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,本文涉及的 API 在 .NET 9 与 .NET 10 中没有变化。
最后更新:2026-10-22

目录

  1. 动画对象的启动时间与缓动效果
  2. 动画的事件监听
  3. 动画的常规控制
  4. 触发器控制动画
  5. 视觉状态管理器控制动画
  6. 抽屉菜单与等待动画案例

写在前面

这份笔记解决什么问题

前面的笔记已经完成:

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. 第 1 章先比较“延迟播放”和“立即播放”。
  3. 第 2 章观察进度条和状态文字是否同步。
  4. 第 3 章依次点击开始、暂停、继续、跳转、停止。
  5. 第 4 章先悬停 Trigger,再点击 EventTrigger。
  6. 第 5 章切换三个视觉状态,观察哪些属性会恢复。
  7. 第 6 章反复开关抽屉、启停等待动画,检查资源释放。

随附示例位置

1
C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAnimationControlDemo

运行:

1
2
3
cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAnimationControlDemo"
dotnet build
dotnet run

Visual Studio 中:

  1. 打开 WpfAnimationControlDemo.csproj。
  2. 确认启动项目是 WpfAnimationControlDemo。
  3. 按 F5。
  4. 六个页签依次操作按钮。
  5. 观察动画和右侧日志是否同步。

创建自己的跟做项目

1
2
3
4
cd "C:\Users\North\Desktop\WPF学习笔记\示例代码"
dotnet new wpf -n WpfAnimationControlPractice -o .\WpfAnimationControlPractice
cd .\WpfAnimationControlPractice
dotnet 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 本节目标

完成本节后,你要能够:

  1. 使用 BeginTime 延迟动画启动。
  2. 区分“延迟”和“缓动”的作用范围。
  3. 给时间线添加缓动函数。
  4. 知道延迟动画在等待时不会提前改变目标值。
  5. 能解释为什么同样时长的动画看起来快慢不同。

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
0.8 秒等待 + 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 跟做检查点

  • 点击“播放”后,先等待约 0.8 秒。
  • 等待期间卡片位置不变。
  • 开始移动后,结尾明显减速。
  • 点击“立即开始”后,没有等待时间。
  • 重置后回到起点。
  • 能说出 BeginTime 与 EasingFunction 的差别。

1.9 常见错误

现象 常见原因 修正方法
动画完全不开始 BeginTime 写成了 Duration 检查时间线参数
等待期间就移动 把延迟写进了关键帧 延迟放在外层时间线
延迟结束突然跳变 起点值没有固定 设置 From 或基础值
缓动看不出来 时长太短 增加到 1 秒以上观察
重复播放等待越来越长 每次创建新的延迟时钟 复用 Storyboard
立即播放仍等待 仍调用了延迟 Storyboard 使用独立的 BeginAnimation

1.10 小练习

  1. 把延迟改成 0.3 秒。
  2. 把缓动改成 BackEase。
  3. 用 BeginTime = 0 比较同一动画。
  4. 让目标位置改成 400。
  5. 在日志中记录开始时间、动画时长和结束时间。

2. 动画的事件监听

2.1 本节目标

完成本节后,你要能够:

  1. 订阅 CurrentTimeInvalidated。
  2. 订阅 CurrentStateInvalidated。
  3. 从 Clock 读取 CurrentTime、CurrentProgress、CurrentState。
  4. 区分进度事件和状态事件。
  5. 不在事件处理器中重复创建时钟。

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 跟做检查点

  • 点击开始后进度条逐渐增长。
  • 暂停后进度条数值不再变化。
  • 继续后从暂停位置继续。
  • 状态文字能显示 Active 和 Filling。
  • 停止后不再更新进度。
  • 知道暂停时 ClockState 仍可能是 Active。

2.10 常见错误

现象 常见原因 修正方法
事件没有触发 没有保留时钟引用 保存 AnimationClock
进度一直为空 CurrentProgress 为 null 先判断再读取
状态文字写 Paused 编译失败 ClockState 没有 Paused 用控制器状态描述暂停
进度更新太频繁 每帧更新大量 UI 合并或降频显示
多次订阅后重复输出 重复创建时钟 开始前先停止旧时钟
停止后仍在更新 只停止动画,没有移除时钟 BeginAnimation(..., null)

2.11 小练习

  1. 把动画时长改成 5 秒。
  2. 在日志中记录 CurrentTime。
  3. 暂停时输出 CurrentTime,继续后再比较。
  4. 增加一个按钮,把进度条设为 50%。
  5. 观察 Filling 和 Stopped 的区别。

3. 动画的常规控制

3.1 本节目标

完成本节后,你要能够:

  1. 使用 Pause、Resume 控制时钟。
  2. 使用 Seek 跳转到指定时间。
  3. 使用 Stop 结束动画。
  4. 区分“停止时钟”和“重置属性”。
  5. 根据动画时长计算当前位置。

3.2 四种操作的关系

动画常规控制

图 4:可控时钟的启动、暂停、继续、跳转与停止。

操作 作用 是否保留位置
Begin 启动时钟 从起点开始
Pause 冻结时钟 保留当前位置
Resume 继续时钟 从当前位置继续
Seek 跳到指定时间 直接改变当前位置
Stop 结束时钟 不保证属性自动回到基础值

如果动画总时长是 2.4 秒,进度是 0.5,当前位置就是:

1
2.4 × 0.5 = 1.2 秒

3.3 Stop 为什么不等于 Reset

常见误解:

1
Stop 后控件应该回到起点

实际上:

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 的计算:

1
当前位置 = 总时长 × 当前进度

3.8 运行截图

动画常规控制页签

运行截图 3:暂停在 1.20 / 2.40 秒,进度条保持约一半。

3.9 跟做检查点

  • 开始后进度条向前推进。
  • 暂停后时间停止。
  • 继续后从暂停位置继续。
  • Seek 到中点后位置跳到约一半。
  • Stop 后时钟结束。
  • 能说出 Stop 与 Reset 的差别。

3.10 常见错误

现象 常见原因 修正方法
暂停后再开始从头播放 又调用了 Begin 暂停后使用 Resume
Seek 后没有更新 没有读取当前进度 订阅时间事件
Stop 后没有归零 把 Stop 当成了 Reset 额外恢复基础值
重复点击开始叠加 没有停止旧时钟 先 Stop 再 Begin
位置与进度不一致 属性值和动画时钟分开计算 统一由进度换算
暂停时状态文字显示 Paused 错误使用 ClockState 用控制器操作描述状态

3.11 小练习

  1. 把总时长改成 4 秒。
  2. 跳到 25% 和 75% 的位置。
  3. 暂停后点击 Seek,观察会发生什么。
  4. 在 Stop 后增加“重置”按钮。
  5. 在日志中记录 CurrentTime 和 CurrentProgress。

4. 触发器控制动画

4.1 本节目标

完成本节后,你要能够:

  1. 使用 Trigger 响应属性变化。
  2. 使用 EventTrigger 响应路由事件。
  3. 使用 MultiTrigger 组合多个条件。
  4. 理解 EnterActions 和 ExitActions。
  5. 知道触发器动画适合什么场景。

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>

这里的条件含义:

1
2
3
鼠标在按钮上
并且
按钮可用

如果按钮被禁用,即使鼠标悬停,也不会满足组合条件。

4.7 运行截图

触发器控制页签

运行截图 4:上方卡片演示 Trigger,下方卡片演示 EventTrigger。

4.8 跟做检查点

  • 鼠标进入上方卡片后透明度变化。
  • 鼠标离开后透明度恢复。
  • 点击“事件触发”后下方卡片滑入。
  • 事件触发器能重复执行。
  • MultiTrigger 只有两个条件同时满足才生效。
  • 能区分 EnterActions 和 ExitActions。

4.9 常见错误

现象 常见原因 修正方法
悬停动画只执行一次 只写了 EnterActions 补上 ExitActions
事件触发器不响应 事件名写错 检查 RoutedEvent
动画无法重复 没有重新开始 Storyboard 每次触发先停止旧动画
MultiTrigger 不生效 条件没有全部满足 检查每个属性值
触发器动画与代码控制冲突 两套动画同时改同一属性 统一入口
目标属性没有动画效果 属性不是依赖属性 检查目标属性类型

4.10 小练习

  1. 给 Trigger 增加宽度变化。
  2. 把 EventTrigger 改成鼠标双击事件。
  3. 给 MultiTrigger 增加 IsKeyboardFocused。
  4. 让事件动画执行两次。
  5. 比较触发器和 ClockController 的控制能力。

5. 视觉状态管理器控制动画

5.1 本节目标

完成本节后,你要能够:

  1. 创建 VisualStateGroup。
  2. 在一个状态组中定义多个互斥状态。
  3. 在 VisualState 中放 Storyboard。
  4. 使用 GoToState 或 GoToElementState 切换状态。
  5. 理解状态切换时的属性恢复。

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 后透明度会恢复

假设状态顺序是:

1
Disabled → Normal

Disabled 把透明度动画到 0.55,Normal 没有显式设置透明度。WPF 切换状态时会移除旧状态动画的影响,因此透明度会回到状态切换前的基础值。

这就是视觉状态适合控件外观的原因:

1
2
每个状态描述完整外观
切换时由 WPF 处理状态之间的差异

5.7 运行截图

视觉状态页签

运行截图 5:Expanded 状态宽度扩大,背景变为绿色。

5.8 跟做检查点

  • 点击 Normal 后宽度恢复。
  • 点击 Expanded 后有弹性扩展效果。
  • 点击 Disabled 后宽度缩小、透明度降低。
  • 任意时刻只有一个状态生效。
  • 状态日志显示切换名称。
  • 知道 GoToState 和 GoToElementState 的差别。

5.9 常见错误

现象 常见原因 修正方法
找不到状态 状态组不在查找范围 检查起始元素
状态名拼错 字符串与 x:Name 不一致 统一命名
状态切换没有动画 VisualState 没有 Storyboard 添加动画
动画不恢复 状态没有完整描述外观 检查基础值和状态值
目标名找不到 TargetName 写错 检查元素名称
把状态当成永久开关 状态组是互斥模型 用状态而不是布尔值

5.10 小练习

  1. 增加一个 Hover 状态。
  2. 让 Expanded 状态改变圆角。
  3. 让 Disabled 状态改变文字颜色。
  4. 用 ComboBox 选择状态。
  5. 比较视觉状态与普通触发器动画的差异。

6. 抽屉菜单与等待动画案例

6.1 本节目标

完成本节后,你要能够:

  1. 用位移和透明度制作抽屉菜单。
  2. 用遮罩表达内容层级。
  3. 用旋转和透明度制作等待动画。
  4. 让两个动画可以独立启停。
  5. 避免重复播放时叠加多个时钟。

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 跟做检查点

  • 打开抽屉时菜单从左侧滑入。
  • 遮罩透明度从 0 增加到约 0.35。
  • 关闭抽屉时方向和打开相反。
  • 等待动画持续旋转。
  • 停止等待后旋转停下。
  • 重复打开、关闭不会叠加动画。

6.10 常见错误

现象 常见原因 修正方法
抽屉关闭后仍能点到菜单 只移动了视觉,没有处理命中测试 关闭后设置不可见或禁用
遮罩挡住所有点击 遮罩可命中测试 设置 IsHitTestVisible=False
等待动画越转越快 每次开始都创建新时钟 先停止旧 Storyboard
关闭后仍在运行 Forever 动画没有停止 页面退出时停止
抽屉关闭方向错误 只复用了打开动画 单独创建关闭动画
透明度没有恢复 停止后没有恢复基础值 重置属性

6.11 小练习

  1. 把抽屉宽度改成 240。
  2. 增加抽屉打开后的内容延迟淡入。
  3. 给等待动画增加文字呼吸效果。
  4. 在页面关闭事件中停止等待动画。
  5. 把遮罩透明度改成 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.ExitCode
Get-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 动画
页面关闭后还在运行 是否在退出时停止动画
关闭方向不对 是否复用了打开动画
动画停止后位置不恢复 是否重置基础属性

综合练习

练习一:延迟入场

要求:

  1. 三张卡片依次延迟 0.2、0.4、0.6 秒。
  2. 每张卡片从右侧进入。
  3. 使用 CubicEase EaseOut。
  4. 进入完成后保持位置。
  5. 重置后全部回到起点。

练习二:动画状态面板

要求:

  1. 显示动画进度。
  2. 显示 CurrentState。
  3. 暂停后进度冻结。
  4. 继续后从原位置恢复。
  5. 停止后释放时钟。

练习三:触发器卡片

要求:

  1. 鼠标悬停改变透明度和阴影。
  2. 点击按钮触发一次滑入。
  3. 使用 MultiTrigger 组合悬停和可用状态。
  4. 重复点击不会叠加。
  5. 重置后恢复默认外观。

练习四:视觉状态按钮

要求:

  1. 定义 Normal、Hover、Pressed、Disabled 四个状态。
  2. 状态切换有平滑动画。
  3. 状态组只保留一个状态。
  4. 切换后日志记录状态名。
  5. 退出后没有残留动画。

练习五:抽屉与加载

要求:

  1. 抽屉滑入时遮罩同步淡入。
  2. 抽屉关闭时反向执行。
  3. 等待动画持续旋转。
  4. 停止等待时角度归零。
  5. 页面关闭时停止所有动画。

结语

这一篇的主线是“控制”:

1
2
3
4
5
6
BeginTime 控制什么时候开始
事件监听 控制你能看到什么
ClockController 控制时钟怎样运行
触发器 让界面状态自己驱动动画
视觉状态 管理互斥外观
综合案例 把控制放进真实交互

每次写动画,可以按下面顺序检查:

1
2
3
4
5
6
7
动画什么时候开始?
动画运行中需要显示进度吗?
需要暂停、继续或跳转吗?
这个反馈适合触发器还是代码控制?
这是控件外观状态吗?
重复播放时会不会叠加时钟?
停止后属性和资源是否恢复?

把这些问题回答清楚,动画就不只是“能动”,而是可以稳定地接入真实界面。