React Native: Building an app for indecisive couples

React Native: Building an app for indecisive couples

The ultimate decision maker app that helps with life's dilemmas.

Introduction

An all too familiar scenario.

"What should we eat?" - A

"What do you feel like having?" - B

"Asian? - A

"Hmm.." - B

"How about Western?" - A

"Hmm, I don't know." - B

"What do you want?" - A

Argument ensues.

Let's put an end to this misery by creating a decision-making app.

Requirements

  • Node.js and npm
  • An iOS or Android Device
  • It is highly recommended that you avoid reading this article while having a meal with your partner.

Initial Setup

  1. Install Node.js. Node.js comes with npm aka Node Package Manager and is required to install our Gulp packages.
  2. We will be using Expo so go ahead and install the Expo CLI.
$ npm install -g expo-cli
  1. Create our React Native project and name it "Indecisive App". (Note: Select the blank template and use Yarn to install dependencies.)
$ expo init IndecisiveApp
  1. Start the development server.
$ cd IndecisiveApp
$ yarn start

We can preview the application in three ways.

1. MacOS - Run on iOS simulator.
2. Windows - Run on Android device/emulator.
3. MacOS and Windows - Download the Expo iOS or Android app and preview on an actual device.

We will be using the iOS simulator throughout this tutorial.

The directory structure should look like the following.

├── App.js
├── app.json
├── assets
├── babel.config.js
├── node_modules
├── package.json
└── yarn.lock

Yes or No?

Let's create a basic layout for our application.

Open App.js and add the following imports.

import React from 'react';
import { Button, Alert, StyleSheet, Text, View } from 'react-native';

Create a container view with an image, title and button.

export default class App extends React.Component {
render() {

let pic = {
    uri: 'https://yesno.wtf/assets/yes/12-e4f57c8f172c51fdd983c2837349f853.gif'
    };

return (
    <View style={styles.container}>
    <Text>Ask me a question and tap the button below.</Text>
        <Button
        onPress={() => {
            Alert.alert('Yes!');
        }}
        title="Tap Me"
        style={styles.button}
        />
    <Image source={pic} style={{width: 193, height: 110}}/>
    </View>
    );
}

Set up basic styling for the elements.

const styles = StyleSheet.create({
    container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
    },
    button: {
    width: 260,
    alignItems: 'center',
    backgroundColor: '#2196F3'
    },
    image: {
    width: 193,
    height: 110
    }
});

Fetch API

We could create our own custom logic to generate a random Yes or No answer for our application.

That's kinda boring.

Instead, we will be using the Fetch API; supported out-of-the-box in React Native; to pull data from an external datasource.

We will be leveraging on https://yesno.wtf's API for our random answer generator.

Edit App.js with the following.

let api ='https://yesno.wtf/api/';

export default class App extends React.Component {

    constructor(props){
        super(props);
        this.state ={
        answer: "",
        image: "",
        }
    }

    async componentDidMount() {
    try {
        const response = await fetch(api);
        if (!response.ok) {
        throw Error(response.statusText);
        }
        const json = await response.json();
        this.setState({
        answer: json.answer,
        image: json.image,
        });
    } catch (error) {
        console.log(error);
    }
    }

    render() {

    return (
        <View style={styles.container}>
        <Text>Ask me a question and tap the button below.</Text>
            <Button
            onPress={() => {
                Alert.alert('Yes!');
            }}
            title="Tap Me"
            style={styles.button}
            />

        <Text style={styles.answer}>{this.state.answer}</Text>
        <Image source={{uri:this.state.image}} style={styles.image}>
        </Image>
        </View>
    );
    }
}
...
const styles = StyleSheet.create({
    ...
    ...
    answer: {
    textAlign: 'center',
    color: 'white',
    fontSize: 32,
    textTransform: 'uppercase'
    },
});
...

What Happened?

1. We declared a variable, api, as our datasource.
2. Initialised empty states for the answer text and image source attribute.
3. Set up a lifecycle method, componentDidMount and fetched data from the datasource.
4. Set new states for the answer text and image source attribute and render it in our view.

Reusable Functions

We want the app to show different results for each tap of the button.

Refactor the data request into its own function and invoke it in the componentDidMount lifecycle method.

...
...
async fetchData() {
    try {
        const response = await fetch(api);
        if (!response.ok) {
        throw Error(response.statusText);
        }
        const json = await response.json();
        this.setState({
        answer: json.answer,
        image: json.image,
        });
    } catch (error) {
        console.log(error);
    }
}

async componentDidMount() {
    this.fetchData();
    }
...
...

Async/Await is a special syntax to work with Promises in ES2017. Read more about it here.

Change the button's OnPress method to fetch new data.

...
<Button
    onPress={() => {
    this.fetchData();
    }}
    title="Tap Me"
    style={styles.button}
/>
...

Zoom and Enhance

Our app is doing something useful at last.

Let's enhance it by providing some contextual feedback.

  1. Display an indicator when the app is loading.
  2. Hide the result for the initial load.
  3. Change the button text and hide the title for subsequent queries.
  4. Fit the image to the full width of the device.
  5. Convert the button to a Touchable component.
  6. Add an image loader.

Display an indicator when the app is loading

Import the ActivityIndicator component.

import { ActivityIndicator, Button, Alert, Image, StyleSheet, Text, View } from 'react-native';

Initialise a isLoading state and set it to true.

constructor(props){
        super(props);
        this.state ={
        isLoading: true,
        answer: "",
        image: "",
        }
    }

Set the state of isLoading to false if fetchData() returns a response.

async fetchData() {
    try {
        ...
        const json = await response.json();
        this.setState({
        isLoading: false,
        answer: json.answer,
        ...
    }

Render the ActivityLoader component if isLoading is false.

...
render() {
    if(this.state.isLoading){
        return(
        <View style={styles.container}>
            <ActivityIndicator/>
        </View>
        )
    }

    return (
...

Hide the result for the initial load

Instead of fetching data, we set the isLoading state for the componentDidMount lifecycle method.

Yes, it's that simple.

...
async componentDidMount() {
    this.setState({
        isLoading: false,
    });
    }
...

Change the button text and hide the title for subsequent queries

We will store the button's text as a string and assign a boolean state to our title so that the application will know when to re-render them with the updated state.

...
constructor(props){
        super(props);
        this.state ={
        isLoading: true,
        answer: "",
        image: "",
        title: true,
        buttonText: "Yes or No?"
        }
    }
...
...
const json = await response.json();
        this.setState({
        isLoading: false,
        answer: json.answer,
        image: json.image,
        title: false,
        buttonText: "Try Again?"
        });
...
return (
        <View style={styles.container}>
        { this.state.title ?
            <Text style={styles.header}>Ask me a question and tap the button below.</Text> : null
        }
            <Button
            onPress={() => {
                this.fetchData();
            }}
            title={this.state.buttonText}
            style={styles.button}
            />
...

    header: {
    textAlign: 'center',
    fontSize: 32,
    padding: 30,
    },
...

Fit the image to the full width of the device

We want the image to adapt to different devices so that our partner can see the response.

So far, we have assigned a fixed width and height for the image.

Let's make it responsive by calculating the width of the device and resizing the image into a 16-by-9 ratio full-width image.

Import the Dimensions component to our application.

import { ActivityIndicator, Button, Alert, Image, Dimensions, StyleSheet, Text, View } from 'react-native';

Calculate and store the width and height of the image as variables.

...
const dimensions = Dimensions.get('window');
const imageHeight = Math.round(dimensions.width * 9 / 16);
const imageWidth = dimensions.width;
...

Change the image styles to reference the new variables.

...
image: {
    height: imageHeight,
    width: imageWidth,
    },
...

Convert the button to a Touchable component

With Touchable components, we can capture tapping gestures and display feedback on button taps.

To do that, we replace our Button with a TouchableOpacity component.

import { ActivityIndicator, TouchableOpacity, Alert, Image, Dimensions, StyleSheet, Text, View } from 'react-native';

Replace our Button component with the TouchableOpacity component and set styles for the button text.

...
<TouchableOpacity
            onPress={() => this.fetchData()}
            >
            <View style={styles.button}>
            <Text style={styles.buttonText}>{this.state.buttonText}</Text>
            </View>
        </TouchableOpacity>
...

    buttonText: {
    padding: 20,
    color: 'white',
    textTransform: 'uppercase'
    },
...

You Spin Me Right Round, Baby

You may have noticed a sudden and abrupt flash of content when the image loads.

This leads to an unpleasant user experience. We don't want that.

Deciding what to eat is stressful enough.

What happened was that though the API returned the value of the image's source attribute, it still takes time to fetch and render the image within the application.

To fix this, we will add a progress indicator when the image is loading.

Open the terminal and stop the development server by hitting CTRL+C.

Install the required React Native libraries.

$ yarn add react-native-image-progress --save
$ yarn add react-native-progress --save

Import the libraries into our project.

import Image from 'react-native-image-progress';
import * as Progress from 'react-native-progress';

Replace the Image component with the following in App.js.

...
{ this.state.image ? <Image
            source={{uri:this.state.image}}
            indicator={Progress.Circle}
            indicatorProps={{
                size: 80,
            }}
            style={styles.picture}>
            <Text style={styles.answer}>{this.state.answer}</Text>
            </Image> : null }
...

For information on how to customise the progress loader, see documentations for React Native Progress and React Native Image Progress.

Start the development server.

$ yarn start

The entire App.js source code.

import React from 'react';
import { ActivityIndicator, TouchableOpacity, Alert, Dimensions, StyleSheet, Text, View } from 'react-native';

import Image from 'react-native-image-progress';
import * as Progress from 'react-native-progress';

const dimensions = Dimensions.get('window');
const imageHeight = Math.round(dimensions.width * 9 / 16);
const imageWidth = dimensions.width;

let api ='https://yesno.wtf/api/';

export default class App extends React.Component {

    constructor(props){
        super(props);
        this.state ={
        isLoading: true,
        answer: "",
        image: "",
        title: true,
        buttonText: "Yes or No?"
        }
    }

    async fetchData() {
    try {
        const response = await fetch(api);
        if (!response.ok) {
        throw Error(response.statusText);
        }
        const json = await response.json();
        this.setState({
        isLoading: false,
        answer: json.answer,
        image: json.image,
        title: false,
        buttonText: "Try Again?"
        });
    } catch (error) {
        console.log(error);
    }
    }

    async componentDidMount() {
    this.setState({
        isLoading: false,
    });
    }

    render() {
    if(this.state.isLoading){
        return(
        <View style={styles.container}>
            <ActivityIndicator/>
        </View>
        )
    }

    return (
        <View style={styles.container}>
            { this.state.title ?
            <Text style={styles.header}>Ask me a question and tap the button below.</Text> : null
            }
            { this.state.image ? <Image
                source={{uri:this.state.image}}
                indicator={Progress.Circle}
                indicatorProps={{
                size: 80,
                }}
                style={styles.picture}>
                <Text style={styles.answer}>{this.state.answer}</Text>
            </Image> : null }
            <TouchableOpacity
                onPress={() => this.fetchData()}
                >
            <View style={styles.button}>
                <Text style={styles.buttonText}>{this.state.buttonText}</Text>
            </View>
            </TouchableOpacity>
        </View>
        )
    }
}

const styles = StyleSheet.create({
    container: {
    flex:1,
    justifyContent:"center",
    alignItems:"center"
    },
    header: {
    textAlign: 'center',
    fontSize: 32,
    padding: 30,
    },
    answer: {
    textAlign: 'center',
    color: 'white',
    backgroundColor: 'rgba(0,0,0,0)',
    fontSize: 32,
    textTransform: 'uppercase'
    },
    button: {
    width: 260,
    alignItems: 'center',
    backgroundColor: '#2196F3'
    },
    buttonText: {
    padding: 20,
    color: 'white',
    textTransform: 'uppercase'
    },
    picture: {
    justifyContent: 'center',
    resizeMode: 'cover',
    height: imageHeight,
    width: imageWidth,
    marginBottom: 30,
    }
});

Conclusion

Life is hectic. We are constantly bombarded with hard-hitting questions every day like "Where Should We Eat?" or "What’s for dinner?".

By leveraging on technology, we managed to establish peace, love and harmony with our spouses and partners.

So remember, the next time a friend asks you for help with culinary conundrums, you can always let them know that "There’s An App For That™".

Try out this app on Expo.io.

Latest Posts

Top ten things I Google at workTop ten things I Google at work
How to order pizza the RPA way!How to order pizza the RPA way!
How Gordon Ramsay saved our struggling development teamHow Gordon Ramsay saved our struggling development team