65.9K
CodeProject 正在变化。 阅读更多。
Home

FrameBasedAnimation:在 WPF 中集体动画化多个属性

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (30投票s)

2008年9月22日

CPOL

28分钟阅读

viewsIcon

154556

downloadIcon

2916

在 WPF 中构建可重用的旋转轮指示器:第 1 部分。

引言

虽然动画是 WPF 非常强大的功能,但它仍然以属性为中心的方式呈现。也就是说,我们只能**每个时间线**为一个对象的单个属性设置动画。这可能导致动画混乱,分解成多个不相交和重叠的时间线,随着复杂性的增加,这些时间线变得越来越难以可视化。本文着眼于一种将此类基于属性的动画“重构”为基于时间帧的动画的方法,每个帧可以包含多个属性设置器,表示这些属性在特定时间点的状态,以及这种概念的必要性是如何产生的。

注意:本文相当长,但这主要是因为我包含了大量关于我是如何提出这个想法的幕后信息。我已经将对一般想法的理解不重要的部分标记出来。

背景

我正在使用 WPF 实施一个动画“旋转轮”指示器,就像您现在随处可见的那种,以提供一个完全分辨率无关(因此无限可伸缩)、平滑动画的矢量解决方案。原则上,这个想法很简单;但是,正如 WPF 中的许多事情一样,这被证明具有欺骗性。

本文将引导您完成 WPF 旋转轮指示器的创建,以及它是如何导致在 WPF 下思考动画的新方式的。

绘制轮子

尽管本文主要是为了演示 WPF 中一种新型动画抽象的必要性和用途,但其次要目的是分享一个可重用的动画旋转指示器类;因此,让我们看看指示器本身将如何定义,然后如何进行动画处理。如果您只对动画方面感兴趣,并且已经精通 WPF 绘图(这里的事情不会过于复杂),请随意跳过此部分。

我第一次尝试绘制轮子是把所有东西放在一个 DrawingImage 中,并使用 GeometryDrawing 来容纳形状。然而,虽然我能够设置基本形式,但我很快遇到了几个问题*,并决定在 Canvas 上使用 Line 对象会更有效。这个决定可能会导致一些额外的开销(因为 Shape 对象比 GeometryDrawing 更复杂),但由于我们只处理八个非常小的 Line 对象,这应该不重要。

*第一个问题是,将 GeometryDrawing 托管在 DrawingImage 中意味着几何图形总是“修剪”的(想想 Photoshop),即使在旋转和动画期间也是如此,这导致了一个奇怪的副作用,即微调器在动画时似乎在图像内移动。我无法在该模型下解决这个问题。第二个问题是意识到 GeometryGroup 内的所有几何图形必须共享相同的画笔,此外,Opacity 设置只能在 Image 级别应用,这将不允许我们轻松控制各个辐条的不透明度,正如您稍后将看到的。

那么,我们如何绘制它呢?嗯,首先要注意的是,在大多数情况下,我们处理的是八条相同的线,它们从中心点向外辐射。我们可以尝试用数学方法精确计算每条线的起点和终点坐标,但有一个更简单的解决方案:旋转。

让我们从画一条线开始

<Line x:Name="n" Y1="-10" Y2="-18" />

我选择这些看起来很奇怪的起点和终点,是因为我们希望将辐条从原点(0,0)向外辐射,以便稍后更容易旋转。请注意,我只定义了每个点的 Y 分量;这是因为默认值为 0,这正是我们对 X1X2 所需的。因此,我们正在绘制一条从 (0,-10) 到 (0,-18) 的线。

(如果您还没有弄清楚,n 代表北方。)

现在,如果我们不提供更多细节,我们就什么也看不见。这是因为形状的默认描边不知何故是 Transparent。所以,我们需要将其更改为 Black。我们还需要更改 StrokeThicknessStrokeStartLineCap 以及 StrokeEndLineCap。如果我们必须重复所有这些设置八次,那将会非常繁琐!所以,让我们偷懒,在我们的 UserControl.Resources 中定义一个 Style 来保存这些设置

<UserControl.Resources>
    <Style
        TargetType="{x:Type Line}">
        <Setter Property="Stroke" Value="Black" />
        <Setter Property="StrokeThickness" Value="5.8" />
        <Setter Property="StrokeStartLineCap" Value="Round" />
        <Setter Property="StrokeEndLineCap" Value="Round" />
    </Style>
</UserControl.Resources>

接下来,让我们绘制第二条 Line 并将其旋转 45 度

<Line x:Name="ne" Y1="-10" Y2="-14">
    <Line.RenderTransform>
        <RotateTransform Angle="45" />
    </Line.RenderTransform>
</Line>

注意:这条线只有 4 个点长(-10 到 -14,而不是 -18)。这是因为北线最初应该很大。然而,实际上,这些值是无意义的,因为它们将由动画设置,所以我们可以删除所有的 Y2 值,并在样式中设置 Y1,使我们的线定义更简单,但我们现在将它们保留下来以说明旋转(您可以在 XAML 设计器中看到)。

我们将对剩余的六根辐条重复此操作,使用角度 90 (e)、135 (se)、180 (s)、225 (sw)、270 (w) 和 315 (nw)。

LayoutTransform 与 RenderTransform

WPF 提供了两种基于两种通用变换类别旋转对象的方法:LayoutTransformRenderTransform。我们应该使用哪一种呢?嗯,这结果是个显而易见的选择,但这只是在我第一次尝试使用 LayoutTransform 旋转辐条之后,结果是以下一团糟

wonky.png

一旦我把它们改成 RenderTransform,一切就完美对齐了

straight.png

现在我们的轮子画得很好看,让我们让它转起来吧!

动画化!

WPF 提供了一些强大的内置动画类,称为 TimelineStoryboard,但这些名称有些误导,因为它们不允许您像在现实生活中的故事板中那样真正“布局”动画的各个部分。相反,WPF 期望您为窗口中动画的每个属性都有一个不同的 Storyboard,因此,以这种方式可视化动画的最终结果会变得非常困难,尤其是当它们涉及多个对象和属性时,就像我们的旋转轮一样。换句话说,WPF 认为目标属性是主导的,并将其放在分解的外部,而时间是次要的,位于特定属性的动画内部。这意味着您无法轻松地在**全局**级别上考虑**多个**属性随时间推移的进程。

Storyboard 实际上是 ParallelTimeline 的子类,这有点讽刺,因为人们会期望故事板是顺序的。放置在 Storyboard 中的 Timeline 实际上是并行执行的,除非为每个子项指定了显式开始时间(如果您想调整子项的持续时间,这很麻烦,因为 ParallelTimeline 类允许子动画重叠,因此偏移量必须手动精确计算)。另请注意,WPF 目前不支持**顺序时间线**的概念,其中子项的开始时间是根据前面子项的持续时间自动计算的。FrameBasedAnimation 提供了一个真正的顺序故事板,并有助于缓解这种疏忽。

我认为“Storyboard”不是这个类的最佳名称,因为在现实生活中,故事板是一系列顺序的场景,它们描绘了动画的进展——动画的**所有**部分——随着时间的推移。在现实生活中,故事板中没有场景**重叠**的概念。FrameBasedAnimation 提供了一个真实的动画故事板,允许您从自上而下的、基于时间的角度布局动画,将动画中涉及的所有对象都包含在一个全局时间线中,我们即将看到它是如何工作的。

理论

请随意跳过这部分——我提供它只是为了智力价值和理解——它绝不是使用微调器控件或新动画类所必需的,但它将帮助您理解其动机。

我称 FrameBasedAnimation 为“重构”动画,因为它涉及将动画的表示形式从 WPF 的本机形式重构(在数学意义上)为**不同但等效**的形式。在数学中,因式分解涉及改变运算符的优先顺序,通常从乘法 (*) 到加法 (+)。例如,如果我们有一个像这样的表达式的“展开”形式

xa + ya

...以数学优先级(乘法在加法之前)的自然顺序表示,我们可以将其“分解”为另一种形式,通过将加法置于乘法之前(因此需要括号),从而得到

a(x + y)

(我不会深入探讨如何做到这一点——这不重要。我相信你们都怀念高中代数。)完全相同的原理适用于 WPF 中的动画因式分解,只是,我们现在将**目标属性**和**时间**视为我们的“运算符”,而不是乘法和加法。

WPF 自然地将操作优势放在目标属性上(或者将优先级放在时间上),这意味着目标属性是动画中表示的最外部事物(就像第一个示例中的加法),然后是时间,这样动画中的每个关键时间都与共同的外部属性相关。将**属性**视为上述示例中的 *x* 和 *y*,将**时间**视为 a。为了正确理解正在发生的事情,让我们引入另一个时间点 *b*。我们将从 WPF 本机因式分解开始

x(a + b) + y(a + b)

请注意属性是外部元素,时间 *a* 和 *b* 内部应用于两个属性。当我们展开它时,我们得到

xa + ya + xb + yb (intermediate, expanded form)

FrameBasedAnimation 通过重构这个展开的表达式来工作,使得**时间**——而不是**目标属性**——成为最外部(被分解出来)的表达式

a(x + y) + b(x + y)

请注意,这两个表达式是等效的,但它们看起来非常不同。现在,如果这是实际的数学,我们可以将其进一步分解为

(a + b)(x + y)

...但我们不能在 XAML 中这样做,因为 XAML 非常分层,并且无法理解这意味着什么(或者换句话说,根本无法在 XAML 中表达这一点)。然而,正如我们稍后将看到的,我们实际上可以在代码中利用这个原理,使我们的工作更容易。(目前,只需认识到这是由于在时间 *a* 和 *b* 的两个点上,我们主要执行相同的步骤(x + y),因此这有可能实现自动化。)

所以,这导致了动画的重构视图,将**时间**放在外部(就像一个真实的故事板),并控制该帧中所有对象集体发生的事情。我个人发现这更容易可视化,而不是原子地思考每个单独属性的变化,正如您将看到的,它将为一些巧妙的可能性打开大门,包括最终能够实现我们的旋转轮。

那么,让我们开始工作,看看这个理论是如何实际实现的。

实现

对于那些跳过它的人,上述理论概述了我想法的起源:将目标属性与时间的优先顺序颠倒,使得时间在外部,而对可能众多对象的属性状态的更改在内部——这与 WPF 动画的本机表示相反,后者将目标属性放在外部,而该属性随时间的变化在内部。

为了证明这将有多么有用,我实际上使用我假设的以时间为主导的动画模型构思了微调器动画的第一个测试实现,然后手动将其转换为 WPF 的本机以属性为主导的模型。我一开始只用了四个辐条以保持简单。这是我想出的

给定一组使用 Setter 定义的基于时间的“帧”,这些帧同时调整多个属性的状态,从而形成该帧的多个属性的快照,我们希望将这些分组重构为包含随时间变化的单一基于属性的动画,并并行运行这些基于属性的时间线。

所以,例如,假设我们有类似这样的东西(伪代码)

<FrameBasedAnimation Duration="0:0:4" RepeatBehavior="Always">
    <Frame KeyTime=t0>
        <Setter Target="w" Property="Y2" Value="-14" />
        <Setter Target="n" Property="Y2" Value="-18" />
        <Setter Target="e" Property="Y2" Value="-14" />
    </Frame>
    <Frame KeyTime=t1>
        <Setter Target="n" Property="Y2" Value="-14" />
        <Setter Target="e" Property="Y2" Value="-18" />
        <Setter Target="s" Property="Y2" Value="-14" />
    </Frame>
    <Frame KeyTime=t2>
        <Setter Target="e" Property="Y2" Value="-14" />
        <Setter Target="s" Property="Y2" Value="-18" />
        <Setter Target="w" Property="Y2" Value="-14" />
    </Frame>
    <Frame KeyTime=t3>
        <Setter Target="s" Property="Y2" Value="-14" />
        <Setter Target="w" Property="Y2" Value="-18" />
        <Setter Target="n" Property="Y2" Value="-14" />
    </Frame>
    <!-- Time t4 == t0 -->
</FrameBasedAnimation>

...并将其转换为这样(也是伪代码)

<DoubleAnimationUsingKeyFrames Target="n" Property="Y2">
    <LinearDoubleKeyFrame KeyTime=t0 Value="-18" /> // starting point
    <LinearDoubleKeyFrame KeyTime=t1 Value="-14" />
    <LinearDoubleKeyFrame KeyTime=t3 Value="-14" /> // no change
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Target="e" Property="Y2">
    <LinearDoubleKeyFrame KeyTime=t0 Value="-14" />
    <LinearDoubleKeyFrame KeyTime=t1 Value="-18" />
    <LinearDoubleKeyFrame KeyTime=t2 Value="-14" />
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Target="s" Property="Y2">
    <LinearDoubleKeyFrame KeyTime=t1 Value="-14" />
    <LinearDoubleKeyFrame KeyTime=t2 Value="-18" />
    <LinearDoubleKeyFrame KeyTime=t3 Value="-14" />
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Target="w" Property="Y2">
    <LinearDoubleKeyFrame KeyTime=t0 Value="-14" />
    <LinearDoubleKeyFrame KeyTime=t2 Value="-14" />
    <LinearDoubleKeyFrame KeyTime=t3 Value="-18" />
</DoubleAnimationUsingKeyFrames>

让我们看看这里发生了什么。首先,在我上面理想的形式中,我们用 KeyTime 属性限定每个帧。这类似于例如 LinearDoubleKeyFrameKeyTime 属性,只是它现在应用于一组更改,表示为 Setter

现在,这引出了下一个主要问题:在处理 WPF 中基于属性的动画时,每种**类型**的动画都有其自己的相应动画类。例如,有 DoubleAnimationDoubleAnimationUsingKeyFramesPointAnimationPointAnimationUsingKeyFrames 等等(我听说有人提到有 21 种不同的动画类——无论如何,有很多)。所以,我们遇到的一个问题是 Setter 的**抽象**性质与动画类的具体性质之间的对比。特别是,我们需要能够根据 Setter 中涉及的属性类型来确定我们需要什么类型的动画。然而,经过一番努力,这应该是有可能实现的。*

*为本例目的,我不会实现所有 21 个具体的动画类。我们将主要使用 double 类型,因为这是动画化我们的旋转轮所必需的。但是,通过下载源代码,您可以轻松地添加对您需要的任何其他类型的支持。更多详细信息在源代码中;如果您需要帮助,请随时发表评论。

为了可视化正在发生的事情,SetterAnimation 中的每个 Frame 都代表一个特定的时间点。在时间 *t0*,我们希望北辐条变大(值是负数,因为我们是从轮子的中心向外绘制,辐条从顶部开始然后旋转),并且它周围的东辐条和西辐条都变小,这样辐条在单个帧的范围内完全生长和收缩,并且我们始终只有一个辐条在帧边界处变大。我们也可以显式定义南辐条,但因为它位于已经很小的东辐条和西辐条之间,所以没有必要。但是,如果您喜欢代码显式,或者它使事情更容易理解,请随意。要记住的关键是我们正在定义在关键时间点**多个**属性和对象的**集体**状态。FrameBasedAnimation 将自动动画化这些属性从一个时间点到下一个时间点。

为了可视化重构本身,我们只是将所有提及相同对象/属性组合(例如 *n.Y2*)的帧分组到一个单一的基于属性的动画中。因此,例如,属性 *n.Y2* 在时间 *t0*、*t1* 和 *t3* 被提及,这正是您在上面翻译的代码中在第一个 DoubleAnimationUsingKeyFrames 对象(对应于 n)下看到的内容。

无论如何,我将这个手动计算的伪 XAML 转换成了真正的 XAML,看看它是否有效,结果(经过几次调整后——见下文),它确实有效!作为参考,下面是上面代码转换成真正的 XAML 后的样子

<DoubleAnimationUsingKeyFrames Storyboard.TargetName="n" 
     Storyboard.TargetProperty="Y2" Duration="0:0:4">
    <LinearDoubleKeyFrame KeyTime="0:0:0" Value="-18" />
    <LinearDoubleKeyFrame KeyTime="0:0:1" Value="-14" />
    <LinearDoubleKeyFrame KeyTime="0:0:3" Value="-14" />
    <LinearDoubleKeyFrame KeyTime="0:0:4" Value="-18" />
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="e" 
    Storyboard.TargetProperty="Y2" Duration="0:0:4">
    <LinearDoubleKeyFrame KeyTime="0:0:0" Value="-14" />
    <LinearDoubleKeyFrame KeyTime="0:0:1" Value="-18" />
    <LinearDoubleKeyFrame KeyTime="0:0:2" Value="-14" />
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="s" 
    Storyboard.TargetProperty="Y2" Duration="0:0:4">
    <LinearDoubleKeyFrame KeyTime="0:0:1" Value="-14" />
    <LinearDoubleKeyFrame KeyTime="0:0:2" Value="-18" />
    <LinearDoubleKeyFrame KeyTime="0:0:3" Value="-14" />
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="w" 
    Storyboard.TargetProperty="Y2" Duration="0:0:4">
    <LinearDoubleKeyFrame KeyTime="0:0:0" Value="-14" />
    <LinearDoubleKeyFrame KeyTime="0:0:2" Value="-14" />
    <LinearDoubleKeyFrame KeyTime="0:0:3" Value="-18" />
    <LinearDoubleKeyFrame KeyTime="0:0:4" Value="-14" />
</DoubleAnimationUsingKeyFrames>

在比较实际的 XAML 和我猜测的伪 XAML 时,有几点需要注意。首先是每个 DoubleAnimationUsingKeyFrames 下面都有第四个条目。这是因为,出于某种原因,WPF 没有自动循环动画,并且将最后一帧和第一帧之间的过渡视为一个离散的步骤。可能有一种方法可以使这部分自动插值,但无论如何,显式地将初始状态重复为最终状态解决了问题(我稍后在代码中使用了类似的技巧,以在最终类中实现这一点)。另请注意,上面东节点和南节点不需要第四个状态,因为它们的最终和初始状态已经相同。当然,添加它不会有什么害处,但没有必要。

作战计划

所以,既然我们知道这有可能(耶!),让我们开始着手将我们假设的伪形式转换为 WPF 能够理解的真实形式。我们想做的是创建一个新的动画类,它可以在 XAML 中配置,但同时,让该类在 WPF 中显示为传统的 Timeline,这样它就可以包含在常规的 Storyboard 中,并以相同的方式触发。我们将通过从 ParallelTimeline 派生我们的类来做到这一点。我选择 ParallelTimeline 是因为它本质上是 Storyboard 的轻量级版本,并且允许我们根据我们的新表示计算并行动画(它们确实需要并行,因为关键在于我们将以时间为主的表示转换为并行的以属性为主的表示;所以,虽然我们的 FrameBasedAnimation 的帧到帧进展是顺序的,但当它被重构为以属性为主的形式时,那些被分解的属性的时间线几乎肯定会重叠)。

换句话说,FrameBasedAnimation 实际上只不过是从自定义表示到标准表示的转换器。因此,我们稍后将引入一个 Render() 方法来实际执行此转换。然而,在我们开始使用 Render() 之前,让我们看看我们的类将如何在 XAML 中指定,以及我们如何利用一些特殊的 XAML 属性来简化我们的工作。我们需要做的第一件事是表示一个帧集合。所以,让我们将 FrameCollection 类定义为 ObservableCollection

public class FrameCollection: ObservableCollection<Frame> { }

接下来,让我们看看 Frame 本身应该是什么。如果我们查看我们的伪代码,我们可以看到 Frame 本质上应该由一个 Setter 集合组成,所以让我们也将 Frame 定义为 ObservableCollection<Setter>,并添加一个 KeyTime 属性,以便我们可以指定帧应该代表的时间点

public class Frame: ObservableCollection<Setter> {
    public KeyTime KeyTime { get; set; }
}

最后,我们将实现 FrameBasedAnimation 本身。我们已经提到这个类应该继承自 ParallelAnimation,并且我们知道它应该包含一个 FrameCollection(称之为 Frames)。我们还将添加一个 LoopAnimation 属性来解决我们之前遇到的动画无法正确循环回其起始点的问题,当然还有我们的 Render() 方法

[ContentProperty( "Frames" )]
public class FrameBasedAnimation: ParallelTimeline {

    protected FrameCollection _frames;
    public FrameCollection Frames {
        get {
            if ( _frames == null )
                _frames = new FrameCollection();
            return _frames;
        }
    }

    public bool LoopAnimation { get; set; }

    public FrameBasedAnimation() {
        LoopAnimation = true; // default value
    }

    public void Render() {
        ...
    }

    ...
}

ContentProperty 属性

WPF 附带一个方便的属性,允许我们声明类的内容属性。类的内容属性是在 XAML 中定义“内部”父元素时初始化的默认属性,它允许我们避免使用笨拙的属性元素语法

例如,在下面的 XAML 片段中

<Button>Hello world!</Button>

文本“Hello world!” 是按钮的**内容**,它所赋值的属性是 Button 的内容属性。每个类都可以定义自己的内容属性,并且 ContentProperty 是一个可继承的属性,这意味着如果我们不显式重写 ContentProperty,一个类将从其父类继承其内容属性。此外,内容属性隐式支持初始化集合,只要指定为内容属性的属性是一个集合*。由于 ObservableCollection 是一个集合,我们的 Frames 属性可以设置为 FrameBasedAnimation 的内容属性,并直接用一组 Frame 元素初始化,而无需将它们显式包装在 FrameCollection

<FrameBasedAnimation ...>
    <Frame ... />
    <Frame .../>
    ...
</FrameBasedAnimation>

*您应该注意一个小问题:为了使隐式集合内容属性初始化工作,指定为类的内容属性必须是**只读**的。换句话说,我们只能公开一个 getter,而不是一个 setter。如果您同时公开两者,它将不会被隐式视为集合初始化,而是一个单一的内容属性。

渲染()

现在,我们准备看看 Render() 方法。这里事情变得真正有趣!在为我的新动画类开发想法时,我只对 Render() 方法将如何实际工作有一个粗略的想法,正如我上面概述的,但没有任何具体的东西。在这里,我将向您展示当我最终需要实现它时我所弄清楚的。

让我们首先看看我们有什么可以使用的。FrameBasedAnimation 本质上是 Frame 的集合,每个 Frame 本质上是 Setter 的集合。请记住,我们的目标是将其转换为 WPF **属性**动画的集合。因此,我们层次结构的“内部”(Setter 的属性)需要成为外部。我们将从递归地迭代这两个集合级别开始,但首先,我们只需确保 Frames 按时间顺序排列(这在稍后将变得很重要)

Frames.OrderBy<Frame, KeyTime>( delegate( Frame target ) {
    return target.KeyTime;
} );

foreach ( Frame frame in Frames ) {
    foreach ( Setter setter in frame ) {
        ...
    }
}

接下来,我们将需要某种方式对属性进行分组——这类似于我们从假设的展开形式中分解出共同属性。我们需要在循环的迭代中跟踪这种分组,因此最好的方法是通过 Dictionary,以属性为键。

然而,我们这里遇到了一个问题:“属性”对于 WPF 动画来说并不是一个单一的概念:它是 TargetNameTargetProperty 的组合。为了解决这个问题,我们需要创建一个新类,将 TargetNameTargetProperty 的组合视为一个可比较(和可哈希)的单一值。不深入太多细节,这是我们需要的

internal class ObjectPropertyPair {
    public string TargetName { get; set; }
    public DependencyProperty TargetProperty { get; set; }

    public ObjectPropertyPair( string targetName, 
           DependencyProperty targetProperty ) {
        this.TargetName = targetName;
        this.TargetProperty = targetProperty;
    }

    public override bool Equals( object obj ) {
        ObjectPropertyPair next = obj as ObjectPropertyPair;

        if ( next == null )
            return false;

        return this.TargetName == next.TargetName && 
               this.TargetProperty == next.TargetProperty;
    }

    public override int GetHashCode() {
        return TargetName.GetHashCode() ^ TargetProperty.GetHashCode();
    }
}

请注意,我们必须重写 Equals()(用于执行比较)和 GetHashCode(),因为我们将此对象用作 Dictionary 中的键,Dictionary 内部会调用每个键值的 GetHashCode(),以便高效地分发和搜索键。如果我们不重写 GetHashCode()(我通过艰难的方式发现的),我们的 Dictionary 将无法根据 ObjectPropertyPair 实例查找条目,从而使其实际上成为一个 List

由于这个类的构造非常通用且不特定于类型,因此可以将其通用实现为 Pair<T1,T2> 类。我快速在 Google 上搜索了一下,看看是否有任何内置的系统类用于此目的,但没有找到,因此我编写了自己的。

这解决了关键问题,并允许我们使用 ObjectPropertyPair 索引我们的 Dictionary。我们 Dictionary 的值类型将是一个特定于目标属性的 WPF 动画类的实例,并且特定于我们将用于对特定于该目标属性的基于时间的关键帧进行分组的特定类型。由于我们将在此处存储 WPF 动画类的实际具体实例,例如 DoubleAnimationUsingKeyFrames(请注意,这些将始终是关键帧变体,因为我们的实现专门基于映射到关键帧的概念),我们可以使用通用的 AnimationTimeline 类,它还为我们提供了对 Duration 属性的类型安全访问,我们将将其设置为等于提供给 FrameBasedAnimationDuration,以便我们所有的并行子动画都具有相同的持续时间。

所以,我们现在已经设置好了 Dictionary,并且正在逐步处理基于帧的表示的每个内部 Setter。其余部分非常简单。首先,我们创建一个 ObjectPropertyPair 实例,我们将用它作为 Dictionary 的键

ObjectPropertyPair pair = new ObjectPropertyPair( setter.TargetName, setter.Property ); 

接下来,我们将检查是否存在针对此特定对的现有动画(这是正在进行的分解),如果不存在则创建一个

AnimationTimeline animation;
if ( !index.ContainsKey( pair ) ) {
    animation = CreateAnimationFromType( setter.Property.PropertyType );
    Storyboard.SetTargetName( animation, setter.TargetName );
    Storyboard.SetTargetProperty( animation, new PropertyPath( setter.Property ) );
    animation.Duration = this.Duration;

    index.Add( pair, animation );
}
animation = index[ pair ];

我们第一次创建动画实例时,需要为其分配识别目标对象和属性所需的信息,并为其分配与整个 FrameBasedAnimation 相同的持续时间。

CreateAnimationFromType() 是我们如何解决我们的表示与 WPF 的表示之间的抽象/具体阻抗不匹配(如果你愿意的话)的问题。操作非常简单:我们只需传递正在设置的属性类型(从 Setter 中方便地获取),CreateAnimationFromType() 返回一个适当的具体动画类的 UsingKeyFrames 变体的空实例

private AnimationTimeline CreateAnimationFromType( Type type ) {
    switch ( type.ToString() ) {
        case "System.Double":
            return new DoubleAnimationUsingKeyFrames();

        // *** Add support for additional types here and below. ***

        default:
            throw new ArgumentException( ... );
    }
}

如您所见,我使其非常易于通过添加其他动画类型进行扩展,如果您愿意的话。

现在我们有了空白的具体实例,我们需要分配它的 TargetNameTargetProperty。由于这些是附加属性(属于 Storyboard),我们可以使用 Storyboard 类的静态 SetTargetName()SetTargetProperty() 方法,将刚刚创建的动画实例和适当的值(同样,从 Setter 中获取)传递给它们。

请注意,我们必须将**PropertyPath**传递给 SetTargetProperty() 方法,而不是属性本身的引用。幸运的是,SetTargetProperty() 在其方法签名中强制执行此操作;但是,设置 DependencyProperty 的替代(通用)形式(如下所示)没有这种奢侈。更糟糕的是,它期望的类型和 setter.Property 的类型都是 DependencyProperty,这可能非常具有误导性,所以要小心!

animation.SetValue( Storyboard.TargetPropertyProperty, 
                    new PropertyPath( setter.Property ) );

最后,我们需要根据 Setter 的值和父 FrameKeyTime 创建实际的 KeyFrame 以添加到动画对象中

( (IKeyFrameAnimation)animation ).KeyFrames.Add(
    CreateKeyFrameFromType( setter.Property.PropertyType, 
                            setter.Value, frame.KeyTime ) );

请注意,我们必须将 animation 强制转换为 IKeyFrameAnimation,因为其原生类型 AnimationTimeline 不知道它是一个 UsingKeyFrames 变体。因此,重要的是我们只在 CreateAnimationFromType() 内部实例化动画类的 UsingKeyFrames 变体。

CreateKeyFrameFromType() 几乎与 CreateAnimationFromType() 相同,因此我将在此处不再赘述。它只是简单地创建 LinearDoubleKeyFrame 的实例,并将其从 Setter 中获取的值和从 Frame 中获取的 KeyTime 传递给它。

双重循环就到这里了。接下来,我们只需迭代添加到 Dictionary 中的每个动画对象,并将它们逐个添加到 FrameBasedAnimationChildren 属性中(您会记得,它继承自 ParallelTimeline 并表示 WPF 将看到的基于属性的动画集合)。但是,在此之前,还有最后一件事要实现,那就是 LoopAnimation 属性。这是一个小技巧,它只是简单地复制每个具体动画实例的第一个 KeyFrame,并将其投影到动画的持续时间之外。您可能会问,在动画持续时间之外添加帧有什么意义。虽然动画确实会在达到这些帧之前循环,但我们仍然可能会有正在进行的**插值**影响动画持续时间内的属性状态。

为了可视化这一点,考虑 FrameBasedAnimation 只要求我们为给定帧(即在该时间点)指定属性的**最终**状态,并且很可能在再次提及同一属性之前经过多个帧*。FrameBasedAnimation(多亏了 WPF 动画引擎)插值这些点,使得在这些帧之间,动画平滑发生。然而,当动画循环时,并且下一个帧实际上发生在循环的下一次迭代中时,WPF 不知何故不会自动插值这些属性值,我们必须“欺骗”它,让它认为该值仍在出现。因此,通过将帧在未来投影 Duration 的跨度,它会**看起来**与 WPF 同样多的时间,就像动画循环并“真实”帧生效后一样。

foreach ( AnimationTimeline animation in index.Values ) {
    if ( LoopAnimation ) {
        // Finally, tie each animation closed by projecting its initial frame.
        // Assume there will always be at least one frame.
        IKeyFrame firstFrame = 
          (IKeyFrame)( (IKeyFrameAnimation)animation ).KeyFrames[ 0 ]; 
        ( (IKeyFrameAnimation)animation ).KeyFrames.Add( CreateKeyFrameFromType(
            firstFrame.Value.GetType(), firstFrame.Value.ToString(),
            KeyTime.FromTimeSpan( firstFrame.KeyTime.TimeSpan + 
                                  this.Duration.TimeSpan ) ) );
    }

    this.Children.Add( animation );
}

*我们可以假设它至少会被提及一次;否则,将不会发生插值,并且该属性不会发生动画。虽然这样做不会造成任何伤害(也就是说,我们只是表面上假设,而不是作为实现的一部分),但这样做会相当没有意义,除非它可能使您的代码更容易理解。

又有一些奇怪的类型转换,这要归功于 WPF 使用过度具体的动画类。请注意,这里假设动画将始终至少有一个帧。这是可以接受的,因为我们从不创建动画对象,除非有帧要添加到其中。另请注意,我们假设 KeyFrames 集合中的第一个帧是按时间顺序排列的第一个帧。这就是为什么在方法的开头按 KeyTimeFrames 进行排序很重要的原因。

就这样!我们现在已经完成了 Render() 方法,我们可以尝试一下。这是基于我们全新的 FrameBasedAnimation 类的最终工作 XAML 看起来的样子!

<Animation:FrameBasedAnimation
    x:Name="frameAnim" RepeatBehavior="Forever" SpeedRatio="2" Duration="0:0:4">
    <Animation:Frame KeyTime="0:0:0">
        <Setter TargetName="w" Property="Line.Y2" Value="-14" />
        <Setter TargetName="n" Property="Line.Y2" Value="-18" />
        <Setter TargetName="e" Property="Line.Y2" Value="-14" />
    </Animation:Frame>
    <Animation:Frame KeyTime="0:0:1">
        <Setter TargetName="n" Property="Line.Y2" Value="-14" />
        <Setter TargetName="e" Property="Line.Y2" Value="-18" />
        <Setter TargetName="s" Property="Line.Y2" Value="-14" />
    </Animation:Frame>
    <Animation:Frame KeyTime="0:0:2">
        <Setter TargetName="e" Property="Line.Y2" Value="-14" />
        <Setter TargetName="s" Property="Line.Y2" Value="-18" />
        <Setter TargetName="w" Property="Line.Y2" Value="-14" />
    </Animation:Frame>
    <Animation:Frame KeyTime="0:0:3">
        <Setter TargetName="s" Property="Line.Y2" Value="-14" />
        <Setter TargetName="w" Property="Line.Y2" Value="-18" />
        <Setter TargetName="n" Property="Line.Y2" Value="-14" />
    </Animation:Frame>
</Animation:FrameBasedAnimation>

太棒了!我们现在可以编译并运行它,看到我们的基于帧的动画按预期运行!

优化

我之前提到过,我们可以利用每个帧大体相同的事实,但从不同的“辐条”角度来看,通过在代码中而不是在 XAML 中生成动画,使用循环。所以,让我们快速看看如何做到这一点,最后,让其他四根辐条也参与进来,并添加一些巧妙的效果,比如逐渐消失的透明度,这将真正使我们的微调器看起来专业。

首先,注释掉 FrameBasedAnimation 实例中的 Frame,然后打开 *Spinner.xaml.cs*。我们将在构造函数中动态添加帧。我将直接向您展示结果,并概述其工作原理,而不是逐步引导您完成此过程

KeyTime time = KeyTime.FromTimeSpan( TimeSpan.FromSeconds( 0 ) );
int numFrames = canvas.Children.Count;

frameAnim.Duration = new Duration( TimeSpan.FromSeconds( numFrames ) );

for ( int i = 0; i < numFrames; i++ ) {
    Frame f = new Frame();
    frameAnim.Frames.Add( f );

    f.KeyTime = time;

    // One frame per second (we can speed this up with SpeedRatio)
    time = KeyTime.FromTimeSpan( time.TimeSpan + TimeSpan.FromSeconds( 1 ) );

    f.Add( new Setter( Line.Y2Property, "-14",
        ( (Line)canvas.Children[ ( i + numFrames - 1 ) % numFrames ] ).Name ) );
    f.Add( new Setter( Line.Y2Property, "-16",
        ( (Line)canvas.Children[ ( i ) % numFrames ] ).Name ) );
    f.Add( new Setter( Line.Y2Property, "-14",
        ( (Line)canvas.Children[ ( i + 1 ) % numFrames ] ).Name ) );
}

frameAnim.Render();

首先,初始化一个时间计数器,它将跟踪每个帧的 KeyTime(我们将以一秒为单位递增,因为我们始终可以通过 SpeedRatio 调整最终动画的速度)。接下来,通过计算 canvas.ChildrenLine 段的数量来确定我们需要多少帧(将整个动画的 Duration 设置为那么多秒)。然后,我们希望迭代每个帧,并将适当的帧和 Setter 添加到 Frames 集合中,将 KeyTime 设置为我们的时间计数器并递增它。

然后,我们只需添加 Setter 本身。这部分有点复杂,因为我们需要根据 Line 对象在集合中的索引以编程方式确定它们的名称。我使用模数(% numFrames)来“包装”增量/减量,使其落在数组的边界内。另请注意,我们向 Setter 传递的是一个 string,而不是一个实际的 doubleSetter 是一个 XAML 类,它期望以 string 的形式接收其参数。另请注意,出于类似原因,我们必须实际传递目标的名称而不是目标本身。

最后,调用 frameAnim.Render(),就完成了!

动画不透明度

创建专业外观的旋转轮动画的最后一步是随着轮子的旋转,动画化每个辐条的不透明度,给前导辐条一种“彗星”外观,随着它的旋转,尾巴在它后面消散。由于我们新的动画机制,实现这一点出奇地容易。

使用 FrameBasedAnimation 的关键是思考动画的快照。因为每个“帧”的构造方式都完全相同,唯一改变的是当前的辐条,所以我们可以相对地思考事物。因此,例如,如果我们希望彗星的“尾巴”有四个辐条长,我们只需将当前辐条的 Opacity 设置为 1.0,将四个辐条之后(i - 4 % numFrames)的 Opacity 设置为 0.2(这使得点始终以微弱的方式显示)。最后,我们还需要将当前辐条正前方的辐条设置为 0.2,以便在该辐条和尾巴末端之间构建一条非插值“线”。这涉及三个 Setter,如下所示

f.Add( new Setter( Line.OpacityProperty, "0.2",
    ( (Line)canvas.Children[ ( i + numFrames - 4 ) % numFrames ] ).Name ) );
f.Add( new Setter( Line.OpacityProperty, "1",
    ( (Line)canvas.Children[ ( i ) % numFrames ] ).Name ) );
f.Add( new Setter( Line.OpacityProperty, "0.2",
    ( (Line)canvas.Children[ ( i + 1 ) % numFrames ] ).Name ) );

您将 Setter 添加到 Frame 的顺序无关紧要。

<Line x:Name="sw" Opacity="0.4">
    <Line.RenderTransform>
        <RotateTransform Angle="225" />
    </Line.RenderTransform>
</Line>
<Line x:Name="w" Opacity="0.6">
    <Line.RenderTransform>
        <RotateTransform Angle="270" />
    </Line.RenderTransform>
</Line>
<Line x:Name="nw" Opacity="0.8">
    <Line.RenderTransform>
        <RotateTransform Angle="315" />
    </Line.RenderTransform>
</Line>

一旦解决了这个问题,我们就可以编译并运行,并获得一个漂亮、专业外观的旋转轮动画!

final.png

限制

FrameBasedAnimation 目前仅支持帧之间的线性插值;但是,通过向 FrameBasedAnimation 类添加一个属性并递归地(在 Render() 中)将其应用于每个动画,添加对离散或样条插值的支持将是一件简单的事情。

同样,目前只实现了对 double 动画的支持,但我特意通过在几个独立的 方法中放置 switch 语句,使其易于扩展。请查看源代码,如果您需要帮助,请发表评论。应该很清楚该怎么做。

第二部分

第 2 部分将侧重于将我们的旋转轮指示器打磨成一个可重用组件,并且将不再关注动画方面。敬请期待下个月的发布!

结论

我写这篇文章很开心——能够以有趣的新方式操纵现有平台来工作总是很棒的,它真正展示了 WPF 令人难以置信的可扩展性。我希望其他人能从我的动画类中受益,因为我认为这是一种更自然的动画思考方式。至少,我希望这篇文章能作为 WPF 动画机制更细致的教程,对其的理解对所有 WPF 初学者和专家都很有价值。

最后,您可以完全自由地将此源代码用于任何免费或商业项目。享受!

关注点

我很想在文章中附上旋转器运行时的 GIF 动画截图,但我没有合适的软件。如果有人能推荐一款好用(且免费,或有免费试用期)的,能在 Vista 上运行的 GIF 动画截图工具,请在评论中给我留言。

历史

  • 2008年9月22日 – 首次发表!
© . All rights reserved.