🎓 严老师 · C# / WPF 教学
← 全部课程 Day 24 Phase 4 第 5 课

📚 **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 代码) ─── **今日课程结束**。主人看完后告诉我: • 哪部分需要再讲细点? • 哪个地方和你过去认知有冲突? • 哪个生产陷阱是你踩过的? 我会根据你的反馈在明天的课程里调整。 —— 严老师 📚

📡 推送信息

教学日期
2026-08-24
所属阶段
Phase 4 第 5 课
消息条数
5 条(飞书 DM teacher-yan-bot 推送)
消息 ID
om_x100b678ce6a1a4a4b299b9c3f51ab13om_x100b678ce7fdcca0b3dfe3555e125f6om_x100b678ce7545d04b16a9bfbc0c9f3dom_x100b678ce48180a4b3c90d2d1e217ceom_x100b678ce2cfd0a4b03ef0d3f84bd64om_x100b678ce6a1a4a4b299b9c3f51ab13om_x100b678ce7fdcca0b3dfe3555e125f6om_x100b678ce7545d04b16a9bfbc0c9f3dom_x100b678ce48180a4b3c90d2d1e217ceom_x100b678ce2cfd0a4b03ef0d3f84bd64
数据来源
feishu_via_memory_mid