八、动画框架

安卓提供了各种强大的应用编程接口来将动画应用于用户界面元素。本章旨在提供可用选项的概述,以帮助您决定哪种方法最适合您的需求。

在我们开始添加动画之前,我们将稍微重构一下我们的代码,以便在布局完成后,通过在我们的基础片段上创建一个回调来更容易地使用动画。

然后,我们将看到如何定义一个可以在ImageView中使用的传统逐帧动画。我们还将看到如何以AnimatedSprite的形式将它们合并到我们的GameEngine中。

这一章的核心是关于动画视图的不同方法。我们将开始讨论插值器及其在安卓动画框架中的作用。然后,我们将学习名为视图动画的旧方法,我们将使用它来动画游戏的一些领域,包括如何显示和隐藏我们的自定义对话框。

然后,我们将讨论ValueAnimatorPropertyAnimator,最后是ViewPropertyAnimator,解释它们是如何不同的,比视图动画更加通用和复杂,以及在哪些情况下它们是首选的。我们也将做一些他们的例子。

最后,我们将使用不同的方法制作主屏幕文本视图的动画,这样您就可以检查它们的异同。

更新基础片段

通常动画(尤其是ViewPropertyAnimator)需要完成视图的布局才能应用。我们在GameFragment中已经有了一个方法,所以我们将对其进行推广,使其成为BaseFragment的一部分。

该方法使用ViewTreeObserver检查视图的布局何时完成。我们将添加到BaseFragment的代码如下:

@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
  super.onViewCreated(view, savedInstanceState);
  getYassActivity().applyTypeface(view);
  final ViewTreeObserver obs = view.getViewTreeObserver();
  obs.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
    @Override
    public synchronized void onGlobalLayout() {
      ViewTreeObserver viewTreeObserver = getView().getViewTreeObserver();
      if (viewTreeObserver.isAlive()) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
          viewTreeObserver.removeGlobalOnLayoutListener(this);
        } else {
          viewTreeObserver.removeOnGlobalLayoutListener(this);
        }
        onLayoutCompleted();
      }
    }
  });
}

由于我们已经从GameFragment中删除了大量的代码,新版本就简单多了:

@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
  super.onViewCreated(view, savedInstanceState);
  view.findViewById(R.id.btn_play_pause).setOnClickListener(this);
}

@Override
protected void onLayoutCompleted() {
  prepareAndStartGame();
}

通过这些修改,当我们开始添加动画时,我们可以在本章后面的MainMenuFragment中使用onLayoutCompleted

动画制作

AnimationDrawable 是你在安卓系统中定义逐帧动画的方式。它将可绘制资源描述为按顺序播放以创建动画的其他可绘制资源的列表。这是一部最传统意义上的动画:一系列独立的画面,一个接一个地播放。

我们可以使用AnimationDrawable类在代码中定义动画的帧,但是使用 XML 要容易得多。该文件列出了构成动画的帧及其持续时间。XML 由<animation-list>类型的根节点和一系列<item>类型的子节点组成,它们使用可绘制的资源和帧持续时间来定义帧:

<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
  android:oneshot=["true" | "false"] >
  <item
    android:drawable="@[package:]drawable/drawable_resource_name"
    android:duration="integer" />
</animation-list>

这个 XML 文件属于你的安卓项目的res/drawable/目录,因为它被认为是一个可绘制的。

AnimationDrawable资源被放置在drawable目录中。

让我们用一个例子来看看这个。我们将制作一个简单的动画,让我们飞船的灯光闪烁。为此,我们将使用四个框架:

  • 关灯(普通飞船)
  • 左灯亮着
  • 关灯(再次)
  • 右灯亮着

请注意,我们可以为不同的帧重用相同的 drawable,从而节省一些空间。

AnimationDrawable

我们飞船动画的四帧

带闪烁灯的飞船的定义是这样的:

<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
  android:oneshot="false">
  <item android:drawable="@drawable/ship_2" android:duration="600" />
  <item android:drawable="@drawable/ship_1" android:duration="400" />
  <item android:drawable="@drawable/ship_2" android:duration="600" />
  <item android:drawable="@drawable/ship_3" android:duration="400" />
</animation-list>

我们让灯只亮 400 毫秒,然后 600 毫秒不亮。然后,我们去另一盏灯。

我们已经定义了oneShotfalse。这使得动画在最后一帧结束后从头开始重复。如果你想让动画只播放一次,你应该将oneShot设置为true

为了测试这一点,我们可以在主菜单的布局中添加一个ImageView,并为其设置AnimationDrawable:

<ImageView
  android:id="@+id/ship_animated"
  android:layout_width="50dp"
  android:layout_height="50dp"
  android:layout_centerHorizontal="true"
  android:src="@drawable/ship_animated"
  android:layout_below="@+id/btn_start"
/>

如果我们尝试这样做,我们会看到动画不起作用。AnimationDrawable不是自动播放的。此外,需要注意的是,AnimationDrawablestart方法不能在活动的onCreate方法中调用,因为AnimationDrawable还没有完全附着在窗口上。我们将不得不等到窗口完全创建,这将由活动中的onWindowFocusChanged方法通知。

AnimationDrawables 不是自动播放的,我们必须用代码启动它们。

不过动画可以从片段的onViewCreated方法开始。由于我们已经有了稍后调用的onLayoutCompleted方法,为了一致性,我们将使用这个方法:

@Override
protected void onLayoutCompleted() {
  ImageView iv = (ImageView) getView().findViewById(R.id.ship_animated);
  ((AnimationDrawable)iv.getDrawable()).start();
}

但这对于我们来说还不够:AnimationDrawable定义了一个可以在ImageView中使用的逐帧动画。真正有趣的是能够使用相同的 XML 定义来描述动画精灵。为此,我们将创建一个从Sprite扩展而来的新类来处理动画。

动画精灵

要创建动画精灵,我们需要注意AnimationDrawable的细节。由于我们已经有了在屏幕上绘制Sprite的所有代码,新的AnimatedSprite类将只需要注意计算选择应该绘制哪个位图的时间。

请注意,这仅适用于AnimationDrawable当所有帧都被定义为位图时,我们的Sprite基类不支持形状等其他 XML 资源。

我们来看看AnimatedSprite的代码:

public abstract class AnimatedSprite extends Sprite {

  private final AnimationDrawable mAnimationDrawable;
  private int mTotalTime;
  private long mCurrentTime;

  public AnimatedSprite(GameEngine gameEngine, int drawableRes, BodyType bodyType) {
    super(gameEngine, drawableRes, bodyType);
    // Now, the drawable must be an animation drawable
    mAnimationDrawable = (AnimationDrawable) mSpriteDrawable;
    // Calculate the total time of the animation
    mTotalTime = 0;
    for (int i=0; i<mAnimationDrawable.getNumberOfFrames(); i++) {
      mTotalTime += mAnimationDrawable.getDuration(i);
    }
  }

  @Override
  protected Bitmap obtainDefaultBitmap() {
    AnimationDrawable ad = (AnimationDrawable) mSpriteDrawable;
    return ((BitmapDrawable) ad.getFrame(0)).getBitmap();
  }

  @Override
  public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
    mCurrentTime += elapsedMillis;
    if (mCurrentTime > mTotalTime) {
      if (mAnimationDrawable.isOneShot()) {
        return;
      }
      else {
        mCurrentTime = mCurrentTime % mTotalTime;
      }
    }
    long animationElapsedTime = 0;
    for (int i=0; i<mAnimationDrawable.getNumberOfFrames(); i++) {
      animationElapsedTime += mAnimationDrawable.getDuration(i);
      if (animationElapsedTime > mCurrentTime) {
        mBitmap = ((BitmapDrawable) mAnimationDrawable.getFrame(i)).getBitmap();
        break;
      }
    }
  }
}

我们创建了一个名为obtainDefaultBitmap的新方法,它是从构造函数中调用的。对于普通精灵,这个方法只返回位图。在AnimatedDrawable的情况下,我们将其初始化为第一帧。

构造函数的参数与普通精灵相同,但如果可绘制资源不是AnimationDrawable,则会抛出ClassCastException。不包括错误处理以使代码更容易理解。

构造器中做的另一件事是通过将所有帧的持续时间相加来计算AnimationDrawable的总时间。我们每次运行onUpdate都会需要这个值,所以要提前获取。

onUpdate期间,我们将经过的毫秒数加到总时间上,然后我们检查AnimatedSprite已经运行的总时间是否长于动画的总时间。如果是的情况,我们检查是否AnimationDrawable设置为oneShot。如果是oneShot,我们什么都不做,因为最后一个形象已经定了。如果要重复动画,我们只需应用模块操作符使mCurrentTime回到区间即可。

一旦我们知道当前时间将在动画时间范围内,我们就迭代帧,检查哪一个是当前帧,并将该帧中的位图设置为mImage 成员变量,这是基类在画布上绘制时使用的变量。

在画布上绘制位图已经由父Sprite类完成。

注意从AnimatedSprite开始扩展的所有类在覆盖onUpdate.的同时必须调用 super 方法,否则更新镜像的代码不会被执行。

扩展AnimatedSprite时,不要忘记在覆盖onUpdate的同时调用 super。

现在,让我们制作游戏中飞船的动画。

我们只需要更新PlayerAnimatedSprite开始扩展,改变我们传递给构造函数的图像资源,记得调用onUpdate中的超级方法:

public class Player extends AnimatedSprite {

  public Player(GameEngine gameEngine) {
    super(gameEngine, R.drawable.ship_animated, BodyType.Circular);
    []
  }

  @Override
  public void onUpdate(long elapsedMillis, GameEngine gameEngine) {
    super.onUpdate(elapsedMillis, gameEngine);
    []
  }
}

我们有一艘带闪烁灯的宇宙飞船!

动画视图

安卓框架提供了两个动画系统:

  • 观看动画
  • 属性动画

视图动画从第一个安卓版本就已经出现了,而属性动画是在安卓 3.0 中引入的。推荐后者,因为它更一致,提供更多功能。

视图动画系统只能用于动画视图。它还受到限制,因为它只显示了View对象的几个方面来制作动画,例如视图的缩放和旋转,但不显示背景颜色。

视图动画系统的另一个缺点是,它只修改视图的绘制位置,而不修改实际视图本身。例如,如果您制作按钮的动画以在屏幕上移动,按钮将正确绘制,但单击按钮时考虑的实际位置不会改变,这可能会有问题。

视图动画修改的是视图的绘制位置,而不是视图本身。

另一方面,属性动画系统允许我们动画化任何对象(视图和非视图)的任何属性,并且对象本身实际上被修改。

然而,视图动画系统更容易使用,并且需要更少的代码。如果视图动画完成了您需要做的所有事情,就不需要使用属性动画系统。

视图动画更简单。属性动画更高级。

使用ViewPropertyAnimation时,动画只接收最终值的参数,因为它是从当前值动画的。这可能需要一些初始化。

总之,了解两种动画系统并应用最适合每种情况的系统是很好的。

不考虑系统,动画通常很容易在安卓系统中实现,但是需要做很多工作来调整参数以使动画感觉正确。一个感觉不好的动画比完全没有动画更糟糕,但是一个正确的动画让游戏感觉更好更流畅。在处理细节的时候,要准备好多注意细节。

调整动画需要大量时间。

根据经验,动画应该足够长,以至于引人注目(否则,添加动画将毫无意义),但不能太长,以至于让游戏感觉缓慢。这意味着过渡动画的持续时间应该在 300 到 400 毫秒之间。

XML 与代码

视图动画和属性动画(几乎与任何资源一样)都可以用代码或 XML 定义。除非您需要一些只能在运行时获得的值,否则最好使用 XML,因为所有文件都在代码外部,动画可以在不接触 Java 源代码的情况下修改。

将动画定义为一个资源还允许我们在代码中的不同位置使用它们,并确保动画中的任何变化都会影响到所有使用它的地方。如果我们在代码中定义动画,我们将不得不检查动画构建的每个地方,或者依赖于实用程序类,这不好处理。

有一个中间地带,您可以用 XML 定义动画,然后阅读它并使用代码修改一些参数。这种方法相当强大;它让我们可以控制动画,同时将它的大部分定义保留在代码之外。

插值器

动画系统在开始时间和结束时间之间播放动画。动画的每一帧都在开始和结束之间的特定时间显示。默认情况下,它遵循线性函数,但这是可以改变的。在游戏中,这种技术通常被称为补间,但在安卓系统中,它被称为插值。让我们看看它是如何工作的。

插值器相当于一般游戏术语中的补间动画。

动画使用时间索引来计算值。这个时间索引基本上是一个规范化的时间,一个介于 0.0 和 1.0 之间的值。

在最简单的情况下,时间索引的值被用来计算对象的变换。在变换的情况下,0.0 对应于开始位置,1.0 对应于结束位置,0.5 对应于开始和结束之间的中间位置。这正是线性插值器的作用。

一般来说,我们可以通过使用数学函数将时间索引转换为另一个值。这正是插值器的作用。

时间插值器本质上是一个函数,它取 0.0 到 1.0 之间的一个值,并将其转换为另一个值,用于计算动画作为时间索引。

安卓提供了一套默认插值器,涵盖了基本配置,应该足以应对大多数情况。如果您需要一些非常特殊的东西,您可以创建自己的插值器,只需要实现一种方法接口。

我们不打算进入数学函数的细节,而只是概述它们看起来像什么。安卓中定义的插值器有:

  • 线性:简单的线性函数。
  • 循环:动画以时间索引 1 为全圆周,遵循正弦曲线。
  • 反弹:动画到达终点时会反弹几次。
  • 减速:动画向结尾减速。
  • 加速:动画越接近尾声越快。
  • 加速减速:动画开始加速,结束减速。
  • 过冲:动画越过终点,然后返回。
  • 预判:开始之前,动画回去得到一个冲动。这与过冲相反。
  • 预期过冲:这结合了过冲和预期。

Interpolators

安卓的不同插值器

对于遵循材质设计准则的动画,API 级别 21 中增加了一些插值器。我们真的不需要他们来玩游戏。我们想要有趣、好看的动画;我们不在乎它们看起来是否真实,这是材质设计的核心特征。

插值器是一个常见的概念,可以应用于我们将要使用的所有动画视图的方式。

查看动画

安卓系统中最原始也是最简单的视图动画方式是使用视图动画。这将通过从 XML 加载或以编程方式创建一个Animation对象,然后将其应用于视图。它们相对容易设置,并提供足够的功能来满足大多数需求。

有一些关于视图动画的重要细节。它们如下:

  • 当我们制作视图动画时,视图的所有子视图也会受到影响。
  • 无论动画如何移动或调整大小,动画视图的边界都不会自动调整以适应它。即使如此,动画仍将被绘制在其视图范围之外,并且不会被剪辑。但是,如果动画超出父视图的边界,则会发生剪辑。这可以通过在父视图中将clipChildren设置为假来解决。
  • 动画完成后,视图将恢复到原始状态。如果您计划使用这种类型的动画来显示或隐藏视图,您必须确保在动画开始之前和动画结束之后将其可见性设置为所需的状态。这可以通过使用监听器轻松实现。
  • 在动画制作过程中,视图的边界不会改变。这意味着无论视图绘制在哪里,触摸区域都是相同的。这也是为什么我们想要使用属性动画的最相关的原因之一。

定义动画的文件必须放在res/animation文件夹下,它们的定义如下:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
  android:interpolator="@[package:]anim/interpolator_resource"
  android:shareInterpolator=["true" | "false"] >
  <alpha
    android:fromAlpha="float"
    android:toAlpha="float" />
  <scale
    android:fromXScale="float"
    android:toXScale="float"
    android:fromYScale="float"
    android:toYScale="float"
    android:pivotX="float"
    android:pivotY="float" />
  <translate
    android:fromXDelta="float"
    android:toXDelta="float"
    android:fromYDelta="float"
    android:toYDelta="float" />
  <rotate
    android:fromDegrees="float"
    android:toDegrees="float"
    android:pivotX="float"
    android:pivotY="float" />
  <set>
    ...
  </set>
</set>

格式中定义的一些属性是位置。它们可以用三种不同的方式来定义:

  • 相对于默认位置的像素(例如 50)
  • 相对于视图本身的百分比(例如 50%)
  • 相对于父视图的百分比(例如 50%p)

不鼓励使用像素,通常使用相对于视图或父视图的百分比。

集合只不过是对其他属性进行分组的一种方式。大多数情况下,您将只使用一个,但是它们可以嵌套来定义更复杂的动画。

该集合可以有一个插值器,如果shareInterpolator属性设置为真,该插值器将应用于所有子集合。这允许所有动画流畅地一起流动。这就是大部分时间的使用方式,但是每个组件都有可能有自己的插值器。

这些概念与我们在DrawThread上使用变换矩阵时已经使用的概念基本相同。我们可以缩放、平移、旋转和修改 alpha。

阿尔法是最简单的一个;它只有初始值和最终值。

缩放接收两个轴上的初始和最终缩放以及枢轴点。该枢轴点是将应用标尺的位置。通常以百分比的形式提供。最常见的配置是将 50%放在两个轴上,因此它从视图的中心开始增长。但是其他配置可以很好地工作,比如两者都为 0%,这将使它从左上角开始增长。

平移在两个轴上接收起点和终点的增量。它们也是位置,可以参照父视图的百分比来定义。

旋转接收从度和到度,以及应用旋转的枢轴点。请注意,这允许您从相对于父视图的位置旋转视图,甚至在视图本身之外,这可能会很方便。

所有标签都有一些共同的属性。它们如下:

  • startOffset:允许我们定义一个偏移量,这样动画就不会马上开始。
  • duration:定义动画将持续多长时间。
  • repeatCount:允许我们让动画无限重复或者特定次数重复。
  • repeatMode:仅在重复时使用。它允许我们反转动画,而不是从头开始重复。
  • interpolator:要使用的插值器(如果shareInterpolator设置为假)。

有一个已知的问题是repeatCount在 XML 中定义时不适用于集合,尽管它确实适用于单个动画。然而,你可以在载入动画后在代码中设置repeatCount,这也是可行的。

当在 XML 中为集合定义时,r epeatCount将不起作用。

重复动画与startOffset交互的方式可能与直觉相反。偏移被认为是动画的一部分,因此它被重复。我们将在本章后面看到一个这样的例子。

同样,就像变换矩阵一样,动画中定义的顺序非常重要。先平移后旋转的结果和我们先旋转后平移的结果不一样。这一点大家现在应该都清楚了。

动画对话框

我们将使用视图动画制作游戏中对话框的显示和隐藏动画。

虽然我们没有使用该平台的默认对话框动画,但建议在我们的游戏中保持一致,并确保所有对话框都有相同的动画。这就是为什么要在一个地方进行更改,所以所有的对话框都将使用相同的动画。

让我们看看我们必须对BaseCustomDialog进行的修改,以添加动画:

public void show() {
  if (mIsShowing) {
    return;
  }
  mIsHiding = true;
  [...]
  startShowAnimation();
}

private void startShowAnimation() {
  Animation dialogIn = AnimationUtils.loadAnimation(mParent, R.animator.dialog_in);
  mRootView.startAnimation(dialogIn);
}

public void dismiss() {
  if (!mIsShowing) {
    return;
  }

  if (mIsHiding) {
    return;
  }
  mIsHiding = true;
  startHideAnimation();
}

private void startHideAnimation() {
  Animation dialogOut = AnimationUtils.loadAnimation(mParent, R.animator.dialog_out);
  dialogOut.setAnimationListener(this);
  mRootView.startAnimation(dialogOut);
}

@Override
public void onAnimationEnd(Animation paramAnimation) {
  hideViews();
  mIsShowing = false;
  onDismissed();
}

protected void onDismissed() {
}

我们有一个在show末尾调用的startShowAnimation方法,和一个在dismiss末尾调用的startHideAnimation方法。

两种方法都相当简单;他们使用AnimationUtils加载Animation,然后使用startAnimation方法将其应用于mRootView

但是,有一些细节需要评论:

  • 我们只是在开始动画之前向内容添加视图,并在动画完成后删除它们,因此不需要更改它们的可见性。在其他情况下(动画结束后视图仍保留在层次结构中),您可能需要更新视图在AnimationListener中的可见性。
  • BaseCustomDialog实现AnimationListener,我们用它来检测隐藏动画什么时候结束,去掉那一刻的视图。
  • 我们有一个新的方法叫做onDismissed。一旦动画结束,这就被称为。直到现在,对话的取消是一个瞬间的操作。现在已经不是这样了。辞退时所做的动作应移至onDismiss
  • 我们使用两个变量来确定对话框的状态:mIsShowingmIsHiding。从显示动画开始到消除动画完成,该对话框被视为正在显示。然而,我们不应该忽略已经被忽略的对话,因此mIsHiding有必要防止这种情况。

动画本身是用 XML 定义的,所以它们独立于对话框是动画的事实。我们将看到几个对话框动画,以更深入地了解框架及其可能性。我们将使用成对的互补动画:

  • 从中心增长/收缩到中心
  • 从顶部进入/从顶部退出

为了使对话框从中心向外扩展和收缩,我们只需要使用缩放。

从中心开始增长的代码如下:

<?xml version="1.0" encoding="UTF-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
  android:interpolator="@android:anim/decelerate_interpolator"
  >
  <scale
    android:fromXScale="0.5"
    android:toXScale="1.0"
    android:fromYScale="0.5"
    android:toYScale="1.0"
    android:pivotX="50%"
    android:pivotY="50%"          
    android:duration="400"
  />
</set>

收缩到中心的定义如下:

<?xml version="1.0" encoding="UTF-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
  android:interpolator="@android:anim/accelerate_interpolator">
  <scale
    android:fromXScale="1.0"
    android:toXScale="0.5"
    android:fromYScale="1.0"
    android:toYScale="0.5"
    android:pivotX="50%"
    android:pivotY="50%"
    android:duration="400"
  />
</set>

如你所见,两个动画相似,但fromto的参数相反。

请注意,我们从 0.5 开始,而不是 0。较小尺寸的动画并不真正可见,主要是因为减速插值器,但是如果您愿意,可以将其设置为 0。

我们还将两个轴上的枢轴点设置为 50%。这就是它从中心开始扩展的原因。

一个有趣的变体是只在一个轴上应用比例。感觉像是从屏幕中间展开的视图。这是留给读者的练习。

另一对动画使用平移代替缩放。我们将使对话框从顶部进入和退出,但改变代码使其使用屏幕的任何一侧确实很容易。

这是从顶部输入的动画定义:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
  android:interpolator="@android:anim/overshoot_interpolator">
  <translate
    android:fromYDelta="-100%p"
    android:toYDelta="0%p"
    android:duration="500" />
</set>

这是从顶部退出的代码:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android" 
    android:interpolator="@android:anim/anticipate_interpolator">
  <translate
    android:fromYDelta="0%p"
    android:toYDelta="-100%p"
    android:duration="500" />
</set>

请注意, y 增量使用的是相对于父视图的百分比。输入动画从顶部(一整屏向上)开始,大小为父动画的-100%。对于现有的动画,我们只需要反转增量。

最后,关于这些动画还有一个重要的决定要做,那就是使用哪些插值器。最常见的配置有:

  • 两者都是线性的:简单,但有点无聊
  • 减速以显示/加速以隐藏:这感觉比线性更平滑,并给出更专业的外观
  • 过冲显示/预期隐藏:由于视图越过结束位置,然后返回,它使动画感觉更有趣

您可以使用动画和插值器的任意组合,或者创建自己的外观和感觉。修改代码试试看,直到对结果满意为止。仅仅通过改变插值器,动画感觉就不一样了。

将对话框中的动作延迟至“已取消”

因为动画需要一些时间,所以当用户点击按钮时在对话框上执行的动作应该被延迟,直到动画完成。

为此,我们将存储被点击的视图的 id,然后在onDismissed方法中检查它以触发适当的动作。这是我们必须对每个对话框进行的更改。

让我们先来看看我们必须对GameOverDialog做出的改变:

@Override
public void onClick(View v) {
  mSelectedId = v.getId();
  dismiss();
}

@Override
protected void onDismissed() {
  if (mSelectedId == R.id.btn_exit) {
    mListener.exitGame();
  }
  else if (mSelectedId == R.id.btn_resume) {
    mListener.startNewGame();
  }
}

很简单,对吧?代码和之前差不多,只是从onClick移到了onDismiss,所以后面执行。

接下来,PauseDialog类似:

@Override
public void onClick(View v) {
  [...]
  else if (v.getId() == R.id.btn_exit) {
    mSelectedId = v.getId();
    super.dismiss();
  }
  else if (v.getId() == R.id.btn_resume) {
    mSelectedId = v.getId();
    super.dismiss();
  }
}

@Override
protected void onDismissed () {
  if (mSelectedId == R.id.btn_exit) {
    mListener.exitGame();
  }
  else if (mSelectedId == R.id.btn_resume) {
    mListener.resumeGame();
  }
}

@Override
public void dismiss() {
  super.dismiss();
  mSelectedId = R.id.btn_resume;
}

这种情况有点复杂,因为有些按钮仍然会启动一些动作(音乐和声音),但不会关闭对话框。我们还添加了一个默认的选定操作(在这种情况下是继续),当用户关闭对话框时使用。

请注意,onClick内的两个动作都显式调用super.dismiss()方法,以避免被默认动作覆盖。

最后,对于QuitDialog,我们又有了同样的想法:

@Override
public void onClick(View v) {
  mSelectedId = v.getId();
  dismiss();
}

@Override
protected void onDismissed() {
  if (mSelectedId == R.id.btn_exit) {
    mListener.exit();
  }
}

就是这里。对话框为动画,并且在对话框消失后执行动作。

脉动按钮

让我们使用动画视图再添加一个动画。我们将动画按钮开始游戏,使其在两个轴上循环增长和收缩,模拟按钮脉动。想法是它是一个“想被点击”的按钮。

为此,我们将使用合成动画。动画将在 X 轴和 Y 轴上缩放按钮,但动画会有所不同。X 将在动画的整个持续时间内增长,而 Y 将只在第二部分增长。然后我们让动画在反向模式下无限重复。

XML 动画的代码如下所示:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
  android:interpolator="@android:anim/accelerate_decelerate_interpolator">
  <scale
    android:fromXScale="1.0"
    android:toXScale="1.2"
    android:fromYScale="1.0"
    android:toYScale="1.0"
    android:pivotX="50%"
    android:pivotY="50%"
    android:duration="800"
    android:repeatMode="reverse"
    android:repeatCount="infinite"
  />
  <scale
    android:fromXScale="1.0"
    android:toXScale="1.0"
    android:fromYScale="1.0"
    android:toYScale="1.1"
    android:pivotX="50%"
    android:pivotY="50%"
    android:startOffset="300"
    android:duration="500"
    android:repeatMode="reverse"
    android:repeatCount="infinite"
  />
</set>

正如我们在上一节中提到的,repeatCount属性不适用于<set>标签。我们可以用代码来完成,但是更简单的方法是把它添加到每个动画中,因为我们只有两个。这就是为什么两个<scale>标签上都设置了repeatCountrepeatMode

注意infinite关键字被接受为repeatCount。我们不需要为它使用尴尬的常数。

重复与startOffset互动的方式有时是反直觉的。startOffset的值将应用于每次迭代。在这种特殊情况下,这个行为就派上了用场,因为我们希望在每次迭代中 y 轴上的动画比 x 上的动画开始得晚。但是如果我们制作一个延迟开始的重复动画,它将不会像预期的那样工作。

startOffset是动画的一部分,它将包含在每个重复中。

对于具有延迟开始的重复动画,最佳解决方案是使用不同的方法来添加初始延迟。安卓系统为我们提供了Timer / TimerTask和发布Runnable的可能性。

将动画设置为视图非常简单,只需在MainMenuFragment中输入几行代码;一个加载Animation,一个启动:

@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
  super.onViewCreated(view, savedInstanceState);
  []
  Animation pulseAnimation = AnimationUtils.loadAnimation(getActivity(), R.animator.button_pulse);
  view.findViewById(R.id.btn_start).startAnimation(pulseAnimation);
}

随意玩弄参数,甚至使两个分量有不同的时间,所以它们互相抵消。我们在例子中的值被选择成使它非常引人注目;你可以使用更小的最终比例和/或更长的周期使它更微妙,这是我推荐的。

属性动画

第二种在安卓中管理动画的方式是在安卓 3.0 中引入的(API 级别 11)。它是以非常通用的方式设计的,因此它可以处理任何对象的任何属性上的动画。该系统是可扩展的,并允许你动画自定义类型的属性。

有许多方法可以使用属性动画。最简单的就是用ValueAnimator。这就像定义一个从一个值到另一个值的动画一样简单,它有一个持续时间,并且可选地有一个插值器。然后添加一个监听器,每次有新值时都会调用这个监听器,最后开始动画。

这段代码将创建一个ValueAnimator,沿着 1000 毫秒从 0 到 42 的浮动动画:

ValueAnimator animation = ValueAnimator.ofFloat(0f, 42f);
animation.setDuration(1000);
animation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
  @Override
  public void onAnimationUpdate(ValueAnimator animation) {
    Float currentValue = (Float) animation.getAnimatedValue();
    // Do something with the value
  }
});
animation.start();

值动画师本身并不修改值,但是你可以在监听器的onAnimationUpdate方法中控制你想用动画值做什么。

我们不会在 YASS 使用任何ValueAnimator,但是它们对于其他类型的游戏来说确实很有用。只要我们希望变量从一个值平滑过渡到下一个值,我们就可以使用它们。价值动画师对游戏感兴趣的一些情况是:

  • 完成一个级别后添加奖励分数
  • 完成任务/击败对手后增加经验值
  • 命中后生命值降低/恢复药剂后生命值增加

一般来说,只要我们有想要平滑动画的值,就可以使用值动画师。您甚至可以使用自定义进度条来显示该值,并在ValueAnimator的回调上更新它。

如果想让安卓直接修改对象中的属性值,可以用PropertyAnimator代替ValueAnimator。对于视图的这种特殊情况,我们有一个名为ViewPropertyAnimator的特殊类,它比PropertyAnimator更容易使用和阅读,并且是专门为动画视图而设计的。

viewpertanimator

这种动画技术提供了一种简单的方法,可以使用单个底层Animator对象并行动画显示视图的多个属性。它还会修改视图属性的实际值。

使用ViewPropertyAnimator的一个缺点是限制更多。我们只能对视图的基本属性(位置、比例、阿尔法和旋转)进行动画制作,而使用PropertyAnimation我们几乎可以对任何东西进行动画制作。

提前值得一提的是,这种动画技术只需要一个动画的最终值。这意味着从视图的当前值开始动画。这意味着有时,您可能需要将视图初始化到初始位置。

因为这种类型的动画作为视图值的修改工作,一旦动画完成,动画视图将保持其最终状态。这使得它们对于益智类或棋盘类游戏非常有用。

动画结束后,使用ViewPropertyAnimator动画的视图停留在结束位置。

ViewPropertyAnimator使用两个概念获得绘制视图的坐标:位置和平移。您可以设置位置动画或平移动画。如果你打算只使用其中的一个,那也没什么区别。只要记住translateX的原点(也称为[0,0])在视图的当前位置,视图将在其位置和平移的矢量和处绘制。

驾驶宇宙飞船

要看到ViewPropertyAnimator的威力,我们要在主菜单中增加另一个动画。我们将乘坐我们用来逐帧显示动画的宇宙飞船,然后让它在屏幕上随机移动。

我认为这个动画太多了,让主菜单感觉太拥挤,所以我建议在最终游戏中删除它,但它仍然是框架工作方式的一个很好的例子。

因为每个动画都是从视图的前一个位置开始的,所以产生的效果是旧框架无法实现的。

让我们看看代码:

@Override
protected void onLayoutCompleted() {
  [...]
  animateShip();
}

private void animateShip() {
  View iv = getView().findViewById(R.id.ship_animated);
  // Get a random position on the screen
  Random r = new Random();
  int targetX = r.nextInt(getView().getWidth());
  int targetY = r.nextInt(getView().getHeight());
  // Animate
  iv.animate()
    .x(targetX)
    .y(targetY)
    .setDuration(500)
    .setInterpolator(new AccelerateDecelerateInterpolator())
    .setListener(new Animator.AnimatorListener() {
      @Override
      public void onAnimationEnd(Animator animation) {
        animateShip();
      }

      @Override
      public void onAnimationStart(Animator animation) {}

      @Override
      public void onAnimationCancel(Animator animation) {}

      @Override
      public void onAnimationRepeat(Animator animation) {}
    });
}

一旦布局完成,我们就调用animateShip,然后每次动画完成时,我们都会再次调用。

为了制作飞船的动画,我们获得想要制作动画的视图,然后使用Random和片段根视图的尺寸在屏幕上选择一个随机位置。

我们称之为 T2 风景。这会返回一个 ViewPropertyAnimator类型的对象。我们可以在这个对象上使用不同的方法来配置动画,并且每个方法都将再次返回该对象,因此它们可以被链接到一个非常容易阅读的代码中。

我们通过设置目标 xy 位置(我们根本没有接触平移)、选择持续时间和设置AccelerateDecelerateInterpolator 类型的插值器来配置动画。我们也在设置一个监听器,这样动画结束的时候我们会得到通知,我们可以调用animateShip来创建另一个。注意AnimationListener是一个接口,我们必须实现它的所有方法,即使我们不使用它们。

最后,我们可以调用start使动画立即开始,但这不是必须的。

我们总是使用相同的动画持续时间,所以有时船会比其他船移动得快得多。我们可以通过使用起点和终点之间的距离来计算动画的持续时间,从而使其具有恒定的速度。

检查在布局中的哪个位置添加了 ImageView很重要,因为 z 索引是由订单提供的。我建议你把船直接放在背景图像后面,所以它在标题和按钮后面。

激活主菜单

为了完成一章,我们将制作游戏标题和字幕的动画。为了比较安卓制作动画的不同可能性,我们将以三种不同的方式创建它们:用 XML 查看动画、用代码ViewPropertyAnimation和用 XML 创建对象动画师。

首先,我们将制作主标题的动画,使其从屏幕左侧进入中心的正常位置。我们将使用弹跳插值器使它看起来很有趣。

作为良好的实践,我们将在res/values 文件夹下使用名为integers.xml的文件将动画的开始偏移和持续时间外部化为整数:

<integer name="tittle_start_offset">400</integer>
<integer name="tittle_duration">1600</integer>

动画不会马上开始,让玩家有时间真正注意屏幕。我们有很长的持续时间,因为反弹插值器看起来不太好。

使用 XML 视图动画的第一个版本是这样定义的:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
  android:interpolator="@android:anim/bounce_interpolator">
  <translate
    android:startOffset="@integer/tittle_start_offset"
    android:fromXDelta="-100%p"
    android:toXDelta="0%p"
    android:repeatCount="0"
    android:duration="@integer/tittle_duration" />
</set>

我们使用与父视图相关的百分比作为原始增量,将视图完全移出屏幕。-100%p 表示左侧父宽度的 100%。

加载动画并启动它的代码要放在onLayoutCompleted里面,非常简单:

Animation titleAnimation = AnimationUtils.loadAnimation(getActivity(), R.animator.title_enter);
title.startAnimation(titleAnimation);

注意,动画被认为是我们一叫startAnimation就开始了。这意味着平移也在开始偏移期间被动画化,并被设置为动画的初始值。

对于视图动画师来说,开始偏移被认为是动画的一部分,在动画等待开始时,该值被设置为初始值。

让我们将这个定义与代码中ViewPropertyAnimator的定义进行比较:

View title = getView().findViewById(R.id.main_title);
title.setTranslationX(-getView().getWidth());

int duration = getResources().getInteger(R.integer.tittle_duration);
int startOffset = getResources().getInteger(R.integer.subtitle_start_offset);

title.animate()
  .translationX(0)
  .setStartDelay(startOffset)
  .setDuration(duration)
  .setInterpolator(new BounceInterpolator())
  .start();

因为持续时间和偏移被定义为整数,所以我们需要在运行动画之前获得它们。

由于视图最初被放置在我们希望它完成的位置,我们将制作平移动画并保持位置不变。

请注意,由于ViewPropertyAnimator只接收最终值作为参数,我们需要将其设置为屏幕外的默认初始位置。为此,我们使用视图的setTranslationX方法。通过这样做,翻译的最终值是 0。

最后,我们设置插值器并调用start。结果与前面的方法相同,但是正如您所看到的,过程中有一些显著的差异。

第三种方法是用 XML 将这个动画定义为对象动画师,然后在代码中加载和使用它。XML 定义如下:

<set xmlns:android="http://schemas.android.com/apk/res/android"
  android:interpolator="@android:anim/bounce_interpolator">
  <objectAnimator
    android:interpolator="@android:anim/bounce_interpolator"
    android:propertyName="translationX"
    android:valueTo="0"
    android:startOffset="@integer/tittle_start_offset"
    android:duration="@integer/tittle_duration" />
</set>

<objectAnimator>标签本身是通用的,并且使用propertyName作为属性的名称来使用反射进行修改。

定义动画后,我们必须加载并启动它:

title.setTranslationX(-title.getX()-title.getWidth());

AnimatorSet set = (AnimatorSet) AnimatorInflater.loadAnimator(getActivity(), R.animator.title_enter_property);
set.setTarget(title);
set.start();

就像前面的例子一样,我们需要对翻译进行初始化。虽然我们可以在 XML 中设置一个valueFrom,但是当我们定义 XML 时,我们不知道屏幕的大小,并且我们不能使用引用到父视图的值,所以我们必须用代码初始化它。

总之,这三个版本在概念上执行相同的动画,但是它们的定义方式略有不同。

让我们看另一个例子。对于字幕,我们将动画阿尔法,使其在标题动画完成后出现。为了使这个动画在前一个动画之后运行,我们可以使用开始延迟,或者我们可以为前一个动画设置一个监听器,并在这个动画结束时启动它。

使用监听器更精确,但是增加延迟要简单得多,所以我们将继续这样做。正如我们对标题动画所做的那样,我们将为持续时间和开始偏移定义一些整数,在这种情况下,它是标题动画的持续时间和开始偏移的总和:

<integer name="subtitle_start_offset">2000</integer>
<integer name="subtitle_duration">600</integer>

使用视图动画的 XML 是这样的:

<set xmlns:android="http://schemas.android.com/apk/res/android">
  <alpha android:fromAlpha="0.0"
    android:toAlpha="1.0"
    android:startOffset="@integer/subtitle_start_offset"
    android:duration="@integer/subtitle_duration"/>
</set>

运行它的代码也与我们刚刚看到的标题非常相似:

Animation subtitleAnimation = AnimationUtils.loadAnimation(context, R.animator.subtitle_enter);
subtitle.startAnimation(subtitleAnimation);

同样,startOffset被认为是动画的一部分的事实非常方便,因为它允许我们在前 2000 毫秒将 alpha 设置为 0,而无需触摸视图。

我们来对比一下和ViewPropertyAnimation:

View subtitle = getView().findViewById(R.id.main_subtitle);
subtitle.setAlpha(0);

int subtitleDuration = getResources().getInteger(R.integer.subtitle_duration);
int subtitleStartOffset = getResources().getInteger(R.integer.subtitle_start_offset);

subtitle.animate()
  .alpha(1)
  .setDuration(subtitleDuration)
  .setStartDelay(subtitleStartOffset)
  .setInterpolator(new DecelerateInterpolator())
  .start();

同样,与前一个非常相似,我们需要初始化视图中 alpha 的值。我们还需要加载整数值,以便在配置中使用它们。

最后,同样的动画在 XML 中被定义为PropertyAnimation:

<set xmlns:android="http://schemas.android.com/apk/res/android">
<objectAnimator
  android:interpolator="@android:anim/decelerate_interpolator"
  android:propertyName="alpha" 
  android:valueFrom="0"
  android:valueTo="1"
  android:startOffset="@integer/subtitle_start_offset"
  android:duration="@integer/subtitle_duration" />
</set>

然后,它被加载并分配给字幕视图:

subtitle.setAlpha(0);
AnimatorSet set = (AnimatorSet) AnimatorInflater.loadAnimator(context, R.animator.fade_in_property);
set.setTarget(subtitle);
set.start();

在这种情况下,我们还需要将 alpha 预设为 0,因为对于PropertyAnimator来说,开始偏移不被认为是动画的一部分。如果我们不设置它,它将保持设置为 1,直到动画开始运行。

一般没有银子弹,每种类型的动画针对不同的情况比较好。虽然使用简单的视觉效果使游戏看起来更好,但视图动画师通常就足够了,也更容易设置。如果视图将是交互式的,并且您使用动画来翻译游戏的元素,那么PropertyAnimation(或其任何变体)是唯一的方法。

总结

我们已经学习了如何制作逐帧动画,以及如何使用安卓提供的两种不同框架制作视图动画,视图动画和PropertyAnimation

我们研究了它们之间的差异和局限性,并了解了何时使用其中一个。

我们已经激活了对话框和主菜单。对于标题,我们已经看到了如何用不同的方法获得相同的结果。

总而言之,游戏看起来流畅多了,因为现在很多内容都是动画的。

我们可以说游戏结束了,但是我们会添加更多的功能,让它更有趣。谷歌为我们提供了一个管理成绩和排行榜的 API。这两个功能都是谷歌 Play 服务的一部分,这也是我们接下来要做的!