17

A Definitive React-Native Guide for React Developers: Part III.

 4 years ago
source link: https://www.tuicool.com/articles/JNrI32U
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Welcome back! In the previous episodes of this React-Native tutorial series, we initialized the routing, added our custom font, and built our first component while working on the home screen.

Now, we are going to work on the main game logic and the Game screen.

Table of contents below:

  • Creating the Game screen
    • How to create multiple screens?
    • How can you modify the stack navigator?
  • Type checking with prop-types
    • What is PropTypes?
    • Does it differ from PropTypes in React? How?
    • Adding type checking to your components
  • Navigating from one Screen to Another
    • How can you navigate between screens?
    • How to disable default navigation gestures like swiping back?
    • What’s a good navigation UX?
  • Defining the Main Game Mechanics
    • How will the game behave?
  • Creating the Random Color Generator
    • How can you create a random color?
    • How to keep utilities separated from your screen logic?
  • Developing the Main Logic
    • Creating the initial state
    • Initializing a timer
  • Generating the Grid
    flex
    
  • Handling Taps on Tiles
    • How can you decide if the user tapped on the right tile?
    • Creating the event handlers in compliance with the game rules
    • Generating new rounds
    • Resizing the grid

You can find the whole codebase of our react-native mobile app here!

In the third article, He wrote: “Let there be game!”, and there was a game.

Let’s initialize our Game screen inside our screens directory by creating a Game directory with an index.js and styles.js . Then, in the Routes.js , import the screen so that we can use it in our router:

import Game from "./Game";

Also, inside the first argument of the createStackNavigator , there’s already a Home object: use that as a sort of template to add the Game screen to the router.

const StackNavigator = createStackNavigator(
 {
   Home: {
     screen: Home
   },
   Game: {
     screen: Game
   }
 },
…

After you save your code, the app will crash. (If it didn’t, good luck debugging it.) That’s because the Game/index.js is empty but we are already importing and using it in our router. Let’s initialize it with some boilerplate to silence the error!

import React, { Component } from "react";
import { View } from "react-native";
import { Header } from "../../components";
import styles from "./styles";

export default class Home extends Component {
 render() {
   return (
     <View style={styles.container}>
       <Header />
     </View>
   );
 }
}

Notice how it’s already using the ./styles - let’s define it! In the styles.js , add the following code:

import { StyleSheet } from "react-native";

export default StyleSheet.create({
 container: {
   flex: 1,
   backgroundColor: "#0a0a0a",
   justifyContent: "center",
   alignItems: "center",
 }
});

Also, the Header is a reusable component, but we need to modify it so that it suits our needs. As you can see on the picture below, the font size is slightly smaller.

You may want to work around it with a fontSize number property so that the size can be modified any time, or with an isMini boolean property that you can simply pass for the component, and it will automatically decide the font size.

Both approaches are totally valid, but I’ll go with the fontSize number property approach because I think it’s more flexible and future-proofed, since we can pass in any number we’d like.

How about PropTypes?

In React, you may already be familiar with the concept of PropTypes - you can type-check the components properties with it. In React-Native, you can use the very same method for type checking like in React: you just import the PropTypes with the line import PropTypes from ‘prop-types’ and then at the end of the file, you just add the .propTypes and .defaultProps properties. After that, everything will be all set:

Header.propTypes = {
 fontSize: PropTypes.number
}

Header.defaultProps = {
 fontSize: 55
}

However, we are not applying this property to the text itself - yet. Delete the fontSize property from the StyleSheet to make sure that the two properties won’t have a battle in the background and overwrite each other, and since we used a stateless functional component to declare the Header, we can’t use this.props . We can, however, use the arguments of the function to access the props by modifying the declaration line as it follows:

const Header = ({ fontSize }) => ( … }

And from now on, you can just add the fontSize to every Text components style property like this:

<Text style={[styles.header, { fontSize }]}>blinder</Text>

Now, pass the desired fontSize prop to the Header component in the Game screen. After reloading the app, you’ll see that the Header component is now rendering properly on both screens -

or you’d see if you could go there, but we can’t yet, so let’s fix that!

Navigating from One Screen to Another

Before we start building our game screen, it is a good idea to add routing so that we can get there and see what we are building. It couldn’t be any simpler with react-navigator : we just need to add this.props.navigation.navigate('Game'); to our onPlayPress event handler: the react-navigator already managed to pass a navigation object as a property to our Home screen, and we can use its functions to navigate between screens. If you save the code and tap on the Play button, you are going to be routed to the Game screen.

Notice that by swiping back, you can get back to the Home screen. This may be the expected behavior when building an app, but it would be very nerve-racking to accidentally swipe back to the home screen while playing the game so it may be a good idea to disable this feature for now.

Please note that when you disable both the swipe navigation and the navigation bar, you need to be sure that have your own button on the UI that the user can use to navigate back to the previous screen!

You can read more about good navigation UX in Apple’s Human Interface Guidelines.

You can easily disable the swipe navigation on a particular screen by disabling the gesturesEnabled property in the navigationOptions of the Game screen in the Router.js , as it follows:

Game: {
     screen: Game,
     navigationOptions: {
       gesturesEnabled: false,
     },
   }

If you reload the app and try to swipe back from the Game screen, you’ll notice that you can’t, and that’s the behavior we wanted to achieve, so let’s move on.

We’ll get started by understanding the underlying game logic before trying to build the UI.

How will this work, exactly?

When the player starts the game, they will see a 2x2 grid with one tile slightly off:

They will have 0 points and 15 seconds after starting the game. When touching the correct tile, they’ll get +1 point and +2 seconds. If they touch the wrong tile, they get -2 seconds as a punishment. You can never win this game - it’s endless.

The grid will grow over time, but the maximum is a 5x5:

The colors are going to be randomly generated by generating the 0-255 values and passing these as an RGB color to the tiles.

The differentiating tile will have its RGB values mutated with a random value between 10 and 20.

Let’s create our random RGB value generator!

Since we are trying to make our code clean, we don’t want to create this in the Game directory. We’ll also have some other utilities, so let’s create a utilities directory in the root of the project, create an index.js and a color.js , and initialize the index.js before moving on:

export * from './color'

export default {}

And create our RGB value generator and the mutator in the color.js :

export const generateRGB = () => {
   const r = Math.floor(Math.random() * 255);
   const g = Math.floor(Math.random() * 255);
   const b = Math.floor(Math.random() * 255);
   return { r, g, b }
};

export const mutateRGB = ({ r, g, b }) => {
   const newR = r + Math.floor(Math.random() * 20) + 10;
   const newG = g + Math.floor(Math.random() * 20) + 10;
   const newB = b + Math.floor(Math.random() * 20) + 10;
   return { r: newR, g: newG, b: newB }
};

The mutator may seem a bit hacky:

it creates a random number between 10 and 20 and adds it to the original RGB value passed as a prop, then returns the new colors.

Defining the Main Logic

Now that we have some utilities for working with colors, we should set some basic things up on the Game screen, too - for example, defining the initial state is a good place to start off:

state = {
   points: 0,
   timeLeft: 15,
 };

Also, adding a timer that divides the timeLeft in the state by one after every second can be done with setInterval() . Component lifecycle methods work the same way as in React, thus we can use componentWillMount() and componentWillUnmount() to create and destroy our timer:

componentWillMount() {
   this.interval = setInterval(() => {
     this.setState(state => ({ timeLeft: state.timeLeft - 1 }));
   }, 1000);
 }

 componentWillUnmount() {
   clearInterval(this.interval);
 }

Notice how I added the interval to the Game screens scope (or this ) - it’s in order that we can destroy it later in the componentWillUnmount() . If this arrow function thingy in the this.setState() looks a bit weird, be sure to check out the React docs -

it will convince you on why you shouldn’t use this.setState({ timeLeft: this.state.timeLeft - 1 }) .

Let’s build the grid with some flex magic :sparkles:

The key component on the screen is the grid with all them colorful tiles hangin’ out there, so let’s build that with flex. We are trying to keep the code needed as small as we can - so we are going to generate the grid instead of hardcoding it. Go to the screens/Game/index.js and import our shiny new utilities:

import { generateRGB, mutateRGB } from '../../utilities';

Then when declaring the state, initialize the first color, too:

state = {
   points: 0,
   timeLeft: 15,
   rgb: generateRGB()
 };

Next off, add some constants to our render() function - we will use these later:

const { rgb } = this.state;
const { width } = Dimensions.get("window");

If you are not familiar with this syntax yet, it’s called object destructuring. With this, you can access an objects (e.g. this.state ) properties without writing out this.state.rgb , but by destructuring and then just typing in rgb .

Dimensions is yet another very useful React-Native class (and you need to import it before you can use it, so be sure to add it to the top of the file): you can get the devices’ width and height and add event listeners to when the dimensions change (e.g. window resize, screen orientation change). Be sure to check out the related docs!

Now, add the container that will wrap our tiles:

<View style={{ height: width * 0.875, width: width * 0.875, flexDirection: 'row' }}>
</View>

Notice that it uses width * 0.875 for both width and height: I decided to style like this so that it’s always a perfect square and the containers dimensions match. You can style differently if you want to, but I recommend going with this.

Now that the container and the helper functions are finished, we can generate the grid inside the container with the following code:

{Array(2).fill().map((val, columnIndex) => (
  <View style={{ flex: 1, flexDirection: 'column' }} key={columnIndex}>
     {Array(2).fill().map((val, rowIndex) => (
        <TouchableOpacity
          key={`${rowIndex}.${columnIndex}`}
          style={{
             flex: 1,
             backgroundColor: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
             margin: 2
          }}
          onPress={() => console.log(rowIndex, columnIndex)}
        />
      ))}
  </View>
))}

This solution may seem a little bit hacky for first, but it’s flexible and definitely shorter than hard-coding every possible grid layout. It creates an empty array with 2 items with the Array(2) , fills it so that we can map over it, and creates a <View> with the style properties flex: 1 to fill in the available space and it makes sure that the rows are under each other with flexDirection: column .

Then, inside these rows, it generates the tiles by creating another empty array and iterating over it and adding a TouchableOpacity for every iteration. This view has a flex: 1 property to flexibly fill up the space available, the backgroundColor is just a string with the RGB values (more about passing colors in React-Native in the docs), and the margin is added to make sure that there’s some space between the tiles.

You can add more margin to make the game harder or get rid of it to make the game very-very easy, but I think the 2-pixel margin is just ideal for a fun gameplay.

For now, the game looks like this:

It’s a nice grid - but let’s just don’t stop there. We need a differentiating tile, too! But before we dive into that, make sure that we have the size of the grid in our state:

state = {
  points: 0,
  timeLeft: 15,
  rgb: generateRGB(),
  size: 2
};

Then in the grid generators, replace every Array(2) with Array(size) . Use object destructuring to get the size out of the this.state .

After you replaced them and you still get a 2x2 grid, so you’re good to go to generate the position and the color of the differentiating tile:

generateSizeIndex = size => {
 return Math.floor(Math.random() * size);
};

generateNewRound = () => {
 const RGB = generateRGB();
 const mRGB = mutateRGB(RGB);
 const { points } = this.state;
 const size = Math.min(Math.max(Math.floor(Math.sqrt(points)), 2), 5);
 this.setState({
   size,
   diffTileIndex: [this.generateSizeIndex(size), this.generateSizeIndex(size)],
   diffTileColor: `rgb(${mRGB.r}, ${mRGB.g}, ${mRGB.b})`,
   rgb: RGB
 });
};

The generateSizeIndex() generates a new number between 0 and the size passed as an argument. The generateNewRound() creates the data needed for a new round (differentiating tile index and color) and modifies the state to match it. This will run every time the user taps on the correct tile, and when initializing the game for the first time.

Call the this.generateNewRound() at the top of the componentWillMount() to make sure that the game initializes a round when the player opens the screen.

Now, we have a dynamic grid, and the properties of the differing tile: now, the only thing we need to do is to merge these two so that the differing tiles' color actually differs from the others.

First, in the render() where we destructure the state, add diffTileIndex and diffTileColor . Then, when passing the backgroundColor to the TouchableOpacity , you can check if the tile that’s being generated is the differing tile with the following: rowIndex == diffTileIndex[0] && columnIndex == diffTileIndex[1] . This checks if the row and the column of the tile and the differing tile match. We can use a ternary operator to modify the color of the tile, as it follows:

backgroundColor:
  rowIndex == diffTileIndex[0] && columnIndex == diffTileIndex[1]
     ? diffTileColor
     : `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,

If you refresh the app now, you’ll have one tile with the color slightly off:

If you don’t see the difference, increase the brightness on your screen.

At this point, we are just a few steps away from finishing off the grid -

a proper onPress handler is the only thing needed before we can move on to the bottom bar, so let’s finish off with that!

Handling Taps on Tiles

We need to add 1 point and 3 seconds to the timer if the player taps on the right tile, then generate a new color and increase the size of the grid if needed. If the player taps on the wrong tile, we decrease the timer by 1 second as a punishment and shake the grid (shaking the grid will be implemented later in the “Shaking the grid - animating in React Native” section, so don’t worry about that now!).

First, just defining a placeholder event handler and passing it as a prop onto our TouchableOpacity will do it:

onTilePress = (rowIndex, columnIndex) => {
  console.log(`row ${rowIndex} column ${columnIndex} pressed!`)
}

And in the <TouchableOpacity> :

onPress={() => this.onTilePress(rowIndex, columnIndex)}

And boom, when you press a tile, you will see which one was pressed in the console.

In the onTilePress() , we can use the same ternary code as we used when passing the backgroundColor to determine if the user has tapped on the differing tile:

onTilePress = (rowIndex, columnIndex) => {
  const { diffTileIndex, points, timeLeft } = this.state;
  if(rowIndex == diffTileIndex[0] && columnIndex == diffTileIndex[1]) {
    // good tile
    this.setState({ points: points + 1, timeLeft: timeLeft + 2 });
  } else {
    // wrong tile
    this.setState({ timeLeft: timeLeft - 2 });
  }
}

If you add console.log(this.state) to the end of the onTilePress() , you’ll be able to see how the state mutates over time. However, the grid does not change, so let’s call this.generateNewRound() in the good tile block, and modify the function a little bit so that it generates new color, too:

generateNewRound = () => {
   const RGB = generateRGB();
   const mRGB = mutateRGB(RGB);
   this.setState({
     diffTileIndex: [this.generateSizeIndex(), this.generateSizeIndex()],
     diffTileColor: `rgb(${mRGB.r}, ${mRGB.g}, ${mRGB.b})`,
     rgb: RGB,
   });
 };

Now tapping the correct tile will give you some extra time, points, and a new color. The only thing left is to increase the grid size.

Before generating a new index for the winner tile, calculate the grid size:

generateNewRound = () => {
   const RGB = generateRGB();
   const mRGB = mutateRGB(RGB);
   const { points } = this.state;
   const size = Math.min(Math.max(Math.floor(Math.sqrt(points)), 2), 5);
   this.setState({
     size,
     diffTileIndex: [
       this.generateSizeIndex(size),
       this.generateSizeIndex(size),
     ],
     diffTileColor: `rgb(${mRGB.r}, ${mRGB.g}, ${mRGB.b})`,
     rgb: RGB,
   });
 };

This sizing method may look a bit odd, so let’s break it down to see a clearer picture of how it works: I wanted to achieve a one-liner, well-balanced solution for making the game harder by increasing the grid size over time.

My initial idea was to make the grids size the square root of the points - so when you reach 4 points it is 2, at 9 points it is 3, at 16 points it is 4, and so on. But I needed a minimum size for the grid (2) because before reaching that, there would be either zero or one tile. I also wanted a hard cap on the maximum size of the grid (it’s 5 in the example, but it could be any number you’d like).

Now if you play the game, you’ll start with a 2x2 grid, and as your points increase, you’ll see the tiles getting smaller, expanding into a 5x5 grid over time. Great job there!

Summing it Up

If you lost your way somewhere through this article or just want to clone the directory and pick up from there, you can access the code that’s finished as of now on the GitHub repo.

If you'd like to read the previous 2 parts of this article, I suggest you start here:

  • React-Native Guide for React Developers: Part I.
  • React-Native Guide for React Developers: Part II.

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK