In this React-Native sound and animation tutorial, you’ll learn tips on how you can add animation and sound effects to your mobile application. We’ll also discuss topics like persisting data with React-Native AsyncStorage.
To showcase how you can do these things, we’ll use our Mobile Game which we’ve been building in the previous 4 episodes of this tutorial series.
- Part I: Getting Started with React Native – intro, key concepts & setting up our developer environment
- Part II: Building our Home Screen – splitting index.js & styles.js, creating the app header, and so on..
- Part III: Creating the Main Game Logic + Grid – creating multiple screens, type checking with prop-types, generating our flex grid
- Part IV: Bottom Bar & Responsible Layout – also, making our game pausable and adding a way to lose!
- Part V: You’re reading it!
- Part VI: React-Native Testing with Expo, Unit Testing with Jest
Quick recap: In the previous episodes of our React-Native Tutorial Series, we built our React-Native game’s core: you can finally collect points, see them, and even lose.
Now let’s spice things up and make our game enjoyable with music, react native animations & sound effects, then finish off by saving the high score!
Adding Sound to our React-Native Game
As you may have noticed, we have a /music
and /sfx
directory in the assets, but we didn’t quite touch them until now. They are not mine, so let’s just give credit to the creators: the sound effects can be found here, and the music we’ll use are made by Komiku.
We will use the Expo’s built-in Audio API to work with music. We’ll start by working in the Home/index.js
to add the main menu theme.
First off, import the Audio API from the ExpoKit:
import { Audio } from 'expo';
Then import the music and start playing it in the componentWillMount()
:
async componentWillMount() {
this.backgroundMusic = new Audio.Sound();
try {
await this.backgroundMusic.loadAsync(
require("../../assets/music/Komiku_Mushrooms.mp3")
);
await this.backgroundMusic.setIsLoopingAsync(true);
await this.backgroundMusic.playAsync();
// Your sound is playing!
} catch (error) {
// An error occurred!
}
This will load the music, set it to be a loop and start playing it asynchronously.
If an error happens, you can handle it in the catch
section – maybe notify the user, console.log()
it or call your crash analytics tool. You can read more about how the Audio API works in the background in the related Expo docs.
In the onPlayPress
, simply add one line before the navigation:
this.backgroundMusic.stopAsync();
If you don’t stop the music when you route to another screen, the music will continue to play on the next screen, too.
Speaking of other screens, let’s add some background music to the Game screen too, with the same steps, but with the file ../../assets/music/Komiku_BattleOfPogs.mp3
.
Spicing Things up with SFX
Along with the music, sound effects also play a vital part in making the game fun. We’ll have one sound effect on the main menu (button tap), and six on the game screen (button tap, tile tap – correct/wrong, pause in/out, lose).
Let’s start with the main menu SFX, and from there, you’ll be able to add the remaining to the game screen by yourself (I hope).
We only need a few lines of code to define a buttonFX object that is an instance of the Audio.Sound()
, and load the sound file in the same try-catch block as the background music:
async componentWillMount() {
this.backgroundMusic = new Audio.Sound();
this.buttonFX = new Audio.Sound();
try {
await this.backgroundMusic.loadAsync(
require("../../assets/music/Komiku_Mushrooms.mp3")
);
await this.buttonFX.loadAsync(
require("../../assets/sfx/button.wav")
);
...
You only need one line of code to play the sound effect. On the top of the onPlayPress
event handler, add the following:
onPlayPress = () => {
this.buttonFX.replayAsync();
...
Notice how I used replayAsync
instead of playAsync
– it’s because we may use this sound effect more than one time, and if you use playAsync
and run it multiple times, it will only play the sound for the first time. It will come in handy later, and it’s also useful for continuing with the Game screen.
It’s easy as one, two, three! Now, do the six sound effects on the game screen by yourself:
- Button tap
../../assets/sfx/button.wav
- Play it when pressing the Exit button
- Tile tap – correct
../../assets/sfx/tile_tap.wav
- Play it in the
onTilePress
/good tile
block
- Tile tap – wrong
../../assets/sfx/tile_wrong.wav
- Play it in the
onTilePress
/wrong tile
block
- Pause – in
../../assets/sfx/pause_in.wav
- Play it in the
onBottomBarPress
/case "INGAME"
block
- Pause – out
../../assets/sfx/pause_out.wav
- Play it in the
onBottomBarPress
/case "PAUSED"
block
- Lose
../../assets/sfx/lose.wav
- Play it in the interval’s
if (this.state.timeLeft <= 0)
block - Also stop the background music with
this.backgroundMusic.stopAsync();
- Don’t forget to start playing the background music when starting the game again. You can do this by adding
this.backgroundMusic.replayAsync();
to theonBottomBarPress
/case "LOST"
block.
Our game is already pretty fun, but it still lacks the shaking animation when we’re touching the wrong tile – thus we are not getting any instant noticeable feedback.
A Primer to React-Native Animations (with example)
Animating is a vast topic, thus we can only cover the tip of the iceberg in this article. However, Apple has a really good WWDC video about designing with animations, and the Human Interface Guidelines is a good resource, too.
We could use a ton of animations in our app (e.g. animating the button size when the user taps it), but we’ll only cover one in this tutorial: The shaking of the grid when the player touches the wrong tile.
This React Native animation example will have several benefits: it’s some sort of punishment (it will take some time to finish), and as I mentioned already, it’s instant feedback when pressing the wrong tile, and it also looks cool.
There are several animation frameworks out there for React-Native, like react-native-animatable, but we’ll use the built-in Animated API for now. If you are not familiar with it yet, be sure to check the docs out.
Adding React-Native Animations to our Game
First, let’s initialize an animated value in the state that we can later use in the style of the grid container:
state = {
...
shakeAnimation: new Animated.Value(0)
};
And for the <View>
that contains the grid generator (with the shitton of ternary operators in it), just change <View>
to <Animated.View>
. (Don’t forget to change the closing tag, too!) Then in the inline style, add left: shakeAnimation
so that it looks something like this:
<Animated.View
style={{
height: height / 2.5,
width: height / 2.5,
flexDirection: "row",
left: shakeAnimation
}
>
{gameState === "INGAME" ?
...
Now let’s save and reload the game. While playing, you shouldn’t notice any difference. If you do, you did something wrong – make sure that you followed every step exactly.
Now, go to the onTilePress()
handler and at the // wrong tile
section you can start animating the grid. In the docs, you’ll see that the basic recommended function to start animating with in React Native is Animated.timing()
.
You can animate one value to another value by using this method, however, to shake something, you will need multiple, connected animations playing after each other in a sequence. For example modifying it from 0 to 50, then -50, and then back to 0 will create a shake-like effect.
If you look at the docs again, you’ll see that Animated.sequence([])
does exactly this: it plays a sequence of animations after each other. You can pass in an endless number of animations (or Animated.timing()
s) in an array, and when you run .play()
on this sequence, the animations will start executing.
You can also ease animations with Easing
. You can use back
, bounce
, ease
and elastic
– to explore them, be sure to check the docs. However, we don’t need them yet as it would really kill the performance now.
Our sequence will look like this:
Animated.sequence([
Animated.timing(this.state.shakeAnimation, {
toValue: 50,
duration: 100
}),
Animated.timing(this.state.shakeAnimation, {
toValue: -50,
duration: 100
}),
Animated.timing(this.state.shakeAnimation, {
toValue: 50,
duration: 100
}),
Animated.timing(this.state.shakeAnimation, {
toValue: -50,
duration: 100
}),
Animated.timing(this.state.shakeAnimation, {
toValue: 0,
duration: 100
})
]).start();
This will change the shakeAnimation
in the state to 50, -50, 50, -50 and then 0. Therefore, we will shake the grid and then reset to its original position. If you save the file, reload the app and tap on the wrong tile, you’ll hear the sound effect playing and see the grid shaking.
Moving away animations from JavaScript thread to UI thread
Animations are an essential part of every fluid UI, and rendering them with performance efficency in mind is something that every developer needs to strive for.
By default, the Animation API runs on the JavaScript thread, blocking other renders and code execution. This also means that if it gets blocked, the animation will skip frames. Because of this, we want to move animation drivers from the JS thread to the UI thread – and good news is, this can be done with just one line of code with the help of native drivers.
To learn more about how the Animation API works in the background, what exactly are “animation drivers” and why exactly it is more efficient to use them, be sure to check out this blog post, but let’s move forward.
To use native drivers in our app, we only need to add just one property to our animations: useNativeDriver: true
.
Before:
Animated.timing(this.state.shakeAnimation, {
toValue: 0,
duration: 100
})
After:
Animated.timing(this.state.shakeAnimation, {
toValue: 0,
duration: 100,
useNativeDriver: true
})
And boom, you’re done, great job there!
Now, let’s finish off with saving the high scores.
Persisting Data – Storing the High Scores
In React-Native, you get a simple, unencrypted, asynchronous, and persistent key-value storage system: AsyncStorage.
It’s recommended not to use AsyncStorage while aiming for production, but for a demo project like this, we can use it with ease. If you are aiming for production, be sure to check out other solutions like Realm or SQLite, though.
First off, we should create a new file under utils
called storage.js
or something like that. We will handle the two operations we need to do – storing and retrieving data – with the AsyncStorage
API.
The API has two built-in methods: AsyncStorage.setItem()
for storing, and AsyncStorage.getItem()
for retrieving data. You can read more about how they work in the docs linked above. For now, the snippet above will be able to fulfill our needs:
import { AsyncStorage } from "react-native";
export const storeData = async (key, value) => {
try {
await AsyncStorage.setItem(`@ColorBlinder:${key}`, String(value));
} catch (error) {
console.log(error);
};
export const retrieveData = async key => {
try {
const value = await AsyncStorage.getItem(`@ColorBlinder:${key}`);
if (value !== null) {
return value;
} catch (error) {
console.log(error);
};
By adding this, we’ll have two asyncAsynchrony, in software programming, refers to events that occur outside of the primary program flow and methods for dealing with them. External events such as signals or activities prompted by a program that occur at the same time as program execution without causing the program to block and wait for results are examples of this category. Asynchronous input/output is an... functions that can be used to store and persist data from the AsyncStorage
. Let’s import our new methods and add two keys we’ll persist to the Game screen’s state:
import {
generateRGB,
mutateRGB,
storeData,
retrieveData
} from "../../utilities";
...
state = {
points: 0,
bestPoints: 0, // < new
timeLeft: 15,
bestTime: 0, // < new
...
And display these values in the bottom bar, next to their corresponding icons:
<View style={styles.bestContainer}>
<Image
source={require("../../assets/icons/trophy.png")}
style={styles.bestIcon}
/>
<Text style={styles.bestLabel}>{this.state.bestPoints}</Text>
</View>
. . .
<View style={styles.bestContainer}>
<Image
source={require("../../assets/icons/clock.png")}
style={styles.bestIcon}
/>
<Text style={styles.bestLabel}>{this.state.bestTime}</Text>
</View>
Now, let’s just save the best points first – we can worry about storing the best time later. In the timer, we have an if
statement that checks whether we’ve lost already – and that’s the time when we want to update the best point, so let’s just check if your actual points are better than our best yet, and if it is, update the best:
if (this.state.timeLeft <= 0) {
this.loseFX.replayAsync();
this.backgroundMusic.stopAsync();
if (this.state.points > this.state.bestPoints) {
this.setState(state => ({ bestPoints: state.points }));
storeData('highScore', this.state.points);
this.setState(me{ gameState: "LOST" });
} else {
...
And when initializing the screen, in the async componentWillMount()
, make sure to read in the initial high score and store it in the state so that we can display it later:
retrieveData('highScore').then(val => this.setState({ bestPoints: val || 0 }));
Now, you are storing and retrieving the high score on the game screen – but there’s a high score label on the home screen, too! You can retrieve the data with the same line as now and display it in the label by yourself.
We only need one last thing before we can take a break: storing the highest time that the player can achieve. To do so, you can use the same functions we already use to store the data (but with a different key!), However, we’ll need a bit different technique to check if we need to update the store:
this.interval = setInterval(async () => {
if (this.state.gameState === "INGAME") {
if (this.state.timeLeft > this.state.bestTime) {
this.setState(state => ({ bestTime: state.timeLeft }));
storeData('bestTime', this.state.timeLeft);
. . .
This checks if our current timeLeft is bigger than the best that we achieved yet. At the top of the componentWillMount
, don’t forget to retrieve and store the best time along with the high score, too:
retrieveData('highScore').then(val => this.setState({ bestPoints: val || 0 }));
retrieveData('bestTime').then(val => this.setState({ bestTime: val || 0 }));
Now everything’s set. The game is starting to look and feel nice, and the core features are already starting to work well – so from now on, we don’t need too much work to finish the project.
Next up in our React-Native Tutorial
In the next episode of this series, we will look into making our game responsive by testing on devices ranging from iPhone SE to Xs and last but not least, testing on Android. We will also look into improving the developer experience with ESLint and add testing with Jest.
Don’t worry if you still feel a bit overwhelmed, mobile development may be a huge challenge, even if you are already familiar with React – so don’t lose yourself right before the end. Give yourself a rest and check back later for the next episode!
If you want to check out the code that’s been finished as of now – check out the project’s GitHub repo.
In case you’re looking for outsourced development services, don’t hesitate to reach out to RisingStack.