面向已经会创建简单动画和关键帧动画,准备继续学习变化节奏、运动轨迹、播放速率与重复累加的读者。 推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,本文涉及的 API 在 .NET 9 与 .NET 10 中没有变化。 最后更新:2026-10-22
目录
缓动关键帧动画的使用
路径动画的使用
动画对象的相关速率
动画对象的执行累加计算
写在前面 这份笔记解决什么问题 前面的笔记已经完成:
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 章比较三种 EasingMode,先建立“进度曲线”的直觉。
第 2 章观察路径、X 动画和 Y 动画三者怎样配合。
第 3 章先只看 SpeedRatio,再学习加减速比例。
第 4 章把基础值、动画偏移和重复轮次分开计算。
每章都完成检查点和小练习,再进入下一章。
随附示例位置 1 C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAnimationEasingPathDemo
运行:
1 2 3 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAnimationEasingPathDemo" dotnet build dotnet run
Visual Studio 中:
打开 WpfAnimationEasingPathDemo.csproj。
确认启动项目是 WpfAnimationEasingPathDemo。
按 F5。
四个页签分别点击“播放、暂停、继续、重置”。
观察动画和日志是否同步。
创建自己的跟做项目 1 2 3 4 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码" dotnet new wpf -n WpfEasingPathPractice -o .\WpfEasingPathPractice cd .\WpfEasingPathPracticedotnet 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 本节目标 完成本节后,你要能够:
创建 EasingDoubleKeyFrame。
使用 EasingFunction 改变进度曲线。
比较 EaseIn、EaseOut、EaseInOut。
区分缓动关键帧和普通 From/To 动画。
同时播放三条缓动时间线并统一控制。
1.2 缓动改变的是进度
图 2:From、To、Duration 相同,进度随时间的曲线不同。
横轴表示归一化时间:
纵轴表示归一化进度:
三种常见模式:
模式
速度变化
EaseIn
开始慢,后面快
EaseOut
开始快,后面慢
EaseInOut
两端慢,中间快
缓动不会改变起点、终点和总时长,只会改变“什么时候走到哪里”。
1.3 为什么使用缓动关键帧 普通关键帧可以描述:
但没有描述两个时间点之间的速度曲线。
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 跟做检查点
1.10 常见错误
现象
常见原因
修正方法
三条动画终点不同
To 或关键帧值写错
统一目标值
看不出缓动差异
时长太短
先使用 1500ms 以上
暂停只停一条
分别控制多个 Storyboard
统一放入一个 Storyboard
缓动没有生效
使用了普通 LinearKeyFrame
改为 EasingDoubleKeyFrame
动画结束后跳回
FillBehavior 或基础值问题
检查结束行为
播放叠加
每次播放都创建新时钟
开始前 Stop 旧时钟
1.11 小练习
把三种模式改成 BackEase。
比较 CubicEase 和 QuadraticEase。
把时长改成 3000ms,观察差异。
把终点改成 420,重新比较。
使用 ElasticEase 做一次弹性进入效果。
2. 路径动画的使用 2.1 本节目标 完成本节后,你要能够:
创建 PathGeometry。
使用 BezierSegment 定义曲线路径。
使用 DoubleAnimationUsingPath。
用 PathAnimationSource.X 和 .Y 同时驱动两个属性。
正确重置路径动画。
2.2 路径动画不是一条属性动画 普通位移动画只改一个数:
路径动画要沿一条二维曲线移动,需要同时得到:
因此通常使用两条动画:
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); }
构造函数中调用:
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 跟做检查点
2.11 常见错误
现象
常见原因
修正方法
只水平或垂直移动
只创建了一条路径动画
X、Y 各建一条
卡片不沿路径
X/Y 动画时长不同
统一时长与开始时间
路径找不到
资源 Key 写错
检查 StaticResource
路径动画报错
目标是元素而不是变换
目标设为 TranslateTransform
重置后仍有偏移
只重置了 X
同时重置 X、Y
动画结束后消失
填充行为或路径坐标问题
检查 FillBehavior 和起点
2.12 小练习
把路径改成椭圆。
使用 LineSegment 做折线路径。
使用 ArcSegment 做圆弧路径。
比较路径动画和 X/Y 两条普通动画的差异。
让卡片在路径终点后再沿原路返回。
3. 动画对象的相关速率 3.1 本节目标 完成本节后,你要能够:
使用 SpeedRatio 改变整体播放速度。
使用 AccelerationRatio 控制开头加速比例。
使用 DecelerationRatio 控制结尾减速比例。
理解加减速比例之和不能超过 1。
区分“整体速率”和“内部节奏”。
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 跟做检查点
3.11 常见错误
现象
常见原因
修正方法
速率没有区别
所有动画 SpeedRatio 都是 1
设置不同倍率
动画不开始
SpeedRatio 为 0 或负数
使用大于 0 的值
加速比例设置失败
两个比例之和超过 1
重新分配比例
和缓动混淆
把 Ratio 当成 Easing
区分整体倍率与内部速度曲线
暂停后位置跳动
重置了基础值
检查暂停逻辑
播放叠加
每次播放没有停止旧时钟
先 Stop 再 Begin
3.12 小练习
增加 2.00× 卡片,观察到达顺序。
把 0.65× 改成 0.40×。
给三条动画设置不同的 AccelerationRatio。
设置 AccelerationRatio=0.3、DecelerationRatio=0.4。
故意让两个比例之和大于 1,记录异常并恢复。
4. 动画对象的执行累加计算 4.1 本节目标 完成本节后,你要能够:
理解动画值、基础值和最终结果的关系。
使用 IsAdditive 把动画结果叠加到基础值。
使用 IsCumulative 让重复轮次继续累加。
区分“加法”和“累计”。
正确计算重复播放后的最终值。
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。普通动画不会把基础值再加一次,所以结果是:
普通动画会替代属性原来的动画层显示值,所以不要把基础值 30 再加一次。基础值仍然存在,但普通动画播放期间由动画输出控制显示结果。
4.4 IsAdditive IsAdditive=true 表示动画值要叠加到基础值上,而不是直接替换基础值。
1 2 IsAdditive = true , IsCumulative = false
一轮结束仍然得到:
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 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
再写出期望公式:
公式只适用于本例这种简单线性重复。如果关键帧之间还有非线性变化,必须读取实际动画值,不能机械套用。
4.12 跟做检查点
4.13 常见错误
现象
常见原因
修正方法
重复后越走越远
IsCumulative=true
明确是否需要累计
IsAdditive 看不出区别
只播放一轮
增加重复或后续动画对比
最终值计算错误
基础值和动画值混在一起
分开记录
普通动画误加基础值
把动画输出当成了 Additive
普通结果直接使用动画输出
重置后位置错误
没有恢复基础 X
重置基础值
动画越来越快
每轮都创建新时钟
复用同一 Storyboard
暂停后累计丢失
重置了时间线或基础值
只 Pause,不要 Stop
4.14 小练习
把基础 X 改成 50,重新计算。
把单轮目标改成 40。
把重复次数改成 5。
对比 IsAdditive=true, IsCumulative=false 和两者都开启。
写一个日志方法,输出基础值、当前值和轮次。
四个主题放在一起比较 它们分别改什么
主题
改变内容
不影响
缓动关键帧
进度曲线
起终点、总时长
路径动画
运动轨迹
路径本身的几何形状
相关速率
播放快慢与内部节奏
目标值和路径
执行累加
重复结果的组合方式
单轮关键帧值
选择顺序 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 .ExitCodeGet-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
综合练习 练习一:自然进入卡片 要求:
卡片从右侧进入。
使用缓动关键帧。
进入时同时改变透明度和位移。
使用 EaseOut 收尾。
播放结束后保持最终位置。
练习二:沿曲线路径移动的图标 要求:
使用 PathGeometry 定义曲线。
X、Y 两条路径动画同时驱动。
路径动画可暂停、继续和重置。
显示起点和终点。
在日志中记录 X、Y 两条动画的时长。
练习三:三档播放速度 要求:
三张卡片目标相同。
使用 0.5×、1.0×、2.0×。
记录到达顺序。
暂停后全部停住。
继续后按各自速度完成。
练习四:重复累加演示 要求:
基础 X 为 40。
单轮目标为 60。
重复 4 次。
分别演示普通、IsAdditive、IsCumulative。
写出最终值并验证。
结语 这一篇的四个主题都在控制“变化过程”,但作用点不同:
1 2 3 4 缓动:进度怎样随时间变化 路径:位置沿着什么几何轨迹变化 速率:播放时钟整体多快,时间线内部怎样加减速 累加:重复播放时结果怎样组合
每次做动画,可以按下面顺序检查:
1 2 3 4 5 6 目标值和基础值是什么? 需要直线还是路径? 进度曲线是否自然? 播放速度是否需要倍率? 重复时应该回到起点,还是继续累加? 暂停、继续、重置后数值是否仍然正确?
把这些问题回答清楚,比单纯记住某个动画类的名字更重要。