Chapter 1

Hungry Burds: A Simple Android Game

IN THIS CHAPTER

check Coding an Android Game

check Using Android animation

check Creating random play

What started as a simple pun involving an author's last name has turned into this minibook’s Chapter 1 — the most self-indulgent writing in the history of technical publishing. This chapter describes a very simple Android game. The goal of the game is to feed cheeseburgers to Barry Burd. You feed Barry by tapping on the screen.

In case you're wondering, the name Burd has no etymological connection with animals that fly. According to one theory, the name Burd comes from the Russian word boroda, which means beard. Another conjecture ties it to longer Russian surnames such as Burdinsky and Burdstakovich. U.S. census data reveals that roughly 2 people in 100,000 have the last name Burd. Compare that with 11 in 100,000 who spell their names Bird, and a whopping 32 in 100,000 for Byrd.

Introducing the Hungry Burds Game

When the game begins, the screen is mostly blank. Then, for a random amount of time (ranging from a quarter of a second to a whole second), a Burd image fades in and out of view, as shown in Figure 1-1.

Screenshot for playing Hungry Burds game. A  new cheeseburger shows up at the top of the screen next to Your Score, and a row labeled High Score shows the largest number of cheeseburgers eaten during any round of play.

FIGURE 1-1: Playing Hungry Burds.

If the user touches the Burd before it fades away, the app feeds Barry by displaying a tiny cheeseburger. The new cheeseburger shows up at the top of the screen next to the words Your Score. In the meantime, a row labeled High Score shows the largest number of cheeseburgers eaten during any round of play. (Refer to Figure 1-1.)

A PLAY AGAIN button sits quietly at the bottom of the screen. When the game ends, the PLAY AGAIN button comes alive, and the user can enjoy another fun-filled round of Hungry Burds.

Warning For many apps, timing isn't vitally important: For them, a consistently slow response is annoying but not disabling. But for a game like Hungry Burds, timing makes a big difference. Running Hungry Burds on a slow emulator feels more like a waiting game than an action game. If your emulator is slow, run the app on a real-life device.

The Hungry Burds Kotlin code is about 200 lines long. As game programs go, that's miniscule. But this chapter's example can't be dozens of pages long. That's why many features that you see in a realistically engineered game are missing. Here are a few of them:

  • The Hungry Burds game doesn't access data over a network. The game's High Score leaderboard doesn't tell you how well you did compared with your friends or with other players around the world. The leaderboard applies to only one device — the one you're using to play the game.

    Google Play Game Services is a collection of network-enabled tools to help you add leaderboards, achievements, and multiplayer features to your game app. Game Services belongs to a category of things called back-end services. When you publish an app, you can sign up for one or more of these back-end services. For information about publishing your app, visit Book 6.

  • The game runs only in portrait mode. In the app's AndroidManifest.xml file, the activity element has an android:screenOrientation="portrait" attribute. This keeps the app in portrait mode and saves everyone from having to worry about orientation changes. Android Studio marks this attribute as an error because professional developers frown on such restrictions. But the code runs, and limiting play to portrait mode keeps this chapter's example reasonable in size.

    Tip If your app's AndroidManifest.xml file has an android:screenOrientation="portrait" attribute, the file should also contain a <uses-feature android:name="android.hardware.screen.portrait"/> element. For details about the uses-feature element, see Book 5, Chapter 2.

  • The screen measurements that control the game are crude. Creating a visual app that involves drawing, custom images, or motion of any kind involves some math. You need math to make measurements, estimate distances, detect collisions, and complete other tasks. To do the math, you produce numbers by making Android API calls, and you use the results of your calculations in Android API library calls.

    Hungry Burds does only a minimal amount of math, and makes no API calls that aren't absolutely necessary. As a result, some items on the screen don't always look their best.

  • The game has no settings. The number of Burds displayed, the duration of each Burd's display, and the size of each Burd's display are all hard-coded in the game's Kotlin file. The constants' names are numberOfBurds, maximumShowTime, minimumShowTime, maximumImageEdge, and minimumImageEdge. As a developer, you can change the values in the code and reinstall the game. But the ordinary player can't change these numbers.
  • The game may not be challenging with the default constants. Is this good news? After playing Hungry Burds a few times, you can play it over and over again and always win. Of course, real games don't work that way. A real game presents some sort of a challenge. Otherwise, the winner gets no sense of achievement.

    If you want Hungry Burds to be challenging, dig into the code and change the values of constants such as maximumShowTime and minimumImageEdge. Experiment to find values that make the game interesting. And, if there are no such values, don't be disappointed. Hungry Burds is a learning tool, not a replacement for Grand Theft Auto.

The Hungry Burds Project's Files

The project's build.grade file is nothing special. The only thing you have to watch for is minSdkVersion. The minSdkVersion has to be 21 or higher. That's because the Kotlin code calls the SoundPool.Builder class's constructor and the View class's generateViewId method. Those features aren't available in Android API levels below 21.

The project's activity_main.xml file is in Listing 1-1. The file's only noteworthy items are two of the PLAY AGAIN button's attributes. The button starts off being disabled and unclickable. That way, when the game starts, the user can't press PLAY AGAIN. Later, when a round of play ends, the app's Kotlin code enables the PLAY AGAIN button.

LISTING 1-1: The Main Activity's Layout File

<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/screenLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<LinearLayout
android:id="@+id/highScoreRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="20dp"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">

<TextView
android:id="@+id/highScoreTextView"
style="@android:style/TextAppearance.Medium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:text="@string/high_score"/>

<LinearLayout
android:id="@+id/highScoreBurgersLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center_vertical"
android:layout_marginStart="10dp"
android:layout_marginLeft="10dp"
android:orientation="horizontal"/>
</LinearLayout>

<LinearLayout
android:id="@+id/yourScoreRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="20dp"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/highScoreRow">

<TextView
android:id="@+id/yourScoreTextView"
style="@android:style/TextAppearance.Medium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:text="@string/your_score"/>

<LinearLayout
android:id="@+id/yourScoreBurgersLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center_vertical"
android:layout_marginStart="10dp"
android:layout_marginLeft="10dp"
android:orientation="horizontal"/>
</LinearLayout>

<Button
android:id="@+id/playAgainButton"
style="@android:style/TextAppearance.Medium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="false"
android:enabled="false"
android:onClick="playAgain"
android:text="@string/play_again"
app:layout_constraintBottom:toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

The res/drawable directory of the Hungry Burds project has two files: burd.png and burger.png. The burd.png file is a picture of Barry. The burger.png file is a tiny picture of a cheeseburger. When the game runs, the Kotlin code adds ImageView objects containing these .png files to various layouts within activity_main.xml.

The Main Activity

The Hungry Burds game has only one activity: the app's main activity. So you can digest the game's Kotlin code in its entirety in one big gulp. To make this gulp palatable, Listing 1-2 has an outline of the main activity's code. (If outlines don't work for you and you want to examine the code in its entirety, see Listing 1-3.)

LISTING 1-2: An Outline of the App's Kotlin Code

package com.allmycode.hungryburds

// Import classes.

class MainActivity : AppCompatActivity(), View.OnTouchListener {

// Declare variables.

public override fun onCreate(savedInstanceState: Bundle?) {
// Get the size of the device's screen.
// Get the high score from the last run.
// Display the high score (a number of cheeseburgers).
// Prepare a sound to make when the user touches a Burd.
}

override fun onStart() {
showABurd()
}

private fun showABurd() {
// Add a Burd image of random size at some random
// place on the screen. (At first, the Burd is
// invisible because it's transparent.)
// Make this activity be the Burd image's onTouch listener.
// Create an animation to make the Burd fade in and then fade out.
// Start the animation.
}

override fun onTouch(view: View?, event: MotionEvent?): Boolean {
// Remove the Burd image's touch listener
// so that a second touch has no effect.
// Display a new cheeseburger image in the Your Score row.
// Play a sound.
}

private fun getListenerFor(view : ImageView) : AnimatorListener {
override fun onAnimationEnd(animation: Animator?) {
// If the count of Burd images shown is less than 10,
showABurd() // Again!
// Otherwise,
// save the high score and enable the PLAY AGAIN button.
}
}
}

The heart of the Hungry Burds code is the code's game loop, as shown in Figure 1-2.

When Android executes the onStart method, the code calls the showABurd method. The showABurd method does what its name suggests by animating an image from alpha level 0 to alpha level 1 and then back to alpha level 0. (Alpha level 0 is fully transparent; alpha level 1 is fully opaque.)

When the animation ends, the onAnimationEnd method checks the number of Burds that have already been displayed. If the number is less than ten, the onAnimationEnd method calls showABurd again, and the game loop continues. Otherwise, the game ends with a newly enabled PLAY AGAIN button.

Illustration of the code's game loop depicting one Burd after another. When Android executes the onStart method, the code calls the showABurd method.

FIGURE 1-2: Showing one Burd after another.

The main activity implements OnTouchListener. When the user touches a Burd, the activity's onTouch method adds a cheeseburger to the Your Score display, as shown in the following snippet:

override fun onTouch(view: View?, event: MotionEvent?): Boolean {
view?.setOnTouchListener(null)
addBurgerTo(yourScoreBurgersLayout)
soundPool.play(soundId, 1f, 1f, 0, 0, 1f)
countTouched++
return true
}

The Code, All the Code, and Nothing But the Code

Following the basic outline of the game's code in the previous section, Listing 1-3 contains the entire text of the game's MainActivity.kt file.

LISTING 1-3: The App's Kotlin Code

package com.allmycode.hungryburds

import android.animation.Animator
import android.animation.Animator.AnimatorListener
import android.animation.AnimatorInflater
import android.animation.AnimatorSet
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.media.AudioAttributes
import android.media.SoundPool
import android.os.Bundle
import android.util.Size
import android.view.MotionEvent
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import kotlinx.android.synthetic.main.activity_main.*
import java.util.Random


class MainActivity : AppCompatActivity(), View.OnTouchListener {
private val numberOfBurds = 10
private val maximumShowTime = 1000L
private val minimumShowTime = 250L
private val maximumImageEdge = 200
private val minimumImageEdge = 50

private var countShown = 0
private var countTouched = 0
private var highScore = 0
private var random = Random()

private lateinit var displaySize: Size

private lateinit var prefs: SharedPreferences

private lateinit var soundPool: SoundPool
private var soundId = 0

private var isStarted = false

public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

val metrics = resources.displayMetrics
displaySize = Size(metrics.widthPixels, metrics.heightPixels)

prefs = getPreferences(Context.MODE_PRIVATE)
highScore = prefs.getInt("highScore", 0)
displayHighScore()

prepareSound()
}

override fun onStart() {
super.onStart()
isStarted = true
showABurd()
}

private fun displayHighScore() {
for (i in 1..highScore) {
addBurgerTo(highScoreBurgersLayout)
}
}

private fun addBurgerTo(layout: LinearLayout) {
layout.addView(ImageView(this).apply {
setImageResource(R.drawable.burger)
})
}

private fun prepareSound() {
soundPool = SoundPool.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setContentType(
AudioAttributes.CONTENT_TYPE_SONIFICATION
)
.setUsage(AudioAttributes.USAGE_GAME)
.build()
).build()
soundId = soundPool.load(this, R.raw.beep, 1)
}

private fun showABurd() {
val duration = (random.nextInt((maximumShowTime
- minimumShowTime).toInt()) + minimumShowTime)
val imageEdgeSize = (random.nextInt(maximumImageEdge
- minimumImageEdge) + minimumImageEdge)
val burd = ImageView(this).also {
it.setImageResource(R.drawable.burd)
it.setOnTouchListener(this)
it.layoutParams = ConstraintLayout.LayoutParams(
imageEdgeSize, imageEdgeSize
)
}
addToScreen(burd)
val animatorSet = AnimatorInflater.loadAnimator(
this,
R.animator.fade_in_out
) as AnimatorSet
animatorSet.also {
it.addListener(getListenerFor(burd))
it.duration = duration
it.setTarget(burd)
it.start()
}
}

private fun addToScreen(view: View) {
view.id = View.generateViewId()
screenLayout.addView(view, 0)
ConstraintSet().apply {
clone(screenLayout)
connect(
view.id,
ConstraintSet.LEFT,
screenLayout.id,
ConstraintSet.LEFT,
random.nextInt(displaySize.width) * 7 / 8
)
connect(
view.id,
ConstraintSet.TOP,
screenLayout.id,
ConstraintSet.TOP,
random.nextInt(displaySize.height) * 4 / 5
)
applyTo(screenLayout)
}
}

@SuppressLint("ClickableViewAccessibility")
override fun onTouch(view: View?, event: MotionEvent?): Boolean {
view?.setOnTouchListener(null)
addBurgerTo(yourScoreBurgersLayout)
soundPool.play(soundId, 1f, 1f, 0, 0, 1f)
countTouched++
return true
}

private fun getListenerFor(view: ImageView): AnimatorListener {
return object : AnimatorListener {
override fun onAnimationStart(animation: Animator?) {
}

override fun onAnimationRepeat(animation: Animator?) {
}

override fun onAnimationCancel(animation: Animator?) {
}

override fun onAnimationEnd(animation: Animator?) {
screenLayout.removeView(view)
if (isStarted) {
if (++countShown < numberOfBurds) {
showABurd()
} else {
saveHighScore()
playAgainButton.isEnabled = true
playAgainButton.isClickable = true
}
}
}
}
}

private fun saveHighScore() {
if (countTouched > highScore) {
highScore = countTouched
val editor = prefs.edit()
editor.putInt("highScore", highScore)
editor.apply()
}
}

fun playAgain(view: View) {
playAgainButton.isEnabled = false
playAgainButton.isClickable = false
countShown = 0
countTouched = 0
highScoreBurgersLayout.removeAllViews()
displayHighScore()
yourScoreBurgersLayout.removeAllViews()
showABurd()
}

override fun onStop() {
super.onStop()
isStarted = false
}
}

Setting Up the Game

The game's one and only activity begins the way most Android activities begin. First, you have some property declarations, and then you have an onCreate method. This section explores that introductory code.

Declaring properties

The activity's properties are used throughout the game's run. As usual, you use val rather than var to declare any property whose value doesn't change.

In Listing 1-3, notice the use of the word lateinit. You can probably guess what that's about. It's about late (rather than early) initialization. Here's the story:

Kotlin is fussy about initializing properties. Other languages may let you get away with something, like this:

// Bad code:
var total : Int

// and later…

total += nextValue

But Kotlin will have none of it. If you haven't given total an initial value, you shouldn't be adding something to total.

Sometimes, Kotlin's insistence on initializing properties makes things difficult. A property declaration goes outside any method declarations, so Android evaluates these declarations before calling onCreate. The problem is, some values aren't available before Android calls onCreate. Take, for example, the prefs property in Listing 1-3, shown previously. This property has type SharedPreferences, but you can't call getSharedPreferences until you've started running onCreate. What's a programmer to do?

In Listing 1-3, Kotlin's lateinit keyword puts the initialization of prefs on the back burner. The lateinit keyword tells the compiler to be patient and wait for the prefs property to get a value. “I know what I'm doing,” says the lateinit programmer, “and I promise to assign a value to prefs before I call the prefs object's getInt method.” And Kotlin responds by saying, “Okay. I trust you.”

Similar issues apply to the game's displaySize and soundPool properties. So, in Listing 1-3, the declarations of these properties use lateinit.

You can read more about SharedPreferences in Book 3, Chapter 4.

Technical Stuff In the app's prepareSound method, the value of soundId depends on soundPool, so you'd expect the soundId declaration to use lateinit. But Kotlin doesn't permit the use of lateinit on primitive types such as Int. Some blog posts suggest exotic workarounds, but initializing soundId to 0 works nicely.

The onCreate Method

To begin a run of Hungry Burds, Listing 1-3 gets the device's screen size. The code also gets the highest score from the device's previous Hungry Burds runs. To get the highest score, Listing 1-3 uses Android's SharedPreferences class.

In Book 3, Chapter 4, you can read about Android's MediaPlayer class. The MediaPlayer works well for playing songs and videos, but it's not good for brief sound bursts — things like beeps, thunderclaps, happy “you win” rings, or discouraging “bah-waahhh” horns. For such momentary sounds, you use the SoundPool class. The sidebar explains why.

Starting with API level 21, you create a SoundPool by making a SoundPool.Builder and chaining methods one after another until you reach the build method. On your way to the build method, the setAudioAttributes method uses its own AudioAttributes.Builder class. By the time you're done with all the build calls, you have a real, live SoundPool instance.

But wait! There's another step. When you populate the SoundPool instance with an actual sound file resource, you get an Int value that identifies the sound. Listing 1-3 puts this value in its soundId variable. When the user taps a Burd, the app calls the SoundPool instance's play method and passes that soundId as a parameter. Sure enough, the sound file bursts from the device's speakers.

Displaying a Burd

In Listing 1-3, the last statement in the onStart method is a call to the showABurd method. The next few sections describe how the app goes about showing a Burd.

Creating random values

A typical game involves random choices. (You don't want Burds to appear in the same places every time you play the game.) Truly random values are difficult to generate. But an instance of the java.util.Random class creates what appear to be random values (pseudorandom values) in ways that the programmer can help determine.

For example, a Random object's nextDouble method returns a double value between 0.0 and 1.0 (with 0.0 being possible but 1.0 being impossible). The Hungry Burds code uses a Random object's nextInt method. A call to nextInt(10) returns an int value from 0 to 9.

If displaySize.width is 720 (which stands for 720 pixels), the call to random.nextInt(displaySize.width) in Listing 1-3 returns a value from 0 to 719. And because maximumShowTime differs from minimumShowTime by the Long value 750L, the expression

random.nextInt((maximumShowTime - minimumShowTime).toInt())

stands for a value from 0 to 749. (The toInt call fulfills the promise that the nextInt method's parameter is an Int, not a Long value.) By adding back minimumShowTime (in Listing 1-3), you make duration a number between 250 and 999. The same kind of calculation sets imageEdgeSize to a number between 50 and 200. The imageEdgeSize is the height and width of the next Burd image being displayed.

Creating a Burd

Android's ImageView class represents objects that contain images. You put an image file (a .png file, a .jpg file, or a .gif file) in your project's res/drawable directory. In the Kotlin code, a call to the ImageView object's setImageResource method associates the ImageView object with the .png, .jpg, or .gif file. Consider the following lines in Listing 1-3:

val burd = ImageView(this).also {
it.setImageResource(R.drawable.burd)
it.setOnTouchListener(this)
it.layoutParams = ConstraintLayout.LayoutParams(
imageEdgeSize, imageEdgeSize
)
}

This code uses Kotlin's also function along with the special it identifier. In Kotlin, also, apply, and let are scope functions. A scope function allows you to apply several methods to an object without repeating the object's name before each method application. Inside an also block, the word it refers to the block's target object (the object that comes immediately before .also in the code). If you don't like the way Listing 1-3 creates a new ImageView instance, you can replace the also code with the following four statements:

val burd = ImageView(this)
burd.setImageResource(R.drawable.burd)
burd.setOnTouchListener(this)
burd.layoutParams =
ConstraintLayout.LayoutParams(imageEdgeSize, imageEdgeSize)

The also version is nice because also puts the creation of the new ImageView into a tidy code bundle. One glance at the also block makes it clear that the code sets a particular object's properties. Here's what happens inside the ImageView instance's also block:

  • The call to setImageResource associates the app/res/drawable/burd.png file with this ImageView instance.
  • The call to setOnTouchListener tells Android that this main activity handles the image view's touch events. You can read more about that in the “Handling a Touch Event” section, later in this chapter.
  • The assignment to the image view's layoutParams property tells Android how large the view should be. You've already used Random to set the value of imageEdgeSize. You want to apply that imageEdgeSize to your brand new Burd image. You don't simply say “Make each image's edges be imageEdgeSize pixels long.” Instead, you say “In a ConstraintLayout, make each image's edges be imageEdgeSize pixels long.” Each of Android's LayoutParams classes are the inner classes of one layout or another. There's ConstraintLayout.LayoutParams, LinearLayout.LayoutParams, ViewGroup.LayoutParams, and a bunch of others.

Placing a Burd on the constraint layout

In Listing 1-3, the addToScreen method does what its name suggests: It puts a new view on the device's screen. Adding a view to a particular place in a constraint layout involves several steps:

  1. Give the view an id.

    In a layout's XML file, you can write

    android:id="@+id/newBurd"

    But you can't do that in the middle of a Kotlin file. Instead, you call the View class's generateViewId method.

  2. Add the view to the constraint layout.

    By default, the new view goes in one of the layout's two upper corners. Which corner gets the view depends on a device's language setting. For most languages, it's the upper-left corner. But for right-to-left languages, it's the upper-right corner.

  3. Get a constraint set.

    To reposition your new view in the activity's constraint layout, you make an instance of Android's ConstraintSet class. In Listing 1-3, you start with a clone of a ConstraintSet from the activity's own constraint layout. You position the new view by making changes to this clone.

  4. Connect new constraints to the constraint set.

    The code in Listing 1-3 connects two constraints to the constraint set. The first constraint sets the distance between the new view's left edge and the screen's left edge. The second constraint sets the distance between the top of the view and the top of the screen. Figure 1-3 shows you the meanings of the connect method's parameters.

    Illustration for positioning a view in a constraint set. The first constraint sets the distance between the new view’s left edge and the screen’s left edge; the second constraint sets the distance between the top of the view and the top of the screen.

    FIGURE 1-3: Positioning a view in a constraint set.

    Listing 1-3 generates values randomly to determine the position of a new Burd. A Burd's left edge is no farther than ⅞ of the way across the screen, and the Burd's top edge is no lower than ⅘ of the way down the screen. If you don't multiply the screen's width by ⅞ (or some such fraction), an entire Burd can be positioned beyond the right edge of the screen. The user sees nothing while the Burd comes and goes. The same kind of thing can happen if you don't multiply the screen's height by ⅘.

    Technical Stuff The fractions ⅞ and ⅘, which help determine each new Burd's position, are crude guesstimates of a portrait screen's requirements. A more refined app would carefully measure the available turf and calculate the optimally sized region for positioning new Burds.

  5. Apply the constraint set to the app's screen.

    In Listing 1-3, the constraint set's applyTo method takes care of that step.

Animating a Burd

Android has two types of animation:

  • View animation: An older system in which you animate with either tweening or frame-by-frame animation, as described in this list:

    • Tweening: You tell Android how an object should look initially and how the object should look eventually. You also tell Android how to change from the initial appearance to the eventual appearance. (Is the change gradual or sudden? If the object moves, does it move in a straight line or in a curve of some sort? Will it bounce a bit when it reaches the end of its path?)

      With tweening, Android considers all your requirements and figures out exactly how the object looks between the start and the finish of the object's animation.

    • Frame-by-frame animation: You provide several snapshots of the object along its path. Android displays these snapshots in rapid succession, one after another, giving the appearance of movement or of another change in the object's appearance.

      The animation-list example in Book 3, Chapter 5 is an example of frame animation.

    View animation has two significant shortcomings. First, view animation doesn't apply to things that aren't views. With view animation, you can change the position of a bouncing ball, but you can't change the price of bananas or the value of one dollar in euros. Those things don't extend the android.view.View class.

    In addition, view animation doesn't make lasting changes to a view's values. You can make a view appear to move, but the view doesn't really move. Imagine view animating a button from a corner of the screen to the center. The button seems to be in the center but, when the user taps the center of the screen, nothing happens. On the other hand, if the user taps the original corner, the button responds. That's pretty strange!

    So, what's better than view animation?

  • Property animation: A newer system (introduced in Android 3.0, API Level 11) in which you can modify any property of an object over a period of time.

    With property animation, you can change anything about any kind of object, whether the object appears on the device's screen or not. For example, you can increase an earth object's average temperature from 15° Celsius to 18° Celsius over a period of ten minutes. Rather than display the earth object, you can watch the way average temperature affects water levels and plant life.

    Unlike view animation, the use of property animation changes the value stored in an object's field. For example, you can use property animation to change a widget from being invisible to being visible. When the property animation finishes, the widget remains visible.

To make Burd images fade in and out, the Hungry Burds app uses property animation. A file named fade_in_out.xml sits quietly in the project's app/res/animator folder. Listing 1-4 has the code.

LISTING 1-4: An Animator Resource File

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:ordering="sequentially">
<objectAnimator
android:propertyName="alpha"
android:valueFrom="0.0"
android:valueTo="1.0"
android:valueType="floatType"/>
<objectAnimator
android:propertyName="alpha"
android:valueFrom="1.0"
android:valueTo="0.0"
android:valueType="floatType"/>
</set>

The file in Listing 1-4 describes an animator set. The set contains two objectAnimator elements — one for fading in and the other for fading out. The fade-in animator brings an object from alpha level 0.0 (complete transparency) to alpha level 1.0 (complete opacity). The fade-out animator does the opposite.

Warning If you create an animator set and you don't specify an android:ordering="sequentially" attribute, the set defaults to android:ordering="together". In Listing 1-4, removing that attribute would make Android play the fade-in and fade-out animators simultaneously. If you were a For Dummies author and you were testing your code, you'd see each image pop quickly into view and then disappear slowly. You'd wonder why this was happening, and you'd waste a lot of time trying to figure it out. When you finally found the strange behavior's cause, you'd feel pretty foolish, but you'd remind yourself that everyone makes mistakes. That's exactly what would happen.

In Android, inflation has nothing to do with the price of oil or the air in a balloon. Inflation is the way you pull information into your Kotlin code from an XML file. In Listing 1-3, the showABurd method inflates the fade_in_out.xml file. The next few lines apply a listener for the animation, set the animation's duration to a randomly generated value, and link the animation to a new Burd image. Last, but not least, the code calls start.

Handling a Touch Event

Listing 1-3, earlier in the chapter, contains the following code:

val burd = ImageView(this).also {
it.setImageResource(R.drawable.burd)
it.setOnTouchListener(this)
// … Etc.

The main activity listens for the user to touch the Burd image. What's a touch, and how does it differ from a click?

When you click a button, you generate more than one event. Pressing down on the button is a touch event. Lifting your finger up and off the button is another touch event. If you slide your finger along the surface of the button, those are even more touch events. A click isn't a simple thing. A click is a combination of touch events. In game development, the time it takes to respond to a click might as well be an eon. If you program Listing 1-3 to listen for click events, half the user's taps go unnoticed. That's why the main activity isn't an OnClickListener. Instead, the header of the MainActivity class's declaration makes that class implement the OnTouchListener interface.

When the user touches a Burd, Android calls the onTouch method in Listing 1-3. The onTouch method does a number of interesting things:

  • The onTouch method tells Android to stop listening for touch events on that Burd. You don't want the user to get six points for sliding a finger along a Burd image. After one touch to a particular image, that Burd is cooked!
  • The onTouch method updates the user's score. Calling addBurgerTo puts a tiny picture of a burger next to the app's Your Score text view.
  • The onTouch method plays a victory sound. Having prepared the soundPool object in onCreate, the code calls that object's play method. The play method's parameters are as follows:

    play(which_sound, leftVolume, rightVolume, priority, loop, rate)

    • The first parameter is the soundId — a value that the prepareSound method in Listing 1-3 creates.
    • The next two Float values (both 1f) play the sound at 100 percent of the device's current volume level.

      If both values were 0f, you'd hear nothing at all.

    • Higher-priority sounds take precedence over lower-priority sounds but, in Listing 1-3, you have only one sound. So, the lowest possible priority (namely, 0) is okay.
    • The loop parameter tells Android how many times to replay the sound, with 0 meaning “don't replay.” The special loop value –1 would mean “repeat indefinitely.” (Hitting the Android device with a hammer would certainly override that directive.)
    • For the play method's last parameter, the value 1f represents normal speed. You can set this value as low as 0.5 (for half speed) or as high as 2.0 (for double speed).
  • The onTouch method increments the count of successful Burd image touches. (There's nothing particularly interesting about this, so just march on and read the next bullet.)
  • The onTouch method returns true. The value true tells Android that this touch event has been handled. A return value of false would tell Android to pass the buck to some other code that can handle the image's touch event.

In Listing 1-3, a SuppressLint line comes right before the onTouch method's declaration. When you override onTouch, you run the risk of squelching some of Android's accessibility features. A program named Lint runs in the background to warn you about such things. Normally, Lint would recommend that you fire your own click event inside your onTouch method's body, but the listing's @SuppressLint("ClickableViewAccessibility") annotation tells Lint to ignore the ClickableViewAccessibility issue.

Tip A better version of the Hungry Burds game would heed Lint's warning. For more details, visit https://developer.android.com/guide/topics/ui/accessibility/custom-views.html#custom-touch-events.

Finishing Up

In Listing 1-3, the getListenerFor method creates an object — one that responds to the animating of a Burd image. This AnimatorListener object contains bodies for the methods onAnimationStart, onAnimationRepeat, onAnimationCancel, and onAnimationEnd. Nothing happens in three of the four methods, and that's okay.

The onAnimationEnd method checks the count of Burds that have already been shown. If that count is less than numberOfBurds, the onAnimationEnd method calls showABurd again, and the game loop continues. If not, the method calls saveHighScore to update the app's shared preferences. The method also enables the PLAY AGAIN button, whose listener can start another round of play.

What happens if something interrupts a round of play? What if a phone call comes in? What if the user taps Android's Home button or swipes up from the bottom of the screen? If you're not careful, the game keeps running without anyone playing it. In the worst case, you have two copies of the game playing simultaneously, both displaying Burd images on the same white background.

To keep these weird things from happening, Listing 1-3 overrides the onStart and onStop methods. Android calls onStop when an activity ceases to be visible, but calling onStop doesn't automatically end the app's game loop. (In fact, calling onDestroy wouldn't automatically end the game loop! How about that?)

To block the relentless appearance of Burd images, the onStop method in Listing 1-3 sets isStarted to false. At that point, an if statement in the onAnimationEnd method refuses to call showABurd, and the repeated display of a For Dummies author's face comes to a screeching halt.

But wait! The user can end the phone call and return to the Hungry Burds game. If that happens, the onStart method in Listing 1-3 sets isStarted to true and calls showABurd. In that case, Hungry Burds takes up right where it left off. Play on, fair user!

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
35.171.45.182