面向已经会使用 Storyboard、变换、路径和触发器,准备把动画做成完整界面效果的读者。 推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,本文涉及的 API 在 .NET 9 与 .NET 10 中没有变化。 最后更新:2026-10-22
目录
等待动画案例
多轴机械臂动画案例
蚂蚁线动画案例
溶液波浪动画案例
控件自定义封装方案
写在前面 这份笔记解决什么问题 前面已经学过:
1 2 3 4 5 简单动画 关键帧动画 缓动、路径、速率与累加 动画时间、事件与控制 触发器与视觉状态
现在开始把多个动画组合成一个完整效果:
1 2 3 4 5 6 等待动画怎样既有节奏又不抢注意力? 机械臂为什么不是简单设置四个角度? 蚂蚁线为什么只改一个属性就会流动? 溶液波浪怎样让几何形状持续变化? 怎样把一次性动画封装成可复用控件? 多个控件实例怎样各自播放、各自停止?
这一篇的五个案例按难度逐步推进:
1 2 3 4 5 等待动画:多个低强度动画叠加 机械臂:父子层级与多轴旋转 蚂蚁线:虚线偏移与方向 溶液波浪:几何绘制与相位动画 控件封装:依赖属性、强制回调、内部时钟
图 1:从单个效果逐步走到可复用控件。
推荐学习顺序
先运行随附示例,依次操作五个页签。
第 1 章先看等待动画的旋转、呼吸和点节奏。
第 2 章按“底座 → 肩部 → 肘部 → 夹爪”的层级理解旋转。
第 3 章只改变 StrokeDashOffset,观察蚂蚁线方向。
第 4 章先画静态液面,再启动相位动画。
第 5 章比较两个波浪实例的颜色、液位、振幅和速度。
每章完成检查点和小练习,再进入下一章。
随附示例位置 1 C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAnimationCaseStudyDemo
运行:
1 2 3 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAnimationCaseStudyDemo" dotnet build dotnet run
Visual Studio 中:
打开 WpfAnimationCaseStudyDemo.csproj。
确认启动项目是 WpfAnimationCaseStudyDemo。
按 F5。
五个页签依次操作按钮。
观察动画、参数和日志是否同步。
创建自己的跟做项目 1 2 3 4 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码" dotnet new wpf -n WpfAnimationCasePractice -o .\WpfAnimationCasePractice cd .\WpfAnimationCasePracticedotnet 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 10 WpfAnimationCasePractice ├─ App.xaml ├─ App.xaml.cs ├─ MainWindow.xaml ├─ MainWindow.xaml.cs ├─ LiquidWaveControl.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 WpfAnimationCasePractice.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 using System.Collections.ObjectModel;namespace WpfAnimationCasePractice.ViewModels ;public sealed class MainViewModel : ObservableObject { private string _waitingStatus = "等待动画尚未启动" ; private string _robotStatus = "机械臂停在初始姿态" ; private string _antsStatus = "蚂蚁线尚未启动" ; private string _waveStatus = "溶液波浪尚未启动" ; private string _packageStatus = "两个实例使用同一个控件类型" ; public ObservableCollection<string > WaitingLog { get ; } = new (); public ObservableCollection<string > RobotLog { get ; } = new (); public ObservableCollection<string > AntsLog { get ; } = new (); public ObservableCollection<string > WaveLog { get ; } = new (); public ObservableCollection<string > PackageLog { get ; } = new (); public string WaitingStatus { get => _waitingStatus; set => SetProperty(ref _waitingStatus, value ); } public string RobotStatus { get => _robotStatus; set => SetProperty(ref _robotStatus, value ); } public string AntsStatus { get => _antsStatus; set => SetProperty(ref _antsStatus, value ); } public string WaveStatus { get => _waveStatus; set => SetProperty(ref _waveStatus, value ); } public string PackageStatus { get => _packageStatus; set => SetProperty(ref _packageStatus, value ); } public void AddWaiting (string line ) => Add(WaitingLog, line); public void AddRobot (string line ) => Add(RobotLog, line); public void AddAnts (string line ) => Add(AntsLog, line); public void AddWave (string line ) => Add(WaveLog, line); public void AddPackage (string line ) => Add(PackageLog, 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 namespace WpfAnimationCasePractice ;public static class AnimationRules { public static double ClampUnit (double value ) { return Math.Clamp(value , 0 , 1 ); } public static double NormalizeAngle (double angle ) { var normalized = angle % 360 ; return normalized < 0 ? normalized + 360 : normalized; } public static bool IsPositiveSpeed (double speed ) { return speed > 0 && double .IsFinite(speed); } public static string FormatAngle (double angle ) { return $"{NormalizeAngle(angle):0 } °" ; } }
1. 等待动画案例 1.1 本节目标 完成本节后,你要能够:
用旋转表示“正在处理”。
用透明度呼吸降低视觉压迫。
用点提示形成节奏。
正确停止永久重复动画。
重置所有动画属性。
1.2 等待动画为什么不能只转圈 只旋转的圆环可以表达“忙碌”,但很容易产生两个问题:
因此实际等待动画常组合三类变化:
元素
动画
作用
圆环
RotateTransform.Angle
表示持续处理
整体透明度
Opacity
呼吸,降低机械感
三个点
依次变化的透明度
形成节奏
图 2:等待动画由旋转、呼吸和点节奏叠加。
1.3 跟做:第 1 页界面结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 <TabItem Header ="1 等待动画案例" > <Grid Margin ="22" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="420" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="让等待状态清楚、安静、可停止" FontSize ="22" FontWeight ="Bold" /> <TextBlock Margin ="0,7,0,16" Foreground ="#617089" TextWrapping ="Wrap" Text ="等待动画通常组合旋转、透明度呼吸和文字提示。" /> <Border Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="10" Padding ="16" > <TextBlock Text ="{Binding WaitingStatus}" Foreground ="#9B3030" FontWeight ="Bold" TextWrapping ="Wrap" /> </Border > <WrapPanel Margin ="0,14,0,0" > <Button Padding ="14,7" Margin ="0,0,9,8" Content ="开始等待" Click ="OnWaitingStartClick" /> <Button Padding ="14,7" Margin ="0,0,9,8" Content ="停止等待" Click ="OnWaitingStopClick" /> </WrapPanel > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="180" /> </Grid.RowDefinitions > <Border Background ="#FBFCFE" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="9" > <StackPanel HorizontalAlignment ="Center" VerticalAlignment ="Center" > <Grid x:Name ="WaitingSpinner" Width ="116" Height ="116" RenderTransformOrigin ="0.5,0.5" > <Grid.RenderTransform > <RotateTransform x:Name ="WaitingRotate" /> </Grid.RenderTransform > <Ellipse Stroke ="#DCE5F2" StrokeThickness ="11" /> <Path Data ="M 58,8 A 50,50 0 0 1 103,35" Stroke ="#2456A6" StrokeThickness ="11" StrokeStartLineCap ="Round" StrokeEndLineCap ="Round" /> </Grid > <TextBlock Margin ="0,26,0,0" HorizontalAlignment ="Center" Text ="正在准备" FontSize ="22" FontWeight ="Bold" /> <TextBlock Margin ="0,8,0,0" HorizontalAlignment ="Center" Foreground ="#617089" Text ="请稍候,数据正在加载" /> <StackPanel Margin ="0,24,0,0" Orientation ="Horizontal" HorizontalAlignment ="Center" > <Ellipse x:Name ="WaitingDot1" Width ="8" Height ="8" Fill ="#2456A6" /> <Ellipse x:Name ="WaitingDot2" Width ="8" Height ="8" Margin ="9,0" Fill ="#7EA8E8" /> <Ellipse x:Name ="WaitingDot3" Width ="8" Height ="8" Fill ="#B9CBEA" /> </StackPanel > </StackPanel > </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Padding ="8" Background ="#0F172A" Foreground ="#DCE7F8" BorderThickness ="0" FontFamily ="Consolas" ItemsSource ="{Binding WaitingLog}" /> </Grid > </Grid > </TabItem >
1.4 跟做:创建等待动画 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 private Storyboard? _waitingStoryboard;private void BuildWaitingAnimation (){ _waitingStoryboard = new Storyboard(); var spin = new DoubleAnimation { From = 0 , To = 360 , Duration = TimeSpan.FromMilliseconds(950 ), RepeatBehavior = RepeatBehavior.Forever }; Storyboard.SetTarget(spin, WaitingRotate); Storyboard.SetTargetProperty( spin, new PropertyPath(RotateTransform.AngleProperty)); var breathe = new DoubleAnimation { From = 0.45 , To = 1 , Duration = TimeSpan.FromMilliseconds(620 ), AutoReverse = true , RepeatBehavior = RepeatBehavior.Forever }; Storyboard.SetTarget(breathe, WaitingSpinner); Storyboard.SetTargetProperty( breathe, new PropertyPath(UIElement.OpacityProperty)); _waitingStoryboard.Children.Add(spin); _waitingStoryboard.Children.Add(breathe); AddDotPulse(_waitingStoryboard, WaitingDot1, 0 ); AddDotPulse(_waitingStoryboard, WaitingDot2, 0.16 ); AddDotPulse(_waitingStoryboard, WaitingDot3, 0.32 ); } private static void AddDotPulse ( Storyboard storyboard, FrameworkElement dot, double delaySeconds ){ var pulse = new DoubleAnimation { From = 0.3 , To = 1 , Duration = TimeSpan.FromMilliseconds(420 ), BeginTime = TimeSpan.FromSeconds(delaySeconds), AutoReverse = true , RepeatBehavior = RepeatBehavior.Forever }; Storyboard.SetTarget(pulse, dot); Storyboard.SetTargetProperty( pulse, new PropertyPath(UIElement.OpacityProperty)); storyboard.Children.Add(pulse); }
这里用了两个不同的时间尺度:
1 2 3 旋转:950ms 一圈 呼吸:620ms 一次明暗变化 点:420ms 一次,彼此错开 160ms
它们不是同步的,所以整体看起来更自然。
1.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 public void SimulateWaitingStart (){ _waitingStoryboard?.Stop(this ); WaitingRotate.Angle = 0 ; WaitingSpinner.Opacity = 1 ; WaitingDot1.Opacity = 0.35 ; WaitingDot2.Opacity = 0.35 ; WaitingDot3.Opacity = 0.35 ; _waitingStoryboard?.Begin(this , true ); ViewModel.WaitingStatus = "等待动画运行中:旋转、呼吸和三点提示" ; ViewModel.AddWaiting("Start | Rotate + Fade + Dots" ); } public void SimulateWaitingStop (){ _waitingStoryboard?.Stop(this ); WaitingRotate.Angle = 0 ; WaitingSpinner.Opacity = 1 ; WaitingDot1.Opacity = 1 ; WaitingDot2.Opacity = 1 ; WaitingDot3.Opacity = 1 ; ViewModel.WaitingStatus = "等待动画已停止" ; ViewModel.AddWaiting("Stop | 恢复静态状态" ); }
1.6 运行截图
运行截图 1:圆环旋转、整体呼吸,三个点形成节奏。
1.7 跟做检查点
1.8 常见错误
现象
常见原因
修正方法
越转越快
重复创建 Forever 动画
开始前先停止旧 Storyboard
停止后仍在闪
只停止了部分子动画
用一个 Storyboard 管理
旋转中心不对
没有设置 RenderTransformOrigin
设为 0.5,0.5
等待动画太抢眼
颜色过深、速度过快
降低对比度、延长时间
停止后透明度残留
没有恢复 Opacity
停止后显式设置基础值
页面关闭后还在运行
没有处理窗口关闭
关闭时停止动画
1.9 小练习
把旋转一圈改成 1.4 秒。
把点的延迟间隔改成 0.22 秒。
给文字增加轻微的透明度呼吸。
把圆环颜色改成绿色。
在窗口关闭时停止等待动画。
2. 多轴机械臂动画案例 2.1 本节目标 完成本节后,你要能够:
用嵌套布局建立机械臂层级。
给每一节设置正确的旋转中心。
让多个关节同时执行不同角度的动画。
理解父级旋转会带着子级运动。
能排查子节位置异常。
2.2 为什么机械臂不能只做四个独立旋转 机械臂不是四个互不相关的图形:
图 3:机械臂的旋转层级和各自的旋转中心。
层级关系:
1 2 3 4 Base └─ Shoulder └─ Elbow └─ Gripper
如果肩部旋转时,肘部没有放在肩部内部,肘部就不会跟着肩部一起动。
每个关节必须绕连接点旋转,而不是绕自身中心随便转。
层级
旋转中心
原因
底座
0.5, 0.95
接近地面连接点
肩部
0.5, 1.0
绕肩关节底部
肘部
0.5, 0.1
绕肘关节顶部
夹爪
0.5, 0.5
绕自身中心
坐标是相对元素自身大小的比例:
1 2 3 0.0 = 左边或上边 0.5 = 中间 1.0 = 右边或下边
2.4 跟做:第 2 页界面结构 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 138 139 140 <TabItem Header ="2 多轴机械臂动画案例" > <Grid Margin ="22" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="420" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="多层旋转组成一条机械臂" FontSize ="22" FontWeight ="Bold" /> <TextBlock Margin ="0,7,0,16" Foreground ="#617089" TextWrapping ="Wrap" Text ="每一节机械臂都绕自己的连接点旋转。" /> <Border Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="10" Padding ="16" > <StackPanel > <TextBlock Text ="{Binding RobotStatus}" Foreground ="#9B3030" FontWeight ="Bold" TextWrapping ="Wrap" /> <TextBlock Margin ="0,10,0,0" Foreground ="#617089" Text ="底座:0° 肩部:-34° 肘部:68° 夹爪:-22°" /> </StackPanel > </Border > <WrapPanel Margin ="0,14,0,0" > <Button Padding ="14,7" Margin ="0,0,9,8" Content ="执行抓取" Click ="OnRobotPlayClick" /> <Button Padding ="14,7" Margin ="0,0,9,8" Content ="回到原位" Click ="OnRobotResetClick" /> </WrapPanel > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="180" /> </Grid.RowDefinitions > <Border Background ="#FBFCFE" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="9" ClipToBounds ="True" > <Canvas Height ="440" > <Line X1 ="50" Y1 ="365" X2 ="780" Y2 ="365" Stroke ="#DCE5F2" StrokeThickness ="3" /> <Grid Width ="300" Height ="300" Canvas.Left ="255" Canvas.Top ="60" RenderTransformOrigin ="0.5,0.95" > <Grid.RenderTransform > <RotateTransform x:Name ="RobotBaseRotate" /> </Grid.RenderTransform > <Rectangle Width ="94" Height ="32" VerticalAlignment ="Bottom" HorizontalAlignment ="Center" Fill ="#334155" RadiusX ="10" RadiusY ="10" /> <Grid Width ="64" Height ="185" HorizontalAlignment ="Center" VerticalAlignment ="Bottom" Margin ="0,0,0,24" RenderTransformOrigin ="0.5,1" > <Grid.RenderTransform > <RotateTransform x:Name ="RobotShoulderRotate" /> </Grid.RenderTransform > <Rectangle Width ="28" HorizontalAlignment ="Center" Fill ="#2456A6" RadiusX ="12" RadiusY ="12" /> <Grid Width ="44" Height ="150" HorizontalAlignment ="Center" VerticalAlignment ="Top" RenderTransformOrigin ="0.5,0.1" > <Grid.RenderTransform > <RotateTransform x:Name ="RobotElbowRotate" /> </Grid.RenderTransform > <Rectangle Width ="22" Height ="138" HorizontalAlignment ="Center" Fill ="#4C7CC4" RadiusX ="9" RadiusY ="9" /> <Grid Width ="44" Height ="44" Margin ="0,116,0,0" HorizontalAlignment ="Center" VerticalAlignment ="Top" RenderTransformOrigin ="0.5,0.5" > <Grid.RenderTransform > <RotateTransform x:Name ="RobotGripperRotate" /> </Grid.RenderTransform > <Path Data ="M 8,8 L 8,30 M 36,8 L 36,30 M 8,30 L 18,30 M 36,30 L 26,30" Stroke ="#D97706" StrokeThickness ="5" /> </Grid > </Grid > </Grid > </Grid > </Canvas > </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Padding ="8" Background ="#0F172A" Foreground ="#DCE7F8" BorderThickness ="0" FontFamily ="Consolas" ItemsSource ="{Binding RobotLog}" /> </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 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 private Storyboard? _robotStoryboard;private void BuildRobotAnimation (){ _robotStoryboard = new Storyboard(); AddRobotAnimation( _robotStoryboard, RobotBaseRotate, 18 , TimeSpan.FromMilliseconds(1300 )); AddRobotAnimation( _robotStoryboard, RobotShoulderRotate, -58 , TimeSpan.FromMilliseconds(1500 )); AddRobotAnimation( _robotStoryboard, RobotElbowRotate, 30 , TimeSpan.FromMilliseconds(1400 )); AddRobotAnimation( _robotStoryboard, RobotGripperRotate, -12 , TimeSpan.FromMilliseconds(900 )); } private static void AddRobotAnimation ( Storyboard storyboard, RotateTransform transform, double to, TimeSpan duration ){ var animation = new DoubleAnimation { To = to, Duration = duration, FillBehavior = FillBehavior.HoldEnd, EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut } }; Storyboard.SetTarget(animation, transform); Storyboard.SetTargetProperty( animation, new PropertyPath(RotateTransform.AngleProperty)); storyboard.Children.Add(animation); }
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 public void SimulateRobotPlay (){ _robotStoryboard?.Stop(this ); RobotBaseRotate.Angle = 0 ; RobotShoulderRotate.Angle = -34 ; RobotElbowRotate.Angle = 68 ; RobotGripperRotate.Angle = -22 ; _robotStoryboard?.Begin(this , true ); ViewModel.RobotStatus = "执行抓取:底座转向,肩部抬起,肘部折叠,夹爪闭合" ; ViewModel.AddRobot("Base | 0° → 18°" ); ViewModel.AddRobot("Shoulder | -34° → -58°" ); ViewModel.AddRobot("Elbow | 68° → 30°" ); } public void SimulateRobotReset (){ _robotStoryboard?.Stop(this ); RobotBaseRotate.Angle = 0 ; RobotShoulderRotate.Angle = 0 ; RobotElbowRotate.Angle = 0 ; RobotGripperRotate.Angle = 0 ; ViewModel.RobotStatus = "机械臂已回到初始姿态" ; ViewModel.AddRobot("Reset | 所有关节角度 = 0°" ); }
2.7 运行截图
运行截图 2:底座、肩部、肘部和夹爪按不同时长推进到抓取姿态。
2.8 跟做检查点
2.9 常见错误
现象
常见原因
修正方法
子节没有跟着动
子节没有放在父节内部
检查嵌套层级
关节像绕着中心乱转
旋转中心设置错误
检查 RenderTransformOrigin
机械臂飞离底座
父级变换叠加异常
从最外层逐层排查
所有关节同时停
Duration 相同
分别设置时长
复位后仍有偏移
只重置了部分关节
重置四个角度
动画叠加
播放前没有停止旧 Storyboard
先 Stop
2.10 小练习
把肩部结束角度改成 -72°。
把夹爪动画改成 AutoReverse=true。
让肘部先动、肩部后动。
增加一个“抓取并返回”的组合动画。
在日志中显示每个关节的当前角度。
3. 蚂蚁线动画案例 3.1 本节目标 完成本节后,你要能够:
使用 StrokeDashArray 创建虚线。
使用 StrokeDashOffset 让虚线流动。
控制蚂蚁线方向。
区分“路径移动”和“虚线偏移”。
停止后恢复虚线偏移。
3.2 蚂蚁线到底是什么 蚂蚁线不是移动整条路径,而是改变虚线的起点:
1 2 3 路径几何不变 虚线段长度不变 只改变 StrokeDashOffset
图 4:路径不变,虚线偏移持续变化。
3.3 StrokeDashArray 与 StrokeDashOffset
属性
作用
StrokeDashArray
定义虚线段与间隔
StrokeDashOffset
定义虚线从哪里开始
StrokeThickness
定义线的粗细
如果虚线数组是:
可以理解为“线段长、间隔短”。数组中的数字按线宽比例计算。
3.4 跟做:第 3 页界面结构 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 <TabItem Header ="3 蚂蚁线动画案例" > <Grid Margin ="22" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="420" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="虚线也能流动起来" FontSize ="22" FontWeight ="Bold" /> <TextBlock Margin ="0,7,0,16" Foreground ="#617089" TextWrapping ="Wrap" Text ="蚂蚁线不是让整条路径移动,而是改变虚线起点。" /> <Border Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="10" Padding ="16" > <TextBlock Text ="{Binding AntsStatus}" Foreground ="#9B3030" FontWeight ="Bold" TextWrapping ="Wrap" /> </Border > <WrapPanel Margin ="0,14,0,0" > <Button Padding ="14,7" Margin ="0,0,9,8" Content ="开始流动" Click ="OnAntsStartClick" /> <Button Padding ="14,7" Margin ="0,0,9,8" Content ="反向流动" Click ="OnAntsReverseClick" /> <Button Padding ="14,7" Margin ="0,0,9,8" Content ="停止" Click ="OnAntsStopClick" /> </WrapPanel > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="180" /> </Grid.RowDefinitions > <Border Background ="#FBFCFE" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="9" ClipToBounds ="True" > <Canvas Height ="430" > <Path x:Name ="AntsPath" Data ="M 90,120 C 210,25 330,225 455,110 S 650,45 715,175" Stroke ="#2456A6" StrokeThickness ="8" StrokeStartLineCap ="Round" StrokeEndLineCap ="Round" StrokeDashArray ="2 1.2" StrokeDashOffset ="0" /> </Canvas > </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Padding ="8" Background ="#0F172A" Foreground ="#DCE7F8" BorderThickness ="0" FontFamily ="Consolas" ItemsSource ="{Binding AntsLog}" /> </Grid > </Grid > </TabItem >
3.5 跟做:让虚线向前流动 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 private Storyboard? _antsStoryboard;private void StartAnts (bool forward ){ _antsStoryboard?.Stop(this ); _antsStoryboard = new Storyboard(); var direction = forward ? -1 : 1 ; var animation = new DoubleAnimation { From = 0 , To = 26 * direction, Duration = TimeSpan.FromMilliseconds(760 ), RepeatBehavior = RepeatBehavior.Forever }; Storyboard.SetTarget(animation, AntsPath); Storyboard.SetTargetProperty( animation, new PropertyPath( System.Windows.Shapes.Shape.StrokeDashOffsetProperty)); _antsStoryboard.Children.Add(animation); _antsStoryboard.Begin(this , true ); } public void SimulateAntsStart (){ StartAnts(forward: true ); ViewModel.AntsStatus = "蚂蚁线向前流动:StrokeDashOffset 递减" ; ViewModel.AddAnts("Forward | Offset 0 → -26" ); } public void SimulateAntsReverse (){ StartAnts(forward: false ); ViewModel.AntsStatus = "蚂蚁线反向流动:StrokeDashOffset 递增" ; ViewModel.AddAnts("Reverse | Offset 0 → 26" ); }
3.6 跟做:停止并恢复偏移 1 2 3 4 5 6 7 8 9 10 11 12 public void SimulateAntsStop (){ _antsStoryboard?.Stop(this ); AntsPath.BeginAnimation( System.Windows.Shapes.Shape.StrokeDashOffsetProperty, null ); AntsPath.StrokeDashOffset = 0 ; ViewModel.AntsStatus = "蚂蚁线已停止,虚线偏移恢复为 0" ; ViewModel.AddAnts("Stop | StrokeDashOffset = 0" ); }
3.7 运行截图
运行截图 3:路径保持静止,虚线偏移改变后产生流动感。
3.8 跟做检查点
3.9 常见错误
现象
常见原因
修正方法
看不到虚线
没有设置 StrokeDashArray
设置数组
虚线太密或太稀
数组比例不合适
调整线段和间隔
没有流动
偏移量没有变化
动画 StrokeDashOffset
方向相反
偏移正负号相反
交换方向
停止后偏移不归零
没有移除动画并重置
设置偏移为 0
重复播放变快
旧时钟未停止
先 Stop
3.10 小练习
把虚线数组改成 3 2。
把动画时长改成 1.2 秒。
把路径改成闭合矩形。
给虚线增加圆角端点。
让蚂蚁线只在选中时播放。
4. 溶液波浪动画案例 4.1 本节目标 完成本节后,你要能够:
用正弦曲线构造液面。
用相位动画让液面流动。
用两层波浪制造层次。
在 OnRender 中绘制几何图形。
停止波浪并恢复相位。
4.2 波浪的组成
图 5:几何路径决定形状,相位动画决定流动。
波浪由三部分组成:
1 2 3 液位:液面在控件中的高度 振幅:波浪上下起伏的幅度 相位:波浪沿水平方向移动的位置
正弦曲线:
1 y = 基准高度 + sin(x × 频率 + 相位) × 振幅
当 相位 持续变化时,整条曲线的波峰和波谷就会横向移动。
4.3 为什么用两层波浪 单层波浪容易像一条机械曲线。两层波浪可以:
视觉上就会更像液体。
4.4 跟做:第 4 页界面结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 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 <TabItem Header ="4 溶液波浪动画案例" > <Grid Margin ="22" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="420" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel Margin ="0,0,24,0" > <TextBlock Text ="让液面持续呼吸和流动" FontSize ="22" FontWeight ="Bold" /> <TextBlock Margin ="0,7,0,16" Foreground ="#617089" TextWrapping ="Wrap" Text ="波浪由几何路径和相位动画组成。" /> <Border Background ="White" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="10" Padding ="16" > <TextBlock Text ="{Binding WaveStatus}" Foreground ="#9B3030" FontWeight ="Bold" TextWrapping ="Wrap" /> </Border > <WrapPanel Margin ="0,14,0,0" > <Button Padding ="14,7" Margin ="0,0,9,8" Content ="开始波动" Click ="OnWaveStartClick" /> <Button Padding ="14,7" Margin ="0,0,9,8" Content ="停止波动" Click ="OnWaveStopClick" /> </WrapPanel > </StackPanel > <Grid Grid.Column ="1" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="180" /> </Grid.RowDefinitions > <Border Background ="#FBFCFE" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="9" > <local:LiquidWaveControl x:Name ="WaveDisplay" Width ="470" Height ="300" HorizontalAlignment ="Center" VerticalAlignment ="Center" LiquidLevel ="0.62" WaveAmplitude ="9" WaveSpeed ="2.6" /> </Border > <ListBox Grid.Row ="1" Margin ="0,12,0,0" Padding ="8" Background ="#0F172A" Foreground ="#DCE7F8" BorderThickness ="0" FontFamily ="Consolas" ItemsSource ="{Binding WaveLog}" /> </Grid > </Grid > </TabItem >
4.5 跟做:绘制静态波浪 核心绘制在 OnRender 中完成:
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 protected override void OnRender ( DrawingContext drawingContext ){ base .OnRender(drawingContext); var width = ActualWidth; var height = ActualHeight; if (width <= 0 || height <= 0 ) { return ; } var clip = new RectangleGeometry( new Rect(0 , 0 , width, height), 20 , 20 ); drawingContext.PushClip(clip); var surfaceY = height * (1 - LiquidLevel); drawingContext.DrawRectangle( WaveBrush, null , new Rect(0 , surfaceY, width, height - surfaceY)); DrawWave( drawingContext, surfaceY, WavePhase, WaveBrush, 0.92 , WaveAmplitude, width, height); drawingContext.Pop(); }
4.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 private static void DrawWave ( DrawingContext drawingContext, double baseY, double phase, Brush brush, double opacity, double amplitude, double width, double height ){ var geometry = new StreamGeometry(); using (var context = geometry.Open()) { context.BeginFigure( new Point(0 , height), true , true ); for (var x = 0.0 ; x <= width; x += 8 ) { var angle = x / width * Math.PI * 2 * 1.6 + phase; var y = baseY + Math.Sin(angle) * amplitude; context.LineTo( new Point(x, y), true , false ); } context.LineTo( new Point(width, height), true , false ); } geometry.Freeze(); drawingContext.PushOpacity(opacity); drawingContext.DrawGeometry(brush, null , geometry); drawingContext.Pop(); }
4.7 跟做:用相位动画驱动波浪 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public void StartWave (){ BeginAnimation(WavePhaseProperty, null ); var animation = new DoubleAnimation { From = 0 , To = Math.PI * 2 , Duration = TimeSpan.FromSeconds(WaveSpeed), RepeatBehavior = RepeatBehavior.Forever }; BeginAnimation(WavePhaseProperty, animation); } public void StopWave (){ BeginAnimation(WavePhaseProperty, null ); WavePhase = 0 ; }
这里的关键是:
1 2 3 不要在 OnRender 中创建动画 不要在每帧创建新的 Storyboard 只让 WavePhase 属性持续变化
AffectsRender 会让属性变化触发重绘。
4.8 运行截图
运行截图 4:双层波浪在容器内持续改变相位。
4.9 跟做检查点
4.10 常见错误
现象
常见原因
修正方法
控件一片空白
ActualWidth/Height 为 0
检查布局尺寸
波浪画到圆角外
没有裁剪
PushClip
波浪太尖
采样点太少
减小步长
动画卡顿
每帧重新创建几何或动画
复用时钟,只改相位
停止后仍流动
只重置属性,没有移除动画
BeginAnimation(..., null)
液位超出范围
没有强制回调
使用 CoerceValueCallback
4.11 小练习
把液位改成 0.4。
把振幅改成 16。
让第二层波浪相位偏移 1.8。
增加小气泡。
把波浪周期改成 4 秒。
5. 控件自定义封装方案 5.1 本节目标 完成本节后,你要能够:
用依赖属性暴露控件参数。
用强制回调限制参数范围。
用 AffectsRender 触发重绘。
在控件内部管理动画。
用同一个控件创建多个不同实例。
5.2 为什么要封装 如果每个波浪都写在页面里,会遇到:
1 2 3 4 5 XAML 重复 几何代码重复 动画代码重复 参数无法统一限制 多个实例互相干扰
封装后的目标:
图 6:外部只配置属性,内部负责绘制、动画和停止。
5.3 外部 API 1 2 3 4 5 6 7 8 9 LiquidLevel WaveAmplitude WaveSpeed WaveBrush AccentWaveBrush WavePhase StartWave() StopWave()
5.4 跟做:创建控件类 新建 LiquidWaveControl.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 using System.Windows;using System.Windows.Media;using System.Windows.Media.Animation;namespace WpfAnimationCasePractice ;public sealed class LiquidWaveControl : FrameworkElement { private static readonly Brush DefaultWaveBrush = CreateBrush(Color.FromRgb(45 , 156 , 219 )); private static readonly Brush DefaultAccentBrush = CreateBrush(Color.FromRgb(155 , 231 , 255 )); public static readonly DependencyProperty LiquidLevelProperty = DependencyProperty.Register( nameof (LiquidLevel), typeof (double ), typeof (LiquidWaveControl), new FrameworkPropertyMetadata( 0.62 , FrameworkPropertyMetadataOptions.AffectsRender, null , CoerceLiquidLevel)); public static readonly DependencyProperty WaveBrushProperty = DependencyProperty.Register( nameof (WaveBrush), typeof (Brush), typeof (LiquidWaveControl), new FrameworkPropertyMetadata( DefaultWaveBrush, FrameworkPropertyMetadataOptions.AffectsRender)); public static readonly DependencyProperty AccentWaveBrushProperty = DependencyProperty.Register( nameof (AccentWaveBrush), typeof (Brush), typeof (LiquidWaveControl), new FrameworkPropertyMetadata( DefaultAccentBrush, FrameworkPropertyMetadataOptions.AffectsRender)); public static readonly DependencyProperty WaveAmplitudeProperty = DependencyProperty.Register( nameof (WaveAmplitude), typeof (double ), typeof (LiquidWaveControl), new FrameworkPropertyMetadata( 9.0 , FrameworkPropertyMetadataOptions.AffectsRender, null , CoerceAmplitude)); public static readonly DependencyProperty WaveSpeedProperty = DependencyProperty.Register( nameof (WaveSpeed), typeof (double ), typeof (LiquidWaveControl), new FrameworkPropertyMetadata( 2.6 , FrameworkPropertyMetadataOptions.None, null , CoerceSpeed)); public static readonly DependencyProperty WavePhaseProperty = DependencyProperty.Register( nameof (WavePhase), typeof (double ), typeof (LiquidWaveControl), new FrameworkPropertyMetadata( 0.0 , FrameworkPropertyMetadataOptions.AffectsRender)); public double LiquidLevel { get => (double )GetValue(LiquidLevelProperty); set => SetValue(LiquidLevelProperty, value ); } public Brush WaveBrush { get => (Brush)GetValue(WaveBrushProperty); set => SetValue(WaveBrushProperty, value ); } public Brush AccentWaveBrush { get => (Brush)GetValue(AccentWaveBrushProperty); set => SetValue(AccentWaveBrushProperty, value ); } public double WaveAmplitude { get => (double )GetValue(WaveAmplitudeProperty); set => SetValue(WaveAmplitudeProperty, value ); } public double WaveSpeed { get => (double )GetValue(WaveSpeedProperty); set => SetValue(WaveSpeedProperty, value ); } public double WavePhase { get => (double )GetValue(WavePhaseProperty); set => SetValue(WavePhaseProperty, value ); } private static Brush CreateBrush (Color color ) { var brush = new SolidColorBrush(color); brush.Freeze(); return brush; } }
5.5 跟做:强制回调限制参数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 private static object CoerceLiquidLevel ( DependencyObject dependencyObject, object baseValue ){ return Math.Clamp((double )baseValue, 0 , 1 ); } private static object CoerceAmplitude ( DependencyObject dependencyObject, object baseValue ){ return Math.Clamp((double )baseValue, 0 , 40 ); } private static object CoerceSpeed ( DependencyObject dependencyObject, object baseValue ){ var value = (double )baseValue; return value <= 0 ? 0.2 : value ; }
调用方即使传入:
1 2 3 LiquidLevel="2" WaveAmplitude="-30" WaveSpeed="0"
控件内部也会得到安全值。
5.6 跟做:在 OnRender 中绘制 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 protected override void OnRender ( DrawingContext drawingContext ){ base .OnRender(drawingContext); var width = ActualWidth; var height = ActualHeight; if (width <= 0 || height <= 0 ) { return ; } var clip = new RectangleGeometry( new Rect(0 , 0 , width, height), 20 , 20 ); drawingContext.PushClip(clip); var level = Math.Clamp(LiquidLevel, 0 , 1 ); var surfaceY = height * (1 - level); drawingContext.DrawRectangle( WaveBrush, null , new Rect(0 , surfaceY, width, height - surfaceY)); DrawWave( drawingContext, surfaceY + 4 , WavePhase + 1.8 , AccentWaveBrush, 0.34 , WaveAmplitude * 1.15 , width, height); DrawWave( drawingContext, surfaceY, WavePhase, WaveBrush, 0.92 , WaveAmplitude, width, height); drawingContext.Pop(); }
5.7 跟做:在页面中使用多个实例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <local:LiquidWaveControl Width ="260" Height ="240" LiquidLevel ="0.70" WaveAmplitude ="8" WaveSpeed ="2.1" WaveBrush ="#2D9CDB" AccentWaveBrush ="#9BE7FF" /> <local:LiquidWaveControl Width ="260" Height ="240" LiquidLevel ="0.48" WaveAmplitude ="12" WaveSpeed ="3.2" WaveBrush ="#176B4D" AccentWaveBrush ="#8EE0BC" />
两个实例共享同一个控件类型,但:
1 2 3 4 5 颜色不同 液位不同 振幅不同 速度不同 动画时钟各自独立
5.8 运行截图
运行截图 5:两个 LiquidWaveControl 实例使用不同参数。
5.9 跟做检查点
5.10 常见错误
现象
常见原因
修正方法
属性绑定后不重绘
没有设置 AffectsRender
添加元数据选项
液位超过容器
没有强制回调
CoerceValueCallback
多个实例一起停
动画写成了 static
动画属于实例
每次属性变化都创建动画
把动画写在 OnRender 中
动画只在 StartWave 中创建
XAML 找不到控件
命名空间没声明
添加 local 命名空间
控件没有尺寸
Width/Height 或布局缺失
设置尺寸或参与布局
5.11 小练习
增加 BubbleCount 依赖属性。
增加 ShowOutline 属性。
让波浪颜色通过绑定改变。
给控件增加 IsAnimating 只读属性。
把控件移到单独的类库项目。
五个案例放在一起比较 它们分别用到什么
案例
核心属性
主要难点
等待动画
RotateTransform.Angle、Opacity
多个 Forever 动画的停止
机械臂
多个 RotateTransform.Angle
父子层级与旋转中心
蚂蚁线
StrokeDashOffset
方向与偏移量
溶液波浪
自定义 WavePhase
几何绘制与持续相位
控件封装
依赖属性、OnRender
API、强制回调和实例隔离
从效果到封装的顺序 1 2 3 4 5 先做一个能看的静态形状 再让一个属性动起来 再组合多个动画 再抽取参数 最后封装成控件
完整项目结构 跟做项目可以整理成:
1 2 3 4 5 6 7 8 9 10 WpfAnimationCasePractice ├─ App.xaml ├─ App.xaml.cs ├─ MainWindow.xaml ├─ MainWindow.xaml.cs ├─ LiquidWaveControl.cs ├─ AnimationRules.cs └─ ViewModels ├─ ObservableObject.cs └─ MainViewModel.cs
随附完整示例:
1 2 3 4 5 6 7 8 9 10 WpfAnimationCaseStudyDemo ├─ SelfTestRunner.cs ├─ UiProbe.cs ├─ LiquidWaveControl.cs ├─ AnimationRules.cs ├─ Models │ └─ AppOptions.cs └─ ViewModels ├─ ObservableObject.cs └─ MainViewModel.cs
自动测试 1 2 3 4 $exe = "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAnimationCaseStudyDemo\bin\Debug\net9.0-windows\WpfAnimationCaseStudyDemo.exe" $process = Start-Process -FilePath $exe -ArgumentList "--self-test" -PassThru -Wait $process .ExitCodeGet-Content "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfAnimationCaseStudyDemo\bin\Debug\net9.0-windows\self-test-results.txt" -Encoding UTF8
检查内容:
1 2 3 4 5 6 7 8 9 10 等待动画子动画数量 旋转动画是否永久重复 机械臂关节动画数量 肩部目标角度 蚂蚁线动画数量 虚线偏移目标值 液位裁剪 角度归一化 速度有效性 五个案例的启停状态
常见错误速查 等待动画
问题
优先检查
越转越快
是否重复创建 Forever 动画
停止后还闪
是否停止完整 Storyboard
旋转中心不对
RenderTransformOrigin
页面关闭后还运行
是否在 Closed 中停止
动画太抢眼
速度、颜色、对比度
机械臂
问题
优先检查
子节不跟着动
层级嵌套
关节位置错误
旋转中心
父级带着乱飞
外层变换
复位不完整
四个角度是否都重置
动画叠加
是否先 Stop
蚂蚁线
问题
优先检查
没有虚线
StrokeDashArray
不流动
StrokeDashOffset
方向错误
偏移正负
停止不归零
是否重置偏移
重复后变快
是否停止旧时钟
溶液波浪
问题
优先检查
空白
ActualWidth/Height
画出圆角
PushClip
曲线太尖
采样步长
停止后仍流动
是否移除动画
属性变化不重绘
AffectsRender
控件封装
问题
优先检查
XAML 找不到控件
local 命名空间
属性无效
依赖属性注册
参数越界
CoerceValueCallback
多实例互相影响
是否使用 static 状态
动画重复创建
StartWave 是否先清理
综合练习 练习一:等待卡片 要求:
圆环旋转。
文字透明度呼吸。
三个点依次变化。
停止后全部恢复。
窗口关闭时释放动画。
练习二:四轴机械臂 要求:
建立四级嵌套。
每级旋转中心正确。
四个关节时长不同。
支持抓取和复位。
记录每个关节角度。
练习三:闭合蚂蚁线 要求:
使用闭合路径。
虚线沿路径流动。
支持正反方向。
停止后偏移归零。
与选中状态联动。
练习四:三层溶液波浪 要求:
三层不同相位。
三层不同透明度。
液位可配置。
振幅可配置。
启动和停止都只创建一个时钟。
练习五:波浪控件类库 要求:
新建 WPF 类库。
把 LiquidWaveControl 移入类库。
增加 BubbleCount 依赖属性。
增加 IsAnimating 只读依赖属性。
在主项目中引用并使用。
结语 这一篇从“好看的效果”走到了“可复用的控件”:
1 2 3 4 5 等待动画:多个低强度动画叠加 机械臂:父子层级决定运动关系 蚂蚁线:偏移量制造流动 溶液波浪:几何 + 相位 控件封装:依赖属性 + 内部时钟
做综合动画时,可以按下面顺序检查:
1 2 3 4 5 6 7 静态形状是否正确? 动画属性是否只改变一个明确目标? 父级变换会不会影响子级? 重复动画是否先停止旧时钟? 停止后属性能不能恢复? 参数是否应该暴露成依赖属性? 多个实例是否互不干扰?
把这些问题处理好,动画才能从演示代码变成真正可维护的界面能力。