动画 WPF 面板






4.96/5 (43投票s)
为现有 WPF 面板添加动画支持。
引言
本文讨论了如何通过扩展 `Grid`、`StackPanel`、`DockPanel` 或 `WrapPanel` 等类来为已有的 WPF 布局应用自定义动画。或者任何其他承载其他 `UIElement` 的 WPF 控件。此实现的一个重要部分是它应该能够扩展任何现有面板并处理该面板的任何子控件。
在此上下文中,我所说的“动画”是指面板的所有组件随时间移动到其位置并改变其大小的过程。
这是一篇 **VB.NET** 文章,所以所有代码片段都将是 **VB.NET**,但为了完整起见,我包含了 **C#** 和 **VB.NET** 两种解决方案,它们实现了大致等效的功能。
示例视频

我发布了一个关于此项目的 YouTube 视频,快来看看吧,那个自动换行的东西看起来真的很酷。

背景
我认为有两种简单的方法可以让 UI 更具视觉吸引力
- 动画化
- 进行 3D 操作
请注意,我并不是说这样做 UI 的可用性会提高,但对我来说,如果事物能滑动、翻转或缩放,看起来会更整洁。当您能结合这两点并为您的 3D 内容添加动画时,事物看起来会非常酷,就像我 在这里 所做的那样。
WPF 的动画支持相当广泛,但对于本文,我决定进行自定义动画,而不是依赖 `DoubleAnimation` 和 `StoryBoard`。原因是我想找到一种方法,用尽可能少的代码创建现有面板的动画版本。
Using the Code
选择您喜欢的语言,下载解决方案,解压缩并打开。每个解决方案都包含一个类库和一个 WPF 测试应用程序。
所有内容都是使用 *VS2010 Express Edition* 编写的。
方法
那么,如何为现有面板添加动画呢?
嗯,我的方法是考虑屏幕上的两个位置(实际上是三个位置和三个大小,但现在我只讨论位置,并且只讨论其中两个)
- `UIElement` 的 *当前位置*(或 **Cp**)
- 由正在扩展的面板建议的位置(*期望位置*,或 **Dp**)
如果这两个位置能够确定,那么将 `UIElement` 动画化到其*期望位置*的任务只需计算从*当前位置*到*期望位置*的向量,然后沿着该向量选择另一个位置即可。
在数学上,计算这个新位置(或 **Np**)的操作表示为
Np = (Dp - Cp) * AnimationSpeed * ElapsedTimeSinceLastFrame
它看起来像这样
通过多次运行该计算,并在每次更新*当前位置*为刚刚计算出的*新位置*,`UIElement` 就会被动画化。在我的例子中,我使用了一个面板本地计时器,一个 `DispatcherTimer`,并在每次滴答时重新计算 **Np**。
我提到的附加位置是*覆盖位置*,如果出于某种原因需要忽略扩展面板建议的位置,则可以使用它。例如,如果控件应该动画化出屏幕之类的事情。
除了位置之外,**WPF** 面板还设置子元素的大小,因此也需要动画化当前大小、期望大小和覆盖大小。
附加属性
因此,动画背后的数学原理足够简单,但由于此实现必须适用于那些不知道*期望位置*或*覆盖位置*,只知道其实际*当前位置*的控件,因此需要一种方法来存储面板的每个子控件的这些值。
为了解决这个问题,我决定为我的方法所需但尚未包含在 `UIElement` 中的所有属性创建附加属性。在一个名为 `AnimationBase` 的类中,我声明了所有必需的附加属性
Public Shared ReadOnly CurrentPositionProperty As DependencyProperty = _
DependencyProperty.RegisterAttached("CurrentPosition", _
GetType(Point), _
GetType(AnimationBase), _
New PropertyMetadata(New Point()))
Public Shared ReadOnly CurrentSizeProperty As DependencyProperty = _
DependencyProperty.RegisterAttached("CurrentSize", _
GetType(Size), _
GetType(AnimationBase), _
New PropertyMetadata(New Size()))
Public Shared ReadOnly OverrideArrangeProperty As DependencyProperty = _
DependencyProperty.RegisterAttached("OverrideArrange", _
GetType(Boolean), _
GetType(AnimationBase), _
New PropertyMetadata(False))
Public Shared ReadOnly OverridePositionProperty As DependencyProperty = _
DependencyProperty.RegisterAttached("OverridePosition", _
GetType(Point), _
GetType(AnimationBase), _
New PropertyMetadata(New Point()))
Public Shared ReadOnly OverrideSizeProperty As DependencyProperty = _
DependencyProperty.RegisterAttached("OverrideSize", _
GetType(Size), _
GetType(AnimationBase), _
New PropertyMetadata(New Size()))
`OverrideArrangeProperty` 用于指示动画应该趋向于*期望位置*还是*覆盖位置*。
通过使用这些属性,负责动画的类可以跟踪控件当前的位置以及它应该去的位置,但不一定是如何到达那里。为了弄清楚这一点,我采用了这样一种实现方式:从 **Cp** 到 **Dp** 的距离的遍历方式可以被替换为不同的实现。
替换动画计算
为了能够替换计算在此帧中需要遍历 **Cp** 和 **Dp** 之间距离多少的逻辑,`AnimationBase` 类依赖于一个名为 `IArrangeAnimator` 的接口(我选择了“**Arrange**”这个名字,因为此项目的实现依赖于 `UIElelemt.Arrange` 方法的值)。
Public Interface IArrangeAnimator
Function Arrange(ByVal elapsedTime As Double, _
ByVal desiredPosition As Point, _
ByVal desiredSize As Size, _
ByVal currentPosition As Point, _
ByVal currentSize As Size) As Rect
End Interface
本质上,这个接口接收期望位置和大小,以及当前位置和大小,并返回一个 `Rect`,指示 `UIElement` 在 `elapsedTime` 之后应该在什么位置。在示例解决方案中,我只包含了一个该接口的实现,但如果您需要,可以轻松添加自己的实现。
包含的 `IArrangeAnimator` 实现称为 `FractionDistanceAnimator`,因为它以每秒 **x** 像素的速度进行动画,其中 **x** 是剩余距离的一部分。这意味着,如果 `FractionDistanceAnimator` 以 `fraction` 值 0.5 进行初始化,并且从 **Cp** 到 **Dp** 的距离为 100 像素,则其移动速度为每秒 50 像素。显然,在下一次更新时,距离会略短一些,因此下一次更新将以稍慢的速度运行,从而使控件逐渐进入其位置。
`FractionDistanceAnimator` 的实现如下所示
Namespace Animators
Public Class FractionDistanceAnimator
Implements IArrangeAnimator
Private fraction As Double
Public Sub New(ByVal fraction As Double)
Me.fraction = fraction
End Sub
Public Function Arrange(ByVal elapsedTime As Double, _
ByVal desiredPosition As Point, _
ByVal desiredSize As Size, _
ByVal currentPosition As Point, _
ByVal currentSize As Size) As Rect _
Implements IArrangeAnimator.Arrange
Dim deltaX As Double = _
(desiredPosition.X - currentPosition.X) * fraction
Dim deltaY As Double = _
(desiredPosition.Y - currentPosition.Y) * fraction
Dim deltaW As Double = _
(desiredSize.Width - currentSize.Width) * fraction
Dim deltaH As Double = _
(desiredSize.Height - currentSize.Height) * fraction
Return New Rect(currentPosition.X + deltaX, _
currentPosition.Y + deltaY, _
currentSize.Width + deltaW, _
currentSize.Height + deltaH)
End Function
End Class
End Namespace
我喜欢动起来,动起来
为了计算 `IArrangeAnimator.Arrange` 返回的 `Rect`,必须传入当前位置和期望位置(显而易见)。这一切都由 `AnimatorBase.Arrange` 方法处理,该方法对面板中的每个子控件执行四个步骤:
- 使用前面讨论的附加属性获取*当前位置*和*期望位置*
- 通过调用 `IArrangeAnimator.Arrange` 计算 `Rect`
- 使用返回的 `Rect` 更新*当前位置*
- 使用返回的 `Rect` 调用 `UIElement.Arrange`
(对我来说,)为了这一切所需的代码量出奇地少
Public Sub Arrange(ByVal elapsedTime As Double, _
ByVal elements As UIElementCollection,
ByVal animator As IArrangeAnimator)
For Each element As UIElement In elements
Dim desiredPosition As Point
Dim currentPosition As Point = _
element.GetValue(AnimationBase.CurrentPositionProperty)
Dim desiredSize As Size
Dim currentSize As Size = _
element.GetValue(AnimationBase.CurrentSizeProperty)
Dim override As Boolean = _
DirectCast(element.GetValue(AnimationBase.OverrideArrangeProperty), Boolean)
If override Then
desiredPosition = _
DirectCast(element.GetValue(AnimationBase.OverridePositionProperty), Point)
desiredSize = _
DirectCast(element.GetValue(AnimationBase.OverrideSizeProperty), Size)
Else
desiredPosition = element.TranslatePoint(New Point(), owner)
desiredSize = element.RenderSize
End If
Dim rect As Rect = _
animator.Arrange(elapsedTime, desiredPosition, _
desiredSize, currentPosition, currentSize)
element.SetValue(AnimationBase.CurrentPositionProperty, rect.TopLeft)
element.SetValue(AnimationBase.CurrentSizeProperty, rect.Size)
element.Arrange(rect)
Next
End Sub
在计时器上调用该方法基本上就是动画化任何现有面板所需的所有内容。并且由于始终需要计时器,因此 `AnimationBase` 类提供了一个创建它的辅助方法
Public Function CreateAnimationTimer(ByVal owner As UIElement, _
ByVal animationInterval As TimeSpan)
Me.owner = owner
animationTimer = New DispatcherTimer(DispatcherPriority.Render, _
owner.Dispatcher)
animationTimer.Interval = animationInterval
Return animationTimer
End Function
Private Sub AnimationTick(ByVal sender As Object, ByVal e As EventArgs) _
Handles animationTimer.Tick
owner.InvalidateArrange()
End Sub
请注意,滴答处理程序不会直接调用动画方法,而是仅使动画面板的当前布局失效。这反过来又会导致面板重新计算其子控件的布局,此时就可以挂入动画逻辑了。
扩展现有面板
由于几乎所有工作都在 `AnimationBase` 和 `IArrangeAnimator` 中完成,因此在扩展类中需要做的工作非常少。所需的那一点点代码对于每个面板来说都是相同的,对于 `Grid` 来说如下所示:
Public Class AnimatedGrid
Inherits Grid
Private animationBase As AnimationBase = New AnimationBase()
Private animator As IArrangeAnimator
Private lastArrange As DateTime
Public Sub New()
animationBase.CreateAnimationTimer(Me, TimeSpan.FromSeconds(0.05))
animator = New FractionDistanceAnimator(0.1)
End Sub
Public Sub New(ByVal animator As IArrangeAnimator, _
ByVal animationInterval As TimeSpan)
animationBase.CreateAnimationTimer(Me, animationInterval)
Me.animator = animator
End Sub
Protected Overrides Function ArrangeOverride(ByVal arrangeSize As Size) As Size
Dim size As Size = MyBase.ArrangeOverride(arrangeSize)
animationBase.Arrange(Math.Max(0, _
(DateTime.Now - lastArrange).TotalSeconds), _
Children, animator)
lastArrange = DateTime.Now
Return size
End Function
End Class
其他面板的动画版本的实现除了类名和 `Inherits` 语句外,都是相同的。这使得添加动画支持变得非常容易。
关注点
如果您还没有看过顶部的 视频,请看一看,我认为它很好地说明了一个标准的 `WrapPanel` 或 `Grid` 只需要一点动画就能看起来多么酷。
历史
- 2011-02-03:第一个版本