Chapter 1
IN THIS CHAPTER
Coding an Android Game
Using Android animation
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.
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.
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.
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.
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.
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 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 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.
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
}
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
}
}
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.
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.
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.
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.
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.
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:
setImageResource
associates the app/res/drawable/burd.png
file with this ImageView
instance.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.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.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:
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.
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.
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.
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.
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 ⅘.
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.
Apply the constraint set to the app's screen.
In Listing 1-3, the constraint set's applyTo
method takes care of that step.
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.
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
.
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:
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!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.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)
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.
0
) is okay.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).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.)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.
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!
35.171.45.182