This is the second part of my React Native tutorial that shows you how to write a cross-platform mobile game. In Part 1, we've rendered a resolution-independent letter grid with custom glyphs. We're going to add event handling and animations to enliven the app.
The following guide builds on top of the v0.1 code release; the end result can be found in v0.2.
7. Touch event handling
The conventional way to capture click events in React Native is to use four Touchable*
components: TouchableHighlight, TouchableActiveFeedback, TouchableOpacity, and TouchableWithoutFeedback.
TouchableActiveFeedback
is Android-only; TouchableHighlight
often causes undesirable artifacts; TouchableWithoutFeedback
should be used with caution as it provides no visual cues; in most cases such as creating a button, you should consider using TouchableOpacity
.
Let's give it a try by importing the component from React
in boardview.js
.
var { ... TouchableOpacity, // <- New ... } = React;
We then extract a renderTile
method from renderTiles
and wrap each tile <View>
inside a <TouchableOpacity>
:
... renderTiles() { var result = []; for (var row = 0; row < SIZE; row++) { for (var col = 0; col < SIZE; col++) { var key = row * SIZE + col; var letter = String.fromCharCode(65 + key); var position = { left: col * CELL_SIZE + CELL_PADDING, top: row * CELL_SIZE + CELL_PADDING }; result.push(this.renderTile(key, position, letter)); // <- New } } return result; }, // New renderTile(id, position, letter) { return <TouchableOpacity key={id} onPress={() => console.log(id)}> <View style={[styles.tile, position]}> <Text style={styles.letter}>{letter}</Text> </View> </TouchableOpacity>; }, ...
Notice that each <TouchableOpacity>
has a unique key
property because they all nest under the same parent node. As mentioned in Part 1 Section 5, this helps React Native to efficiently compare virtual DOM trees. The inner <View>
and <Text>
don't need keys because they have no sibling nodes at all — it doesn't hurt if you set keys, though.
The onPress
property uses ES6's arrow syntax to declare an anonymous, parameterless event handler. For now, it logs the tile id for debugging; our real game logic will be filled in later.
Play with the app on a device and you'll observe two flaws:
- The
onPress
event handler isn't triggered until you lift your finger (see the screencast above). This behavior is not bad for regular apps because it allows users to easily cancel a click by sliding outside the click target. However, responsiveness is paramount in a game, so we'd like to fire the event handler as soon as a touch starts. You could solve this by binding anonPressIn
handler rather than anonPress
, yet it won't fix the next issue. - If you press a tile and hold your finger, then press another tile, the second click won't register. Again, this behavior is perfectly fine for regular apps, but in our fast-paced game, it'll cause tremendous frustration when a player taps with multiple fingers at high speed.
A quick solution that kills two birds with one stone is to get rid of the TouchableOpacity
wrapper and directly attach an onStartShouldSetResponder
handler to each tile <View>
. The handler should always return false
or a false-y value like undefined
, so that it never nominates itself as the event responder. Our new renderTile
method looks like this:
... renderTile(id, position, letter) { return <View key={id} style={[styles.tile, position]} onStartShouldSetResponder={() => this.clickTile(id)}> <Text style={styles.letter}>{letter}</Text> </View>; }, clickTile(id) { console.log(id); }, ...
As the following screencast demonstrates, each click now immediately triggers the clickTile
event handler. You may also verify on a device that no clicks are lost when multi-touch is involved.
The only thing missing is some form of visual feedback to highlight the tile being pressed. We're going to fix that with animations.
8. Property animation
React Native provides a convenient Animated
module for animating component properties. It's designed in such a way that our rendering logic can remain largely intact when we plug in the animation logic. Let's add a simple opacity animation to see how it works.
First, import Animated
and Easing
:
var { Animated, // <- New Easing, // <- New ... } = React;
Add a getInitialState()
method to the BoardView
component, where we initialize 16 (4x4) Animated.Value
instances, each controlling the opacity of a single tile. Pass 1
to the constructor of Animated.Value
so that all letter tiles are fully opaque at the start.
var BoardView = React.createClass({ getInitialState() { // New method var opacities = new Array(SIZE * SIZE); for (var i = 0; i < opacities.length; i++) { opacities[i] = new Animated.Value(1); } return {opacities}; // ES6 shorthand for {opacities: opacities} }, ...
Once React Native mounts the component, individual Animated.Value
instances can be accessed by this.state.opacities[id]
. Hook them up with the style object of letter tiles as shown below:
renderTiles() { var result = []; for (var row = 0; row < SIZE; row++) { for (var col = 0; col < SIZE; col++) { var id = row * SIZE + col; var letter = String.fromCharCode(65 + id); var style = { left: col * CELL_SIZE + CELL_PADDING, top: row * CELL_SIZE + CELL_PADDING, opacity: this.state.opacities[id], // <- New }; result.push(this.renderTile(id, style, letter)); } } return result; }, renderTile(id, style, letter) { // v- New return <Animated.View key={id} style={[styles.tile, style]} onStartShouldSetResponder={() => this.clickTile(id)}> <Text style={styles.letter}>{letter}</Text> </Animated.View>; },
Make sure that you change <View>
to Animated.View
where Animated.Value
s can be applied to. Otherwise, you'll run into a scary, red screen of error that isn't particularly informative:

Similarly, you can animate Text
with Animated.Text
, Image
with Animated.Image
, or custom components created with createAnimatedComponent
. At this stage, nothing is actually animated yet. We'll kick off the opacity animation in the clickTile
event handler using the Animated.timing
API:
clickTile(id) { var opacity = this.state.opacities[id]; opacity.setValue(.5); // half transparent, half opaque Animated.timing(opacity, { toValue: 1, // fully opaque duration: 250, // milliseconds }).start(); },
With these simple changes, we have achieved the following effect:

Animated.timing
gives us two additional options to fine-tune the animation: easing
(custom easing function) and delay
(delay in milliseconds). You may also want to play with Animated.spring
(bouncing animation) or orchestrate multiple animations with Animated.sequence
(sequential animation) and Animated.parallel
(simultaneous animation).
Faded opacity is a tad boring. Let's replace it with a 3D tilting effect as if tiles revolve around the X-axis. That's right — we can do simple 3D transformations in React Native!
First rename the Animated.Value
s and initialize them with 0
:
getInitialState() { var tilt = new Array(SIZE * SIZE); for (var i = 0; i < tilt.length; i++) { tilt[i] = new Animated.Value(0); } return {tilt}; },
Then replace opacity
in style
with a transform
— an array of transformation objects:
{perspective: ...}
declares the virtual distance from the viewing point to thez=0
plane. Use it to control the intensity of 3D effect: the greater the value is, the further away you are from the objects, the less intense the distortion appears (i.e. objects look more flat).{rotateX: ...}
sets the rotational degrees around the X-axis, creating a tilting effect. Due to an unfortunate API design (as of RN 0.18), all the rotational properties (rotate
,rotateX
,rotateY
, androtateZ
) only take a string (e.g.'30deg'
). We can't directly apply a numerical degree or radian value, so we resort toAnimated.Value
'sinterpolate
function to map a floating point number to a string.
renderTiles() { var result = []; for (var row = 0; row < SIZE; row++) { for (var col = 0; col < SIZE; col++) { var id = row * SIZE + col; var letter = String.fromCharCode(65 + id); var tilt = this.state.tilt[id].interpolate({ inputRange: [0, 1], outputRange: ['0deg', '-30deg'] }); var style = { left: col * CELL_SIZE + CELL_PADDING, top: row * CELL_SIZE + CELL_PADDING, transform: [{perspective: CELL_SIZE * 8}, {rotateX: tilt}] }; result.push(this.renderTile(id, style, letter)); } } return result; },
The final step is to patch the clickTile
method to set up the new animation.
clickTile(id) { var tilt = this.state.tilt[id]; tilt.setValue(1); // mapped to -30 degrees Animated.timing(tilt, { toValue: 0, // mapped to 0 degrees (no tilt) duration: 250, // milliseconds easing: Easing.quad // quadratic easing function: (t) => t * t }).start(); },
Technically, this 3D effect is not accurate — it's a mix of orthogonal projection and perspective projection. If you aspire to render a photorealistic scene with fancy shaders, I'd suggest that you take a look at gl-react-native — wait, no, please please use a real 3D engine. For our little game, I'd say this faux-3D animation is acceptable:

Full source up to this point can be found at https://github.com/zmxv/alpha-reflex/releases/tag/v0.2. In the next article, we'll create a timer component using a different animation technique and implement some game-specific logic.