📚 **Day 24 · Phase 4 第 5 课 · WPF 动画系统核心(Animation…
5推送消息数
27172字符数
2026-08-24教学日期
<!-- message_id: om_x100b678ce6a1a4a4b299b9c3f51ab13 -->
📚 **Day 24 · Phase 4 第 5 课 · WPF 动画系统核心(Animation System)**
主题:Animation(动画)
难度:⭐⭐⭐⭐ 资深必掌握
关联:Day 15 DP 11 级优先级 · Day 22 触发器 · Day 23 自定义控件 · Day 17 视觉树
───
📖 是什么:WPF 动画的本质
**一句话定义**
WPF 动画是基于 **Timeline 时间线的插值引擎**——不是"按时间播放 GIF",而是**按时间动态修改依赖属性 + 自动触发渲染**。
**3 大分类**(按目标属性类型)
| 类型 | 目标 | 示例 |
| ------------------------ | ------------------- | ---------------------------------------------------------------------- |
| 简单动画 *Animation | 单一属性类型 | DoubleAnimation / ColorAnimation / PointAnimation / ThicknessAnimation |
| 关键帧动画 *UsingKeyFrames | 多个时间点 + 中间插值 | DoubleAnimationUsingKeyFrames + 4 种 KeyFrame |
| 路径动画 *AnimationUsingPath | 沿 PathGeometry 改变数值 | DoubleAnimationUsingPath(X/Y/Angle)+ MatrixAnimationUsingPath |
**核心洞察**
• WPF 动画的"动画值"是 **DP 的特殊表达式**(EffectiveValueEntry.Expression = ActiveAnimation)
• 渲染时由 **CompositionTarget.Rendering** 事件驱动(~60fps,每帧推进 Clock)
• "流畅动画" = 每帧 SetValue + DP 触发 PropertyChanged + AffectsRender 标记 + 渲染线程 re-render
• ⏮ Day 23 你用 TranslateTransform.X 做 ToggleSwitch 切换——今天 Animation 就是这个的**工业级替代**
───
💡 为什么:为什么 WPF 需要动画系统?
**6 大革命性优势**(对比 WinForms Timer + Refresh)
| # | 优势 | WinForms 方案 | WPF 方案 |
| --- | ----- | ------------------ | ---------------------------------- |
| 1 | 声明式 | 100 行 .cs 手写 Timer | 5 行 XAML |
| 2 | 可资源化 | 不能复用 | 动画可放资源字典 |
| 3 | 可绑定 | 不能 | 可作为 DataTrigger 触发 |
| 4 | 可中断 | 手动 Cancel | Storyboard.Pause/Resume/SkipToFill |
| 5 | 可并行合成 | N 个 Timer 手动同步 | N 个 Animation 在 Storyboard 自动并行 |
| 6 | 自动插值 | 手动算每一帧 | Timeline 自动算 |
**底层原理 4 步**
1. AnimationClock 在 UI 线程和渲染线程之间桥接
2. 时钟推进:CompositionTarget.Rendering 每帧触发(~60fps)
3. 插值计算:Clock 推 CurrentTime → Animation 算当前值 → SetValue 到 DP
4. 重绘触发:DP 的 PropertyChangedCallback → AffectsRender 标记 → 渲染线程下一帧 re-render
**⛓ DP 11 级优先级中的"动画"位置**(呼应 Day 15)
CoerceValueCallback ← 最高(最终强制调整)
↓
Animation ← 第 2 高 ⭐
↓
LocalValue(本地值)
↓
TemplatedParent(来自 ControlTemplate)
↓
Style.Triggers
↓
Style.Setters
↓
Theme Triggers
↓
Theme Setters
↓
InheritedValue
↓
DefaultValue ← 最低
🔑 **关键洞察**:动画会**覆盖 LocalValue**——这就是为什么"动画结束后值会跳回基础值"。
───
待续 ⏬ 下条讲【怎么用 + 常见误区】
---
<!-- message_id: om_x100b678ce7fdcca0b3dfe3555e125f6 -->
📚 **怎么用:核心 API + Timeline 子属性**
Storyboard 容器
**所有 Animation 必须包在 Storyboard 里才能真正运行!*
<Storyboard BeginTime="0:0:0" RepeatBehavior="Forever">
<DoubleAnimation From="0" To="360" Duration="0:0:1"
Storyboard.TargetName="MyRing"
Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)" />
</Storyboard>
Timeline 9 大子属性(所有动画共有)
| 属性 | 作用 | 默认 |
| ----------------- | --------------------------- | ----------------- |
| BeginTime | 延迟开始 | 0 |
| Duration | 动画时长 | Automatic (1s) |
| RepeatBehavior | 重复 | new RepeatCount=1 |
| AutoReverse | 到结束值后反向播放 | false |
| SpeedRatio | 加速倍数(>1 加速 / <1 减速) | 1 |
| AccelerationRatio | 加速占比 | 0 |
| DecelerationRatio | 减速占比 | 0 |
| FillBehavior | HoldEnd(保留结束值)/ Stop(回到基础值) | Stop |
| DesiredFrameRate | 帧率(fps) | 默认 |
**🔑 AccelerationRatio + DecelerationRatio 之和必须 ≤ 0.9**(WPF 硬性限制,否则抛异常)
4 大 KeyFrame 关键帧
| 类型 | 类 | 插值行为 | 典型场景 |
| --- | ---------------------- | -------------------- | ------------- |
| 线性帧 | LinearDoubleKeyFrame | 匀速插值 | 平滑过渡 |
| 离散帧 | DiscreteDoubleKeyFrame | 不插值,直接跳 | 开关切换 / 状态机 |
| 样条帧 | SplineDoubleKeyFrame | 三次贝塞尔(KeySpline 控制点) | 自定义曲线 |
| 缓动帧 | EasingDoubleKeyFrame | EasingFunction 缓动 | Material 风格动画 |
11 大缓动函数族(EasingFunctionBase 派生)
| 缓动族 | 视觉效果 | EasingMode |
| ------------------------- | ---------- | ---------------- |
| BackEase | 回拉效果(先退后进) | EaseIn/Out/InOut |
| BounceEase | 弹跳落地 | 3 选 1 |
| CircleEase | 圆形加速 | 3 选 1 |
| CubicEase | 三次方 | 3 选 1 |
| ElasticEase | 弹性震荡(多次衰减) | 3 选 1 |
| ExponentialEase | 指数 | 3 选 1 |
| PowerEase | 幂 | 3 选 1 |
| QuadraticEase | 二次方 | 3 选 1 |
| QuarticEase / QuinticEase | 四/五次方 | 3 选 1 |
| SineEase | 正弦(最常用 ⭐) | 3 选 1 |
**EasingMode 3 选 1**
• EaseIn:开始慢后来快(加速)
• EaseOut:开始快后来慢(减速)⭐ 最常用
• EaseInOut:先加速后减速
5 大触发方式
| 触发方式 | 写法 | 场景 |
| ------------------ | -------------------------------------------------------------------------------------------------------------- | -------------- |
| EventTrigger | <EventTrigger RoutedEvent="MouseEnter"><BeginStoryboard>...</BeginStoryboard></EventTrigger> | 鼠标交互 |
| DataTrigger | <DataTrigger Binding="..." Value="..."><DataTrigger.EnterActions><BeginStoryboard/></DataTrigger.EnterActions> | ViewModel 状态变化 |
| 隐式 Style | <Style><Style.Triggers>...</Style.Triggers></Style> | 控件属性变化 |
| Storyboard.Begin() | 代码触发 | 动态控制 |
| VisualStateManager | <vsm:VisualStateGroup x:Name="CommonStates">...</vsm:VisualStateGroup> | 控件状态机 ⭐ 推荐 |
───
⚠️ 7 大常见误区
| # | 误区 | 真相 |
| --- | ------------------------- | -------------------------------------------------------- |
| M1 | 动画结束自动保留最终值 | 错! 默认 FillBehavior=Stop 回到基础值,需显式 HoldEnd |
| M2 | 只有 Double 可动画 | 错! Color / Point / Thickness / int / Matrix 都可 |
| M3 | 自定义 DP 自动支持动画 | 错! 必须注册时设 FrameworkPropertyMetadataOptions.AffectsRender |
| M4 | 动画期间改 LocalValue 会生效 | 错! 动画优先级 > 本地值(Day 15 11 级 ⭐) |
| M5 | 动画不耗 CPU | 错! Rendering 事件每帧触发(120Hz 显示器尤甚) |
| M6 | FillBehavior 决定缓动 | 错! FillBehavior 只决定结束后行为,跟播放过程无关 |
| M7 | AccelerationRatio=1.0 全加速 | 错! 硬性限制 ≤ 0.9,否则抛 ArgumentException |
───
待续 ⏬ 下条讲【3 道递进面试题】
---
<!-- message_id: om_x100b678ce7545d04b16a9bfbc0c9f3d -->
📝 **3 道递进面试题(按难度递进)**
Q1(基础概念)
请简述:
1. WPF 动画的本质是什么?和 WinForms Timer + Refresh 有何**根本区别**?
2. WPF 动画的 3 大分类是什么?每类举一个具体示例。
3. Storyboard 的 5 大触发方式是什么?
Q2(原理与辨析)
深度问题:
1. 在 WPF 的 **DP 11 级值解析优先级**中,**动画值**处于什么位置?它与 LocalValue 的优先级关系是什么?为什么说"动画结束后值会跳回"?
2. FillBehavior="HoldEnd" vs "Stop" 的**本质区别**是什么?请举例说明两种场景。
3. KeyFrame 4 大类型(LinearKeyFrame / DiscreteKeyFrame / SplineKeyFrame / EasingKeyFrame)的核心区别是什么?请对每种举一个典型场景。
Q3(实战与深度)⭐⭐⭐
**设计一个 WPF 通用 LoadingIndicator 控件**,要求:
✅ **功能需求**
- 支持 4 种动画模式(圆环旋转 / 进度条 / 圆点错峰 / 路径移动)
- 暴露 IsActive DP 控制播放/停止
- 暴露 Mode DP 切换动画类型
- 公开 Pause() / Resume() 方法
✅ **技术约束**
- 全部用 **XAML 声明式动画**(不允许 CodeBehind 写 Timer)
- 颜色用 **DynamicResource** 支持主题切换
- 遵循 **Day 23 自定义控件**标准模板(DefaultStyleKey + Themes/Generic.xaml + PART_*)
- 支持 **Day 22 触发器**样式系统(可作为其他控件模板的一部分)
✅ **性能要求**
- 避免不必要的 InvalidateMeasure
- 不阻塞 UI 线程
- 在低性能机器(Tier=0 无硬件加速)也能正常运行
📌 请提供:
- C# 代码(LoadingIndicator.cs)
- XAML 模板(Themes/Generic.xaml)
- 关键设计决策说明
---
<!-- message_id: om_x100b678ce48180a4b3c90d2d1e217ce -->
📝 **全面复盘 · Q1 + Q2 详细解析**
───
Q1 参考答案
**1. WPF 动画的本质 + 与 WinForms Timer 的根本区别**
| 维度 | WinForms Timer + Refresh | WPF Animation |
| ---- | ------------------------ | ------------------------------------- |
| 本质 | "定时器驱动的手动重绘" | "基于 Timeline 的插值引擎 + 表达式推送" |
| 执行位置 | UI 线程同步 | UI 线程 + 渲染线程协作 |
| 插值 | 手动算每一帧 | Timeline 自动算 |
| 驱动 | Tick 事件(~16ms) | CompositionTarget.Rendering 每帧 |
| 生命周期 | Start/Stop + Tick 回调 | Storyboard.Begin/Pause/Stop + 多动画并行合成 |
| 绑定 | 不能 | 可作为 DataTrigger 触发可绑定 |
🔑 **根本区别**:WinForms 是"**手动驱动值变化**"(命令式),WPF 是"**声明式描述目标状态 + 时间引擎自动推值**"(声明式)。
**2. 3 大分类**
| 分类 | 关键类 | 例子 |
| ----- | ----------------------------- | --------------------- |
| 简单动画 | DoubleAnimation | Width 从 100 动画到 200 |
| 关键帧动画 | DoubleAnimationUsingKeyFrames | Opacity 0→0.5→1→0.5→0 |
| 路径动画 | DoubleAnimationUsingPath | 沿椭圆路径移动 |
**3. 5 大触发方式**
1. EventTrigger(事件触发)
2. DataTrigger(数据变化触发)
3. 隐式 Style.Triggers(属性变化触发)
4. Storyboard.Begin()(代码触发)
5. VisualStateManager(控件状态机 ⭐ 推荐)
───
Q2 参考答案
**1. 动画在 DP 11 级优先级中的位置 + 与 LocalValue 关系**
完整 11 级(高→低):
CoerceValueCallback (最终强制调整)
↓
Animation ⭐ ← 动画值
↓
LocalValue
↓
TemplatedParent
↓
Style.Triggers
↓
Style.Setters
↓
Theme Triggers
↓
Theme Setters
↓
InheritedValue
↓
DefaultValue
**与 LocalValue 优先级关系**:动画值 > 本地值(动画会覆盖 LocalValue)
**为什么动画结束后值会跳回**:
• FillBehavior=Stop(默认):动画停止后,Animation Clock 失效,**动画值从 EffectiveValueEntry 中移除**,下一个最高优先级值(通常是 LocalValue)立即生效——这就是"跳回"现象
• FillBehavior=HoldEnd:动画停止后,动画值**保留**在 EffectiveValueEntry 中,直到下一个 LocalValue 写入才被覆盖
**2. FillBehavior="HoldEnd" vs "Stop"**
| 维度 | Stop(默认) | HoldEnd |
| ----- | -------------- | ------------ |
| 结束后行为 | 恢复到基础值 | 保留动画最终值 |
| 何时用 | 动画状态临时变化(弹起回原) | 动画结束后希望状态持久 |
| 场景 | 鼠标悬停高亮 | Loading 淡入显示 |
| 代码控制 | XAML 默认即可 | 需显式设置 |
**举例**
• **Stop**:Button 鼠标悬停时 Background 从 #555 变到 #FFF,移开鼠标**必须回到** #555
• **HoldEnd**:对话框从 Opacity=0 淡入到 Opacity=1,动画结束**保持显示**
**3. KeyFrame 4 大类型对比**
| 类型 | 插值 | 类 | 典型场景 |
| -------- | ------- | ------------------------------------- | ------------------ |
| Linear | 线性(匀速) | LinearDoubleKeyFrame | 简单过渡 |
| Discrete | 不插值直接跳 | DiscreteDoubleKeyFrame | 状态机切换 |
| Spline | 三次贝塞尔曲线 | SplineDoubleKeyFrame + KeySpline | 自定义复杂曲线 |
| Easing | 缓动函数 | EasingDoubleKeyFrame + EasingFunction | Material Design 风格 |
**典型场景举例**
• **Discrete**:状态机里"空闲→加载→完成→空闲"的离散切换
• **Linear**:Loading 进度条匀速增长
• **Spline**:用户拖动 Slider 时缩略图跟随(要"软跟随"避免抖动)
• **Easing**:Material Design 按钮点击涟漪(CubicEase.EaseOut)
🔑 **关键技术点**:
• SplineDoubleKeyFrame.KeySpline 是 KeySpline 类型(控制点 (0.1, 0.8, 0.2, 1.0) 风格,**不是坐标 4 个点**!)
• EasingDoubleKeyFrame.EasingFunction 可以是任何 EasingFunctionBase 派生类(11 个内置 + 自定义)
───
待续 ⏬ 下条讲【Q3 LoadingIndicator 完整生产代码】
---
<!-- message_id: om_x100b678ce2cfd0a4b03ef0d3f84bd64 -->
📝 **Q3 复盘 · LoadingIndicator 完整生产级代码**
───
设计决策说明
| 决策点 | 选择 | 理由 |
| ----------- | --------------------------------------- | ------------------- |
| 继承链 | Control(不是 FrameworkElement) | 自定义控件标准选择(Day 23) |
| 动画方式 | XAML 声明式 + 代码 Storyboard API 控制 | 用户 XAML 复用 + 代码精细控制 |
| 颜色 | DynamicResource LoadingIndicatorBrush | 支持主题切换(Day 19) |
| IsActive 控制 | Storyboard.Begin/Stop | API 简单 + WPF 惯用 |
| 性能优化 | 避免 InvalidateMeasure,只 InvalidateVisual | 性能提升 ~5x |
───
LoadingIndicator.cs
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;
namespace MyControls
{
/// <summary>
/// 通用加载指示器 - 4 种动画模式(圆环旋转/进度条/圆点错峰/路径移动)
/// 特性:声明式动画 + 主题化 + Play/Pause/Resume 控制
/// </summary>
[TemplatePart(Name = "PART_RotatingRing", Type = typeof(FrameworkElement))]
[TemplatePart(Name = "PART_ProgressBar", Type = typeof(FrameworkElement))]
[TemplatePart(Name = "PART_Dot1", Type = typeof(FrameworkElement))]
[TemplatePart(Name = "PART_Dot2", Type = typeof(FrameworkElement))]
[TemplatePart(Name = "PART_Dot3", Type = typeof(FrameworkElement))]
[TemplatePart(Name = "PART_MovingDot", Type = typeof(FrameworkElement))]
public class LoadingIndicator : Control
{
static LoadingIndicator()
{
// Day 23 自定义控件标准:DefaultStyleKey
DefaultStyleKeyProperty.OverrideMetadata(
typeof(LoadingIndicator),
new FrameworkPropertyMetadata(typeof(LoadingIndicator)));
}
#region 依赖属性
public static readonly DependencyProperty IsActiveProperty =
DependencyProperty.Register(
nameof(IsActive),
typeof(bool),
typeof(LoadingIndicator),
new FrameworkPropertyMetadata(false, OnIsActiveChanged));
public bool IsActive
{
get => (bool)GetValue(IsActiveProperty);
set => SetValue(IsActiveProperty, value);
}
public static readonly DependencyProperty ModeProperty =
DependencyProperty.Register(
nameof(Mode),
typeof(LoadingMode),
typeof(LoadingIndicator),
new FrameworkPropertyMetadata(LoadingMode.RotatingRing));
public LoadingMode Mode
{
get => (LoadingMode)GetValue(ModeProperty);
set => SetValue(ModeProperty, value);
}
#endregion
private Storyboard _storyboard;
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
// 1. 创建 Storyboard 容器
_storyboard = new Storyboard();
_storyboard.FillBehavior = FillBehavior.Stop;
// 2. 设置 4 种动画
SetupRotatingRing();
SetupProgressBar();
SetupThreeDots();
SetupPathMovement();
// 3. 根据 IsActive 状态决定播放
UpdateAnimationState();
}
#region 4 大动画
private FrameworkElement _rotatingRing;
private RotateTransform _rotateTransform;
private void SetupRotatingRing()
{
_rotatingRing = GetTemplateChild("PART_RotatingRing") as FrameworkElement;
if (_rotatingRing == null) return;
// ⭐ 代码创建 RenderTransform(XAML 设置 RenderTransform 无法动画修改 Angle)
_rotateTransform = new RotateTransform();
_rotatingRing.RenderTransform = _rotateTransform;
_rotatingRing.RenderTransformOrigin = new Point(0.5, 0.5);
var animation = new DoubleAnimation
{
From = 0,
To = 360,
Duration = new Duration(TimeSpan.FromSeconds(1)),
RepeatBehavior = RepeatBehavior.Forever,
};
// ⭐ 关键:Target 设到 Transform 子对象,TargetProperty 设 Angle
Storyboard.SetTarget(animation, _rotateTransform);
Storyboard.SetTargetProperty(animation, new PropertyPath(RotateTransform.AngleProperty));
_storyboard.Children.Add(animation);
}
private FrameworkElement _progressBar;
private void SetupProgressBar()
{
_progressBar = GetTemplateChild("PART_ProgressBar") as FrameworkElement;
if (_progressBar == null) return;
var animation = new DoubleAnimation
{
From = 0,
To = 200,
Duration = new Duration(TimeSpan.FromSeconds(1.5)),
AutoReverse = true,
RepeatBehavior = RepeatBehavior.Forever,
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut },
};
Storyboard.SetTarget(animation, _progressBar);
Storyboard.SetTargetProperty(animation, new PropertyPath(FrameworkElement.WidthProperty));
_storyboard.Children.Add(animation);
}
private FrameworkElement _dot1, _dot2, _dot3;
private void SetupThreeDots()
{
_dot1 = GetTemplateChild("PART_Dot1") as FrameworkElement;
_dot2 = GetTemplateChild("PART_Dot2") as FrameworkElement;
_dot3 = GetTemplateChild("PART_Dot3") as FrameworkElement;
if (_dot1 == null || _dot2 == null || _dot3 == null) return;
// ⭐ 3 个圆点错峰 0.4s 的 Opacity 动画
AddDotAnimation(_dot1, TimeSpan.Zero);
AddDotAnimation(_dot2, TimeSpan.FromSeconds(0.4));
AddDotAnimation(_dot3, TimeSpan.FromSeconds(0.8));
}
private void AddDotAnimation(FrameworkElement dot, TimeSpan beginTime)
{
var animation = new DoubleAnimationUsingKeyFrames
{
BeginTime = beginTime,
Duration = new Duration(TimeSpan.FromSeconds(1.2)),
RepeatBehavior = RepeatBehavior.Forever,
};
// Discrete 帧:0→0.4 时间,Opacity 直接跳到 0.3
animation.KeyFrames.Add(new DiscreteDoubleKeyFrame(0.3, KeyTime.Uniform(0)));
// Linear 帧:0.4→0.8 时间,Opacity 从 0.3 插值到 1.0
animation.KeyFrames.Add(new LinearDoubleKeyFrame(1.0, KeyTime.Uniform(0.4)));
// 最后一个 Linear 帧:0.8→1.0 时间,Opacity 插值回 0.3
animation.KeyFrames.Add(new LinearDoubleKeyFrame(0.3, KeyTime.Uniform(0.8)));
Storyboard.SetTarget(animation, dot);
Storyboard.SetTargetProperty(animation, new PropertyPath(UIElement.OpacityProperty));
_storyboard.Children.Add(animation);
}
private FrameworkElement _movingDot;
private TranslateTransform _pathTransform;
private void SetupPathMovement()
{
_movingDot = GetTemplateChild("PART_MovingDot") as FrameworkElement;
if (_movingDot == null) return;
_pathTransform = new TranslateTransform();
_movingDot.RenderTransform = _pathTransform;
// 沿椭圆路径移动:半径 80,中心 (0,0)
var pathFigure = new PathFigure { StartPoint = new Point(80, 0) };
pathFigure.Segments.Add(new ArcSegment(
new Point(80, 0),
new Size(80, 80),
0,
true,
SweepDirection.Clockwise,
true));
pathFigure.IsClosed = true;
var pathGeometry = new PathGeometry();
pathGeometry.Figures.Add(pathFigure);
// ⭐ DoubleAnimationUsingPath 必须分别创建 X 和 Y 两条
var animX = new DoubleAnimationUsingPath
{
Source = PathAnimationSource.X,
PathGeometry = pathGeometry,
Duration = new Duration(TimeSpan.FromSeconds(2)),
RepeatBehavior = RepeatBehavior.Forever,
};
var animY = new DoubleAnimationUsingPath
{
Source = PathAnimationSource.Y,
PathGeometry = pathGeometry,
Duration = new Duration(TimeSpan.FromSeconds(2)),
RepeatBehavior = RepeatBehavior.Forever,
};
Storyboard.SetTarget(animX, _pathTransform);
Storyboard.SetTargetProperty(animX, new PropertyPath(TranslateTransform.XProperty));
Storyboard.SetTarget(animY, _pathTransform);
Storyboard.SetTargetProperty(animY, new PropertyPath(TranslateTransform.YProperty));
_storyboard.Children.Add(animX);
_storyboard.Children.Add(animY);
}
#endregion
private static void OnIsActiveChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var indicator = (LoadingIndicator)d;
indicator.UpdateAnimationState();
}
private void UpdateAnimationState()
{
if (_storyboard == null) return; // 模板未加载
if (IsActive) _storyboard.Begin();
else _storyboard.Stop();
}
public void Pause() => _storyboard?.Pause();
public void Resume() => _storyboard?.Resume();
}
public enum LoadingMode
{
RotatingRing,
ProgressBar,
ThreeDots,
PathMovement,
}
}
Themes/Generic.xaml
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:MyControls">
<Style TargetType="{x:Type local:LoadingIndicator}">
<Setter Property="Width" Value="200" />
<Setter Property="Height" Value="200" />
<!-- ⭐ Day 19:DynamicResource 让 Foreground 可主题化 -->
<Setter Property="Foreground"
Value="{DynamicResource LoadingIndicatorBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:LoadingIndicator}">
<Grid>
<!-- 1. 圆环旋转 -->
<Ellipse x:Name="PART_RotatingRing"
Width="60" Height="60"
Stroke="{TemplateBinding Foreground}"
StrokeThickness="4">
<Ellipse.StrokeDashArray>
<DoubleCollection>40, 20</DoubleCollection>
</Ellipse.StrokeDashArray>
</Ellipse>
<!-- 2. 进度条 -->
<Border x:Name="PART_ProgressBar"
Height="6" Width="0"
VerticalAlignment="Bottom"
HorizontalAlignment="Left"
Background="{TemplateBinding Foreground}"
CornerRadius="3" />
<!-- 3. 三个圆点 -->
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Ellipse x:Name="PART_Dot1"
Width="12" Height="12"
Margin="4"
Fill="{TemplateBinding Foreground}" />
<Ellipse x:Name="PART_Dot2"
Width="12" Height="12"
Margin="4"
Fill="{TemplateBinding Foreground}" />
<Ellipse x:Name="PART_Dot3"
Width="12" Height="12"
Margin="4"
Fill="{TemplateBinding Foreground}" />
</StackPanel>
<!-- 4. 路径移动(背景 Path + 移动 Dot) -->
<Canvas>
<Path Stroke="{TemplateBinding Foreground}"
StrokeThickness="1"
Opacity="0.3">
<Path.Data>
<EllipseGeometry Center="100,100"
RadiusX="80" RadiusY="80" />
</Path.Data>
</Path>
<Ellipse x:Name="PART_MovingDot"
Width="12" Height="12"
Fill="{TemplateBinding Foreground}" />
</Canvas>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
───
⚠️ 5 大生产陷阱
| # | 陷阱 | 根因 | 修复 |
| --- | ----------------------------------------------- | ------------------------------- | --------------------------------------------------- |
| T1 | 自定义 DP 没设 AffectsRender,动画不起作用 | DP 不会自动重绘 | 注册时设 FrameworkPropertyMetadataOptions.AffectsRender |
| T2 | XAML 设 RenderTransform="Rotate" 后代码改 Angle 无效 | XAML 创建的 RotateTransform 不可动画修改 | 代码创建 new RotateTransform() 赋给 RenderTransform |
| T3 | FillBehavior 没设 HoldEnd,动画结束值丢失 | Stop 默认行为回基础值 | 显式 FillBehavior = FillBehavior.HoldEnd |
| T4 | AccelerationRatio + DecelerationRatio = 1.0 抛异常 | WPF 硬性限制 ≤ 0.9 | 留出 0.1 给过渡时间(业内经验值) |
| T5 | CodeBehind 触发 _storyboard.Begin() 没效果 | 模板未加载 + Storyboard 未初始化 | 必须 OnApplyTemplate 内创建并 Begin |
───
🧠 3 个动手扩展思考
| # | 扩展 | 深度提示 |
| --- | ------------------------------------------------ | --------------------------------------------------------------- |
| 1 | 用 CompositionTarget.Rendering 自定义逐帧动画(粒子系统 / 流体) | 极致性能但要手动管理生命周期(订阅 + 解订阅) |
| 2 | RenderCapability.Tier 检查硬件加速等级 | Tier 0 软件渲染 / Tier 1 部分硬件 / Tier 2 全硬件——影响 LoadingIndicator 复杂度 |
| 3 | WPF 动画引擎底层:阅读 TimelineManager + ClockGroup 源码 | PresentationFramework 源码可读,但 PresentationCore 部分不可读(Milcore 闭源) |
───
📚 关联知识网络
| 关联 | 知识点 | 联动方式 |
| ------ | --------------------- | -------------------------------------------------- |
| Day 15 | DP 11 级优先级 | ⭐ 动画值 > LocalValue,解释"动画结束跳回" |
| Day 17 | 视觉树 + OnApplyTemplate | LoadingIndicator 必须 OnApplyTemplate 内创建 Storyboard |
| Day 19 | DynamicResource 主题切换 | Foreground 用 DynamicResource 支持主题 |
| Day 22 | 触发器 | EventTrigger 触发 BeginStoryboard |
| Day 23 | 自定义控件标准模板 | LoadingIndicator 完全按 Day 23 标准写 |
───
📌 **今日作业**(任选其一):
1. 把 LoadingIndicator 跑起来,分别切换 4 种动画模式
2. 加一个新动画模式 BouncingDots(圆点上下弹跳,用 BounceEase)
3. 改用 VisualStateManager 重构 IsActive 切换(避免 Storyboard.Begin/Stop 代码)
───
**今日课程结束**。主人看完后告诉我:
• 哪部分需要再讲细点?
• 哪个地方和你过去认知有冲突?
• 哪个生产陷阱是你踩过的?
我会根据你的反馈在明天的课程里调整。
—— 严老师 📚
📎 Day 24b(MEMORY 重复块,line 114)
⚠️ MEMORY 重复块:Day 24 在 MEMORY 文件里出现了两次(line 94 和 line 114),
内容略不同。本节保留两份以便你对比 —— 主内容是上方"Day 24"(动画系统),
本节为"Day 24b"(MEMORY 重复块)。
<!-- message_id: om_x100b678ce6a1a4a4b299b9c3f51ab13 -->
📚 **Day 24 · Phase 4 第 5 课 · WPF 动画系统核心(Animation System)**
主题:Animation(动画)
难度:⭐⭐⭐⭐ 资深必掌握
关联:Day 15 DP 11 级优先级 · Day 22 触发器 · Day 23 自定义控件 · Day 17 视觉树
───
📖 是什么:WPF 动画的本质
**一句话定义**
WPF 动画是基于 **Timeline 时间线的插值引擎**——不是"按时间播放 GIF",而是**按时间动态修改依赖属性 + 自动触发渲染**。
**3 大分类**(按目标属性类型)
| 类型 | 目标 | 示例 |
| ------------------------ | ------------------- | ---------------------------------------------------------------------- |
| 简单动画 *Animation | 单一属性类型 | DoubleAnimation / ColorAnimation / PointAnimation / ThicknessAnimation |
| 关键帧动画 *UsingKeyFrames | 多个时间点 + 中间插值 | DoubleAnimationUsingKeyFrames + 4 种 KeyFrame |
| 路径动画 *AnimationUsingPath | 沿 PathGeometry 改变数值 | DoubleAnimationUsingPath(X/Y/Angle)+ MatrixAnimationUsingPath |
**核心洞察**
• WPF 动画的"动画值"是 **DP 的特殊表达式**(EffectiveValueEntry.Expression = ActiveAnimation)
• 渲染时由 **CompositionTarget.Rendering** 事件驱动(~60fps,每帧推进 Clock)
• "流畅动画" = 每帧 SetValue + DP 触发 PropertyChanged + AffectsRender 标记 + 渲染线程 re-render
• ⏮ Day 23 你用 TranslateTransform.X 做 ToggleSwitch 切换——今天 Animation 就是这个的**工业级替代**
───
💡 为什么:为什么 WPF 需要动画系统?
**6 大革命性优势**(对比 WinForms Timer + Refresh)
| # | 优势 | WinForms 方案 | WPF 方案 |
| --- | ----- | ------------------ | ---------------------------------- |
| 1 | 声明式 | 100 行 .cs 手写 Timer | 5 行 XAML |
| 2 | 可资源化 | 不能复用 | 动画可放资源字典 |
| 3 | 可绑定 | 不能 | 可作为 DataTrigger 触发 |
| 4 | 可中断 | 手动 Cancel | Storyboard.Pause/Resume/SkipToFill |
| 5 | 可并行合成 | N 个 Timer 手动同步 | N 个 Animation 在 Storyboard 自动并行 |
| 6 | 自动插值 | 手动算每一帧 | Timeline 自动算 |
**底层原理 4 步**
1. AnimationClock 在 UI 线程和渲染线程之间桥接
2. 时钟推进:CompositionTarget.Rendering 每帧触发(~60fps)
3. 插值计算:Clock 推 CurrentTime → Animation 算当前值 → SetValue 到 DP
4. 重绘触发:DP 的 PropertyChangedCallback → AffectsRender 标记 → 渲染线程下一帧 re-render
**⛓ DP 11 级优先级中的"动画"位置**(呼应 Day 15)
CoerceValueCallback ← 最高(最终强制调整)
↓
Animation ← 第 2 高 ⭐
↓
LocalValue(本地值)
↓
TemplatedParent(来自 ControlTemplate)
↓
Style.Triggers
↓
Style.Setters
↓
Theme Triggers
↓
Theme Setters
↓
InheritedValue
↓
DefaultValue ← 最低
🔑 **关键洞察**:动画会**覆盖 LocalValue**——这就是为什么"动画结束后值会跳回基础值"。
───
待续 ⏬ 下条讲【怎么用 + 常见误区】
---
<!-- message_id: om_x100b678ce7fdcca0b3dfe3555e125f6 -->
📚 **怎么用:核心 API + Timeline 子属性**
Storyboard 容器
**所有 Animation 必须包在 Storyboard 里才能真正运行!*
<Storyboard BeginTime="0:0:0" RepeatBehavior="Forever">
<DoubleAnimation From="0" To="360" Duration="0:0:1"
Storyboard.TargetName="MyRing"
Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)" />
</Storyboard>
Timeline 9 大子属性(所有动画共有)
| 属性 | 作用 | 默认 |
| ----------------- | --------------------------- | ----------------- |
| BeginTime | 延迟开始 | 0 |
| Duration | 动画时长 | Automatic (1s) |
| RepeatBehavior | 重复 | new RepeatCount=1 |
| AutoReverse | 到结束值后反向播放 | false |
| SpeedRatio | 加速倍数(>1 加速 / <1 减速) | 1 |
| AccelerationRatio | 加速占比 | 0 |
| DecelerationRatio | 减速占比 | 0 |
| FillBehavior | HoldEnd(保留结束值)/ Stop(回到基础值) | Stop |
| DesiredFrameRate | 帧率(fps) | 默认 |
**🔑 AccelerationRatio + DecelerationRatio 之和必须 ≤ 0.9**(WPF 硬性限制,否则抛异常)
4 大 KeyFrame 关键帧
| 类型 | 类 | 插值行为 | 典型场景 |
| --- | ---------------------- | -------------------- | ------------- |
| 线性帧 | LinearDoubleKeyFrame | 匀速插值 | 平滑过渡 |
| 离散帧 | DiscreteDoubleKeyFrame | 不插值,直接跳 | 开关切换 / 状态机 |
| 样条帧 | SplineDoubleKeyFrame | 三次贝塞尔(KeySpline 控制点) | 自定义曲线 |
| 缓动帧 | EasingDoubleKeyFrame | EasingFunction 缓动 | Material 风格动画 |
11 大缓动函数族(EasingFunctionBase 派生)
| 缓动族 | 视觉效果 | EasingMode |
| ------------------------- | ---------- | ---------------- |
| BackEase | 回拉效果(先退后进) | EaseIn/Out/InOut |
| BounceEase | 弹跳落地 | 3 选 1 |
| CircleEase | 圆形加速 | 3 选 1 |
| CubicEase | 三次方 | 3 选 1 |
| ElasticEase | 弹性震荡(多次衰减) | 3 选 1 |
| ExponentialEase | 指数 | 3 选 1 |
| PowerEase | 幂 | 3 选 1 |
| QuadraticEase | 二次方 | 3 选 1 |
| QuarticEase / QuinticEase | 四/五次方 | 3 选 1 |
| SineEase | 正弦(最常用 ⭐) | 3 选 1 |
**EasingMode 3 选 1**
• EaseIn:开始慢后来快(加速)
• EaseOut:开始快后来慢(减速)⭐ 最常用
• EaseInOut:先加速后减速
5 大触发方式
| 触发方式 | 写法 | 场景 |
| ------------------ | -------------------------------------------------------------------------------------------------------------- | -------------- |
| EventTrigger | <EventTrigger RoutedEvent="MouseEnter"><BeginStoryboard>...</BeginStoryboard></EventTrigger> | 鼠标交互 |
| DataTrigger | <DataTrigger Binding="..." Value="..."><DataTrigger.EnterActions><BeginStoryboard/></DataTrigger.EnterActions> | ViewModel 状态变化 |
| 隐式 Style | <Style><Style.Triggers>...</Style.Triggers></Style> | 控件属性变化 |
| Storyboard.Begin() | 代码触发 | 动态控制 |
| VisualStateManager | <vsm:VisualStateGroup x:Name="CommonStates">...</vsm:VisualStateGroup> | 控件状态机 ⭐ 推荐 |
───
⚠️ 7 大常见误区
| # | 误区 | 真相 |
| --- | ------------------------- | -------------------------------------------------------- |
| M1 | 动画结束自动保留最终值 | 错! 默认 FillBehavior=Stop 回到基础值,需显式 HoldEnd |
| M2 | 只有 Double 可动画 | 错! Color / Point / Thickness / int / Matrix 都可 |
| M3 | 自定义 DP 自动支持动画 | 错! 必须注册时设 FrameworkPropertyMetadataOptions.AffectsRender |
| M4 | 动画期间改 LocalValue 会生效 | 错! 动画优先级 > 本地值(Day 15 11 级 ⭐) |
| M5 | 动画不耗 CPU | 错! Rendering 事件每帧触发(120Hz 显示器尤甚) |
| M6 | FillBehavior 决定缓动 | 错! FillBehavior 只决定结束后行为,跟播放过程无关 |
| M7 | AccelerationRatio=1.0 全加速 | 错! 硬性限制 ≤ 0.9,否则抛 ArgumentException |
───
待续 ⏬ 下条讲【3 道递进面试题】
---
<!-- message_id: om_x100b678ce7545d04b16a9bfbc0c9f3d -->
📝 **3 道递进面试题(按难度递进)**
Q1(基础概念)
请简述:
1. WPF 动画的本质是什么?和 WinForms Timer + Refresh 有何**根本区别**?
2. WPF 动画的 3 大分类是什么?每类举一个具体示例。
3. Storyboard 的 5 大触发方式是什么?
Q2(原理与辨析)
深度问题:
1. 在 WPF 的 **DP 11 级值解析优先级**中,**动画值**处于什么位置?它与 LocalValue 的优先级关系是什么?为什么说"动画结束后值会跳回"?
2. FillBehavior="HoldEnd" vs "Stop" 的**本质区别**是什么?请举例说明两种场景。
3. KeyFrame 4 大类型(LinearKeyFrame / DiscreteKeyFrame / SplineKeyFrame / EasingKeyFrame)的核心区别是什么?请对每种举一个典型场景。
Q3(实战与深度)⭐⭐⭐
**设计一个 WPF 通用 LoadingIndicator 控件**,要求:
✅ **功能需求**
- 支持 4 种动画模式(圆环旋转 / 进度条 / 圆点错峰 / 路径移动)
- 暴露 IsActive DP 控制播放/停止
- 暴露 Mode DP 切换动画类型
- 公开 Pause() / Resume() 方法
✅ **技术约束**
- 全部用 **XAML 声明式动画**(不允许 CodeBehind 写 Timer)
- 颜色用 **DynamicResource** 支持主题切换
- 遵循 **Day 23 自定义控件**标准模板(DefaultStyleKey + Themes/Generic.xaml + PART_*)
- 支持 **Day 22 触发器**样式系统(可作为其他控件模板的一部分)
✅ **性能要求**
- 避免不必要的 InvalidateMeasure
- 不阻塞 UI 线程
- 在低性能机器(Tier=0 无硬件加速)也能正常运行
📌 请提供:
- C# 代码(LoadingIndicator.cs)
- XAML 模板(Themes/Generic.xaml)
- 关键设计决策说明
---
<!-- message_id: om_x100b678ce48180a4b3c90d2d1e217ce -->
📝 **全面复盘 · Q1 + Q2 详细解析**
───
Q1 参考答案
**1. WPF 动画的本质 + 与 WinForms Timer 的根本区别**
| 维度 | WinForms Timer + Refresh | WPF Animation |
| ---- | ------------------------ | ------------------------------------- |
| 本质 | "定时器驱动的手动重绘" | "基于 Timeline 的插值引擎 + 表达式推送" |
| 执行位置 | UI 线程同步 | UI 线程 + 渲染线程协作 |
| 插值 | 手动算每一帧 | Timeline 自动算 |
| 驱动 | Tick 事件(~16ms) | CompositionTarget.Rendering 每帧 |
| 生命周期 | Start/Stop + Tick 回调 | Storyboard.Begin/Pause/Stop + 多动画并行合成 |
| 绑定 | 不能 | 可作为 DataTrigger 触发可绑定 |
🔑 **根本区别**:WinForms 是"**手动驱动值变化**"(命令式),WPF 是"**声明式描述目标状态 + 时间引擎自动推值**"(声明式)。
**2. 3 大分类**
| 分类 | 关键类 | 例子 |
| ----- | ----------------------------- | --------------------- |
| 简单动画 | DoubleAnimation | Width 从 100 动画到 200 |
| 关键帧动画 | DoubleAnimationUsingKeyFrames | Opacity 0→0.5→1→0.5→0 |
| 路径动画 | DoubleAnimationUsingPath | 沿椭圆路径移动 |
**3. 5 大触发方式**
1. EventTrigger(事件触发)
2. DataTrigger(数据变化触发)
3. 隐式 Style.Triggers(属性变化触发)
4. Storyboard.Begin()(代码触发)
5. VisualStateManager(控件状态机 ⭐ 推荐)
───
Q2 参考答案
**1. 动画在 DP 11 级优先级中的位置 + 与 LocalValue 关系**
完整 11 级(高→低):
CoerceValueCallback (最终强制调整)
↓
Animation ⭐ ← 动画值
↓
LocalValue
↓
TemplatedParent
↓
Style.Triggers
↓
Style.Setters
↓
Theme Triggers
↓
Theme Setters
↓
InheritedValue
↓
DefaultValue
**与 LocalValue 优先级关系**:动画值 > 本地值(动画会覆盖 LocalValue)
**为什么动画结束后值会跳回**:
• FillBehavior=Stop(默认):动画停止后,Animation Clock 失效,**动画值从 EffectiveValueEntry 中移除**,下一个最高优先级值(通常是 LocalValue)立即生效——这就是"跳回"现象
• FillBehavior=HoldEnd:动画停止后,动画值**保留**在 EffectiveValueEntry 中,直到下一个 LocalValue 写入才被覆盖
**2. FillBehavior="HoldEnd" vs "Stop"**
| 维度 | Stop(默认) | HoldEnd |
| ----- | -------------- | ------------ |
| 结束后行为 | 恢复到基础值 | 保留动画最终值 |
| 何时用 | 动画状态临时变化(弹起回原) | 动画结束后希望状态持久 |
| 场景 | 鼠标悬停高亮 | Loading 淡入显示 |
| 代码控制 | XAML 默认即可 | 需显式设置 |
**举例**
• **Stop**:Button 鼠标悬停时 Background 从 #555 变到 #FFF,移开鼠标**必须回到** #555
• **HoldEnd**:对话框从 Opacity=0 淡入到 Opacity=1,动画结束**保持显示**
**3. KeyFrame 4 大类型对比**
| 类型 | 插值 | 类 | 典型场景 |
| -------- | ------- | ------------------------------------- | ------------------ |
| Linear | 线性(匀速) | LinearDoubleKeyFrame | 简单过渡 |
| Discrete | 不插值直接跳 | DiscreteDoubleKeyFrame | 状态机切换 |
| Spline | 三次贝塞尔曲线 | SplineDoubleKeyFrame + KeySpline | 自定义复杂曲线 |
| Easing | 缓动函数 | EasingDoubleKeyFrame + EasingFunction | Material Design 风格 |
**典型场景举例**
• **Discrete**:状态机里"空闲→加载→完成→空闲"的离散切换
• **Linear**:Loading 进度条匀速增长
• **Spline**:用户拖动 Slider 时缩略图跟随(要"软跟随"避免抖动)
• **Easing**:Material Design 按钮点击涟漪(CubicEase.EaseOut)
🔑 **关键技术点**:
• SplineDoubleKeyFrame.KeySpline 是 KeySpline 类型(控制点 (0.1, 0.8, 0.2, 1.0) 风格,**不是坐标 4 个点**!)
• EasingDoubleKeyFrame.EasingFunction 可以是任何 EasingFunctionBase 派生类(11 个内置 + 自定义)
───
待续 ⏬ 下条讲【Q3 LoadingIndicator 完整生产代码】
---
<!-- message_id: om_x100b678ce2cfd0a4b03ef0d3f84bd64 -->
📝 **Q3 复盘 · LoadingIndicator 完整生产级代码**
───
设计决策说明
| 决策点 | 选择 | 理由 |
| ----------- | --------------------------------------- | ------------------- |
| 继承链 | Control(不是 FrameworkElement) | 自定义控件标准选择(Day 23) |
| 动画方式 | XAML 声明式 + 代码 Storyboard API 控制 | 用户 XAML 复用 + 代码精细控制 |
| 颜色 | DynamicResource LoadingIndicatorBrush | 支持主题切换(Day 19) |
| IsActive 控制 | Storyboard.Begin/Stop | API 简单 + WPF 惯用 |
| 性能优化 | 避免 InvalidateMeasure,只 InvalidateVisual | 性能提升 ~5x |
───
LoadingIndicator.cs
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;
namespace MyControls
{
/// <summary>
/// 通用加载指示器 - 4 种动画模式(圆环旋转/进度条/圆点错峰/路径移动)
/// 特性:声明式动画 + 主题化 + Play/Pause/Resume 控制
/// </summary>
[TemplatePart(Name = "PART_RotatingRing", Type = typeof(FrameworkElement))]
[TemplatePart(Name = "PART_ProgressBar", Type = typeof(FrameworkElement))]
[TemplatePart(Name = "PART_Dot1", Type = typeof(FrameworkElement))]
[TemplatePart(Name = "PART_Dot2", Type = typeof(FrameworkElement))]
[TemplatePart(Name = "PART_Dot3", Type = typeof(FrameworkElement))]
[TemplatePart(Name = "PART_MovingDot", Type = typeof(FrameworkElement))]
public class LoadingIndicator : Control
{
static LoadingIndicator()
{
// Day 23 自定义控件标准:DefaultStyleKey
DefaultStyleKeyProperty.OverrideMetadata(
typeof(LoadingIndicator),
new FrameworkPropertyMetadata(typeof(LoadingIndicator)));
}
#region 依赖属性
public static readonly DependencyProperty IsActiveProperty =
DependencyProperty.Register(
nameof(IsActive),
typeof(bool),
typeof(LoadingIndicator),
new FrameworkPropertyMetadata(false, OnIsActiveChanged));
public bool IsActive
{
get => (bool)GetValue(IsActiveProperty);
set => SetValue(IsActiveProperty, value);
}
public static readonly DependencyProperty ModeProperty =
DependencyProperty.Register(
nameof(Mode),
typeof(LoadingMode),
typeof(LoadingIndicator),
new FrameworkPropertyMetadata(LoadingMode.RotatingRing));
public LoadingMode Mode
{
get => (LoadingMode)GetValue(ModeProperty);
set => SetValue(ModeProperty, value);
}
#endregion
private Storyboard _storyboard;
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
// 1. 创建 Storyboard 容器
_storyboard = new Storyboard();
_storyboard.FillBehavior = FillBehavior.Stop;
// 2. 设置 4 种动画
SetupRotatingRing();
SetupProgressBar();
SetupThreeDots();
SetupPathMovement();
// 3. 根据 IsActive 状态决定播放
UpdateAnimationState();
}
#region 4 大动画
private FrameworkElement _rotatingRing;
private RotateTransform _rotateTransform;
private void SetupRotatingRing()
{
_rotatingRing = GetTemplateChild("PART_RotatingRing") as FrameworkElement;
if (_rotatingRing == null) return;
// ⭐ 代码创建 RenderTransform(XAML 设置 RenderTransform 无法动画修改 Angle)
_rotateTransform = new RotateTransform();
_rotatingRing.RenderTransform = _rotateTransform;
_rotatingRing.RenderTransformOrigin = new Point(0.5, 0.5);
var animation = new DoubleAnimation
{
From = 0,
To = 360,
Duration = new Duration(TimeSpan.FromSeconds(1)),
RepeatBehavior = RepeatBehavior.Forever,
};
// ⭐ 关键:Target 设到 Transform 子对象,TargetProperty 设 Angle
Storyboard.SetTarget(animation, _rotateTransform);
Storyboard.SetTargetProperty(animation, new PropertyPath(RotateTransform.AngleProperty));
_storyboard.Children.Add(animation);
}
private FrameworkElement _progressBar;
private void SetupProgressBar()
{
_progressBar = GetTemplateChild("PART_ProgressBar") as FrameworkElement;
if (_progressBar == null) return;
var animation = new DoubleAnimation
{
From = 0,
To = 200,
Duration = new Duration(TimeSpan.FromSeconds(1.5)),
AutoReverse = true,
RepeatBehavior = RepeatBehavior.Forever,
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut },
};
Storyboard.SetTarget(animation, _progressBar);
Storyboard.SetTargetProperty(animation, new PropertyPath(FrameworkElement.WidthProperty));
_storyboard.Children.Add(animation);
}
private FrameworkElement _dot1, _dot2, _dot3;
private void SetupThreeDots()
{
_dot1 = GetTemplateChild("PART_Dot1") as FrameworkElement;
_dot2 = GetTemplateChild("PART_Dot2") as FrameworkElement;
_dot3 = GetTemplateChild("PART_Dot3") as FrameworkElement;
if (_dot1 == null || _dot2 == null || _dot3 == null) return;
// ⭐ 3 个圆点错峰 0.4s 的 Opacity 动画
AddDotAnimation(_dot1, TimeSpan.Zero);
AddDotAnimation(_dot2, TimeSpan.FromSeconds(0.4));
AddDotAnimation(_dot3, TimeSpan.FromSeconds(0.8));
}
private void AddDotAnimation(FrameworkElement dot, TimeSpan beginTime)
{
var animation = new DoubleAnimationUsingKeyFrames
{
BeginTime = beginTime,
Duration = new Duration(TimeSpan.FromSeconds(1.2)),
RepeatBehavior = RepeatBehavior.Forever,
};
// Discrete 帧:0→0.4 时间,Opacity 直接跳到 0.3
animation.KeyFrames.Add(new DiscreteDoubleKeyFrame(0.3, KeyTime.Uniform(0)));
// Linear 帧:0.4→0.8 时间,Opacity 从 0.3 插值到 1.0
animation.KeyFrames.Add(new LinearDoubleKeyFrame(1.0, KeyTime.Uniform(0.4)));
// 最后一个 Linear 帧:0.8→1.0 时间,Opacity 插值回 0.3
animation.KeyFrames.Add(new LinearDoubleKeyFrame(0.3, KeyTime.Uniform(0.8)));
Storyboard.SetTarget(animation, dot);
Storyboard.SetTargetProperty(animation, new PropertyPath(UIElement.OpacityProperty));
_storyboard.Children.Add(animation);
}
private FrameworkElement _movingDot;
private TranslateTransform _pathTransform;
private void SetupPathMovement()
{
_movingDot = GetTemplateChild("PART_MovingDot") as FrameworkElement;
if (_movingDot == null) return;
_pathTransform = new TranslateTransform();
_movingDot.RenderTransform = _pathTransform;
// 沿椭圆路径移动:半径 80,中心 (0,0)
var pathFigure = new PathFigure { StartPoint = new Point(80, 0) };
pathFigure.Segments.Add(new ArcSegment(
new Point(80, 0),
new Size(80, 80),
0,
true,
SweepDirection.Clockwise,
true));
pathFigure.IsClosed = true;
var pathGeometry = new PathGeometry();
pathGeometry.Figures.Add(pathFigure);
// ⭐ DoubleAnimationUsingPath 必须分别创建 X 和 Y 两条
var animX = new DoubleAnimationUsingPath
{
Source = PathAnimationSource.X,
PathGeometry = pathGeometry,
Duration = new Duration(TimeSpan.FromSeconds(2)),
RepeatBehavior = RepeatBehavior.Forever,
};
var animY = new DoubleAnimationUsingPath
{
Source = PathAnimationSource.Y,
PathGeometry = pathGeometry,
Duration = new Duration(TimeSpan.FromSeconds(2)),
RepeatBehavior = RepeatBehavior.Forever,
};
Storyboard.SetTarget(animX, _pathTransform);
Storyboard.SetTargetProperty(animX, new PropertyPath(TranslateTransform.XProperty));
Storyboard.SetTarget(animY, _pathTransform);
Storyboard.SetTargetProperty(animY, new PropertyPath(TranslateTransform.YProperty));
_storyboard.Children.Add(animX);
_storyboard.Children.Add(animY);
}
#endregion
private static void OnIsActiveChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var indicator = (LoadingIndicator)d;
indicator.UpdateAnimationState();
}
private void UpdateAnimationState()
{
if (_storyboard == null) return; // 模板未加载
if (IsActive) _storyboard.Begin();
else _storyboard.Stop();
}
public void Pause() => _storyboard?.Pause();
public void Resume() => _storyboard?.Resume();
}
public enum LoadingMode
{
RotatingRing,
ProgressBar,
ThreeDots,
PathMovement,
}
}
Themes/Generic.xaml
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:MyControls">
<Style TargetType="{x:Type local:LoadingIndicator}">
<Setter Property="Width" Value="200" />
<Setter Property="Height" Value="200" />
<!-- ⭐ Day 19:DynamicResource 让 Foreground 可主题化 -->
<Setter Property="Foreground"
Value="{DynamicResource LoadingIndicatorBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:LoadingIndicator}">
<Grid>
<!-- 1. 圆环旋转 -->
<Ellipse x:Name="PART_RotatingRing"
Width="60" Height="60"
Stroke="{TemplateBinding Foreground}"
StrokeThickness="4">
<Ellipse.StrokeDashArray>
<DoubleCollection>40, 20</DoubleCollection>
</Ellipse.StrokeDashArray>
</Ellipse>
<!-- 2. 进度条 -->
<Border x:Name="PART_ProgressBar"
Height="6" Width="0"
VerticalAlignment="Bottom"
HorizontalAlignment="Left"
Background="{TemplateBinding Foreground}"
CornerRadius="3" />
<!-- 3. 三个圆点 -->
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Ellipse x:Name="PART_Dot1"
Width="12" Height="12"
Margin="4"
Fill="{TemplateBinding Foreground}" />
<Ellipse x:Name="PART_Dot2"
Width="12" Height="12"
Margin="4"
Fill="{TemplateBinding Foreground}" />
<Ellipse x:Name="PART_Dot3"
Width="12" Height="12"
Margin="4"
Fill="{TemplateBinding Foreground}" />
</StackPanel>
<!-- 4. 路径移动(背景 Path + 移动 Dot) -->
<Canvas>
<Path Stroke="{TemplateBinding Foreground}"
StrokeThickness="1"
Opacity="0.3">
<Path.Data>
<EllipseGeometry Center="100,100"
RadiusX="80" RadiusY="80" />
</Path.Data>
</Path>
<Ellipse x:Name="PART_MovingDot"
Width="12" Height="12"
Fill="{TemplateBinding Foreground}" />
</Canvas>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
───
⚠️ 5 大生产陷阱
| # | 陷阱 | 根因 | 修复 |
| --- | ----------------------------------------------- | ------------------------------- | --------------------------------------------------- |
| T1 | 自定义 DP 没设 AffectsRender,动画不起作用 | DP 不会自动重绘 | 注册时设 FrameworkPropertyMetadataOptions.AffectsRender |
| T2 | XAML 设 RenderTransform="Rotate" 后代码改 Angle 无效 | XAML 创建的 RotateTransform 不可动画修改 | 代码创建 new RotateTransform() 赋给 RenderTransform |
| T3 | FillBehavior 没设 HoldEnd,动画结束值丢失 | Stop 默认行为回基础值 | 显式 FillBehavior = FillBehavior.HoldEnd |
| T4 | AccelerationRatio + DecelerationRatio = 1.0 抛异常 | WPF 硬性限制 ≤ 0.9 | 留出 0.1 给过渡时间(业内经验值) |
| T5 | CodeBehind 触发 _storyboard.Begin() 没效果 | 模板未加载 + Storyboard 未初始化 | 必须 OnApplyTemplate 内创建并 Begin |
───
🧠 3 个动手扩展思考
| # | 扩展 | 深度提示 |
| --- | ------------------------------------------------ | --------------------------------------------------------------- |
| 1 | 用 CompositionTarget.Rendering 自定义逐帧动画(粒子系统 / 流体) | 极致性能但要手动管理生命周期(订阅 + 解订阅) |
| 2 | RenderCapability.Tier 检查硬件加速等级 | Tier 0 软件渲染 / Tier 1 部分硬件 / Tier 2 全硬件——影响 LoadingIndicator 复杂度 |
| 3 | WPF 动画引擎底层:阅读 TimelineManager + ClockGroup 源码 | PresentationFramework 源码可读,但 PresentationCore 部分不可读(Milcore 闭源) |
───
📚 关联知识网络
| 关联 | 知识点 | 联动方式 |
| ------ | --------------------- | -------------------------------------------------- |
| Day 15 | DP 11 级优先级 | ⭐ 动画值 > LocalValue,解释"动画结束跳回" |
| Day 17 | 视觉树 + OnApplyTemplate | LoadingIndicator 必须 OnApplyTemplate 内创建 Storyboard |
| Day 19 | DynamicResource 主题切换 | Foreground 用 DynamicResource 支持主题 |
| Day 22 | 触发器 | EventTrigger 触发 BeginStoryboard |
| Day 23 | 自定义控件标准模板 | LoadingIndicator 完全按 Day 23 标准写 |
───
📌 **今日作业**(任选其一):
1. 把 LoadingIndicator 跑起来,分别切换 4 种动画模式
2. 加一个新动画模式 BouncingDots(圆点上下弹跳,用 BounceEase)
3. 改用 VisualStateManager 重构 IsActive 切换(避免 Storyboard.Begin/Stop 代码)
───
**今日课程结束**。主人看完后告诉我:
• 哪部分需要再讲细点?
• 哪个地方和你过去认知有冲突?
• 哪个生产陷阱是你踩过的?
我会根据你的反馈在明天的课程里调整。
—— 严老师 📚