面向已经会创建简单动画和关键帧动画,准备继续学习变化节奏、运动轨迹、播放速率与重复累加的读者。
推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,本文涉及的 API 在 .NET 9 与 .NET 10 中没有变化。
最后更新:2026-10-22

目录

  1. 缓动关键帧动画的使用
  2. 路径动画的使用
  3. 动画对象的相关速率
  4. 动画对象的执行累加计算

写在前面

这份笔记解决什么问题

前面的笔记已经完成:

1
2
3
DoubleAnimation
Storyboard
线性、离散、样条关键帧

继续做动画时,又会遇到下面这些问题:

1
2
3
4
5
6
7
8
9
起点、终点和时长都一样,为什么有的动画起步慢,有的收尾慢?
EasingDoubleKeyFrame 和 EasingFunction 应该怎样选?
怎样让一个元素沿曲线移动,而不是只走直线?
为什么路径动画需要两条 DoubleAnimationUsingPath?
SpeedRatio 和 AccelerationRatio 到底有什么区别?
AccelerationRatio 与 DecelerationRatio 相加为什么不能超过 1?
IsAdditive 是“加上基础值”还是“每轮继续累加”?
IsCumulative 为什么会让重复动画越走越远?
播放、暂停、继续之后,累加值应该怎样计算?

这篇笔记会把这些概念拆成四条独立实验:

1
2
3
4
缓动关键帧:改变进度曲线
路径动画:改变运动轨迹
相关速率:改变播放快慢与内部节奏
执行累加:改变重复播放的结果

四章知识地图

图 1:四个主题分别控制节奏、轨迹、速度和重复叠加规则。

推荐学习顺序

  1. 先运行随附示例,依次操作四个页签。
  2. 第 1 章比较三种 EasingMode,先建立“进度曲线”的直觉。
  3. 第 2 章观察路径、X 动画和 Y 动画三者怎样配合。
  4. 第 3 章先只看 SpeedRatio,再学习加减速比例。
  5. 第 4 章把基础值、动画偏移和重复轮次分开计算。
  6. 每章都完成检查点和小练习,再进入下一章。

随附示例位置

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

运行:

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

Visual Studio 中:

  1. 打开 WpfAnimationEasingPathDemo.csproj。
  2. 确认启动项目是 WpfAnimationEasingPathDemo。
  3. 按 F5。
  4. 四个页签分别点击“播放、暂停、继续、重置”。
  5. 观察动画和日志是否同步。

创建自己的跟做项目

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

namespace WpfEasingPathPractice.ViewModels;

public sealed class MainViewModel : ObservableObject
{
private string _easingStatus = "等待播放";
private string _pathStatus = "等待播放";
private string _rateStatus = "等待播放";
private string _accumulationStatus = "等待播放";
private string _accumulationValue = "基础 30";

public ObservableCollection<string> EasingLog { get; } = new();
public ObservableCollection<string> PathLog { get; } = new();
public ObservableCollection<string> RateLog { get; } = new();
public ObservableCollection<string> AccumulationLog { get; } = new();

public string EasingStatus
{
get => _easingStatus;
set => SetProperty(ref _easingStatus, value);
}

public string PathStatus
{
get => _pathStatus;
set => SetProperty(ref _pathStatus, value);
}

public string RateStatus
{
get => _rateStatus;
set => SetProperty(ref _rateStatus, value);
}

public string AccumulationStatus
{
get => _accumulationStatus;
set => SetProperty(ref _accumulationStatus, value);
}

public string AccumulationValue
{
get => _accumulationValue;
set => SetProperty(ref _accumulationValue, value);
}

public void AddEasing(string line) => Add(EasingLog, line);
public void AddPath(string line) => Add(PathLog, line);
public void AddRate(string line) => Add(RateLog, line);
public void AddAccumulation(string line) => Add(AccumulationLog, 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
using System.Windows.Media.Animation;

namespace WpfEasingPathPractice;

public static class AnimationRules
{
public static bool IsValidRatioSum(
double accelerationRatio,
double decelerationRatio)
{
return accelerationRatio >= 0
&& decelerationRatio >= 0
&& accelerationRatio + decelerationRatio <= 1;
}

public static string DescribeEasing(EasingMode mode)
{
return mode switch
{
EasingMode.EaseIn => "先慢后快",
EasingMode.EaseOut => "先快后慢",
EasingMode.EaseInOut => "两端慢,中间快",
_ => "未知"
};
}

public static double ClampX(
double x,
double min,
double max)
{
return Math.Clamp(x, min, max);
}
}

这些纯规则不依赖窗口,适合先单独验证,再放进界面。


1. 缓动关键帧动画的使用

1.1 本节目标

完成本节后,你要能够:

  1. 创建 EasingDoubleKeyFrame。
  2. 使用 EasingFunction 改变进度曲线。
  3. 比较 EaseIn、EaseOut、EaseInOut。
  4. 区分缓动关键帧和普通 From/To 动画。
  5. 同时播放三条缓动时间线并统一控制。

1.2 缓动改变的是进度

三种缓动曲线

图 2:From、To、Duration 相同,进度随时间的曲线不同。

横轴表示归一化时间:

1
2
0 = 动画开始
1 = 动画结束

纵轴表示归一化进度:

1
2
0 = 起点值
1 = 终点值

三种常见模式:

模式 速度变化
EaseIn 开始慢,后面快
EaseOut 开始快,后面慢
EaseInOut 两端慢,中间快

缓动不会改变起点、终点和总时长,只会改变“什么时候走到哪里”。

1.3 为什么使用缓动关键帧

普通关键帧可以描述:

1
2
0ms → 0
1800ms → 520

但没有描述两个时间点之间的速度曲线。

EasingDoubleKeyFrame 可以附加 EasingFunction:

1
2
3
4
5
6
7
8
9
10
new EasingDoubleKeyFrame(
520,
KeyTime.FromTimeSpan(
TimeSpan.FromMilliseconds(1800)))
{
EasingFunction = new CubicEase
{
EasingMode = EasingMode.EaseOut
}
};

它仍然属于关键帧动画,只是两个关键帧之间不再使用普通线性插值。

1.4 跟做:窗口与第 1 页

先把 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
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
<Window x:Class="WpfEasingPathPractice.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="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="EasingDoubleKeyFrame 在关键帧之间加入缓动。" />
<TextBlock Margin="0,18,0,0"
Text="{Binding EasingStatus}"
Foreground="#174EA6"
FontWeight="Bold"
TextWrapping="Wrap" />
<WrapPanel Margin="0,14,0,0">
<Button Padding="14,7"
Margin="0,0,9,8"
Content="播放"
Click="OnEasingPlayClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="暂停"
Click="OnEasingPauseClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="继续"
Click="OnEasingResumeClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="重置"
Click="OnEasingResetClick" />
</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="100" X2="760" Y2="100"
Stroke="#DCE5F2" StrokeThickness="3" />
<Line X1="45" Y1="210" X2="760" Y2="210"
Stroke="#DCE5F2" StrokeThickness="3" />
<Line X1="45" Y1="320" X2="760" Y2="320"
Stroke="#DCE5F2" StrokeThickness="3" />

<Border x:Name="EaseInCard"
Width="118" Height="62"
Canvas.Left="45" Canvas.Top="69"
Background="#2456A6"
CornerRadius="10">
<Border.RenderTransform>
<TranslateTransform x:Name="EaseInTransform" />
</Border.RenderTransform>
<TextBlock Text="EaseIn"
Foreground="White"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>

<Border x:Name="EaseOutCard"
Width="118" Height="62"
Canvas.Left="45" Canvas.Top="179"
Background="#176B4D"
CornerRadius="10">
<Border.RenderTransform>
<TranslateTransform x:Name="EaseOutTransform" />
</Border.RenderTransform>
<TextBlock Text="EaseOut"
Foreground="White"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>

<Border x:Name="EaseInOutCard"
Width="118" Height="62"
Canvas.Left="45" Canvas.Top="289"
Background="#D97706"
CornerRadius="10">
<Border.RenderTransform>
<TranslateTransform x:Name="EaseInOutTransform" />
</Border.RenderTransform>
<TextBlock Text="EaseInOut"
Foreground="White"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</Canvas>
</Border>

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

<TabItem Header="2 路径动画">
</TabItem>
<TabItem Header="3 动画对象的相关速率">
</TabItem>
<TabItem Header="4 动画对象的执行累加">
</TabItem>
</TabControl>
</Window>

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
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
using System.Windows;
using System.Windows.Media.Animation;
using WpfEasingPathPractice.ViewModels;

namespace WpfEasingPathPractice;

public partial class MainWindow : Window
{
private Storyboard? _easingStoryboard;
private bool _easingPaused;

public MainWindow()
{
InitializeComponent();

ViewModel = new MainViewModel();
DataContext = ViewModel;

BuildEasingAnimation();
}

public MainViewModel ViewModel { get; }

private void BuildEasingAnimation()
{
var easeIn = CreateEasingFrames(EasingMode.EaseIn);
Storyboard.SetTarget(easeIn, EaseInTransform);
Storyboard.SetTargetProperty(
easeIn,
new PropertyPath(TranslateTransform.XProperty));

var easeOut = CreateEasingFrames(EasingMode.EaseOut);
Storyboard.SetTarget(easeOut, EaseOutTransform);
Storyboard.SetTargetProperty(
easeOut,
new PropertyPath(TranslateTransform.XProperty));

var easeInOut = CreateEasingFrames(EasingMode.EaseInOut);
Storyboard.SetTarget(easeInOut, EaseInOutTransform);
Storyboard.SetTargetProperty(
easeInOut,
new PropertyPath(TranslateTransform.XProperty));

_easingStoryboard = new Storyboard();
_easingStoryboard.Children.Add(easeIn);
_easingStoryboard.Children.Add(easeOut);
_easingStoryboard.Children.Add(easeInOut);
}

private static DoubleAnimationUsingKeyFrames CreateEasingFrames(
EasingMode mode)
{
var frames = new DoubleAnimationUsingKeyFrames
{
Duration = TimeSpan.FromMilliseconds(1800),
FillBehavior = FillBehavior.HoldEnd
};

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

frames.KeyFrames.Add(
new EasingDoubleKeyFrame(
520,
KeyTime.FromTimeSpan(
TimeSpan.FromMilliseconds(1800)))
{
EasingFunction = new CubicEase
{
EasingMode = mode
}
});

return frames;
}
}

三条时间线终点都是 520,时长都是 1800ms,只有缓动模式不同。

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
50
51
52
53
54
55
56
57
58
59
60
61
62
public void SimulateEasingPlay()
{
_easingStoryboard?.Stop(this);
_easingStoryboard?.Begin(this, true);

_easingPaused = false;
ViewModel.EasingStatus =
"三条缓动时间线同时开始,终点相同";
ViewModel.AddEasing(
"Play | EaseIn / EaseOut / EaseInOut");
}

public void SimulateEasingPause()
{
if (_easingStoryboard is null || _easingPaused)
{
return;
}

_easingStoryboard.Pause(this);
_easingPaused = true;
ViewModel.EasingStatus = "缓动关键帧已暂停";
ViewModel.AddEasing("Pause | 三条时间线同时暂停");
}

public void SimulateEasingResume()
{
if (_easingStoryboard is null || !_easingPaused)
{
return;
}

_easingStoryboard.Resume(this);
_easingPaused = false;
ViewModel.EasingStatus = "缓动关键帧已继续";
ViewModel.AddEasing("Resume | 从暂停位置继续");
}

public void SimulateEasingReset()
{
_easingStoryboard?.Stop(this);
_easingPaused = false;

EaseInTransform.X = 0;
EaseOutTransform.X = 0;
EaseInOutTransform.X = 0;

ViewModel.EasingStatus = "已重置,三个卡片回到起点";
ViewModel.AddEasing("Reset | 所有 X = 0");
}

private void OnEasingPlayClick(object sender, RoutedEventArgs e)
=> SimulateEasingPlay();

private void OnEasingPauseClick(object sender, RoutedEventArgs e)
=> SimulateEasingPause();

private void OnEasingResumeClick(object sender, RoutedEventArgs e)
=> SimulateEasingResume();

private void OnEasingResetClick(object sender, RoutedEventArgs e)
=> SimulateEasingReset();

1.7 运行截图

缓动关键帧页签

运行截图 1:三条时间线同时开始,因为缓动不同,同一时刻的位置不同。

1.8 EasingDoubleKeyFrame 和 EasingFunction 的关系

简单动画可以写成:

1
2
3
4
5
6
7
8
9
10
var animation = new DoubleAnimation
{
From = 0,
To = 520,
Duration = TimeSpan.FromMilliseconds(1800),
EasingFunction = new CubicEase
{
EasingMode = EasingMode.EaseOut
}
};

关键帧动画可以写成:

1
2
3
4
5
6
7
8
9
10
new EasingDoubleKeyFrame(
520,
KeyTime.FromTimeSpan(
TimeSpan.FromMilliseconds(1800)))
{
EasingFunction = new CubicEase
{
EasingMode = EasingMode.EaseOut
}
};

区别是:

1
2
简单动画:缓动作用于整条 From → To 时间线
缓动关键帧:缓动作用于关键帧之间的那一段

1.9 跟做检查点

  • 三条动画同时开始。
  • 三条动画的终点都是 X=520。
  • 三条动画的时长都是 1800ms。
  • EaseIn 起步较慢。
  • EaseOut 起步较快、收尾较慢。
  • EaseInOut 两端慢、中间快。
  • 暂停和继续能同时控制三条时间线。

1.10 常见错误

现象 常见原因 修正方法
三条动画终点不同 To 或关键帧值写错 统一目标值
看不出缓动差异 时长太短 先使用 1500ms 以上
暂停只停一条 分别控制多个 Storyboard 统一放入一个 Storyboard
缓动没有生效 使用了普通 LinearKeyFrame 改为 EasingDoubleKeyFrame
动画结束后跳回 FillBehavior 或基础值问题 检查结束行为
播放叠加 每次播放都创建新时钟 开始前 Stop 旧时钟

1.11 小练习

  1. 把三种模式改成 BackEase。
  2. 比较 CubicEase 和 QuadraticEase。
  3. 把时长改成 3000ms,观察差异。
  4. 把终点改成 420,重新比较。
  5. 使用 ElasticEase 做一次弹性进入效果。

2. 路径动画的使用

2.1 本节目标

完成本节后,你要能够:

  1. 创建 PathGeometry。
  2. 使用 BezierSegment 定义曲线路径。
  3. 使用 DoubleAnimationUsingPath。
  4. 用 PathAnimationSource.X 和 .Y 同时驱动两个属性。
  5. 正确重置路径动画。

2.2 路径动画不是一条属性动画

普通位移动画只改一个数:

1
TranslateTransform.X

路径动画要沿一条二维曲线移动,需要同时得到:

1
2
路径上的 X 坐标
路径上的 Y 坐标

因此通常使用两条动画:

1
2
DoubleAnimationUsingPath(Source=X)→ TranslateTransform.X
DoubleAnimationUsingPath(Source=Y)→ TranslateTransform.Y

路径坐标映射

图 3:一条几何路径提供 X、Y,两个动画分别写入两个依赖属性。

2.3 PathGeometry 怎样定义

本例使用:

1
2
3
4
5
6
7
8
9
10
<PathGeometry x:Key="MotionPathGeometry">
<PathGeometry.Figures>
<PathFigure StartPoint="60,260"
IsClosed="False">
<BezierSegment Point1="240,40"
Point2="500,420"
Point3="680,160" />
</PathFigure>
</PathGeometry.Figures>
</PathGeometry>

含义:

1
2
3
4
StartPoint:起点
Point1:第一个控制点
Point2:第二个控制点
Point3:终点

路径本身是几何形状,不决定动画速度。速度仍由时长、缓动和时间线控制。

2.4 跟做:完成第 2 页 XAML

先在 Window.Resources 中加入路径资源:

1
2
3
4
5
6
7
8
9
10
<PathGeometry x:Key="MotionPathGeometry">
<PathGeometry.Figures>
<PathFigure StartPoint="60,260"
IsClosed="False">
<BezierSegment Point1="240,40"
Point2="500,420"
Point3="680,160" />
</PathFigure>
</PathGeometry.Figures>
</PathGeometry>

再把第 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
<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="路径提供 X、Y,两条动画分别驱动两个依赖属性。" />
<TextBlock Margin="0,18,0,0"
Text="{Binding PathStatus}"
Foreground="#176B4D"
FontWeight="Bold"
TextWrapping="Wrap" />
<WrapPanel Margin="0,14,0,0">
<Button Padding="14,7"
Margin="0,0,9,8"
Content="沿路径播放"
Click="OnPathPlayClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="暂停"
Click="OnPathPauseClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="继续"
Click="OnPathResumeClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="重置"
Click="OnPathResetClick" />
</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">
<Path Data="{StaticResource MotionPathGeometry}"
Stroke="#9DB7E1"
StrokeThickness="5"
StrokeDashArray="7,6" />

<Border x:Name="PathCard"
Width="112"
Height="72"
Canvas.Left="60"
Canvas.Top="260"
Background="#176B4D"
CornerRadius="12">
<Border.RenderTransform>
<TranslateTransform x:Name="PathTransform" />
</Border.RenderTransform>
<TextBlock Text="沿路径"
FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</Canvas>
</Border>

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

2.5 跟做:创建两条路径动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private Storyboard? _pathStoryboard;
private bool _pathPaused;

private void BuildPathAnimation()
{
var geometry = (PathGeometry)FindResource(
"MotionPathGeometry");

var x = new DoubleAnimationUsingPath
{
PathGeometry = geometry,
Source = PathAnimationSource.X,
Duration = TimeSpan.FromMilliseconds(2600),
FillBehavior = FillBehavior.HoldEnd
};

Storyboard.SetTarget(x, PathTransform);
Storyboard.SetTargetProperty(
x,
new PropertyPath(TranslateTransform.XProperty));

var y = new DoubleAnimationUsingPath
{
PathGeometry = geometry,
Source = PathAnimationSource.Y,
Duration = TimeSpan.FromMilliseconds(2600),
FillBehavior = FillBehavior.HoldEnd
};

Storyboard.SetTarget(y, PathTransform);
Storyboard.SetTargetProperty(
y,
new PropertyPath(TranslateTransform.YProperty));

_pathStoryboard = new Storyboard();
_pathStoryboard.Children.Add(x);
_pathStoryboard.Children.Add(y);
}

构造函数中调用:

1
BuildPathAnimation();

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

_pathPaused = false;
ViewModel.PathStatus =
"卡片正在沿贝塞尔路径移动";
ViewModel.AddPath(
"Play | PathAnimationSource.X + PathAnimationSource.Y");
}

public void SimulatePathPause()
{
if (_pathStoryboard is null || _pathPaused)
{
return;
}

_pathStoryboard.Pause(this);
_pathPaused = true;
ViewModel.PathStatus = "路径动画已暂停";
ViewModel.AddPath("Pause | 保留当前路径进度");
}

public void SimulatePathResume()
{
if (_pathStoryboard is null || !_pathPaused)
{
return;
}

_pathStoryboard.Resume(this);
_pathPaused = false;
ViewModel.PathStatus = "路径动画已继续";
ViewModel.AddPath("Resume | 从暂停位置继续");
}

public void SimulatePathReset()
{
_pathStoryboard?.Stop(this);
_pathPaused = false;

PathTransform.X = 0;
PathTransform.Y = 0;

ViewModel.PathStatus = "已重置,路径偏移归零";
ViewModel.AddPath("Reset | TranslateTransform = (0, 0)");
}

2.7 运行截图

路径动画页签

运行截图 2:卡片沿贝塞尔路径运动,X 与 Y 两条时间线同步推进。

2.8 两条动画必须保持一致

X 和 Y 动画至少要保持:

1
2
3
4
PathGeometry 相同
Duration 相同
开始时间相同
SpeedRatio 相同

如果只有时长不同,X 已经到达终点,Y 还在中间,卡片就会偏离预期路径。

2.9 路径动画和布局位置

路径动画作用于 TranslateTransform.X 和 .Y,所以:

1
2
3
Canvas.Left / Canvas.Top 不变
布局位置不变
视觉位置变化

这与上一章位移动画的思路一致:优先改变渲染变换,而不是连续修改布局属性。

2.10 跟做检查点

  • 路径曲线能在画布中看到。
  • 卡片从路径起点开始。
  • 播放时卡片沿曲线运动。
  • X、Y 同时变化。
  • 暂停时卡片停在路径中间。
  • 继续后沿剩余路径前进。
  • 重置后 X、Y 都回到 0。

2.11 常见错误

现象 常见原因 修正方法
只水平或垂直移动 只创建了一条路径动画 X、Y 各建一条
卡片不沿路径 X/Y 动画时长不同 统一时长与开始时间
路径找不到 资源 Key 写错 检查 StaticResource
路径动画报错 目标是元素而不是变换 目标设为 TranslateTransform
重置后仍有偏移 只重置了 X 同时重置 X、Y
动画结束后消失 填充行为或路径坐标问题 检查 FillBehavior 和起点

2.12 小练习

  1. 把路径改成椭圆。
  2. 使用 LineSegment 做折线路径。
  3. 使用 ArcSegment 做圆弧路径。
  4. 比较路径动画和 X/Y 两条普通动画的差异。
  5. 让卡片在路径终点后再沿原路返回。

3. 动画对象的相关速率

3.1 本节目标

完成本节后,你要能够:

  1. 使用 SpeedRatio 改变整体播放速度。
  2. 使用 AccelerationRatio 控制开头加速比例。
  3. 使用 DecelerationRatio 控制结尾减速比例。
  4. 理解加减速比例之和不能超过 1。
  5. 区分“整体速率”和“内部节奏”。

3.2 三个属性分别做什么

速率属性分工

图 4:SpeedRatio 改变整体快慢,加减速比例改变时间线内部节奏。

属性 作用
SpeedRatio 整体播放速度倍率
AccelerationRatio 时间线开头用于加速的比例
DecelerationRatio 时间线结尾用于减速的比例

3.3 SpeedRatio 怎样理解

1
2
3
4
5
6
7
var fast = new DoubleAnimation
{
From = 0,
To = 520,
Duration = TimeSpan.FromSeconds(1),
SpeedRatio = 1.5
};

可以粗略理解为:

1
2
3
1.00×:1 秒走完
1.50×:大约 0.67 秒走完
0.65×:大约 1.54 秒走完

Duration 描述的是时间线自身的时长,SpeedRatio 再对播放时钟进行倍速。

3.4 AccelerationRatio 与 DecelerationRatio

这两个属性描述时间线内部的节奏比例,取值范围通常为 0 到 1。

例如:

1
2
3
4
5
6
7
8
var animation = new DoubleAnimation
{
From = 0,
To = 520,
Duration = TimeSpan.FromSeconds(2),
AccelerationRatio = 0.3,
DecelerationRatio = 0.4
};

可以理解为:

1
2
3
开头 30% 的时间用于加速
结尾 40% 的时间用于减速
中间剩余部分接近匀速

约束是:

1
AccelerationRatio + DecelerationRatio <= 1

否则时间线无法分配这些阶段。

3.5 跟做:完成第 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
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
<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="SpeedRatio 改变整体快慢;加减速比例改变内部节奏。" />
<TextBlock Margin="0,18,0,0"
Text="{Binding RateStatus}"
Foreground="#8A5A00"
FontWeight="Bold"
TextWrapping="Wrap" />
<WrapPanel Margin="0,14,0,0">
<Button Padding="14,7"
Margin="0,0,9,8"
Content="开始比赛"
Click="OnRatePlayClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="暂停"
Click="OnRatePauseClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="继续"
Click="OnRateResumeClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="重置"
Click="OnRateResetClick" />
</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="100" X2="760" Y2="100"
Stroke="#DCE5F2" StrokeThickness="3" />
<Line X1="45" Y1="210" X2="760" Y2="210"
Stroke="#DCE5F2" StrokeThickness="3" />
<Line X1="45" Y1="320" X2="760" Y2="320"
Stroke="#DCE5F2" StrokeThickness="3" />

<Border x:Name="SlowCard"
Width="110" Height="60"
Canvas.Left="45" Canvas.Top="70"
Background="#2456A6"
CornerRadius="10">
<Border.RenderTransform>
<TranslateTransform x:Name="SlowTransform" />
</Border.RenderTransform>
<TextBlock Text="0.65×"
Foreground="White"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>

<Border x:Name="NormalCard"
Width="110" Height="60"
Canvas.Left="45" Canvas.Top="180"
Background="#176B4D"
CornerRadius="10">
<Border.RenderTransform>
<TranslateTransform x:Name="NormalTransform" />
</Border.RenderTransform>
<TextBlock Text="1.00×"
Foreground="White"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>

<Border x:Name="FastCard"
Width="110" Height="60"
Canvas.Left="45" Canvas.Top="290"
Background="#D97706"
CornerRadius="10">
<Border.RenderTransform>
<TranslateTransform x:Name="FastTransform" />
</Border.RenderTransform>
<TextBlock Text="1.50×"
Foreground="White"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</Canvas>
</Border>

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

3.6 跟做:创建三档 SpeedRatio

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
private Storyboard? _rateStoryboard;
private bool _ratePaused;

private void BuildRateAnimation()
{
var slow = CreateMoveAnimation(520);
slow.SpeedRatio = 0.65;
Storyboard.SetTarget(slow, SlowTransform);
Storyboard.SetTargetProperty(
slow,
new PropertyPath(TranslateTransform.XProperty));

var normal = CreateMoveAnimation(520);
normal.SpeedRatio = 1.00;
Storyboard.SetTarget(normal, NormalTransform);
Storyboard.SetTargetProperty(
normal,
new PropertyPath(TranslateTransform.XProperty));

var fast = CreateMoveAnimation(520);
fast.SpeedRatio = 1.50;
Storyboard.SetTarget(fast, FastTransform);
Storyboard.SetTargetProperty(
fast,
new PropertyPath(TranslateTransform.XProperty));

_rateStoryboard = new Storyboard();
_rateStoryboard.Children.Add(slow);
_rateStoryboard.Children.Add(normal);
_rateStoryboard.Children.Add(fast);
}

private static DoubleAnimation CreateMoveAnimation(double to)
{
return new DoubleAnimation
{
From = 0,
To = to,
Duration = TimeSpan.FromSeconds(1),
FillBehavior = FillBehavior.HoldEnd,
EasingFunction = new QuadraticEase
{
EasingMode = EasingMode.EaseInOut
}
};
}

3.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
50
public void SimulateRatePlay()
{
_rateStoryboard?.Stop(this);
_rateStoryboard?.Begin(this, true);

_ratePaused = false;
ViewModel.RateStatus =
"三张卡片同一时刻出发,SpeedRatio 不同";
ViewModel.AddRate(
"Play | SpeedRatio = 0.65 / 1.00 / 1.50");
}

public void SimulateRatePause()
{
if (_rateStoryboard is null || _ratePaused)
{
return;
}

_rateStoryboard.Pause(this);
_ratePaused = true;
ViewModel.RateStatus = "速率对比已暂停";
ViewModel.AddRate("Pause | 三条时间线同时停住");
}

public void SimulateRateResume()
{
if (_rateStoryboard is null || !_ratePaused)
{
return;
}

_rateStoryboard.Resume(this);
_ratePaused = false;
ViewModel.RateStatus = "速率对比已继续";
ViewModel.AddRate("Resume | 按各自 SpeedRatio 继续");
}

public void SimulateRateReset()
{
_rateStoryboard?.Stop(this);
_ratePaused = false;

SlowTransform.X = 0;
NormalTransform.X = 0;
FastTransform.X = 0;

ViewModel.RateStatus = "已重置,三张卡片回到起点";
ViewModel.AddRate("Reset | 所有 X = 0");
}

3.8 运行截图

速率对比页签

运行截图 3:同一时刻,1.50× 走得最远,0.65× 走得最少。

3.9 三种速率属性的选择

需求 选择
整体播放快一点 提高 SpeedRatio
整体播放慢一点 降低 SpeedRatio
开头逐渐加速 设置 AccelerationRatio
结尾逐渐减速 设置 DecelerationRatio
两端慢、中间快 同时设置加减速比例,或使用 Easing

如果只是想让速度曲线自然,EasingFunction 通常更直观。如果希望调整时间线的加速和减速时间占比,再考虑两个 Ratio 属性。

3.10 跟做检查点

  • 三张卡片同时开始。
  • 三张卡片的目标位置相同。
  • 1.50× 最早到达。
  • 0.65× 最后到达。
  • 暂停时三条时间线一起停住。
  • 继续后按各自速度继续。
  • 知道 SpeedRatio 与加减速比例的区别。

3.11 常见错误

现象 常见原因 修正方法
速率没有区别 所有动画 SpeedRatio 都是 1 设置不同倍率
动画不开始 SpeedRatio 为 0 或负数 使用大于 0 的值
加速比例设置失败 两个比例之和超过 1 重新分配比例
和缓动混淆 把 Ratio 当成 Easing 区分整体倍率与内部速度曲线
暂停后位置跳动 重置了基础值 检查暂停逻辑
播放叠加 每次播放没有停止旧时钟 先 Stop 再 Begin

3.12 小练习

  1. 增加 2.00× 卡片,观察到达顺序。
  2. 把 0.65× 改成 0.40×。
  3. 给三条动画设置不同的 AccelerationRatio。
  4. 设置 AccelerationRatio=0.3、DecelerationRatio=0.4。
  5. 故意让两个比例之和大于 1,记录异常并恢复。

4. 动画对象的执行累加计算

4.1 本节目标

完成本节后,你要能够:

  1. 理解动画值、基础值和最终结果的关系。
  2. 使用 IsAdditive 把动画结果叠加到基础值。
  3. 使用 IsCumulative 让重复轮次继续累加。
  4. 区分“加法”和“累计”。
  5. 正确计算重复播放后的最终值。

4.2 先固定一个计算模型

累加矩阵

图 5:基础值 30、单轮偏移 80、重复 3 次时的不同结果。

本节统一使用:

1
2
3
基础 X = 30
单轮动画偏移:0 → 80
重复次数:3

关键是分清:

1
2
3
基础值:控件原本的属性值
动画值:时间线在当前时刻算出的值
最终值:WPF 把两者组合后的结果

4.3 普通重复

动画:

1
2
3
4
5
6
7
8
9
10
new DoubleAnimation
{
From = 0,
To = 80,
Duration = TimeSpan.FromMilliseconds(900),
RepeatBehavior = new RepeatBehavior(3),
FillBehavior = FillBehavior.HoldEnd,
IsAdditive = false,
IsCumulative = false
};

每轮都从 0 到 80,最终一轮停在 80。普通动画不会把基础值再加一次,所以结果是:

1
2
动画输出:0 → 80
最终结果:80

普通动画会替代属性原来的动画层显示值,所以不要把基础值 30 再加一次。基础值仍然存在,但普通动画播放期间由动画输出控制显示结果。

4.4 IsAdditive

IsAdditive=true 表示动画值要叠加到基础值上,而不是直接替换基础值。

1
2
IsAdditive = true,
IsCumulative = false

一轮结束仍然得到:

1
30 + 80 = 110

IsAdditive 的关键是“在基础值上再加动画输出”。本例重复播放时,每一轮仍只叠加一次单轮结果,所以最终停在 110。

4.5 IsCumulative

IsCumulative=true 让重复的每一轮在前一轮结果上继续累加:

1
2
3
第 1 轮:30 + 80 = 110
第 2 轮:110 + 80 = 190
第 3 轮:190 + 80 = 270

但在示例中为了与“基础值 30 + 增量 80 × 3”这一讲解口径一致,我们显示:

1
30 + 80 × 3 = 270

三组结果可以并排比较:

1
2
3
普通:80
IsAdditive:30 + 80 = 110
IsAdditive + IsCumulative:30 + 80 × 3 = 270

IsCumulative 的实际累计结果取决于基础值、关键帧和重复轮次,不能只凭一个截图背数字。最可靠的方法是读取属性实际值、记录每轮开始和结束,再与公式逐项核对。

4.6 为什么不能只看最终值

同一个最终值可能来自不同组合:

1
2
3
基础值 30 + 动画 80
基础值 110 + 动画 0
基础值 30 + 两轮各 40

因此排查累加问题时,要同时记录:

1
2
3
4
5
基础值
动画值
当前轮次
是否 Additive
是否 Cumulative

4.7 跟做:完成第 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
112
113
114
115
116
117
118
119
120
121
122
123
<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="IsAdditive 把动画结果叠加到基础值;IsCumulative 让重复轮次继续累计。" />
<TextBlock Margin="0,18,0,0"
Text="{Binding AccumulationStatus}"
Foreground="#9B3030"
FontWeight="Bold"
TextWrapping="Wrap" />
<TextBlock Margin="0,10,0,0"
Text="{Binding AccumulationValue, StringFormat=计算结果:{0}}"
Foreground="#617089" />
<WrapPanel Margin="0,14,0,0">
<Button Padding="14,7"
Margin="0,0,9,8"
Content="播放"
Click="OnAccumulationPlayClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="暂停"
Click="OnAccumulationPauseClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="继续"
Click="OnAccumulationResumeClick" />
<Button Padding="14,7"
Margin="0,0,9,8"
Content="重置"
Click="OnAccumulationResetClick" />
</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="100" X2="760" Y2="100"
Stroke="#DCE5F2" StrokeThickness="3" />
<Line X1="45" Y1="210" X2="760" Y2="210"
Stroke="#DCE5F2" StrokeThickness="3" />
<Line X1="45" Y1="320" X2="760" Y2="320"
Stroke="#DCE5F2" StrokeThickness="3" />

<Border x:Name="NormalRepeatCard"
Width="118" Height="60"
Canvas.Left="30" Canvas.Top="70"
Background="#2456A6"
CornerRadius="10">
<Border.RenderTransform>
<TranslateTransform x:Name="NormalRepeatTransform"
X="30" />
</Border.RenderTransform>
<TextBlock Text="普通"
Foreground="White"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>

<Border x:Name="AdditiveRepeatCard"
Width="118" Height="60"
Canvas.Left="30" Canvas.Top="180"
Background="#176B4D"
CornerRadius="10">
<Border.RenderTransform>
<TranslateTransform x:Name="AdditiveRepeatTransform"
X="30" />
</Border.RenderTransform>
<TextBlock Text="IsAdditive"
Foreground="White"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>

<Border x:Name="CumulativeRepeatCard"
Width="118" Height="60"
Canvas.Left="30" Canvas.Top="290"
Background="#D97706"
CornerRadius="10">
<Border.RenderTransform>
<TranslateTransform x:Name="CumulativeRepeatTransform"
X="30" />
</Border.RenderTransform>
<TextBlock Text="IsCumulative"
Foreground="White"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</Canvas>
</Border>

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

4.8 跟做:创建三种累加配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
private Storyboard? _accumulationStoryboard;
private bool _accumulationPaused;

private void BuildAccumulationAnimation()
{
NormalRepeatTransform.X = 30;
AdditiveRepeatTransform.X = 30;
CumulativeRepeatTransform.X = 30;

var normal = CreateAccumulationAnimation(
isAdditive: false,
isCumulative: false);
Storyboard.SetTarget(normal, NormalRepeatTransform);
Storyboard.SetTargetProperty(
normal,
new PropertyPath(TranslateTransform.XProperty));

var additive = CreateAccumulationAnimation(
isAdditive: true,
isCumulative: false);
Storyboard.SetTarget(additive, AdditiveRepeatTransform);
Storyboard.SetTargetProperty(
additive,
new PropertyPath(TranslateTransform.XProperty));

var cumulative = CreateAccumulationAnimation(
isAdditive: true,
isCumulative: true);
Storyboard.SetTarget(cumulative, CumulativeRepeatTransform);
Storyboard.SetTargetProperty(
cumulative,
new PropertyPath(TranslateTransform.XProperty));

_accumulationStoryboard = new Storyboard();
_accumulationStoryboard.Children.Add(normal);
_accumulationStoryboard.Children.Add(additive);
_accumulationStoryboard.Children.Add(cumulative);
}

private static DoubleAnimation CreateAccumulationAnimation(
bool isAdditive,
bool isCumulative)
{
return new DoubleAnimation
{
From = 0,
To = 80,
Duration = TimeSpan.FromMilliseconds(900),
RepeatBehavior = new RepeatBehavior(3),
FillBehavior = FillBehavior.HoldEnd,
IsAdditive = isAdditive,
IsCumulative = isCumulative
};
}

动画开始后,Begin(this, true) 会创建一个可以控制的时钟;暂停、继续和停止都作用于同一个 Storyboard。

动画时钟控制

图 6:Begin、Pause、Resume、Stop 与重置基础值的关系。

4.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
53
54
public void SimulateAccumulationPlay()
{
_accumulationStoryboard?.Stop(this);
_accumulationStoryboard?.Begin(this, true);

_accumulationPaused = false;
ViewModel.AccumulationStatus =
"普通替代;Additive 叠加;Cumulative 累计";
ViewModel.AccumulationValue =
"目标:普通 80|叠加 110|累计 270";
ViewModel.AddAccumulation(
"Play | 基础 30;单轮 0→80;重复 3 次");
}

public void SimulateAccumulationPause()
{
if (_accumulationStoryboard is null || _accumulationPaused)
{
return;
}

_accumulationStoryboard.Pause(this);
_accumulationPaused = true;
ViewModel.AccumulationStatus = "累加演示已暂停";
ViewModel.AddAccumulation("Pause | 保留当前累计进度");
}

public void SimulateAccumulationResume()
{
if (_accumulationStoryboard is null || !_accumulationPaused)
{
return;
}

_accumulationStoryboard.Resume(this);
_accumulationPaused = false;
ViewModel.AccumulationStatus = "累加演示已继续";
ViewModel.AddAccumulation("Resume | 继续执行剩余轮次");
}

public void SimulateAccumulationReset()
{
_accumulationStoryboard?.Stop(this);
_accumulationPaused = false;

NormalRepeatTransform.X = 30;
AdditiveRepeatTransform.X = 30;
CumulativeRepeatTransform.X = 30;

ViewModel.AccumulationStatus =
"已重置,基础 X = 30,动画偏移归零";
ViewModel.AccumulationValue = "基础 30,动画输出 0";
ViewModel.AddAccumulation("Reset | 动画偏移 = 0");
}

4.10 运行截图

执行累加页签

运行截图 4:普通重复、IsAdditive 和 IsCumulative 在同一组参数下的不同结果。

界面中三张卡片分别显示 80、110、270。如果只看运动距离,普通卡片会停得最近;IsAdditive 会把基础值一起算进去;IsCumulative 则把三轮结果连续累加。

4.11 重复次数和基础值要分开记录

排查累加问题时,建议记录:

1
2
3
4
5
基础 X = 30
单轮目标 = 80
RepeatBehavior = 3
IsAdditive = true
IsCumulative = true

再写出期望公式:

1
最终值 = 基础值 + 单轮增量 × 重复次数

公式只适用于本例这种简单线性重复。如果关键帧之间还有非线性变化,必须读取实际动画值,不能机械套用。

4.12 跟做检查点

  • 三条动画都使用相同的基础值和单轮目标。
  • 普通重复结束后不会无限增大。
  • IsAdditive 的结果与普通结果可以比较。
  • IsCumulative 的重复轮次会继续叠加。
  • 暂停时累计进度保留。
  • 重置后回到基础值。
  • 能说清动画值、基础值和最终值。

4.13 常见错误

现象 常见原因 修正方法
重复后越走越远 IsCumulative=true 明确是否需要累计
IsAdditive 看不出区别 只播放一轮 增加重复或后续动画对比
最终值计算错误 基础值和动画值混在一起 分开记录
普通动画误加基础值 把动画输出当成了 Additive 普通结果直接使用动画输出
重置后位置错误 没有恢复基础 X 重置基础值
动画越来越快 每轮都创建新时钟 复用同一 Storyboard
暂停后累计丢失 重置了时间线或基础值 只 Pause,不要 Stop

4.14 小练习

  1. 把基础 X 改成 50,重新计算。
  2. 把单轮目标改成 40。
  3. 把重复次数改成 5。
  4. 对比 IsAdditive=true, IsCumulative=false 和两者都开启。
  5. 写一个日志方法,输出基础值、当前值和轮次。

四个主题放在一起比较

它们分别改什么

主题 改变内容 不影响
缓动关键帧 进度曲线 起终点、总时长
路径动画 运动轨迹 路径本身的几何形状
相关速率 播放快慢与内部节奏 目标值和路径
执行累加 重复结果的组合方式 单轮关键帧值

选择顺序

1
2
3
4
先确定目标属性和终点
再决定直线还是路径
再决定进度曲线是否自然
最后处理整体速度与重复累加

完整项目结构

跟做项目可以整理成:

1
2
3
4
5
6
7
8
9
WpfEasingPathPractice
├─ 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
WpfAnimationEasingPathDemo
├─ SelfTestRunner.cs
├─ UiProbe.cs
├─ AnimationRules.cs
├─ Models
│ └─ AppOptions.cs
└─ ViewModels
├─ ObservableObject.cs
└─ MainViewModel.cs

自动测试

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

检查内容:

1
2
3
4
5
6
7
8
缓动关键帧数量
EasingDoubleKeyFrame 配置
路径 X/Y 两条时间线
PathAnimationSource.X
SpeedRatio 设置
IsAdditive 与 IsCumulative
加减速比例规则
暂停与继续状态

常见错误速查

缓动关键帧

问题 优先检查
看不出缓动 时长是否太短
缓动无效 是否使用普通关键帧
暂停失效 是否使用可控时钟
播放叠加 是否先停止旧 Storyboard
重置不完整 是否恢复所有变换值

路径动画

问题 优先检查
只走一个方向 是否只创建 X 或 Y
轨迹偏移 两条时间线参数是否一致
路径资源找不到 Key 是否匹配
目标属性错误 是否指向 TranslateTransform
重置不完整 X、Y 是否都恢复

速率

问题 优先检查
速度没有变化 SpeedRatio 是否相同
动画不开始 SpeedRatio 是否大于 0
比例设置失败 两个 Ratio 之和是否超过 1
和缓动混淆 是否把 Ratio 当 Easing
暂停跳动 是否错误重置基础值

累加

问题 优先检查
越走越远 IsCumulative 是否开启
加法无效 IsAdditive 是否开启
只播放一轮 RepeatBehavior 是否正确
最终值不对 基础值和动画值是否分开
暂停后丢失 是否调用 Stop 而非 Pause

综合练习

练习一:自然进入卡片

要求:

  1. 卡片从右侧进入。
  2. 使用缓动关键帧。
  3. 进入时同时改变透明度和位移。
  4. 使用 EaseOut 收尾。
  5. 播放结束后保持最终位置。

练习二:沿曲线路径移动的图标

要求:

  1. 使用 PathGeometry 定义曲线。
  2. X、Y 两条路径动画同时驱动。
  3. 路径动画可暂停、继续和重置。
  4. 显示起点和终点。
  5. 在日志中记录 X、Y 两条动画的时长。

练习三:三档播放速度

要求:

  1. 三张卡片目标相同。
  2. 使用 0.5×、1.0×、2.0×。
  3. 记录到达顺序。
  4. 暂停后全部停住。
  5. 继续后按各自速度完成。

练习四:重复累加演示

要求:

  1. 基础 X 为 40。
  2. 单轮目标为 60。
  3. 重复 4 次。
  4. 分别演示普通、IsAdditive、IsCumulative。
  5. 写出最终值并验证。

结语

这一篇的四个主题都在控制“变化过程”,但作用点不同:

1
2
3
4
缓动:进度怎样随时间变化
路径:位置沿着什么几何轨迹变化
速率:播放时钟整体多快,时间线内部怎样加减速
累加:重复播放时结果怎样组合

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

1
2
3
4
5
6
目标值和基础值是什么?
需要直线还是路径?
进度曲线是否自然?
播放速度是否需要倍率?
重复时应该回到起点,还是继续累加?
暂停、继续、重置后数值是否仍然正确?

把这些问题回答清楚,比单纯记住某个动画类的名字更重要。