添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

WPF 动画进阶编程

前端时间在实现某项业务需求时,涉及到元素状态的控制,较为深入地使用了 WPF Animation (动画)。原本对动画有所了解,但是本次前前后后还遇到不少问题,看似简简单单的 Animation/Storyboard ,其中竟有如此多的猫腻。今天把动画相关的问题分享出来,扒一扒动画的原理,与大家一起探讨学习。

  • 动画的基本用法
    • IAnimatable.BeginAnimation()
    • Storyboard.Begin()
  • “可控的” 动画
    • 相关的几个方法
    • 如何将动画暂停到一半的地方
  • “延时的” Completed 事件
    • 事件不会立即触发
    • 影响 UI 属性的更新

动画的基本用法

为了便于阐明问题,我们构建一个简单的 WPF 界面,基于此界面中的元素来做一些简单的动画。如下 XAML 所示,在 Canvas 中放置一个 Rectangle ,并将 Rectangle RenderTransform 设置为 TransformGroup(变换组/变换集合) ,其中各个 变换分量 的初始值均为零。

<Canvas>
    <Rectangle Name="MyRectangle" Width="100" Height="30" Fill="LightSalmon">
        <Rectangle.RenderTransform>
            <TransformGroup>
                <TranslateTransform x:Name="MyTranslate"/>
                <RotateTransform x:Name="MyRotate"/>
            </TransformGroup>
        </Rectangle.RenderTransform>
    </Rectangle>
</Canvas>

IAnimatable.BeginAnimation()

由于 WPF 的很多类型都有 BeginAnimation(DependencyProperty dp, AnimationTimeline animation) 方法,因此可以轻松地对这些类型的 依赖项属性 直接做动画。如下所示,通过 TranslateTransform.XPropertyFrameworkElement.WidthProperty 实现了 MyRectangle 的 “位置” 和 “宽度” 动画。

// 对 MyRectangle 的 “位置” 做动画
var translateAnimation = new DoubleAnimation
    From = 0, To = 160,
    Duration = new Duration(TimeSpan.FromSeconds(2))
MyTranslate.BeginAnimation(TranslateTransform.XProperty, translateAnimation);
// 对 MyRectangle 的 “宽度” 做动画
var widthAnimation = new DoubleAnimation
    From = 100, To = 200,
    Duration = new Duration(TimeSpan.FromSeconds(2))
MyRectangle.BeginAnimation(FrameworkElement.WidthProperty, widthAnimation);

看了上面的代码,你可能会有疑惑:TranslateTransform (MyTranslate)FrameworkElement (MyRectangle) 是两个完全不相关的类型,MyTranslate.BeginAnimationMyRectangle.BeginAnimation 之间是怎样的关系呢?来扒一扒源码,看一看继承关系:

System.Windows.Media.Animation.IAnimatable
    System.Windows.ContentElement
    System.Windows.UIElement
        System.Windows.FrameworkElement
    System.Windows.Media.Media3D.Visual3D
    System.Windows.Media.Animation.Animatable
        System.Windows.Media.GeneralTransform
            System.Windows.Media.GeneralTransformGroup
            System.Windows.Media.Transform
                System.Windows.Media.TranslateTransform
                ......
        System.Windows.Media.Brush
        System.Windows.Media.Pen
        ......(40+)

如上继承关系所示,所有的 BeginAnimation() 方法都来自于 IAnimatable 接口,FrameworkElement (UIElement) 直接实现了该接口,而 TranslateTransform (GeneralTransform) 继承自实现了该接口的 Animatable 类型。为什么 UIElement 不直接继承自 Animatable 呢?应该是 C# 单继承的缘故,UIElement 继承了 Visual 基类,不能再继承 Animatable 了。进一步扒源码,可以发现 UIElementAnimatable 实现 IAnimatable 的原理是一样的,都是通过 AnimationStorage.BeginAnimation() 来实现动画的。

Storyboard.Begin()

除了通过 IAnimatable.BeginAnimation 来实现单个 依赖项属性 动画,还可以通过 Storyboard 来实现对动画更加精细化的控制。首先通过静态方法 Storyboard.SetTargetName() / Storyboard.SetTarget()Animation 设置 目标对象,然后通过静态方法 Storyboard.SetTargetProperty()Animation 设置 目标属性,最后将 Animation 装进一个 Stroyboard 实例,通过该实例来控制动画。

var widthAnimation = new DoubleAnimation
    From = 100, To = 200,
    Duration = new Duration(TimeSpan.FromSeconds(2))
// 通过 Storyboard 静态方法来指定动画的作用对象和属性
Storyboard.SetTarget(widthAnimation, MyRectangle);
Storyboard.SetTargetProperty(widthAnimation, new PropertyPath(FrameworkElement.WidthProperty));
// 通过 Storyboard 来控制动画
var storyboard = new Storyboard
    Children = new TimelineCollection
        widthAnimation
storyboard.Begin();

需要注意的是,如果通过 Storyboard.SetTargetName() 来指定 目标对象,则需要使用 Storyboard.Begin(FrameworkContentElement) 来启动动画,不然会引发 System.InvalidOperationException: 'No applicable name scope exists to resolve the name 'XXX' 异常。如下的 this 参数,用于指定在 窗体(this) 范围内搜索名为 MyRectangle.Name目标对象

......
Storyboard.SetTargetName(widthAnimation, MyRectangle.Name);
......
storyboard.Begin(this); // this:承载 MyRectangle 的窗体

同样可以通过 Storyboard 来实现上面的 位置动画,除了直接将 MyTranslate 作为 目标对象 外,还可将 MyRectangle 作为 目标对象,在设置 目标属性 时逐级指定到 MyTranslate依赖项属性 。以下是两种实现 位置动画 的方法:

var translateAnimation = new DoubleAnimation
    From = 0, To = 160,
    Duration = new Duration(TimeSpan.FromSeconds(2))
Storyboard.SetTargetName(translateAnimation, "MyTranslate"); // 直接将 MyTranslate 作为目标对象
Storyboard.SetTargetProperty(translateAnimation, new PropertyPath("X"));
var storyboard = new Storyboard
    Children = new TimelineCollection
        translateAnimation
storyboard.Begin(this);
......
Storyboard.SetTarget(translateAnimation, MyRectangle); // 将 MyRectangle 作为目标对象
Storyboard.SetTargetProperty(translateAnimation, new PropertyPath("(UIElement.RenderTransform).(TransformGroup.Children)[0].(TranslateTransform.X)"));
......

问题:为什么 Storyboard.SetTargetMyTranslate 无效?

下面这种使用 Storyboard 的方法达不到你预期的效果,虽然不会抛异常,然而并不能呈现任何动画效果。对比一下上面的代码,先找找差异,再分析原因。

var translateAnimation = new DoubleAnimation
    From = 0, To = 160,




    

    Duration = new Duration(TimeSpan.FromSeconds(2))
Storyboard.SetTarget(translateAnimation, MyTranslate); // 直接将 MyTranslate 作为目标对象
Storyboard.SetTargetProperty(translateAnimation, new PropertyPath("X"));
var storyboard = new Storyboard
    Children = new TimelineCollection
        translateAnimation
storyboard.Begin(); // 即使加上 this 参数,也不管用

为什么 MyRectangle 可以通过 Storyboard.SetTarget 来设置 目标对象,而 MyTranslate 不行呢?我也还没找到原因,唯一能想到的是 MyRectangle (UIElement)MyTranslate (GeneralTransform) 在实现 IAnimatable 接口时是有差异的,前者是直接实现的,后者是在基类 Animatable 中实现的。

注意:释放控制权

对某个 依赖项属性 应用动画后,即使动画已经结束,也无法对该属性值进行修改。需要先将动画从该属性上移除,才能操作该属性,通常在 Animation/Storyboard (Timeline).Completed 事件中来做此事。

widthAnimation.Completed += (s, args) =>
    MyRectangle.BeginAnimation(FrameworkElement.WidthProperty, null);
    MyRectangle.Width = 200;  // 如果没有上一行代码,本操作无效

“可控的” 动画

Storyboard 中有几个好玩的方法/属性/事件,通过这些方法可以实现对动画状态的控制,将这些方法组合起来可以实现奇妙的效果。

相关的几个方法

// 第二个参数指定动画是否可控,只有当此处设置为 true 时,动画才可控
Begin(FrameworkContentElement containingObject, Boolean isControllable)
Pause(FrameworkElement containingObject)
Resume(FrameworkElement containingObject)
SkipToFill(FrameworkElement containingObject) // 会触发Completed事件
Stop(FrameworkContentElement containingObject) // 不会触发Completed事件
Seek(FrameworkElement containingObject, TimeSpan offset, TimeSeekOrigin origin)
GetCurrentState 方法
CurrentTimeInvalidated 事件

下面的代码展示如何创建一个 可控的 动画,并实现动画的 暂停恢复

/// <summary>
/// Window.Loaded事件处理方法
/// </summary>
private void OnLoaded(object sender, RoutedEventArgs routedEventArgs)
    var widthAnimation = new DoubleAnimation
        From = 100, To = 200,
        Duration = new Duration(TimeSpan.FromSeconds(2))
    widthAnimation.Completed += (s, args) =>
        _value = 1;
    Storyboard.SetTargetName(widthAnimation, MyRectangle.Name);
    Storyboard.SetTargetProperty(widthAnimation, new PropertyPath(FrameworkElement.WidthProperty));
    _storyboard = new Storyboard
        Children = new TimelineCollection {widthAnimation}
/// <summary>
/// 开始动画
/// </summary>
private void BeginButton_OnClick(object sender, RoutedEventArgs e)
    _storyboard.Begin(this, true);
/// <summary>
/// 暂停动画
/// </summary>
private void PauseButton_OnClick(object sender, RoutedEventArgs e)
    _storyboard.Pause(this);
/// <summary>
/// 恢复动画
/// </summary>
private void ResumeButton_OnClick(object sender, RoutedEventArgs e)
    _storyboard.Resume(this);

如何将动画暂停到一半的地方

有时候我们需要实现动画的正、反效果,即通过动画使元素达到某个状态并保持,然后按钮触发反向动画,使元素回到初始状态。由于构建动画的算法相当复杂,并且也会有性能问题,所以不想构建正、反两个动画。
那么,如何构建一个动画,使其能暂停到一半的地方,并且可以反向回到初始状态呢?其实现方案为,设置 StoryboardAutoReverse 属性值为 true,并将动画 Pause() 在一半时长的位置,然后通过 Resume() 方法来实现反向动画。此处需要用到 StoryboardCurrentTimeInvalidated 事件,该事件在动画的每一帧都会触发。

private void Storyboard_CurrentTimeInvalidated(object sender, EventArgs e)
    var currentTime = _storyboard.GetCurrentTime(this);
    var totalTime = GetTotalTime(_storyboard); // 单向总时长
    if (currentTime.HasValue)
        // 判断是否到达一半时长(单向总时长)
        var elapse = totalTime - currentTime.Value.TotalMilliseconds;
        if (elapse < totalTime / 50) // 这是个经验阈值
            // 调用 Storyboard.Pasue() 方法所致
            if (_storyboard.GetIsPaused(this))
                // 已经停到了一半时长
            else // 时间到了一半时所致
                _storyboard.Seek(this, TimeSpan.FromMilliseconds(totalTime), TimeSeekOrigin.BeginTime);
                _storyboard.Pause(this);

此处仅展示部分核心代码,具体的实现可参考:WPF巧用动画反转

“延时的” Completed 事件

对于一个动画,假设其 Completed 事件中进行了某些计算,当停止动画时,我们不能立刻获取到 Completed 中更新的值。看一下下面这段代码,你的预期输出是什么?

private Storyboard _storyboard;
private int _value;
/// <summary>
/// Window.Loaded事件处理方法
/// </summary>
private void OnLoaded(object sender, RoutedEventArgs routedEventArgs)
    var widthAnimation = new DoubleAnimation
        From = 100, To = 200,
        Duration = new Duration(TimeSpan.FromSeconds(2))
    widthAnimation.Completed += (s, args) =>
        _value = 1;
    Storyboard.SetTargetName(widthAnimation, MyRectangle.Name);
    Storyboard.SetTargetProperty(widthAnimation, new PropertyPath(FrameworkElement.WidthProperty));
    _storyboard = new Storyboard
        Children = new TimelineCollection {widthAnimation}
/// <summary>
/// 开始动画
/// </summary>
private void BeginButton_OnClick(object sender, RoutedEventArgs e)
    _storyboard.Begin(this, true);
/// <summary>
/// 停止动画
/// </summary>
private void StopButton_OnClick(object sender, RoutedEventArgs e)
    _storyboard.SkipToFill(this);
    Debug.WriteLine($"The Value: {_value}");

输出的结果将会是 “The Value: 0”Completed 事件确实触发了,结果为什么是 0 ,而不是 1 呢?原因在于该事件并非实时的,它要等到动画的下一帧才能响应(动画内部的 Clock 会在每个 Tick 时判断状态,并触发相应事件)。通过 Dispatcher.Invoke(delegate { }, DispatcherPriority.Background) 可以解决此问题。

private void StopButton_OnClick(object sender, RoutedEventArgs e)
    _storyboard.SkipToFill(this);
    // 等待 Completed 事件触发
    Dispatcher.CurrentDispatcher.Invoke(delegate { }, DispatcherPriority.Background);
    Debug.WriteLine($"The Value: {_value}");
                                    使用WPF,可以轻松地创建复杂的图形和动画,实现各种交互效果,以及使用各种不同的数据绑定和样式。无论你是初学者还是有经验的开发人员,都可以通过学习WPF来开发具有创新性和吸引力的应用程序。赶紧学起来吧!
                                    这是一个开源的 Demo 项目,效果可以去博客里看动图
https://blog.csdn.net/liudehuaii18/article/details/114852709
源码已上传到 Gitee
https://gitee.com/bigflowerfat/wpf-demos
最近半年我会持续更新,希望大家能关注下,谢谢了(微信公众号也会同步更新 【你好 WPF】)
                                    控件模型:WPF 提供三个用于创建控件的常规模型,每个模型都提供不同的功能集和灵活度。 三个模型的基类是UserControl、Control 和 FrameworkElement 。其中UserControl称为用户控件继承自ContentControl,提供类似于Window窗口的简单布局控件创建方式(实现组合控件)。而Control 和 FrameworkElement 称为自定义控件,自定义控件比用户控件更低级别,得到的控制越多,但继承的功能就越少。用户控件和自定义控件之间的主要区别之一:自定义控件
                                      在前一章已经学习过WPF动画的第一条规则——每个动画依赖于一个依赖项属性。然而,还有另一个限制。为了实现属性的动态化(换句话说,使用基于时间的方式改变属性的值),需要有支持相应数据类型的动画类。例如,Button.Width属性使用双精度数据类型。为实现属性的动态化,需要使用DoubleAnimation类。但Button.Paddin属性使用的是Thickness结构,所以需要使用ThicknessAnimation类。
  该要求不像WPF动画的第一条规则那么绝对,第一条规则将动画局限于依赖项属性
                                    WPF在C#后台代码中使用BeginAnimation()方法来使动画产生效果,等同于XAML代码,将动画指定到合适的元素和属性。在XAML中,通常是需要触发器来触发效果的。而WPF触发器有这三种基本类型:属性触发器、数据触发器以及事件触发器,可以在触发器中设置页面加载或者鼠标移入/移出事件。使用触发器是关联动画的最常用方式,但并不是唯一的选择。当创建事件触发器时,需要指定开始触发器的路由事件和触...
                                    动画的本质就是在一个时间段内对象位置、角度、颜色、透明度等属性值的连续变化。这些属性种,有些是对象自身的单属性,有些是图形变形 的属性。WPF规定,可以用来制作动画的属性必须是依赖属性。简单的动画由一个元素来完成就可以了,就像一个演员的独角戏,WPF吧简单动画称为AnimationTimeline。复杂的动画就需要UI上的多个元素协同完成,就像电影中的一段场景。复杂动画的协同包括有哪些UI元素参与动画、每个元素的动画行为是什么、动画何时开始何时结束等。WPF把一组协同的动画也称为Storyboard。
                                    可以在window元素中设置,控制整个window内部所有。以上所有属性都支持使用绑定的方式设置。如果想在设计状态下就看到效果,只需将。手动控制播放、暂停、跳转到哪个画面。你可能不想让图片自动播放,只需设置。应用程序中预览、控制GIF图片。运行后就可以看到效果了。元素内设置,只控制该元素。时,永远不会触发该事件。上面这种用法是预览的。没有完全加载的时候,
资源字典控件模板
创建资源字典:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"&g
WPF动画使用三种方法:
(1)线性插值:在开始值和结束之间以逐步增加方式改变属性的动画(线性插值过程)。
(2)关键帧:从一个值突然变成另一值的动画(关键帧动画)。所有关键帧动画都使用 "类型名 + AnimationUsingKeyFrames " 的形式进行命名,比如 StringAnimationUsingKeyFrames和ObjectAnimationUsingKeyFrames。
(3)路径: