面向已经会使用 WPF 基础控件和简单数据绑定,准备系统学习动画、位移、颜色变化与关键帧的读者。
推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,本文涉及的 API 在 .NET 9 与 .NET 10 中没有变化。
最后更新:2026-10-22

目录

  1. 简单动画使用小结
  2. 位移动画处理
  3. 颜色与显示变更动画处理
  4. 线性关键帧动画基本使用
  5. 离散关键帧动画基本使用
  6. 样条关键帧动画基本使用

写在前面

这份笔记要解决什么问题

上一阶段已经理解了 WPF 动画的基本概念。继续动手时,很快会遇到这些具体问题:

1
2
3
4
5
6
7
8
9
简单动画最少需要写哪几个对象?
为什么动画结束后属性会跳回原值?
移动控件应该改 Margin、Canvas.Left,还是使用 TranslateTransform?
颜色动画为什么有时直接报错?
怎样让颜色、透明度和缩放同时变化?
多个关键帧怎样写?
线性关键帧、离散关键帧、样条关键帧到底有什么区别?
为什么关键帧都有 KeyTime,画面看起来却完全不同?
动画怎样暂停、继续和重置?

这篇笔记采用“先运行,再拆解”的方式。每一章都包含:

1
2
3
4
5
6
7
本节目标
原理说明
跟做代码
运行截图
跟做检查点
常见错误
小练习

六章知识地图

图 1:从一条基础时间线出发,再分别学习位移、颜色与三类关键帧。

推荐学习顺序

  1. 先运行随附示例,依次点击六个页签。
  2. 第 1 章把“动画对象、目标、属性、时长、结束行为”这一条链路走通。
  3. 第 2 章亲手比较位移效果和布局属性。
  4. 第 3 章完成颜色、透明度和缩放的同时变化。
  5. 第 4 至 6 章不要跳读,三类关键帧要放在一起比较。
  6. 每章结束都按检查点操作一次,再做章末小练习。

随附示例位置

完整示例位于:

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

在 PowerShell 中运行:

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

如果使用 Visual Studio:

  1. 打开 WpfAnimationKeyFrameDemo.csproj。
  2. 确认启动项目是 WpfAnimationKeyFrameDemo。
  3. 按 F5 启动。
  4. 每个页签依次点击“播放、暂停、继续、重置”。
  5. 同时观察卡片和下方日志。

建立自己的跟做项目

建议另外创建一个练习项目:

1
2
3
4
cd "C:\Users\North\Desktop\WPF学习笔记\示例代码"
dotnet new wpf -n WpfAnimationKeyFramePractice -o .\WpfAnimationKeyFramePractice
cd .\WpfAnimationKeyFramePractice
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
WpfAnimationKeyFramePractice
├─ 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 WpfAnimationKeyFramePractice.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
using System.Collections.ObjectModel;

namespace WpfAnimationKeyFramePractice.ViewModels;

public sealed class MainViewModel : ObservableObject
{
private string _simpleStatus = "等待播放";
private string _moveStatus = "等待播放";
private string _appearanceStatus = "等待播放";
private string _linearStatus = "等待播放";
private string _discreteStatus = "等待播放";
private string _splineStatus = "等待播放";

public ObservableCollection<string> SimpleLog { get; } = new();
public ObservableCollection<string> MoveLog { get; } = new();
public ObservableCollection<string> AppearanceLog { get; } = new();
public ObservableCollection<string> LinearLog { get; } = new();
public ObservableCollection<string> DiscreteLog { get; } = new();
public ObservableCollection<string> SplineLog { get; } = new();

public string SimpleStatus
{
get => _simpleStatus;
set => SetProperty(ref _simpleStatus, value);
}

public string MoveStatus
{
get => _moveStatus;
set => SetProperty(ref _moveStatus, value);
}

public string AppearanceStatus
{
get => _appearanceStatus;
set => SetProperty(ref _appearanceStatus, value);
}

public string LinearStatus
{
get => _linearStatus;
set => SetProperty(ref _linearStatus, value);
}

public string DiscreteStatus
{
get => _discreteStatus;
set => SetProperty(ref _discreteStatus, value);
}

public string SplineStatus
{
get => _splineStatus;
set => SetProperty(ref _splineStatus, value);
}

public void AddSimple(string line) => Add(SimpleLog, line);
public void AddMove(string line) => Add(MoveLog, line);
public void AddAppearance(string line) => Add(AppearanceLog, line);
public void AddLinear(string line) => Add(LinearLog, line);
public void AddDiscrete(string line) => Add(DiscreteLog, line);
public void AddSpline(string line) => Add(SplineLog, line);

private static void Add(
ObservableCollection<string> lines,
string line)
{
lines.Insert(0, line);

while (lines.Count > 30)
{
lines.RemoveAt(lines.Count - 1);
}
}
}

日志使用 Insert(0, ...),所以最新一条显示在最上面。排查关键帧执行顺序时,要注意界面显示顺序和真实发生顺序相反。


1. 简单动画使用小结

1.1 本节目标

完成本节后,你要能够:

  1. 说出简单动画的四个关键问题。
  2. 创建并配置 DoubleAnimation。
  3. 使用 Storyboard.SetTarget 指定动画目标。
  4. 使用 Storyboard.SetTargetProperty 指定依赖属性。
  5. 理解 Duration、FillBehavior 和 EasingFunction。
  6. 完成一个可播放、暂停、继续和重置的淡入动画。

1.2 先记住这条最小链路

简单动画的四个问题

图 2:目标属性、起终点、时长和结束行为,构成一个最小动画闭环。

可以把任何简单属性动画都拆成下面四步:

1
2
3
4
1. 选目标属性
2. 描述值怎样变化
3. 设置持续时间
4. 决定结束后保留还是恢复

写成代码是:

1
2
3
4
5
6
7
8
9
10
11
12
var animation = new DoubleAnimation
{
From = 0,
To = 1,
Duration = TimeSpan.FromMilliseconds(900),
FillBehavior = FillBehavior.HoldEnd
};

Storyboard.SetTarget(animation, SimpleCard);
Storyboard.SetTargetProperty(
animation,
new PropertyPath(UIElement.OpacityProperty));

1.3 为什么目标必须是依赖属性

WPF 动画系统需要保存属性的基础值,并在基础值上叠加动画时钟计算出的当前值。

它还要支持:

1
2
3
4
5
动画优先级
暂停与继续
多个动画同时作用于同一属性
动画停止后恢复基础值
属性变化通知

依赖属性提供了这些能力。普通的自动属性只有一个字段,没有动画时钟和值优先级,所以不能直接作为 DoubleAnimation 的目标。

本节使用:

1
UIElement.Opacity

它是依赖属性,取值范围通常是 0 到 1,并且不会触发布局重新测量。

1.4 From、To、Duration 的含义

1
2
3
4
5
6
var animation = new DoubleAnimation
{
From = 0,
To = 1,
Duration = TimeSpan.FromMilliseconds(900)
};
属性 含义
From 动画起点
To 动画终点
Duration 从起点到终点需要多久

如果省略 From,通常从属性当前值开始。初学阶段先显式写清 From 和 To,更容易观察结果。

1.5 FillBehavior 决定结束后去哪里

FillBehavior 有两个常用值:

值 动画结束后
HoldEnd 保持终点值
Stop 回到基础值

淡入通常希望最后保持完全不透明:

1
FillBehavior = FillBehavior.HoldEnd

如果只想播放一次短暂反馈,结束后回到原来的外观,则可以使用:

1
FillBehavior = FillBehavior.Stop

初学者经常遇到“动画一结束就跳回原样”,大多数情况是 FillBehavior 使用了 Stop,或者代码主动调用了 Stop。

1.6 EasingFunction 让速度更自然

线性变化从开始到结束速度一致。加入缓动后,速度可以发生变化:

1
2
3
4
EasingFunction = new QuadraticEase
{
EasingMode = EasingMode.EaseInOut
}
模式 特点
EaseIn 开始慢,后面快
EaseOut 开始快,后面慢
EaseInOut 两端慢,中间快

淡入使用 EaseInOut 比较自然。按钮回弹通常使用 EaseOut。

1.7 跟做:第 1 页 XAML

先建立六个页签的窗口骨架。把 MainWindow.xaml 改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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
<Window x:Class="WpfAnimationKeyFramePractice.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="WPF 动画与关键帧练习"
Width="1280"
Height="840"
WindowStartupLocation="CenterScreen"
Background="#F3F6FB"
FontFamily="Microsoft YaHei UI">
<TabControl x:Name="TabHost"
Margin="20">
<TabItem Header="1 简单动画使用小结">
<Grid Margin="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="430" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<StackPanel Margin="0,0,24,0">
<TextBlock Text="从一条属性时间线开始"
FontSize="22"
FontWeight="Bold" />
<TextBlock Margin="0,8,0,0"
Foreground="#617089"
TextWrapping="Wrap"
Text="先确定目标、起点、终点、时长,再决定结束后保留还是恢复。" />
<TextBlock Margin="0,18,0,0"
Text="{Binding SimpleStatus}"
Foreground="#174EA6"
FontWeight="Bold"
TextWrapping="Wrap" />
<WrapPanel Margin="0,14,0,0">
<Button Padding="14,7"
Margin="0,0,9,8"
Content="播放"
Click="OnSimplePlayClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="暂停"
Click="OnSimplePauseClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="继续"
Click="OnSimpleResumeClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="重置"
Click="OnSimpleResetClick" />
</WrapPanel>
</StackPanel>

<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="190" />
</Grid.RowDefinitions>

<Border Background="#FBFCFE"
BorderBrush="#DCE5F2"
BorderThickness="1"
CornerRadius="9">
<Border x:Name="SimpleCard"
Width="240"
Height="150"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="#2456A6"
CornerRadius="14"
Opacity="1">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center">
<TextBlock Text="淡入卡片"
FontSize="20"
FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center" />
<TextBlock Margin="0,8,0,0"
Text="Opacity 0 → 1"
Foreground="#D9E7FF"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
</Border>

<ListBox Grid.Row="1"
Margin="0,12,0,0"
Padding="8"
Background="#0F172A"
Foreground="#DCE7F8"
BorderThickness="0"
FontFamily="Consolas"
ItemsSource="{Binding SimpleLog}" />
</Grid>
</Grid>
</TabItem>

<TabItem Header="2 位移动画处理">
</TabItem>
<TabItem Header="3 颜色与显示变更动画">
</TabItem>
<TabItem Header="4 线性关键帧动画">
</TabItem>
<TabItem Header="5 离散关键帧动画">
</TabItem>
<TabItem Header="6 样条关键帧动画">
</TabItem>
</TabControl>
</Window>

其他页签暂时留空是正常的。

1.8 跟做:创建 Storyboard

把 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
41
42
43
44
45
46
47
48
49
50
51
52
using System.Windows;
using System.Windows.Media.Animation;
using WpfAnimationKeyFramePractice.ViewModels;

namespace WpfAnimationKeyFramePractice;

public partial class MainWindow : Window
{
private Storyboard? _simpleStoryboard;
private bool _simplePaused;

public MainWindow()
{
InitializeComponent();

ViewModel = new MainViewModel();
DataContext = ViewModel;

BuildSimpleAnimation();
}

public MainViewModel ViewModel { get; }

private void BuildSimpleAnimation()
{
var opacity = new DoubleAnimation
{
From = 0,
To = 1,
Duration = TimeSpan.FromMilliseconds(900),
FillBehavior = FillBehavior.HoldEnd,
EasingFunction = new QuadraticEase
{
EasingMode = EasingMode.EaseInOut
}
};

Storyboard.SetTarget(opacity, SimpleCard);
Storyboard.SetTargetProperty(
opacity,
new PropertyPath(UIElement.OpacityProperty));

_simpleStoryboard = new Storyboard();
_simpleStoryboard.Children.Add(opacity);

_simpleStoryboard.Completed += (_, _) =>
{
ViewModel.SimpleStatus = "淡入完成,保持完全不透明";
ViewModel.AddSimple("Completed | Opacity = 1");
};
}
}

1.9 跟做:播放、暂停、继续、重置

增加四个方法:

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
public void SimulateSimplePlay()
{
_simpleStoryboard?.Stop(this);
_simpleStoryboard?.Begin(this, true);

_simplePaused = false;
ViewModel.SimpleStatus = "淡入正在播放";
ViewModel.AddSimple("Play | Opacity 0 → 1,900ms");
}

public void SimulateSimplePause()
{
if (_simpleStoryboard is null || _simplePaused)
{
return;
}

_simpleStoryboard.Pause(this);
_simplePaused = true;
ViewModel.SimpleStatus = "淡入已暂停";
ViewModel.AddSimple("Pause | 保留当前透明度");
}

public void SimulateSimpleResume()
{
if (_simpleStoryboard is null || !_simplePaused)
{
return;
}

_simpleStoryboard.Resume(this);
_simplePaused = false;
ViewModel.SimpleStatus = "淡入已继续";
ViewModel.AddSimple("Resume | 从暂停位置继续");
}

public void SimulateSimpleReset()
{
_simpleStoryboard?.Stop(this);
_simplePaused = false;
SimpleCard.Opacity = 1;

ViewModel.SimpleStatus = "已重置,不透明度为 1";
ViewModel.AddSimple("Reset | Opacity = 1");
}

再加入按钮处理器:

1
2
3
4
5
6
7
8
9
10
11
private void OnSimplePlayClick(object sender, RoutedEventArgs e)
=> SimulateSimplePlay();

private void OnSimplePauseClick(object sender, RoutedEventArgs e)
=> SimulateSimplePause();

private void OnSimpleResumeClick(object sender, RoutedEventArgs e)
=> SimulateSimpleResume();

private void OnSimpleResetClick(object sender, RoutedEventArgs e)
=> SimulateSimpleReset();

1.10 运行截图

简单动画页签

运行截图 1:淡入动画播放到中间状态,卡片呈半透明。

1.11 为什么 Begin 要传 true

代码是:

1
_simpleStoryboard?.Begin(this, true);

第二个参数表示创建可控时钟。传 true 后,才可以对这个 Storyboard 调用:

1
2
3
4
Pause
Resume
Stop
Seek

如果只是希望动画自己播放,传 false 也能播放。但要做交互控制时,应使用可控时钟。

可控制时间线的四个动作

图 3:Begin 创建时钟,Pause、Resume、Stop 控制同一个 Storyboard 的状态。

1.12 跟做检查点

  • 第 1 页可以编译并运行。
  • 点击“播放”后卡片逐渐出现。
  • 播放中点击“暂停”,透明度停住。
  • 点击“继续”,从暂停位置接着播放。
  • 点击“重置”,卡片恢复完全不透明。
  • 动画结束后日志出现 Completed。
  • 连续点击“播放”不会叠加多个失控时钟。

1.13 常见错误

现象 常见原因 修正方法
动画完全不动 目标不是依赖属性 检查 SetTargetProperty
结束后跳回原样 FillBehavior=Stop 改为 HoldEnd
暂停按钮无效 Begin 第二个参数为 false 使用 Begin(this, true)
动画越点越快 每次播放都创建新时钟 播放前先 Stop 旧时钟
重置后仍然半透明 只修改属性,没有停止动画 先 Stop 再设置基础值
动画目标为空 InitializeComponent 之前访问控件 在初始化后创建 Storyboard

1.14 小练习

  1. 把时长改成 1500ms,观察节奏。
  2. 把 From 改成 0.3,比较起始状态。
  3. 把 EasingMode 改成 EaseIn,比较速度。
  4. 把 FillBehavior 改成 Stop,观察动画结束后是否跳回。
  5. 增加一个“立即完成”按钮,使用 Seek 跳到动画终点。

2. 位移动画处理

2.1 本节目标

完成本节后,你要能够:

  1. 使用 TranslateTransform 移动控件。
  2. 把动画目标设置为 TranslateTransform.XProperty。
  3. 比较渲染变换和布局属性的成本。
  4. 使用 RenderTransform 保持布局位置不变。
  5. 正确重置变换值。

2.2 移动控件有三种常见写法

1
2
3
修改 Margin
修改 Canvas.Left
使用 TranslateTransform

它们在 WPF 中的处理路径不同。

位移优先使用变换

图 4:位移优先使用渲染变换,避免反复测量和排列。

2.3 为什么 Margin 不适合连续动画

Margin 属于布局属性。连续修改它,布局系统可能需要重新:

1
2
3
4
测量元素
计算可用空间
排列父容器子项
更新命中测试区域

一个元素变化还可能影响其他兄弟元素,形成更大的布局传播。

2.4 Canvas.Left 也不是首选

Canvas.Left 同样是布局相关附加属性。每次变化都可能触发布局处理。

对于真正需要改变逻辑位置的场景,可以修改它;如果只是视觉上移动,优先使用变换。

2.5 TranslateTransform 只改变渲染偏移

TranslateTransform 有两个常用属性:

1
2
X
Y

它作为 RenderTransform 使用:

1
2
3
4
5
6
7
8
9
<Border x:Name="MovingCard"
Canvas.Left="45"
Canvas.Top="215">
<Border.RenderTransform>
<TranslateTransform x:Name="MovingTransform"
X="0"
Y="0" />
</Border.RenderTransform>
</Border>

动画修改 X 时,Canvas.Left 仍然是 45,但屏幕上的元素会向右偏移。

2.6 跟做:完成第 2 页 XAML

把第 2 个 TabItem 改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
<TabItem Header="2  位移动画处理">
<Grid Margin="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="420" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<StackPanel Margin="0,0,24,0">
<TextBlock Text="移动效果优先使用变换"
FontSize="22"
FontWeight="Bold" />
<TextBlock Margin="0,8,0,0"
Foreground="#617089"
TextWrapping="Wrap"
Text="TranslateTransform 主要影响渲染变换,不改变元素在布局中的位置。" />
<TextBlock Margin="0,18,0,0"
Text="{Binding MoveStatus}"
Foreground="#176B4D"
FontWeight="Bold"
TextWrapping="Wrap" />
<WrapPanel Margin="0,14,0,0">
<Button Padding="14,7"
Margin="0,0,9,8"
Content="向右移动"
Click="OnMovePlayClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="暂停"
Click="OnMovePauseClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="继续"
Click="OnMoveResumeClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="重置"
Click="OnMoveResetClick" />
</WrapPanel>
</StackPanel>

<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="190" />
</Grid.RowDefinitions>

<Border Background="#FBFCFE"
BorderBrush="#DCE5F2"
BorderThickness="1"
CornerRadius="9"
ClipToBounds="True">
<Canvas Height="420">
<Line X1="45"
Y1="265"
X2="760"
Y2="265"
Stroke="#DCE5F2"
StrokeThickness="3"
StrokeDashArray="6,5" />
<Border x:Name="MovingCard"
Width="150"
Height="94"
Canvas.Left="45"
Canvas.Top="215"
Background="#176B4D"
CornerRadius="12">
<Border.RenderTransform>
<TranslateTransform x:Name="MovingTransform"
X="0"
Y="0" />
</Border.RenderTransform>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center">
<TextBlock Text="移动卡片"
FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center" />
<TextBlock Margin="0,5,0,0"
Text="X: 0 → 520"
Foreground="#D8F3E8"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
</Canvas>
</Border>

<ListBox Grid.Row="1"
Margin="0,12,0,0"
Padding="8"
Background="#0F172A"
Foreground="#DCE7F8"
BorderThickness="0"
FontFamily="Consolas"
ItemsSource="{Binding MoveLog}" />
</Grid>
</Grid>
</TabItem>

2.7 跟做:创建位移动画

增加字段:

1
2
private Storyboard? _moveStoryboard;
private bool _movePaused;

增加方法:

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
private void BuildMoveAnimation()
{
var move = new DoubleAnimation
{
From = 0,
To = 520,
Duration = TimeSpan.FromMilliseconds(1200),
FillBehavior = FillBehavior.HoldEnd,
EasingFunction = new CubicEase
{
EasingMode = EasingMode.EaseInOut
}
};

Storyboard.SetTarget(move, MovingTransform);
Storyboard.SetTargetProperty(
move,
new PropertyPath(TranslateTransform.XProperty));

_moveStoryboard = new Storyboard();
_moveStoryboard.Children.Add(move);
_moveStoryboard.Completed += (_, _) =>
{
ViewModel.MoveStatus = "移动完成,X = 520";
ViewModel.AddMove("Completed | X = 520");
};
}

构造函数中调用:

1
BuildMoveAnimation();

2.8 跟做:控制位移动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public void SimulateMovePlay()
{
_moveStoryboard?.Stop(this);
_moveStoryboard?.Begin(this, true);

_movePaused = false;
ViewModel.MoveStatus = "向右移动:0 → 520";
ViewModel.AddMove("Play | TranslateTransform.X 0 → 520");
}

public void SimulateMovePause()
{
if (_moveStoryboard is null || _movePaused)
{
return;
}

_moveStoryboard.Pause(this);
_movePaused = true;
ViewModel.MoveStatus = "位移动画已暂停";
ViewModel.AddMove("Pause | 保留当前 X");
}

public void SimulateMoveResume()
{
if (_moveStoryboard is null || !_movePaused)
{
return;
}

_moveStoryboard.Resume(this);
_movePaused = false;
ViewModel.MoveStatus = "位移动画已继续";
ViewModel.AddMove("Resume | 从暂停位置继续");
}

public void SimulateMoveReset()
{
_moveStoryboard?.Stop(this);
_movePaused = false;
MovingTransform.X = 0;

ViewModel.MoveStatus = "已重置,X = 0";
ViewModel.AddMove("Reset | TranslateTransform.X = 0");
}

再增加四个按钮处理器,分别调用上面的方法。

2.9 运行截图

位移动画页签

运行截图 2:卡片通过 TranslateTransform 移动到中段,Canvas.Left 没有改变。

2.10 怎样证明 Canvas.Left 没有变

在播放前和播放后读取:

1
2
var left = Canvas.GetLeft(MovingCard);
var transformX = MovingTransform.X;

运行时会看到:

1
2
Canvas.Left 保持 45
TranslateTransform.X 从 0 增加到 520

这两个值表达不同概念:

1
2
Canvas.Left:布局位置
TranslateTransform.X:渲染偏移

2.11 位移和命中测试

RenderTransform 会改变元素的显示位置,也会影响 WPF 的命中测试转换。一般情况下,鼠标点击视觉上看到的位置是有效的。

但是,业务代码读取布局位置时仍然得到原来的值。例如读取 Canvas.Left,它不会自动加上变换偏移。

需要同时保存“逻辑位置”和“视觉偏移”时,要明确分别维护,避免混用。

2.12 跟做检查点

  • 播放后卡片水平移动。
  • 移动过程中 Canvas.Left 保持原值。
  • TranslateTransform.X 从 0 变化到 520。
  • 暂停时卡片停在当前位置。
  • 重置后回到起点。
  • 连续播放不会在旧时钟上叠加新时钟。
  • 知道 RenderTransform 不参与普通布局测量。

2.13 常见错误

现象 常见原因 修正方法
动画没动 动画目标是元素,但属性路径用了 X 目标设为 TranslateTransform
移动到错误位置 X 和 Y 用错 检查属性路径
越播越远 每次播放都叠加相对动画 播放前先停止并重置
布局被挤动 动画修改了 Margin 改用 TranslateTransform
视觉移动但点击不中 使用了错误的坐标转换 用 TransformToAncestor 等方法换算
重置后仍偏移 只停止动画,没有设置 X=0 停止后恢复基础值

2.14 小练习

  1. 改成沿对角线移动,同时设置 X 和 Y。
  2. 使用 AutoReverse=true 让卡片自动回来。
  3. 使用 RepeatBehavior.Forever 做循环移动,并增加“停止”按钮。
  4. 比较 Margin 动画和 TranslateTransform 动画对兄弟元素的影响。
  5. 在动画中读取 Canvas.GetLeft,确认逻辑位置没有变化。

3. 颜色与显示变更动画处理

3.1 本节目标

完成本节后,你要能够:

  1. 使用 ColorAnimation 修改 SolidColorBrush.Color。
  2. 同时播放颜色、透明度和缩放动画。
  3. 理解为什么颜色动画的目标是画刷,不是元素本身。
  4. 处理冻结画刷。
  5. 统一暂停、继续和重置多条时间线。

3.2 颜色动画的目标是画刷

Border.Background 的类型是 Brush。真正保存颜色的是 SolidColorBrush.Color。

因此颜色动画的路径是:

1
2
3
Border.Background
└─ SolidColorBrush
└─ Color

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
var brush = (SolidColorBrush)AppearanceCard.Background;

var color = new ColorAnimation
{
From = (Color)ColorConverter.ConvertFromString("#2456A6"),
To = (Color)ColorConverter.ConvertFromString("#D97706"),
Duration = TimeSpan.FromMilliseconds(1300)
};

Storyboard.SetTarget(color, brush);
Storyboard.SetTargetProperty(
color,
new PropertyPath(SolidColorBrush.ColorProperty));

3.3 为什么不能把目标写成 Border

如果写成:

1
2
3
4
Storyboard.SetTarget(color, AppearanceCard);
Storyboard.SetTargetProperty(
color,
new PropertyPath(SolidColorBrush.ColorProperty));

运行时就会找不到目标属性。Border 本身没有 Color 属性,颜色在它的画刷上。

3.4 冻结画刷需要 Clone

WPF 为了性能,可能把资源画刷冻结。冻结对象不能再修改,也不能作为动画目标。

判断方法:

1
2
3
4
5
if (brush.IsFrozen)
{
brush = brush.Clone();
AppearanceCard.Background = brush;
}

随附示例使用一个可修改的 SolidColorBrush:

1
2
3
<Border.Background>
<SolidColorBrush Color="#2456A6" />
</Border.Background>

3.5 同时播放三条时间线

颜色与显示状态一起动画

图 5:一个 Storyboard 统一管理颜色、透明度和缩放。

本节的组合动画:

属性 起点 终点
背景颜色 #2456A6 #D97706
不透明度 1 0.72
缩放 1 1.08

三条时间线都设置为:

1
2
3
Duration = TimeSpan.FromMilliseconds(1300);
AutoReverse = true;
FillBehavior = FillBehavior.Stop;

因此它们会同时向前、同时返回,并在结束后恢复基础值。

3.6 跟做:完成第 3 页 XAML

把第 3 个 TabItem 改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
<TabItem Header="3  颜色与显示变更动画">
<Grid Margin="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="430" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<StackPanel Margin="0,0,24,0">
<TextBlock Text="颜色和可见状态一起变化"
FontSize="22"
FontWeight="Bold" />
<TextBlock Margin="0,8,0,0"
Foreground="#617089"
TextWrapping="Wrap"
Text="颜色动画修改 SolidColorBrush.Color;透明度和缩放可以同时配合。" />
<TextBlock Margin="0,18,0,0"
Text="{Binding AppearanceStatus}"
Foreground="#8A5A00"
FontWeight="Bold"
TextWrapping="Wrap" />
<WrapPanel Margin="0,14,0,0">
<Button Padding="14,7"
Margin="0,0,9,8"
Content="播放"
Click="OnAppearancePlayClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="暂停"
Click="OnAppearancePauseClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="继续"
Click="OnAppearanceResumeClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="重置"
Click="OnAppearanceResetClick" />
</WrapPanel>
</StackPanel>

<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="190" />
</Grid.RowDefinitions>

<Border Background="#FBFCFE"
BorderBrush="#DCE5F2"
BorderThickness="1"
CornerRadius="9">
<Border x:Name="AppearanceCard"
Width="250"
Height="160"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="16"
RenderTransformOrigin="0.5,0.5">
<Border.Background>
<SolidColorBrush Color="#2456A6" />
</Border.Background>
<Border.RenderTransform>
<ScaleTransform x:Name="AppearanceScale"
ScaleX="1"
ScaleY="1" />
</Border.RenderTransform>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center">
<TextBlock Text="状态卡片"
FontSize="20"
FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center" />
<TextBlock Margin="0,8,0,0"
Text="颜色 / 透明度 / 缩放"
Foreground="#E9F0FF"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
</Border>

<ListBox Grid.Row="1"
Margin="0,12,0,0"
Padding="8"
Background="#0F172A"
Foreground="#DCE7F8"
BorderThickness="0"
FontFamily="Consolas"
ItemsSource="{Binding AppearanceLog}" />
</Grid>
</Grid>
</TabItem>

3.7 跟做:创建组合动画

增加字段:

1
2
private Storyboard? _appearanceStoryboard;
private bool _appearancePaused;

增加方法:

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
private void BuildAppearanceAnimation()
{
var brush = GetSolidBrush(AppearanceCard);
brush.Color = (Color)ColorConverter.ConvertFromString("#2456A6");

var color = new ColorAnimation
{
From = (Color)ColorConverter.ConvertFromString("#2456A6"),
To = (Color)ColorConverter.ConvertFromString("#D97706"),
Duration = TimeSpan.FromMilliseconds(1300),
AutoReverse = true,
FillBehavior = FillBehavior.Stop
};

Storyboard.SetTarget(color, brush);
Storyboard.SetTargetProperty(
color,
new PropertyPath(SolidColorBrush.ColorProperty));

var opacity = new DoubleAnimation
{
From = 1,
To = 0.72,
Duration = TimeSpan.FromMilliseconds(1300),
AutoReverse = true,
FillBehavior = FillBehavior.Stop
};

Storyboard.SetTarget(opacity, AppearanceCard);
Storyboard.SetTargetProperty(
opacity,
new PropertyPath(UIElement.OpacityProperty));

var scaleX = new DoubleAnimation
{
From = 1,
To = 1.08,
Duration = TimeSpan.FromMilliseconds(1300),
AutoReverse = true,
FillBehavior = FillBehavior.Stop
};

Storyboard.SetTarget(scaleX, AppearanceScale);
Storyboard.SetTargetProperty(
scaleX,
new PropertyPath(ScaleTransform.ScaleXProperty));

var scaleY = new DoubleAnimation
{
From = 1,
To = 1.08,
Duration = TimeSpan.FromMilliseconds(1300),
AutoReverse = true,
FillBehavior = FillBehavior.Stop
};

Storyboard.SetTarget(scaleY, AppearanceScale);
Storyboard.SetTargetProperty(
scaleY,
new PropertyPath(ScaleTransform.ScaleYProperty));

_appearanceStoryboard = new Storyboard();
_appearanceStoryboard.Children.Add(color);
_appearanceStoryboard.Children.Add(opacity);
_appearanceStoryboard.Children.Add(scaleX);
_appearanceStoryboard.Children.Add(scaleY);
}

3.8 跟做:获取可修改画刷

增加辅助方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private static SolidColorBrush GetSolidBrush(Border border)
{
if (border.Background is SolidColorBrush brush)
{
if (brush.IsFrozen)
{
brush = brush.Clone();
border.Background = brush;
}

return brush;
}

brush = new SolidColorBrush(Colors.Transparent);
border.Background = brush;
return brush;
}

3.9 跟做:控制组合动画

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
public void SimulateAppearancePlay()
{
_appearanceStoryboard?.Stop(this);
_appearanceStoryboard?.Begin(this, true);

_appearancePaused = false;
ViewModel.AppearanceStatus =
"颜色、透明度与缩放正在同时变化";
ViewModel.AddAppearance(
"Play | Color + Opacity + Scale");
}

public void SimulateAppearancePause()
{
if (_appearanceStoryboard is null || _appearancePaused)
{
return;
}

_appearanceStoryboard.Pause(this);
_appearancePaused = true;
ViewModel.AppearanceStatus = "组合动画已暂停";
ViewModel.AddAppearance("Pause | 三条时间线同时暂停");
}

public void SimulateAppearanceResume()
{
if (_appearanceStoryboard is null || !_appearancePaused)
{
return;
}

_appearanceStoryboard.Resume(this);
_appearancePaused = false;
ViewModel.AppearanceStatus = "组合动画已继续";
ViewModel.AddAppearance("Resume | 三条时间线同时继续");
}

public void SimulateAppearanceReset()
{
_appearanceStoryboard?.Stop(this);
_appearancePaused = false;

AppearanceCard.Opacity = 1;
AppearanceScale.ScaleX = 1;
AppearanceScale.ScaleY = 1;
GetSolidBrush(AppearanceCard).Color =
(Color)ColorConverter.ConvertFromString("#2456A6");

ViewModel.AppearanceStatus = "已重置颜色、透明度和缩放";
ViewModel.AddAppearance("Reset | 静态值和颜色恢复");
}

再增加四个按钮处理器。

3.10 运行截图

颜色与显示变化页签

运行截图 3:颜色、透明度和缩放同时处于动画中段。

3.11 暂停一个 Storyboard 为什么能同时暂停三条线

四个动画都是同一个 Storyboard 的子时间线:

1
2
3
4
5
Storyboard
├─ ColorAnimation
├─ DoubleAnimation(Opacity)
├─ DoubleAnimation(ScaleX)
└─ DoubleAnimation(ScaleY)

对 Storyboard 调用一次 Pause,子时间线也会一起暂停。

这比分别保存四个动画时钟更容易管理。

3.12 跟做检查点

  • 播放后卡片颜色逐渐变化。
  • 播放后卡片透明度和大小同时变化。
  • 暂停后三个变化同时停住。
  • 继续后三个变化同时恢复。
  • 重置后颜色、透明度和缩放全部恢复。
  • 颜色动画的目标是 SolidColorBrush。
  • 知道冻结画刷需要先 Clone。

3.13 常见错误

现象 常见原因 修正方法
颜色动画报错 目标是 Border 而不是画刷 目标设为 SolidColorBrush
提示不能修改冻结对象 使用了冻结画刷 先 Clone
只变颜色,大小不变 Storyboard 只加入一条时间线 检查 Children
暂停不同步 每个动画分别控制 统一放入同一个 Storyboard
重置后颜色不恢复 只停止动画,没有恢复基础颜色 显式设置颜色
缩放中心错误 没有设置变换原点 设置 RenderTransformOrigin

3.14 小练习

  1. 把目标颜色改成绿色。
  2. 增加 BorderBrush.Color 动画,让边框也变化。
  3. 把 AutoReverse 改为 false,观察结束状态。
  4. 把 FillBehavior 改为 HoldEnd,比较重置行为。
  5. 用一个按钮在“蓝色主题”和“橙色主题”之间切换。

4. 线性关键帧动画基本使用

4.1 本节目标

完成本节后,你要能够:

  1. 创建 DoubleAnimationUsingKeyFrames。
  2. 使用 LinearDoubleKeyFrame 定义多个时间点。
  3. 理解 KeyTime 的含义。
  4. 区分关键帧动画和普通 From/To 动画。
  5. 观察线性关键帧的连续插值。

4.2 什么时候需要关键帧

简单动画只描述“从 A 到 B”。如果要表达:

1
2
3
4
0ms:0
800ms:120
1600ms:360
2400ms:520

就需要多个目标值。关键帧动画可以描述一条包含多个状态的时间线。

4.3 KeyTime 是什么

KeyTime 表示关键帧到达的时间点,不是持续时长。

1
KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(800))

它表示“在动画开始后的第 800 毫秒,到达这个关键帧的值”。

整条时间线的总时长通常由动画的 Duration 决定:

1
Duration = TimeSpan.FromMilliseconds(2400)

如果关键帧时间超出总时长,需要重新检查设计。

4.4 线性关键帧怎样得到中间值

假设两个关键帧是:

1
2
0ms → 0
800ms → 120

在 400ms 时,线性关键帧会得到大约 60。它会连续插值,画面不会跳变。

三种关键帧的区别

图 6:关键帧决定时间点,关键帧类型决定两个时间点之间的插值方式。

4.5 跟做:完成第 4 页 XAML

把第 4 个 TabItem 改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
<TabItem Header="4  线性关键帧动画">
<Grid Margin="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="430" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<StackPanel Margin="0,0,24,0">
<TextBlock Text="在多个时间点定义值"
FontSize="22"
FontWeight="Bold" />
<TextBlock Margin="0,8,0,0"
Foreground="#617089"
TextWrapping="Wrap"
Text="线性关键帧在相邻关键帧之间连续插值,适合多阶段平滑变化。" />
<TextBlock Margin="0,18,0,0"
Text="{Binding LinearStatus}"
Foreground="#174EA6"
FontWeight="Bold"
TextWrapping="Wrap" />
<WrapPanel Margin="0,14,0,0">
<Button Padding="14,7"
Margin="0,0,9,8"
Content="播放"
Click="OnLinearPlayClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="暂停"
Click="OnLinearPauseClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="继续"
Click="OnLinearResumeClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="重置"
Click="OnLinearResetClick" />
</WrapPanel>
</StackPanel>

<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="190" />
</Grid.RowDefinitions>

<Border Background="#FBFCFE"
BorderBrush="#DCE5F2"
BorderThickness="1"
CornerRadius="9"
ClipToBounds="True">
<Canvas Height="420">
<Line X1="45"
Y1="285"
X2="760"
Y2="285"
Stroke="#DCE5F2"
StrokeThickness="3" />
<Border x:Name="LinearCard"
Width="140"
Height="88"
Canvas.Left="45"
Canvas.Top="235"
Background="#2456A6"
CornerRadius="12">
<Border.RenderTransform>
<TranslateTransform x:Name="LinearTransform"
X="0" />
</Border.RenderTransform>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center">
<TextBlock Text="线性关键帧"
FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center" />
<TextBlock Margin="0,5,0,0"
Text="连续插值"
Foreground="#D9E7FF"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
<TextBlock Canvas.Left="48"
Canvas.Top="330"
Text="0ms"
Foreground="#617089" />
<TextBlock Canvas.Left="205"
Canvas.Top="330"
Text="800ms"
Foreground="#617089" />
<TextBlock Canvas.Left="390"
Canvas.Top="330"
Text="1600ms"
Foreground="#617089" />
<TextBlock Canvas.Left="585"
Canvas.Top="330"
Text="2400ms"
Foreground="#617089" />
</Canvas>
</Border>

<ListBox Grid.Row="1"
Margin="0,12,0,0"
Padding="8"
Background="#0F172A"
Foreground="#DCE7F8"
BorderThickness="0"
FontFamily="Consolas"
ItemsSource="{Binding LinearLog}" />
</Grid>
</Grid>
</TabItem>

4.6 跟做:创建线性关键帧

增加字段:

1
2
private Storyboard? _linearStoryboard;
private bool _linearPaused;

增加方法:

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 void BuildLinearAnimation()
{
var frames = new DoubleAnimationUsingKeyFrames
{
Duration = TimeSpan.FromMilliseconds(2400),
FillBehavior = FillBehavior.HoldEnd
};

frames.KeyFrames.Add(
new LinearDoubleKeyFrame(
0,
KeyTime.FromTimeSpan(TimeSpan.Zero)));

frames.KeyFrames.Add(
new LinearDoubleKeyFrame(
120,
KeyTime.FromTimeSpan(
TimeSpan.FromMilliseconds(800))));

frames.KeyFrames.Add(
new LinearDoubleKeyFrame(
360,
KeyTime.FromTimeSpan(
TimeSpan.FromMilliseconds(1600))));

frames.KeyFrames.Add(
new LinearDoubleKeyFrame(
520,
KeyTime.FromTimeSpan(
TimeSpan.FromMilliseconds(2400))));

Storyboard.SetTarget(frames, LinearTransform);
Storyboard.SetTargetProperty(
frames,
new PropertyPath(TranslateTransform.XProperty));

_linearStoryboard = new Storyboard();
_linearStoryboard.Children.Add(frames);
_linearStoryboard.Completed += (_, _) =>
{
ViewModel.LinearStatus = "线性关键帧完成,X = 520";
ViewModel.AddLinear("Completed | 4 个关键帧全部经过");
};
}

4.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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public void SimulateLinearPlay()
{
_linearStoryboard?.Stop(this);
_linearStoryboard?.Begin(this, true);

_linearPaused = false;
ViewModel.LinearStatus =
"线性关键帧:0 → 120 → 360 → 520";
ViewModel.AddLinear(
"Play | LinearDoubleKeyFrame 共 4 个时间点");
}

public void SimulateLinearPause()
{
if (_linearStoryboard is null || _linearPaused)
{
return;
}

_linearStoryboard.Pause(this);
_linearPaused = true;
ViewModel.LinearStatus = "线性关键帧已暂停";
ViewModel.AddLinear("Pause | 保留当前关键帧进度");
}

public void SimulateLinearResume()
{
if (_linearStoryboard is null || !_linearPaused)
{
return;
}

_linearStoryboard.Resume(this);
_linearPaused = false;
ViewModel.LinearStatus = "线性关键帧已继续";
ViewModel.AddLinear("Resume | 继续连续插值");
}

public void SimulateLinearReset()
{
_linearStoryboard?.Stop(this);
_linearPaused = false;
LinearTransform.X = 0;

ViewModel.LinearStatus = "已重置,X = 0";
ViewModel.AddLinear("Reset | 回到第 1 个关键帧");
}

再增加四个按钮处理器。

4.8 运行截图

线性关键帧页签

运行截图 4:卡片位于线性关键帧中段,时间轴显示四个关键时间点。

4.9 线性不等于匀速

需要区分两个概念:

1
2
线性关键帧:两个关键帧之间用线性方式插值
整条动画:由多个不同长度的线段组成,速度仍可能变化

例如:

1
2
3
0 → 120:800ms
120 → 360:800ms
360 → 520:800ms

每一段都是线性的,但每段移动距离不同,所以视觉速度并不相同。

4.10 跟做检查点

  • 动画包含四个关键帧。
  • 每个关键帧的 KeyTime 正确。
  • 卡片连续移动,没有瞬间跳变。
  • 暂停后保留当前关键帧进度。
  • 重置后回到 X=0。
  • 能解释“线性插值”和“整条动画匀速”不是一回事。

4.11 常见错误

现象 常见原因 修正方法
只有第一帧生效 忘记加入后续关键帧 检查 KeyFrames
关键帧顺序错误 添加顺序混乱 按 KeyTime 排列
动画提前结束 Duration 小于最后 KeyTime 调整总时长
画面直接跳变 使用了离散关键帧 改为 LinearDoubleKeyFrame
无法暂停 没有创建可控时钟 Begin(this, true)
重置后残留偏移 没有恢复基础值 设置 X=0

4.12 小练习

  1. 把关键帧改成 0、200、350、520。
  2. 让第二段比第一段更长,观察速度变化。
  3. 增加一个 Y 轴关键帧,做曲线式位移。
  4. 使用 AutoReverse 让关键帧动画返回。
  5. 在日志中记录每一段的目标值。

5. 离散关键帧动画基本使用

5.1 本节目标

完成本节后,你要能够:

  1. 创建 DiscreteColorKeyFrame。
  2. 理解离散关键帧“保持旧值,到点跳变”的行为。
  3. 使用多个离散颜色状态。
  4. 判断哪些场景适合离散变化。
  5. 区分离散关键帧和普通属性赋值。

5.2 离散关键帧不会计算中间值

假设关键帧是:

1
2
0ms → 蓝色
700ms → 黄色

在 0ms 到 699ms 之间,颜色一直是蓝色;到 700ms 时,直接变成黄色。

没有蓝黄混合的中间颜色。

5.3 和线性关键帧对比

同一组时间点的不同插值

图 7:线性是折线,离散是台阶,样条是平滑曲线。

三种方式的时间点可以相同,但中间值完全不同。

5.4 离散关键帧适合什么

适合:

1
2
3
4
5
红绿灯状态
加载步骤提示
开关状态
离散颜色阶段
文字或图标阶段

不适合:

1
2
3
平滑移动
渐变色
自然缩放

5.5 跟做:完成第 5 页 XAML

把第 5 个 TabItem 改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
<TabItem Header="5  离散关键帧动画">
<Grid Margin="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="430" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<StackPanel Margin="0,0,24,0">
<TextBlock Text="值在关键帧处直接跳变"
FontSize="22"
FontWeight="Bold" />
<TextBlock Margin="0,8,0,0"
Foreground="#617089"
TextWrapping="Wrap"
Text="离散关键帧保持旧值,直到下一个 KeyTime 到达时直接切换。" />
<TextBlock Margin="0,18,0,0"
Text="{Binding DiscreteStatus}"
Foreground="#9B3030"
FontWeight="Bold"
TextWrapping="Wrap" />
<WrapPanel Margin="0,14,0,0">
<Button Padding="14,7"
Margin="0,0,9,8"
Content="播放"
Click="OnDiscretePlayClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="暂停"
Click="OnDiscretePauseClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="继续"
Click="OnDiscreteResumeClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="重置"
Click="OnDiscreteResetClick" />
</WrapPanel>
</StackPanel>

<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="190" />
</Grid.RowDefinitions>

<Border Background="#FBFCFE"
BorderBrush="#DCE5F2"
BorderThickness="1"
CornerRadius="9">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center">
<Border x:Name="DiscreteCard"
Width="280"
Height="160"
CornerRadius="16">
<Border.Background>
<SolidColorBrush Color="#2456A6" />
</Border.Background>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center">
<TextBlock Text="等待状态"
FontSize="21"
FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center" />
<TextBlock Margin="0,8,0,0"
Text="值会直接切换"
Foreground="#E9F0FF"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
<TextBlock Margin="0,18,0,0"
Text="0.0s 蓝 0.7s 黄 1.4s 绿 2.1s 红"
Foreground="#617089" />
</StackPanel>
</Border>

<ListBox Grid.Row="1"
Margin="0,12,0,0"
Padding="8"
Background="#0F172A"
Foreground="#DCE7F8"
BorderThickness="0"
FontFamily="Consolas"
ItemsSource="{Binding DiscreteLog}" />
</Grid>
</Grid>
</TabItem>

5.6 跟做:创建离散颜色关键帧

增加字段:

1
2
private Storyboard? _discreteStoryboard;
private bool _discretePaused;

增加方法:

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
private void BuildDiscreteAnimation()
{
var brush = GetSolidBrush(DiscreteCard);
brush.Color = (Color)ColorConverter.ConvertFromString("#2456A6");

var frames = new ColorAnimationUsingKeyFrames
{
Duration = TimeSpan.FromMilliseconds(2100),
FillBehavior = FillBehavior.HoldEnd
};

frames.KeyFrames.Add(
new DiscreteColorKeyFrame(
(Color)ColorConverter.ConvertFromString("#2456A6"),
KeyTime.FromTimeSpan(TimeSpan.Zero)));

frames.KeyFrames.Add(
new DiscreteColorKeyFrame(
(Color)ColorConverter.ConvertFromString("#D97706"),
KeyTime.FromTimeSpan(
TimeSpan.FromMilliseconds(700))));

frames.KeyFrames.Add(
new DiscreteColorKeyFrame(
(Color)ColorConverter.ConvertFromString("#176B4D"),
KeyTime.FromTimeSpan(
TimeSpan.FromMilliseconds(1400))));

frames.KeyFrames.Add(
new DiscreteColorKeyFrame(
(Color)ColorConverter.ConvertFromString("#9B3030"),
KeyTime.FromTimeSpan(
TimeSpan.FromMilliseconds(2100))));

Storyboard.SetTarget(frames, brush);
Storyboard.SetTargetProperty(
frames,
new PropertyPath(SolidColorBrush.ColorProperty));

_discreteStoryboard = new Storyboard();
_discreteStoryboard.Children.Add(frames);
_discreteStoryboard.Completed += (_, _) =>
{
ViewModel.DiscreteStatus = "离散关键帧完成,最后为红色";
ViewModel.AddDiscrete("Completed | 已到达最后一个关键帧");
};
}

5.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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public void SimulateDiscretePlay()
{
_discreteStoryboard?.Stop(this);
_discreteStoryboard?.Begin(this, true);

_discretePaused = false;
ViewModel.DiscreteStatus =
"离散关键帧:颜色会在时间点直接跳变";
ViewModel.AddDiscrete(
"Play | 蓝 → 黄 → 绿 → 红,无中间插值");
}

public void SimulateDiscretePause()
{
if (_discreteStoryboard is null || _discretePaused)
{
return;
}

_discreteStoryboard.Pause(this);
_discretePaused = true;
ViewModel.DiscreteStatus = "离散关键帧已暂停";
ViewModel.AddDiscrete("Pause | 当前颜色保持不变");
}

public void SimulateDiscreteResume()
{
if (_discreteStoryboard is null || !_discretePaused)
{
return;
}

_discreteStoryboard.Resume(this);
_discretePaused = false;
ViewModel.DiscreteStatus = "离散关键帧已继续";
ViewModel.AddDiscrete("Resume | 到达下一个时间点再跳变");
}

public void SimulateDiscreteReset()
{
_discreteStoryboard?.Stop(this);
_discretePaused = false;

GetSolidBrush(DiscreteCard).Color =
(Color)ColorConverter.ConvertFromString("#2456A6");

ViewModel.DiscreteStatus = "已重置为蓝色";
ViewModel.AddDiscrete("Reset | 回到初始颜色");
}

5.8 运行截图

离散关键帧页签

运行截图 5:离散关键帧已经到达黄色阶段,没有蓝黄渐变的中间色。

5.9 离散关键帧和直接赋值有什么区别

直接赋值:

1
brush.Color = Colors.Yellow;

它立即改变当前值,不会建立时间线,也不能暂停、继续或统一管理。

离散关键帧:

1
2
3
4
由动画时钟在指定时间点切换
可以暂停、继续、停止
可以与其他时间线组成 Storyboard
可以记录 Completed

5.10 跟做检查点

  • 动画开始时卡片为蓝色。
  • 到 700ms 时直接变黄。
  • 到 1400ms 时直接变绿。
  • 到 2100ms 时直接变红。
  • 任意两个时间点之间没有渐变。
  • 暂停时颜色保持不变。
  • 重置后回到蓝色。

5.11 常见错误

现象 常见原因 修正方法
颜色仍有渐变 使用了线性颜色关键帧 改为 DiscreteColorKeyFrame
颜色不变化 目标是元素而不是画刷 目标设为 SolidColorBrush
到点没有切换 KeyTime 超出时长 检查 Duration
动画后颜色跳回 FillBehavior=Stop 使用 HoldEnd 或保留基础值
暂停后颜色仍变化 控制的是错误 Storyboard 检查实例引用
重置后颜色不是蓝色 没有恢复基础颜色 显式设置颜色

5.12 小练习

  1. 改成“红、黄、绿”三阶段。
  2. 增加文字状态,在颜色变化时同步修改。
  3. 把最后一个关键帧改为 HoldEnd,观察结束状态。
  4. 比较 DiscreteColorKeyFrame 和 LinearColorKeyFrame 的画面差异。
  5. 做一个“网络状态”指示器:离线、连接中、在线。

6. 样条关键帧动画基本使用

6.1 本节目标

完成本节后,你要能够:

  1. 创建 SplineDoubleKeyFrame。
  2. 使用 KeySpline 控制关键帧之间的速度。
  3. 理解控制点对加速和减速的影响。
  4. 比较样条关键帧和缓动函数。
  5. 完成一个非线性速度的位移动画。

6.2 样条关键帧改变的是速度

样条关键帧仍然连续插值,不会跳变。它改变的是两个关键帧之间的速度曲线。

1
2
3
4
5
6
new SplineDoubleKeyFrame(
520,
KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(1800)))
{
KeySpline = new KeySpline(0, 1, 1, 0)
};

KeySpline 有四个参数:

1
KeySpline(x1, y1, x2, y2)

它们描述贝塞尔曲线的两个控制点。初学阶段不需要先推导数学,可以先观察四种常见组合的视觉差异。

6.3 常见 KeySpline 组合

组合 感觉
0,0,1,1 接近线性
0,1,1,0 快速启动、快速收尾,中间有明显加速
0.2,0.8,0.2,1 起步较慢,后段逐渐加速
0.8,0,1,0.2 起步较快,后段明显减速

不同组合没有绝对好坏,要与交互场景配合。

6.4 跟做:完成第 6 页 XAML

把第 6 个 TabItem 改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
<TabItem Header="6  样条关键帧动画">
<Grid Margin="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="430" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<StackPanel Margin="0,0,24,0">
<TextBlock Text="用贝塞尔控制速度变化"
FontSize="22"
FontWeight="Bold" />
<TextBlock Margin="0,8,0,0"
Foreground="#617089"
TextWrapping="Wrap"
Text="SplineDoubleKeyFrame 通过 KeySpline 控制关键帧之间的速度。" />
<TextBlock Margin="0,18,0,0"
Text="{Binding SplineStatus}"
Foreground="#176B4D"
FontWeight="Bold"
TextWrapping="Wrap" />
<WrapPanel Margin="0,14,0,0">
<Button Padding="14,7"
Margin="0,0,9,8"
Content="播放"
Click="OnSplinePlayClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="暂停"
Click="OnSplinePauseClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="继续"
Click="OnSplineResumeClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="重置"
Click="OnSplineResetClick" />
</WrapPanel>
</StackPanel>

<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="190" />
</Grid.RowDefinitions>

<Border Background="#FBFCFE"
BorderBrush="#DCE5F2"
BorderThickness="1"
CornerRadius="9"
ClipToBounds="True">
<Canvas Height="420">
<Line X1="45"
Y1="275"
X2="760"
Y2="275"
Stroke="#DCE5F2"
StrokeThickness="3" />
<Border x:Name="SplineCard"
Width="150"
Height="92"
Canvas.Left="45"
Canvas.Top="225"
Background="#176B4D"
CornerRadius="12">
<Border.RenderTransform>
<TranslateTransform x:Name="SplineTransform"
X="0" />
</Border.RenderTransform>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center">
<TextBlock Text="样条关键帧"
FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center" />
<TextBlock Margin="0,5,0,0"
Text="速度非线性"
Foreground="#D8F3E8"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
<TextBlock Canvas.Left="48"
Canvas.Top="322"
Text="起点"
Foreground="#617089" />
<TextBlock Canvas.Left="610"
Canvas.Top="322"
Text="终点 520"
Foreground="#617089" />
</Canvas>
</Border>

<ListBox Grid.Row="1"
Margin="0,12,0,0"
Padding="8"
Background="#0F172A"
Foreground="#DCE7F8"
BorderThickness="0"
FontFamily="Consolas"
ItemsSource="{Binding SplineLog}" />
</Grid>
</Grid>
</TabItem>

6.5 跟做:创建样条关键帧

增加字段:

1
2
private Storyboard? _splineStoryboard;
private bool _splinePaused;

增加方法:

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
private void BuildSplineAnimation()
{
var frames = new DoubleAnimationUsingKeyFrames
{
Duration = TimeSpan.FromMilliseconds(1800),
FillBehavior = FillBehavior.HoldEnd
};

frames.KeyFrames.Add(
new SplineDoubleKeyFrame(
0,
KeyTime.FromTimeSpan(TimeSpan.Zero))
{
KeySpline = new KeySpline(0, 1, 1, 0)
});

frames.KeyFrames.Add(
new SplineDoubleKeyFrame(
520,
KeyTime.FromTimeSpan(
TimeSpan.FromMilliseconds(1800)))
{
KeySpline = new KeySpline(0, 1, 1, 0)
});

Storyboard.SetTarget(frames, SplineTransform);
Storyboard.SetTargetProperty(
frames,
new PropertyPath(TranslateTransform.XProperty));

_splineStoryboard = new Storyboard();
_splineStoryboard.Children.Add(frames);
_splineStoryboard.Completed += (_, _) =>
{
ViewModel.SplineStatus = "样条关键帧完成,X = 520";
ViewModel.AddSpline("Completed | 已按样条速度到达终点");
};
}

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
public void SimulateSplinePlay()
{
_splineStoryboard?.Stop(this);
_splineStoryboard?.Begin(this, true);

_splinePaused = false;
ViewModel.SplineStatus =
"样条关键帧:速度由 KeySpline 控制";
ViewModel.AddSpline(
"Play | KeySpline(0, 1, 1, 0),X 0 → 520");
}

public void SimulateSplinePause()
{
if (_splineStoryboard is null || _splinePaused)
{
return;
}

_splineStoryboard.Pause(this);
_splinePaused = true;
ViewModel.SplineStatus = "样条关键帧已暂停";
ViewModel.AddSpline("Pause | 保留当前速度曲线位置");
}

public void SimulateSplineResume()
{
if (_splineStoryboard is null || !_splinePaused)
{
return;
}

_splineStoryboard.Resume(this);
_splinePaused = false;
ViewModel.SplineStatus = "样条关键帧已继续";
ViewModel.AddSpline("Resume | 按样条速度继续");
}

public void SimulateSplineReset()
{
_splineStoryboard?.Stop(this);
_splinePaused = false;
SplineTransform.X = 0;

ViewModel.SplineStatus = "已重置,X = 0";
ViewModel.AddSpline("Reset | 回到起点");
}

6.7 运行截图

样条关键帧页签

运行截图 6:卡片沿同一条位移路径前进,但速度由 KeySpline 控制。

6.8 KeySpline 与 EasingFunction 的关系

两者都能改变速度,但位置不同:

机制 作用位置
EasingFunction 作用于单个动画或关键帧的缓动
KeySpline 作用于关键帧之间的插值

在关键帧动画中,KeySpline 是控制每一段速度最直接的方式。

6.9 怎样观察速度差异

仅看一次播放可能不够明显。可以:

  1. 把时长设为 3000ms。
  2. 在 500ms、1000ms、1500ms 三个时刻截图。
  3. 比较卡片在同一时间间隔内移动的距离。
  4. 改变 KeySpline,重复观察。

同样时间间隔内移动距离不同,说明速度不同。

6.10 跟做检查点

  • 动画包含至少两个 SplineDoubleKeyFrame。
  • 两个关键帧都设置了 KeySpline。
  • 卡片移动连续,没有跳变。
  • 速度明显不是简单匀速。
  • 暂停和继续都有效。
  • 重置后回到 X=0。
  • 能说出 KeySpline 和 EasingFunction 的区别。

6.11 常见错误

现象 常见原因 修正方法
看起来和线性一样 KeySpline 接近线性 使用对比明显的控制点
动画跳变 使用了离散关键帧 改为 SplineDoubleKeyFrame
速度变化不明显 动画太短 加长时长再观察
所有关键帧速度相同 只给部分关键帧设置 KeySpline 检查每个关键帧
暂停后位置跳回 重置了基础值或错误时钟 检查暂停逻辑
修改后没变化 修改的是旧 Storyboard 重新创建并开始动画

6.12 小练习

  1. 把 KeySpline 改成 0,0,1,1,比较线性感。
  2. 改成 0.8,0,1,0.2,观察后段减速。
  3. 增加三个样条关键帧,做“慢快慢”的移动。
  4. 同时修改 ScaleTransform,让移动和缩放使用不同速度。
  5. 做一个带自然回弹的卡片进入动画。

三类关键帧放在一起比较

同一组关键帧

假设都使用下面的时间点和值:

1
2
3
4
0ms   → 0
800ms → 120
1600ms → 360
2400ms → 520

区别只在关键帧类型:

1
2
3
new LinearDoubleKeyFrame(...)
new DiscreteDoubleKeyFrame(...)
new SplineDoubleKeyFrame(...)
类型 中间值 视觉特点 适合场景
线性 连续线性插值 折线感、稳定 多阶段移动
离散 不插值 台阶式跳变 状态切换
样条 贝塞尔插值 连续、速度自然 自然移动、回弹

选择建议

1
2
3
需要连续变化,但不需要特殊速度:线性
需要到点瞬间切换:离散
需要连续变化,并希望速度自然:样条

完整项目结构

完成六章后,练习项目可以整理成:

1
2
3
4
5
6
7
8
9
WpfAnimationKeyFramePractice
├─ App.xaml
├─ App.xaml.cs
├─ MainWindow.xaml
├─ MainWindow.xaml.cs
├─ AnimationRules.cs
└─ ViewModels
├─ ObservableObject.cs
└─ MainViewModel.cs

随附完整示例还包含:

1
2
3
4
5
WpfAnimationKeyFrameDemo
├─ SelfTestRunner.cs 自动检查动画和关键帧配置
├─ UiProbe.cs 页签截图
└─ Models
└─ AppOptions.cs 自测与截图参数

自动测试

随附示例包含十项自动检查:

1
2
3
4
$exe = "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAnimationKeyFrameDemo\bin\Debug\net9.0-windows\WpfAnimationKeyFrameDemo.exe"
$process = Start-Process -FilePath $exe -ArgumentList "--self-test" -PassThru -Wait
$process.ExitCode
Get-Content "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAnimationKeyFrameDemo\bin\Debug\net9.0-windows\self-test-results.txt" -Encoding UTF8

检查内容:

1
2
3
4
5
6
7
8
9
10
简单动画配置
位移动画目标值
组合动画时间线数量
线性关键帧数量
离散关键帧类型
样条关键帧类型
KeyTime 规则
位置范围规则
暂停状态
继续状态

常见错误速查

简单动画

问题 优先检查
动画没反应 目标是否是依赖属性
动画结束跳回 FillBehavior
暂停无效 Begin 是否创建可控时钟
动画叠加 播放前是否 Stop 旧时钟
重置无效 是否同时恢复基础值

位移动画

问题 优先检查
控件没有移动 动画目标是否为变换对象
布局被挤动 是否修改了 Margin
位置计算错误 Canvas.Left 和 X 是否混用
重置后偏移 是否设置 X=0
点击区域不对 是否需要坐标转换

颜色与显示

问题 优先检查
颜色动画报错 目标是否为 SolidColorBrush
画刷不能修改 是否已冻结
只变一个属性 Storyboard 是否加入全部动画
暂停不同步 是否统一控制一个 Storyboard
重置颜色错误 是否显式恢复基础颜色

关键帧

问题 优先检查
关键帧不生效 KeyTime 是否在总时长内
顺序错误 关键帧是否按时间排列
中间值跳变 是否用了离散关键帧
速度不自然 尝试样条关键帧
动画突然停止 最后一个 KeyTime 是否等于 Duration

综合练习

练习一:做一个登录反馈动画

要求:

  1. 点击登录后按钮颜色变化。
  2. 按钮透明度短暂降低。
  3. 登录成功后显示一个绿色卡片。
  4. 所有动画统一由一个 Storyboard 管理。
  5. 失败时立即停止动画并恢复原状。

练习二:做一个通知卡片进入动画

要求:

  1. 卡片从右侧移入。
  2. 使用 TranslateTransform,不要修改 Margin。
  3. 使用样条关键帧让进入速度自然。
  4. 同时播放透明度变化。
  5. 动画完成后保持最终位置。

练习三:做一个状态指示器

要求:

  1. 使用离散颜色关键帧。
  2. 状态依次为“离线、连接中、在线”。
  3. 每个状态至少持续一秒。
  4. 暂停时状态保持。
  5. 重置后回到离线状态。

练习四:比较三种关键帧

要求:

  1. 三个卡片使用相同的时间点和目标值。
  2. 分别使用线性、离散、样条关键帧。
  3. 同时开始播放。
  4. 在同一时刻截图比较位置。
  5. 写出三种方式适合的真实场景。

结语

这一篇的六个主题最终可以归纳成三个层次:

1
2
3
4
5
6
7
8
第一层:一条属性时间线
DoubleAnimation + Storyboard

第二层:选择正确的目标
TranslateTransform / SolidColorBrush / ScaleTransform

第三层:用关键帧描述复杂时间线
Linear / Discrete / Spline

学习动画时,不要只记类名。每次实现一个效果,都可以先回答:

1
2
3
4
5
6
我要改变哪个依赖属性?
这个属性在哪个对象上?
动画结束后要保留还是恢复?
中间值是连续变化还是瞬间跳变?
需要怎样的速度曲线?
谁来统一暂停、继续和停止?

能稳定回答这些问题,再复杂的动画也可以拆成几条清楚的时间线。