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.XProperty
和 FrameworkElement.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.BeginAnimation
和 MyRectangle.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
了。进一步扒源码,可以发现 UIElement
和 Animatable
实现 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.SetTarget
对 MyTranslate
无效?
下面这种使用 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);
}
如何将动画暂停到一半的地方
有时候我们需要实现动画的正、反效果,即通过动画使元素达到某个状态并保持,然后按钮触发反向动画,使元素回到初始状态。由于构建动画的算法相当复杂,并且也会有性能问题,所以不想构建正、反两个动画。
那么,如何构建一个动画,使其能暂停到一半的地方,并且可以反向回到初始状态呢?其实现方案为,设置 Storyboard
的 AutoReverse
属性值为 true
,并将动画 Pause()
在一半时长的位置,然后通过 Resume()
方法来实现反向动画。此处需要用到 Storyboard
的 CurrentTimeInvalidated
事件,该事件在动画的每一帧都会触发。
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}");
}