WPF动画进阶编程

WPF动画进阶编程

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

主要内容

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

动画的基本用法

为了便于阐明问题,我们构建一个简单的 WPF 界面,基于此界面中的元素来做一些简单的动画。如下 XAML 所示,在 Canvas 中放置一个 Rectangle,并将 RectangleRenderTransform 设置为 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}");
}

猜你喜欢

转载自blog.csdn.net/Iron_Ye/article/details/82834087