WPF 动画与视觉效果
大约 10 分钟约 3095 字
WPF 动画与视觉效果
简介
WPF 提供了强大的动画系统,支持属性动画、路径动画、关键帧动画等。动画可以作用于任何依赖属性,配合 Storyboard 和 Trigger 实现 UI 的动态效果。WPF 还支持硬件加速渲染,能实现流畅的视觉效果。
特点
基本动画
线性插值动画
<!-- DoubleAnimation — 数值属性动画 -->
<Button Content="点击我" Width="100" Height="40">
<Button.Triggers>
<EventTrigger RoutedEvent="Button.Click">
<BeginStoryboard>
<Storyboard>
<!-- 宽度从 100 变到 200,持续 0.3 秒 -->
<DoubleAnimation Storyboard.TargetProperty="Width"
From="100" To="200"
Duration="0:0:0.3" />
<!-- 透明度从 1 变到 0.5 -->
<DoubleAnimation Storyboard.TargetProperty="Opacity"
From="1" To="0.5"
Duration="0:0:0.3" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Button.Triggers>
</Button>ColorAnimation — 颜色动画
<!-- 鼠标悬停时背景色渐变 -->
<Border Width="200" Height="100" CornerRadius="8">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#3498DB"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetProperty="Background.Color"
To="#2ECC71" Duration="0:0:0.2"/>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<ColorAnimation Storyboard.TargetProperty="Background.Color"
To="#3498DB" Duration="0:0:0.2"/>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="悬停变色" Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>ThicknessAnimation — 边距动画
<!-- 滑入效果 -->
<Border x:Name="SlidePanel" Background="#2C3E50" Width="250" Height="100"
Margin="-250,0,0,0">
<Border.Triggers>
<EventTrigger RoutedEvent="Border.Loaded">
<BeginStoryboard>
<Storyboard>
<ThicknessAnimation Storyboard.TargetProperty="Margin"
From="-250,0,0,0" To="0,0,0,0"
Duration="0:0:0.5">
<ThicknessAnimation.EasingFunction>
<CubicEase EasingMode="EaseOut"/>
</ThicknessAnimation.EasingFunction>
</ThicknessAnimation>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Border.Triggers>
</Border>关键帧动画
DoubleAnimationUsingKeyFrames
<!-- 关键帧动画 — 精确控制每个时间点 -->
<Rectangle x:Name="Ball" Width="30" Height="30" Fill="#E74C3C" RadiusX="15" RadiusY="15">
<Rectangle.Triggers>
<EventTrigger RoutedEvent="Rectangle.Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever">
<DoubleAnimationUsingKeyFrames
Storyboard.TargetProperty="(Canvas.Left)">
<LinearDoubleKeyFrame Value="0" KeyTime="0:0:0" />
<LinearDoubleKeyFrame Value="200" KeyTime="0:0:1" />
<LinearDoubleKeyFrame Value="200" KeyTime="0:0:2" />
<LinearDoubleKeyFrame Value="0" KeyTime="0:0:3" />
</DoubleAnimationUsingKeyFrames>
<!-- 弹跳效果 -->
<DoubleAnimationUsingKeyFrames
Storyboard.TargetProperty="(Canvas.Top)">
<EasingDoubleKeyFrame Value="0" KeyTime="0:0:0"/>
<EasingDoubleKeyFrame Value="100" KeyTime="0:0:0.5">
<EasingDoubleKeyFrame.EasingFunction>
<BounceEase Bounces="3" Bounciness="2"/>
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
<EasingDoubleKeyFrame Value="0" KeyTime="0:0:1"/>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Rectangle.Triggers>
</Rectangle>StringAnimationUsingKeyFrames
<!-- 文字打字机效果 -->
<TextBlock x:Name="TypingText" FontSize="20" FontWeight="Bold">
<TextBlock.Triggers>
<EventTrigger RoutedEvent="TextBlock.Loaded">
<BeginStoryboard>
<Storyboard>
<StringAnimationUsingKeyFrames
Storyboard.TargetProperty="Text"
Duration="0:0:3">
<DiscreteStringKeyFrame Value="" KeyTime="0:0:0"/>
<DiscreteStringKeyFrame Value="H" KeyTime="0:0:0.3"/>
<DiscreteStringKeyFrame Value="He" KeyTime="0:0:0.6"/>
<DiscreteStringKeyFrame Value="Hel" KeyTime="0:0:0.9"/>
<DiscreteStringKeyFrame Value="Hell" KeyTime="0:0:1.2"/>
<DiscreteStringKeyFrame Value="Hello" KeyTime="0:0:1.5"/>
<DiscreteStringKeyFrame Value="Hello " KeyTime="0:0:1.8"/>
<DiscreteStringKeyFrame Value="Hello W" KeyTime="0:0:2.1"/>
<DiscreteStringKeyFrame Value="Hello Wo" KeyTime="0:0:2.4"/>
<DiscreteStringKeyFrame Value="Hello Wor" KeyTime="0:0:2.7"/>
<DiscreteStringKeyFrame Value="Hello Worl" KeyTime="0:0:2.8"/>
<DiscreteStringKeyFrame Value="Hello World" KeyTime="0:0:3"/>
</StringAnimationUsingKeyFrames>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</TextBlock.Triggers>
</TextBlock>缓动函数
EasingFunction
<!-- WPF 内置缓动函数 -->
<!-- 弹性 — 回弹效果 -->
<ElasticEase EasingMode="EaseOut" Oscillations="3" Springiness="1"/>
<!-- 弹跳 — 球体落地效果 -->
<BounceEase EasingMode="EaseOut" Bounces="3" Bounciness="2"/>
<!-- 圆形 — 平滑加减速 -->
<CircleEase EasingMode="EaseInOut"/>
<!-- 三次方 — 通用缓入缓出 -->
<CubicEase EasingMode="EaseInOut"/>
<!-- 二次方 -->
<QuadraticEase EasingMode="EaseOut"/>
<!-- 指数 -->
<ExponentialEase EasingMode="EaseOut" Exponent="5"/>
<!-- 正弦 -->
<SineEase EasingMode="EaseInOut"/>
<!-- 回弹 — 超出再回到目标 -->
<BackEase EasingMode="EaseOut" Amplitude="0.5"/>代码中的动画
C# 创建动画
/// <summary>
/// 在代码中创建和控制动画
/// </summary>
public partial class MainWindow : Window
{
private Storyboard? _currentStoryboard;
public MainWindow()
{
InitializeComponent();
}
// 淡入动画
public void FadeIn(FrameworkElement element, double duration = 0.3)
{
var animation = new DoubleAnimation
{
From = 0,
To = 1,
Duration = TimeSpan.FromSeconds(duration),
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
};
element.BeginAnimation(UIElement.OpacityProperty, animation);
}
// 淡出动画
public void FadeOut(FrameworkElement element, double duration = 0.3, Action? onComplete = null)
{
var animation = new DoubleAnimation
{
From = 1,
To = 0,
Duration = TimeSpan.FromSeconds(duration),
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn }
};
if (onComplete != null)
{
animation.Completed += (s, e) => onComplete();
}
element.BeginAnimation(UIElement.OpacityProperty, animation);
}
// 滑动进入
public void SlideIn(FrameworkElement element, double duration = 0.5)
{
var transform = new TranslateTransform { X = element.ActualWidth };
element.RenderTransform = transform;
var animation = new DoubleAnimation
{
From = element.ActualWidth,
To = 0,
Duration = TimeSpan.FromSeconds(duration),
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
};
transform.BeginAnimation(TranslateTransform.XProperty, animation);
}
// 缩放动画
public void ScaleUp(FrameworkElement element, double from = 0.8, double to = 1.0)
{
var transform = new ScaleTransform(from, from);
element.RenderTransformOrigin = new Point(0.5, 0.5);
element.RenderTransform = transform;
var animX = new DoubleAnimation(from, to, TimeSpan.FromSeconds(0.3))
{
EasingFunction = new BackEase { EasingMode = EasingMode.EaseOut, Amplitude = 0.3 }
};
var animY = new DoubleAnimation(from, to, TimeSpan.FromSeconds(0.3))
{
EasingFunction = new BackEase { EasingMode = EasingMode.EaseOut, Amplitude = 0.3 }
};
transform.BeginAnimation(ScaleTransform.ScaleXProperty, animX);
transform.BeginAnimation(ScaleTransform.ScaleYProperty, animY);
}
// 抖动动画 — 错误提示
public void Shake(FrameworkElement element)
{
var transform = new TranslateTransform();
element.RenderTransform = transform;
var animation = new DoubleAnimationUsingKeyFrames();
animation.KeyFrames.Add(new LinearDoubleKeyFrame(0, KeyTime.FromTimeSpan(TimeSpan.Zero)));
animation.KeyFrames.Add(new LinearDoubleKeyFrame(-10, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(50))));
animation.KeyFrames.Add(new LinearDoubleKeyFrame(10, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(100))));
animation.KeyFrames.Add(new LinearDoubleKeyFrame(-5, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(150))));
animation.KeyFrames.Add(new LinearDoubleKeyFrame(5, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(200))));
animation.KeyFrames.Add(new LinearDoubleKeyFrame(0, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(250))));
animation.RepeatBehavior = new RepeatBehavior(2);
transform.BeginAnimation(TranslateTransform.XProperty, animation);
}
}动画封装工具类
通用动画库
/// <summary>
/// 动画工具类 — 封装常用动画效果
/// </summary>
public static class AnimationHelper
{
// 页面切换动画
public static void PageTransition(FrameworkElement oldPage, FrameworkElement newPage)
{
// 旧页面淡出 + 向左滑出
var outAnimation = new DoubleAnimation
{
From = 1, To = 0,
Duration = TimeSpan.FromSeconds(0.2)
};
// 新页面淡入 + 从右滑入
var transform = new TranslateTransform { X = 50 };
newPage.RenderTransform = transform;
newPage.Opacity = 0;
var inFade = new DoubleAnimation(0, 1, TimeSpan.FromSeconds(0.3))
{
BeginTime = TimeSpan.FromSeconds(0.15),
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
};
var inSlide = new DoubleAnimation(50, 0, TimeSpan.FromSeconds(0.3))
{
BeginTime = TimeSpan.FromSeconds(0.15),
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
};
oldPage.BeginAnimation(UIElement.OpacityProperty, outAnimation);
newPage.BeginAnimation(UIElement.OpacityProperty, inFade);
transform.BeginAnimation(TranslateTransform.XProperty, inSlide);
}
// 脉冲动画 — 持续缩放
public static void Pulse(FrameworkElement element)
{
var storyboard = new Storyboard();
var scaleX = new DoubleAnimationUsingKeyFrames
{
RepeatBehavior = RepeatBehavior.Forever
};
scaleX.KeyFrames.Add(new EasingDoubleKeyFrame(1.0, KeyTime.FromTimeSpan(TimeSpan.Zero)));
scaleX.KeyFrames.Add(new EasingDoubleKeyFrame(1.05, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(0.5))));
scaleX.KeyFrames.Add(new EasingDoubleKeyFrame(1.0, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1))));
Storyboard.SetTarget(scaleX, element);
Storyboard.SetTargetProperty(scaleX, new PropertyPath("RenderTransform.ScaleX"));
storyboard.Children.Add(scaleX);
storyboard.Begin();
}
}路径动画
MatrixAnimationUsingPath
<!-- 沿路径移动元素 -->
<Canvas>
<Canvas.Resources>
<PathGeometry x:Key="MotionPath">
<PathFigure StartPoint="50,200">
<BezierSegment Point1="150,50" Point2="250,50" Point3="350,200"/>
</PathFigure>
</PathGeometry>
</Canvas.Resources>
<Ellipse x:Name="MovingBall" Width="20" Height="20" Fill="#E74C3C">
<Ellipse.Triggers>
<EventTrigger RoutedEvent="Ellipse.Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever">
<MatrixAnimationUsingPath
Storyboard.TargetProperty="RenderTransform"
Duration="0:0:3"
Path="{StaticResource MotionPath}"
DoesRotateWithTangent="True"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Ellipse.Triggers>
<Ellipse.RenderTransform>
<MatrixTransform/>
</Ellipse.RenderTransform>
</Ellipse>
<!-- 显示路径 -->
<Path Data="{StaticResource MotionPath}" Stroke="#95A5A6" StrokeThickness="1"
StrokeDashArray="4 2"/>
</Canvas>DoubleAnimationUsingPath
<!-- 使用路径控制 X/Y 坐标 -->
<Canvas>
<Rectangle x:Name="PathFollower" Width="15" Height="15" Fill="#3498DB" RadiusX="7" RadiusY="7">
<Rectangle.Triggers>
<EventTrigger RoutedEvent="Rectangle.Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever" AutoReverse="True">
<DoubleAnimationUsingPath
Storyboard.TargetProperty="(Canvas.Left)"
PathGeometry="M 10,100 C 50,10 200,10 300,100"
Duration="0:0:3"
Source="X"/>
<DoubleAnimationUsingPath
Storyboard.TargetProperty="(Canvas.Top)"
PathGeometry="M 10,100 C 50,10 200,10 300,100"
Duration="0:0:3"
Source="Y"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Rectangle.Triggers>
</Rectangle>
</Canvas>视觉状态管理
VisualStateManager
<!-- 使用 VisualStateManager 管理复杂状态切换 -->
<Border x:Name="Card" CornerRadius="8" Padding="20" Margin="10">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#FFFFFF"/>
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.2"/>
</Setter.Value>
</Setter>
</Style>
</Border.Style>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal">
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Opacity" To="1" Duration="0:0:0.2"/>
</Storyboard>
</VisualState>
<VisualState x:Name="Hover">
<Storyboard>
<ColorAnimation Storyboard.TargetProperty="Background.Color" To="#F0F8FF" Duration="0:0:0.15"/>
<DoubleAnimation Storyboard.TargetProperty="(Effect.ShadowDepth)" To="5" Duration="0:0:0.15"/>
<DoubleAnimation Storyboard.TargetProperty="(Effect.BlurRadius)" To="20" Duration="0:0:0.15"/>
</Storyboard>
</VisualState>
<VisualState x:Name="Pressed">
<Storyboard>
<ColorAnimation Storyboard.TargetProperty="Background.Color" To="#E8F0FE" Duration="0:0:0.1"/>
<DoubleAnimation Storyboard.TargetProperty="(Effect.ShadowDepth)" To="1" Duration="0:0:0.1"/>
</Storyboard>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
<TextBlock Text="卡片内容" FontSize="16"/>
</Border>动画性能优化
硬件加速与渲染提示
/// <summary>
/// 动画性能优化技巧
/// </summary>
public static class AnimationPerformance
{
// 1. 使用 RenderTransform 而非 LayoutTransform
// LayoutTransform 触发布局重算,RenderTransform 只触发重绘
// 推荐:使用 RenderTransform 做动画
// 2. 启用缓存模式(适用于复杂矢量图形)
public static void EnableCacheMode(FrameworkElement element)
{
// 将元素缓存为位图,避免每帧重绘
element.CacheMode = new BitmapCache
{
EnableClearType = true,
RenderAtScale = 1.0,
SnapsToDevicePixels = true
};
}
// 3. 使用 Timeline.DesiredFrameRate 降低帧率
public static Storyboard CreateLowFpsAnimation(double fps = 30)
{
var storyboard = new Storyboard
{
DesiredFrameRate = (int)fps // 默认 60,降到 30 节省性能
};
return storyboard;
}
// 4. 冻结不需要修改的资源
public static void FreezeResources()
{
var brush = new SolidColorBrush(Colors.Red);
brush.Freeze(); // 冻结后不可修改,但可以跨线程使用
var transform = new TranslateTransform(100, 0);
// 不要冻结动画中使用的 Transform
var pen = new Pen(brush, 1);
pen.Freeze();
}
// 5. 避免在动画中修改影响布局的属性
// 避免:Width、Height、Margin、Padding(触发 Measure + Arrange)
// 推荐:RenderTransform、Opacity、Clip(只触发 Render)
// 6. 大量元素动画时使用 VirtualizingStackPanel
// 启用虚拟化减少可视树中的元素数量
}
// 通用页面过渡动画服务
public class PageTransitionService
{
private Storyboard? _currentTransition;
// 淡入淡出
public void FadeTransition(FrameworkElement element, Action? onComplete = null)
{
_currentTransition?.Stop();
element.Opacity = 0;
var sb = new Storyboard { DesiredFrameRate = 60 };
var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromSeconds(0.25))
{
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
};
Storyboard.SetTarget(fadeIn, element);
Storyboard.SetTargetProperty(fadeIn, new PropertyPath("Opacity"));
sb.Children.Add(fadeIn);
sb.Completed += (s, e) => onComplete?.Invoke();
_currentTransition = sb;
sb.Begin();
}
// 滑入过渡
public void SlideTransition(FrameworkElement element,
SlideDirection direction = SlideDirection.FromRight)
{
var transform = new TranslateTransform();
element.RenderTransform = transform;
double fromX = direction switch
{
SlideDirection.FromRight => element.ActualWidth,
SlideDirection.FromLeft => -element.ActualWidth,
SlideDirection.FromBottom => 0,
_ => 0
};
double fromY = direction switch
{
SlideDirection.FromBottom => element.ActualHeight,
SlideDirection.FromTop => -element.ActualHeight,
_ => 0
};
var sb = new Storyboard();
if (fromX != 0)
{
var slideX = new DoubleAnimation(fromX, 0, TimeSpan.FromSeconds(0.3))
{
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
};
Storyboard.SetTarget(slideX, transform);
Storyboard.SetTargetProperty(slideX, new PropertyPath("X"));
sb.Children.Add(slideX);
}
if (fromY != 0)
{
var slideY = new DoubleAnimation(fromY, 0, TimeSpan.FromSeconds(0.3))
{
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
};
Storyboard.SetTarget(slideY, transform);
Storyboard.SetTargetProperty(slideY, new PropertyPath("Y"));
sb.Children.Add(slideY);
}
sb.Begin();
}
}
public enum SlideDirection { FromRight, FromLeft, FromTop, FromBottom }动画触发方式对比
| 触发方式 | 适用场景 | 特点 |
|---|---|---|
| EventTrigger | XAML 中事件触发 | 声明式,适合简单场景 |
| DataTrigger | 数据变化触发 | 绑定友好,适合 MVVM |
| VisualState | 控件状态管理 | 适合自定义控件开发 |
| C# 代码 | 复杂逻辑控制 | 灵活,适合动态参数 |
| Storyboard | 组合动画 | 统一管理,支持暂停/恢复 |
动画类型总览
| 动画类型 | 适用属性 | 说明 |
|---|---|---|
| DoubleAnimation | 数值(Width, Opacity) | 线性插值 |
| ColorAnimation | Color | 颜色渐变 |
| ThicknessAnimation | Thickness | 边距渐变 |
| PointAnimation | Point | 坐标移动 |
| SizeAnimation | Size | 尺寸变化 |
优点
缺点
总结
WPF 动画系统功能强大且易用。简单动画用 XAML 声明,复杂动画用 C# 代码创建。掌握线性动画、关键帧动画和缓动函数,就能实现大部分 UI 动效。注意性能:避免过多同时运行的动画,适当使用硬件加速。
关键知识点
- 先分清主题属于界面层、ViewModel 层、线程模型还是设备接入层。
- WPF 文章真正的价值在于把 UI、数据、命令、线程和资源关系讲清楚。
- 上位机场景里,稳定性和异常恢复常常比界面花哨更重要。
- WPF 主题往往要同时理解依赖属性、绑定、可视树和命令系统。
项目落地视角
- 优先明确 DataContext、绑定路径、命令源和线程切换位置。
- 涉及设备时,补齐超时、重连、日志、告警和资源释放策略。
- 复杂控件最好提供最小可运行页面,便于后续复用和排障。
- 优先确认 DataContext、绑定路径、命令触发点和资源引用来源。
常见误区
- 把大量逻辑堆进 code-behind,导致页面难测难维护。
- 在后台线程直接操作 UI,或忽略 Dispatcher 切换。
- 只验证正常路径,不验证设备掉线、权限缺失和资源泄漏。
- 在 code-behind 塞太多状态与业务逻辑。
进阶路线
- 继续向 MVVM 工具链、控件可复用性、性能优化和视觉树诊断深入。
- 把主题放回真实设备流程,思考启动、连接、采集、显示、告警和恢复。
- 沉淀成控件库、通信层和诊断工具,提高整套客户端的复用度。
- 继续补齐控件库、主题系统、诊断工具和启动性能优化。
适用场景
- 当你准备把《WPF 动画与视觉效果》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合桌面业务系统、监控看板、工控上位机和设备交互界面。
- 当系统需要同时处理复杂 UI、后台任务和硬件通信时,这类主题尤为关键。
落地建议
- 优先明确 View、ViewModel、服务层和设备层边界,避免代码隐藏过重。
- 涉及实时刷新时,提前设计 UI 线程切换、节流和资源释放。
- 对硬件通信、日志、告警和异常恢复建立标准流程。
排错清单
- 先检查 DataContext、绑定路径、INotifyPropertyChanged 和命令状态是否正常。
- 排查 Dispatcher 调用、死锁、后台线程直接操作 UI 的问题。
- 出现内存增长时,优先检查事件订阅、图像资源和窗口生命周期。
复盘问题
- 如果把《WPF 动画与视觉效果》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《WPF 动画与视觉效果》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《WPF 动画与视觉效果》最大的收益和代价分别是什么?
