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
Let's put an end to this misery by creating a decision-making app.
$ npm install -g expo-cli
$ expo init IndecisiveApp
$ 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
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
}
});
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.
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}
/>
...
Our app is doing something useful at last.
Let's enhance it by providing some contextual feedback.
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 (
...
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,
});
}
...
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,
},
...
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,
},
...
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 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,
}
});
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™".