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

为程序员准备的动画糖果

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (81投票s)

2010年4月6日

LGPL3

17分钟阅读

viewsIcon

89962

downloadIcon

7509

一个类库,允许(几乎)任何控件显示动画

控件上的动画(例如 DataListView)

谷歌羡慕者匿名会

“我欢迎大家参加本周的谷歌羡慕者匿名会。大家好。我叫菲利普,是一名谷歌羡慕者。我几乎控制了我的病情六天。但今天早上,我彻底失控了。我正在正常工作,然后我看到了!你们都知道接下来会发生什么。我的呼吸突然变得急促而浅。我拿起纸袋来冷静一下。但为时已晚。嫉妒的浪潮席卷了我。情急之下,我快速拨打了谷歌羡慕者朋友史蒂夫和比尔的电话,他们和我聊了一会儿。大约一分钟后,痉挛过去了,我(或多或少)恢复了正常,尽管一整天都挥之不去嫉妒的苦涩味道。

在我看来,我嫉妒的来源并非他们数十亿美元的开发预算、他们绝佳的工作条件或他们每股千美元以上的股价。所有这些都显得微不足道,与我真正着迷的对象相比:他们的动画!他们如何毫不费力地为他们的应用程序添加小小的旋转星、发光文本或闪烁的火花。我的应用程序运行正常且听话——但却静态且被动,缺乏让谷歌应用可爱又酷的动态视觉效果。

但现在不会了!各位羡慕者,我向你们展示 Sparkle 动画框架。有了这个框架,你们也可以在应用程序中添加动画,摆脱谷歌羡慕的束缚。”

30秒或更少时间内理解 Sparkle

好了。这次稍微认真一点。Sparkle 库的目的是允许(几乎)任何 Control 显示动画。

Sparkle 库的设计目标是:

  • 在任何控件上进行短暂动画。Sparkle 库旨在覆盖在现有控件之上绘制短暂动画。
  • 声明式。Sparkle 库是声明式的。你说出你希望动画做什么,然后你运行动画。动画在开始之前就已经完全定义好了。
  • 非交互式。Sparkle 库不处理用户交互——它不监听鼠标移动、点击或拖动。它不做碰撞检测或物理模型。它只是绘制视觉效果。

要使用该库本身,您需要掌握它的四个主要概念:

  1. 动画 (Animations)。动画是精灵 (sprite) 被放置的画布。它是绘制事物的白板。
  2. 精灵 (Sprites)。精灵是可以绘制的事物。精灵有几种类型——一种用于图像,另一种用于文本,还有一种用于形状。通过继承 Sprite(或实现 ISprite)来创建自己的精灵类型是很常见的。
  3. 效果 (Effects)。效果是随时间改变精灵的事物。它们是库中的“驱动者”,负责实际做事。精灵静静地在那里,完全被动,看起来很漂亮,但效果会推动它们移动、改变可见性、旋转或调整大小。同样,您可以使用现有的 Effects,或通过 IEffect 接口实现自己的效果。
  4. 定位器 (Locators)。定位器是知道如何计算点或矩形的事物。它们不是说“将此精灵放在 (10, 20)”,而是允许你说“将此精灵放在另一个精灵的右下角”。这个概念可能有点难以理解,但一旦掌握了,它就非常强大。

如何使用它

向应用程序添加任何类型的动画是一个多步骤的过程。使用 Sparkle,创建动画的工作流程是:

  1. 确定动画将出现在哪里。那就是你的 Animation
  2. 思考你想要展示什么。它们是你的 Sprites
  3. 思考你希望每个 Sprite 做什么。它们是你的 Effects
  4. Effect 需要“where”(位置)时,就需要 Locators

简单示例

要感受如何使用该库,没有什么比看代码更好的了。所以我们来做一个简单的例子,让一个词在控件上移动。

根据我们的工作流程(上方),我们首先需要确定动画将在哪里出现。所以让我们创建一个新项目,带有一个新窗体,并在上面放置一个 UserControl,将其停靠以填满整个窗体。在窗体上放一个名为“Run”的按钮。在该按钮的点击处理程序中,我们将执行运行动画的工作。

我们需要的第一件事是在该 UserControl 上创建一个 Animation

AnimationAdapter adapter = new AnimationAdapter(this.userControl1);  
Animation animation = adapter.Animation;  

AnimationAdapter 是将 Animation 连接到现有控件的类。我们使用了 UserControl,但它可以是任何支持 Paint 事件的 Control

一旦我们有了 Animation,工作流程就要求我们确定要看到什么:那就是我们的 Sprites。我们想展示“Sparkle”这个词,所以我们创建一个 TextSprite。如果我们想展示图像, there is an ImageSprite,要显示形状, there is a ShapeSprite。当然,您可以通过实现 ISprite 接口或继承 Sprite 来创建自己的精灵。

TextSprite sparkle = new TextSprite("Sparkle!", new Font("Gill Sans", 48), Color.Blue);  

好的。我们有了我们的精灵。我们希望它做什么?我们想把它从动画的左上角移动到右下角。移动(或任何其他随时间的变化)需要一个 Effect。在这种情况下,我们需要一个 MoveEffect。您可以直接创建它们——使用 new MoveEffect(...)——或者您可以使用 Effects 工厂,它有很多方法来创建 Effect 对象。

sparkle.Add(100, 1000, Effects.Move(Corner.TopLeft, Corner.BottomRight));  

这表示,“在精灵开始后的 100 毫秒后开始,持续 1000 毫秒,将此精灵从动画的右上角移动到左下角。”

这就是我们目前希望精灵做的一切,所以现在我们将精灵添加到动画中。并非所有精灵在动画开始时都处于活动状态,所以当我们添加精灵到动画时,我们还要告诉它精灵何时应该开始。在这种情况下,我们*确实希望*精灵在动画开始时就开始,所以我们给精灵的开始时间是 0

animation.Add(0, sparkle);  

最后,我们告诉动画开始运行。

animation.Start();

一切顺利,您应该会看到类似这样的内容:

对于那些注意到上述图形缺少动画的基本要求(即动画性)的人来说,CodeProject 不支持页面内的动画,所以点击这里查看实际动画。

更有趣一点

您是否感到不那么惊喜?不可否认,它并不那么令人印象深刻,但您只写了六行代码!但如果我们添加一些不同的效果,您可以轻松地做出更令人印象深刻的事情。例如,如果我们想让文本在控件边缘行走,同时旋转和淡出。

sparkle.Add(0, 5000, Effects.Rotate(0, 360 * 4));
sparkle.Add(0, 5000, Effects.Blink(5));
sparkle.Add(0, 5000, Effects.Walk(Locators.AnimationBounds(-100, -50), 
	WalkDirection.Anticlockwise)); 

这给 Sprite 三个同时运行的效果。前两个相当明显,但第三个有点棘手。它使用 Effects 工厂来创建一个效果,该效果会围绕一个矩形行走精灵。要行走到的矩形是一个“where”(位置),所以它使用了一个 Locator。这个特定的定位器返回动画的边界,并偏移了 (100, 50)。

将这些结合起来就会产生这个:

同样,您必须点击这里查看实际动画。

不可否认,这有点俗气,但它确实能让你对只多写几行代码能做什么有一个想法。

动画

动画有两个不同的功能:

  1. 它实现了一个基于定时器滴答的动画系统。在每次时钟滴答时,它会推进动画的状态:它决定哪些精灵应该变为活动/非活动状态,让效果有机会施展魔法。这部分不执行任何渲染——它只是改变动画部分的状态。如果需要渲染任何东西,动画会触发一个 Redraw 事件。
  2. 它根据精灵的当前状态进行绘制。通常,会有人监听 Animation 上的 Redraw 事件,并响应该事件,重新绘制动画。为此,它调用 Animation.Draw(Graphics g) 方法。这将动画在其当前状态下渲染到给定的 Graphics 对象上。此操作不会改变 Animation 的状态。

除了动画精灵和渲染它们之外,动画还支持控制其执行的基本命令集:

  • Start()
  • Pause()/Unpause()
  • Stop()

重复行为

Animations 有一个 Repeat 属性,用于控制动画到达结束时的行为。

  • Repeat.None - 动画直接退出。所有精灵消失。这是默认值。
  • Repeat.Pause - 动画暂停。动画结束时可见的所有精灵保持可见并静止。
  • Repeat.Loop - 动画重新开始。

精灵

精灵是实际的视觉效果——用户可以看到的漂亮无用之物。它们保存任何所需的状态信息——位置、大小、颜色、透明度——然后利用这些状态信息在被要求时绘制自身。它们不会改变自己的状态——那是 Effects 的责任。

Sparkle 库附带了几种精灵类型:

  • ImageSprite。它接受一个 Image 并根据精灵的状态进行绘制。如果给定的 Image 本身就是帧动画,Sparkle 框架将自动为其制作动画。我认为只有 Microsoft 支持 GIF 格式的帧动画。
  • TextSpriteTextSprites 绘制文本(无奖)。但它们可以进行比这更多的格式化。文本可以着色(ForeColor 属性),它们可以用背景绘制(BackColor 属性)。它们可以在文本周围绘制边框(BorderWidthBorderColor 属性)。边框可以是矩形(将 CornerRounding 设置为 0)或圆角矩形(将 CornerRounding 设置为大于 0——16 通常很不错)。
  • ShapeSprites。它们绘制规则形状(正方形、矩形、圆角矩形、三角形、椭圆形/圆形)。与 TextSprites 一样,ShapeSprites 可以具有 ForeColor(形状边框的颜色)、BackColor(用于形状的填充部分)和 PenWidth(边框的宽度)。

请记住,所有颜色都可以设置 alpha 值,这允许在绘制精灵时实现不同程度的透明度。

自定义精灵

预期应用程序会实现新的精灵来实现任何所需的专用绘图。要做到这一点,您需要实现 ISprite 接口或直接继承 Sprite

public interface ISprite : IAnimateable
{
   /// <summary>
   /// Gets or sets where the sprite is located
   /// </summary>
   Point Location { get; set; }
   
   /// <summary>
   /// Gets or sets how transparent the sprite is. 
   /// 0.0 is completely transparent, 1.0 is completely opaque.
   /// </summary>
   float Opacity { get; set; }
   
   /// <summary>
   /// Gets or sets the scaling that is applied to the extent of the sprite.
   /// The location of the sprite is not scaled.
   /// </summary>
   float Scale { get; set; }
   
   /// <summary>
   /// Gets or sets the size of the sprite
   /// </summary>
   Size Size { get; set; }
   
   /// <summary>
   /// Gets or sets the angle in degrees of the sprite.
   /// 0 means no angle, 90 means right edge lifted vertical.
   /// </summary>
   float Spin { get; set; }
   
   /// <summary>
   /// Gets or sets the bounds of the sprite. This is boundary within which
   /// the sprite will be drawn.
   /// </summary>
   Rectangle Bounds { get; set; }
   
   /// <summary>
   /// Gets the outer bounds of this sprite, which is normally the
   /// bounds of the control that is hosting the story board.
   /// Nothing outside of this rectangle will be drawn.
   /// </summary>
   Rectangle OuterBounds { get; }
   
   /// <summary>
   /// Gets or sets the reference rectangle in relation to which
   /// the sprite will be drawn. This is normal the ClientArea of
   /// the control that is hosting the story board, though it
   /// could be a subarea of that control (e.g. a particular 
   /// cell within a ListView).
   /// </summary>
   /// <remarks>This value is controlled by ReferenceBoundsLocator property.</remarks>
   Rectangle ReferenceBounds { get; set; }
   
   /// <summary>
   /// Gets or sets the locator that will calculate the reference rectangle 
   /// for the sprite.
   /// </summary>
   IRectangleLocator ReferenceBoundsLocator { get; set; }
   
   /// <summary>
   /// Gets or sets the point at which this sprite will always be placed.
   /// </summary>
   /// <remarks>
   /// Most sprites play with their location as part of their animation.
   /// But other just want to stay in the same place. 
   /// Do not set this if you use Move or Goto effects on the sprite.
   /// </remarks>
   IPointLocator FixedLocation { get; set; }
   
   /// <summary>
   /// Gets or sets the bounds at which this sprite will always be placed.
   /// </summary>
   /// <remarks>See remarks on FixedLocation</remarks>
   IRectangleLocator FixedBounds { get; set; }
   
   /// <summary>
   /// Draw the sprite in its current state
   /// </summary>
   /// <param name="g"></param>
   void Draw(Graphics g);
   
   /// <summary>
   /// Add an Effect to this sprite. This effect will run at the beginning of
   /// the sprite and will have 0 duration.
   /// </summary>
   /// <param name="effect">The effect to be applied to the sprite</param>
   void Add(IEffect effect);
   
   /// <summary>
   /// Add an Effect to this sprite. This effect will commences startTicks
   /// after the sprite begins and will have 0 duration
   /// </summary>
   /// <param name="startTick">When will the effect begins?</param>
   /// <param name="effect">What effect will be applied?</param>
   void Add(long startTick, IEffect effect);
   
   /// <summary>
   /// The main entry point for adding effects to Sprites.
   /// </summary>
   /// <param name="startTick">When will the effect begin?</param>
   /// <param name="duration">For how long will it last?</param>
   /// <param name="effect">What effect will be applied?</param>
   void Add(long startTick, long duration, IEffect effect);
}

仔细查看现有的精灵,了解它们的实现方式。特别注意坐标变换在处理 LocationRotation 属性中的作用。

效果

效果是 Sparkle 库中的驱动者。它们推动 Sprites,将它们移动到这里或那里,使它们可见或不可见,旋转它们。任何时候你想让一个 Sprite 改变,你都需要一个 Effect

Effects 被赋予一个 Sprite,并被告知它们何时开始以及运行多长时间。

this.imageSprite.Add(100, 250, new FadeEffect(0.0f, 0.8f));

这表示,“在精灵在动画中开始后的 100 毫秒后,此 FadeEffect 应该在 250 毫秒内将精灵从隐藏(0.0 不透明度)淡入到 80% 可见(0.8 不透明度)。”

许多 Effects 通过“缓动”工作——它们被赋予一个初始值和一个目标值,随着效果的进行,效果会逐渐改变其 Sprite 上的某个属性,从初始值到结束值。在上面的例子中,FadeEffect 的初始值是 0.0,结束值是 0.8。随着动画的进行,FadeEffect 会逐渐将 imageSpriteOpacity 属性从 0.0 更改到 0.8。因此,在精灵开始后 100 毫秒,imageSprite 将是隐藏的;在 225 毫秒后,它将是 40% 可见;在 350 毫秒后,它将是 80% 可见,然后效果将停止。

效果工厂

Effects 工厂包含创建许多常用效果的 static 方法。

  • Move(Corner to)

    将精灵从其当前位置移动到动画的一个角落。

  • Move(Corner from, Corner to)

    将精灵从动画的一个角落移动到另一个角落。这有无数种变体,允许以不同的方式指定开始和结束位置。

  • Goto(Corner to)

    跳转到(类似于大富翁)给定的角落,没有任何过渡。

  • Fade(float from, float to)

    将精灵的 Opacity 从起始值更改为结束值,从而有效地淡入或淡出。

  • Rotate(float from, float to)

    更改精灵的 Spin(旋转)值(以度为单位),从起始值到结束值。

  • Scale(float from, float to)

    更改精灵的 Scale(缩放),使其变大或变小。

  • Bounds(IRectangleLocator locator)

    更改精灵的 Bounds(边界)。

  • Walk(IRectangleLocator locator)

    这是第一个有趣的效果。它改变了精灵的位置,使其“绕着”给定矩形的周长“行走”。这有几种变体,可以指定精灵的哪个确切点将被行走,行走的哪个方向,以及行走从哪里开始。

  • Blink(int repetitions)

    另一个有趣的效果。它改变了精灵的 Opacity,使其闪烁多次。有几种变体允许更改“闪烁”的特性:淡入需要多长时间,保持可见,淡出,保持不可见。

  • Repeater(int repetitions, IEffect effect)

    将给定的 Effect 应用于 Sprite 多次。

自定义效果

当然,同样期望应用程序会想要以自己的方式改变 Sprite。您可能想要一个沿着弧线弹跳精灵的移动效果,或者在两张图像之间进行棋盘格过渡的效果。要实现自己的功能,您需要实现 IEffect 接口。

public interface IEffect
{
    /// <summary>
    /// Gets or set the sprite to which the effect will be applied
    /// </summary>
    ISprite Sprite { get; set; }

    /// <summary>
    /// Signal that this effect is about to applied to its sprite for the first time
    /// </summary>
    void Start();

    /// <summary>
    /// Apply this effect to the underlying sprite
    /// </summary>
    /// <param name="fractionDone">How far through the total effect are we?
    /// This will always in the range 0.0 .. 1.0.</param>
    void Apply(float fractionDone);

    /// <summary>
    /// The effect has completed
    /// </summary>
    void Stop();

    /// <summary>
    /// Reset the effect AND the sprite to its condition before the effect was applied.
    /// </summary>
    void Reset();
}

效果需要知道它们正在改变的 Sprite,并且知道它们何时开始和停止。序列图看起来会是这样:

  1. Start()
  2. Apply() [调用 0 次或多次,值为 0.0..1.0]
  3. Stop()
  4. Reset() [调用 0 次或 1 次]

唯一有趣的部分是 Apply() 方法。这就是 Effect 执行实际工作的地方。请注意,Effect 收到的是“完成的比例”值,而不是滴答数(或其他类似的东西)。Effects 不能依赖于以任何特定顺序调用 Apply():第一次调用 Apply() 可能是 fractionDone=0.1,下一次可能是 0.9,然后是 0.5

另请注意,Reset() 必须将 Effect *以及* Sprite 的状态恢复到 EffectStart() 之前的原始条件。这意味着在 Start() 方法中,效果通常会存储它们将要更改的任何状态,然后在 Reset() 中将该状态放回。

定位器

在某些方面,定位器是最难理解的概念。如果你能理解这个概念,其他一切通常都会迎刃而解。

Locator 是一个点或矩形,它可以在需要时计算自身。一个普通的 Point 是固定的,但一个 PointLocator 每次调用时都可以不同。通过使用 Locator,计算点“how”(如何)可以被替换为在运行时使用任何它喜欢的策略。

例如,MoveEffect 改变 SpriteLocation。它可以被编码为将 Sprite 移动到 AnimationTopLeft

this.Sprite.Location = this.Animation.Bounds.Location;

这是一个很好且显而易见的方法,但不是很灵活。如果我们随后想将精灵移动到动画的中心,我们就必须写另一行代码,然后提供一种方法来选择要执行哪一行。以及为我们想要的每一种可能的位置再写一行代码。

但有了 LocatorsMoveEffect 只是说:

this.Sprite.Location = this.Locator.GetPoint();

通过使用这种额外的抽象层,计算“where”(位置)的智能被放置在一个单独的对象中,并可从中重用。

标准定位器

Locators 是一个工厂,它有 static 方法来生成许多常见的定位器。当然,您也可以直接创建定位器——这只是一个便利。

  • IPointLocator At(int, int)

    创建一个固定点的 PointLocator

  • IPointLocator SpriteAligned(Corner corner)

    创建一个 PointLocator,该定位器是 Sprite 必须移动到的位置,以便给定的 Corner 位于 Animation 的相应 Corner。因此,Locators.SpriteAligned(Corner.BottomRight) 计算 Sprite 必须移动到的位置,以便其 BottomRight Corner 位于 Animation 的 BottomRight Corner。

  • IPointLocator SpriteAligned(Corner corner, Point offset)

    与上面相同,但该点偏移了给定的固定量。

  • IPointLocator SpriteAligned(Corner corner, float proportionX, float proportionY)

    创建一个 PointLocator,该定位器是 Sprite 必须移动到的位置,以便给定的 Corner 位于 Animation 边界上、下方成比例的点。因此,Locators.SpriteAligned(Corner.BottomRight, 0.6f, 07.7) 计算 Sprite 必须移动到的位置,以便其 BottomRight Corner 位于 Animation 的 60% 处和 70% 下方。

  • IPointLocator SpriteBoundsPoint(Corner corner)

    创建一个 PointLocator,它计算 Sprite 边界的给定 Corner。

  • IPointLocator SpriteBoundsPoint(float proportionX, float proportionY)

    创建一个 PointLocator,它计算 Sprite 边界上给定比例的横向和纵向位置。

  • IRectangleLocator At(int, int, int, int)

    创建一个固定矩形的 RectangleLocator

  • IRectangleLocator AnimationBounds()

    创建一个 Animation 边界的 RectangleLocator

  • IRectangleLocator AnimationBounds(int x, int y)

    创建一个 Animation 边界的 RectangleLocator,该边界内嵌了给定的量。

  • IRectangleLocator SpriteBounds()

    创建一个 Sprite 边界的 RectangleLocator

  • IRectangleLocator SpriteBounds(int x, int y)

    创建一个 Sprite 边界的 RectangleLocator,该边界内嵌了给定的量。

AnimationAdaptor

上面提到的 AnimationAdaptor 提供了一个例子,将 Animation.Redraw 事件连接到 Animation.Draw() 方法。当 Animation 触发 Redraw 事件时,AnimationAdaptor 会使它的 Control 无效。这会导致 Control 重新绘制自身,当控件触发 Paint 事件时,AnimationAdaptor 通过 Draw() 方法渲染动画。Voilà!任何 Control(具有 Paint 事件)都可以显示动画。

AnimationAdaptor 可以用于 PanelsButtonsLabelsPictureBoxesUserControls、数字微调控件,以及(奇怪的是)DataGridView。它不能用于另一个控件,因为它们不支持有用的 Paint 事件。对于 RichTextBox 和其他控件,无法做什么,但如果您想在 ListViewTreeView 上添加动画,可以看看 ObjectListView 项目,它支持这些动画。

Sparkle 设计的一个美妙之处在于它可以轻松地在另一个框架中使用。要在 WPF 中使用它,只需要一个相当于 AnimationAdaptor 的东西,它监听 Animation 上的重绘事件,然后采取措施使动画重绘自身。

性能

Sparkle 库在按照其设计目标使用时性能相当不错。动画化几十个精灵和几十个效果对性能的影响很小。在我的笔记本电脑上,大约 20 个带有各种效果的精灵只使用了大约 2-3% 的 CPU。限制因素不是动画,而是底层控件的重绘。目前,整个控件在每一帧都被重绘。对于简单的控件,如 ButtonsUserControls,这不是问题,但对于复杂的控件,如 DataGridView,这种重绘很快就会变得很耗费资源。

在稍后的版本中,我将优化为只使控件最小可能区域失效。

我还没有尝试使用 Sparkle 处理数千个精灵。那真的不是它的目的。

状态和稳定性

Sparkle 是一个新库。它对我来说工作得很好,但我确信其中存在 bug。请报告它们,我会修复它们。

接口和主要类是稳定的,但尚未固定(不可更改)。我可能会在 ISprite 接口中添加一些属性(我认为它需要 Skew)。

结论

放手去做吧。用闪闪发光、时髦的动画让您的用户眼花缭乱。

现在当我看到谷歌的应用程序时,我不再感到嫉妒。我仍然没有他们的工作条件或股价,但当一颗小星星旋转并淡出时,我想:“嘿,我也可以做到!”

待办事项

  • 更有效地处理更新(计算损坏区域并仅重绘该部分)。
  • 添加更炫的效果,如发光和镜像。
  • 添加复合效果(同时将效果应用于多个精灵的方式)。
  • 允许不同的插值计算。目前所有插值都是线性的。我们应该允许例如加速度,这样演示中的下落形状在下落时会加速。
  • 允许动画反转。

库许可证

本库在 LGPL v2.0 下发布。

历史

v0.8 - 2010年3月30日

  • 首次公开发布
© . All rights reserved.