Posted 9 min to read

This tutorial will show you the process of creating a simple chat application in React Native. Instead of the traditional, text-based look, we will use Google Maps to visualize messages that will be fetched in real time from Firebase Realtime Database.

Creating project

We will use Expo CLI. This tool greatly facilitates the process of developing. Open the terminal and run the following commands:

# Install Expo CLI
npm install -g expo-cli

# Initialize new project
expo init mapchat

# Go to project directory
cd mapchat

# Run the application
expo start

At this point in the mapchat directory we have a sample application structure, which is ready to run in a simulator or a real device using the Expo Client app.

Installing other dependencies

To build the application we will also need:

  • react-native-maps - to display map and markers with messages,
  • expo-location - to fetch the current location of a device,
  • firebase - to write and read messages from the Firebase Realtime Database,
  • moment - for easy date formatting.

Displaying the map

First, create a src/helpers/map.js file containing a helper function that returns an object with the data of the displayed region in the structure required by the react-native-maps package:

// src/helpers/map.js

export function getRegion(latitude, longitude, distance) {
    const oneDegreeOfLatitudeInMeters = 111.32 * 1000;

    const latitudeDelta = distance / oneDegreeOfLatitudeInMeters;
    const longitudeDelta = distance / (oneDegreeOfLatitudeInMeters * Math.cos(latitude * (Math.PI / 180)));

    return {
        latitude,
        longitude,
        latitudeDelta,
        longitudeDelta
    }
};

This function calculates the latitudeDelta and longitudeDelta values based on the given geographical coordinates and distance (in meters), which we will use to control the map zoom.

Open the existing App.js file and replace existing code with the following:

// App.js

import React, { Component } from 'react';
import { StyleSheet, View, Text } from 'react-native';
import MapView from 'react-native-maps';
import { getRegion } from './src/helpers/map';

export default class App extends Component {
  state = {}

  componentDidMount() {}

  onChangeText() {}

  onSendPress() {}

  getLocation = async () => {}

  render() {
    return (
      <View style={styles.container}>
        <MapView
          ref={(ref) => this.map = ref}
          style={styles.map}
          initialRegion={getRegion(48.860831, 2.341129, 160000)}
        />
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center'
  },
  map: {
    ...StyleSheet.absoluteFillObject
  }
});

In the code above, we have imported components we need at this moment, and then inside the App class we have declared app state and methods, which are currently empty but will be explained and filled with the code later. Next, we render the MapView component and set the object returned by our getRegion() helper function to the initialRegion prop. In addition, by using the ref prop we created a reference to the map, so that we can refer to it at later stages from other places in the code.

Calling the getRegion(48.860831, 2.341129, 160000) function (Paris coords included here as an example) returns the following object:

Object {
  "latitude": 48.860831,
  "latitudeDelta": 1.437297879985627,
  "longitude": 2.341129,
  "longitudeDelta": 2.1847076799791414,
}

To zoom in/out the map, simply change the third parameter (distance), which will calculate new latitudeDelta and longitudeDelta values on which the zoom level depends.

Also note that we have passed the style prop to the MapView component with rules to expand the container to maximum width and height of the screen (StyleSheet.absoluteFill is the faster equivalent for position: 'absolute', left: 0, right: 0, top: 0, bottom: 0). Not setting the container size is one of the most common problems with displaying the map.

Current app state with map

Getting the device location

We need the location to be saved in the database together with the messages sent by the user in order to display a marker in the right place of the map. Also, it will be useful for changing the default display region of the map to a region near the user. We will get it using the previously installed expo-location package.

Add at the top of the App.js file:

// App.js

(...)
import * as Location from 'expo-location';
import * as Permissions from 'expo-permissions';
(...)

Then, inside the App class, define the default application state in which location will be stored:

// App.js

(...)
  state = {
    location: {
      latitude: null,
      longitude: null
    }
  }
(...)

Further, in the same file, fill in the empty getLocation method with following code:

// App.js

(...)
  getLocation = async () => {
    let { status } = await Permissions.askAsync(Permissions.LOCATION);

    if (status === 'granted') {
      let location = await Location.getCurrentPositionAsync({});

      this.setState({
        location: {
          latitude: location.coords.latitude,
          longitude: location.coords.longitude
        }
      });

      this.map.animateToRegion(getRegion(location.coords.latitude, location.coords.longitude, 16000));
    }
  }
(...)

Calling this method will ask for permission to access the device location. If permission is granted, the location is saved in the application state. Then, using the map reference, we call animateToRegion() method, causing the map to scroll smoothly to your location.

We would like this method to be called immediately after starting the application, so let's call it in the componentDidMount method:

// App.js

(...)
  componentDidMount() {
    this.getLocation();
  }
(...)

Firebase configuration

Create a file src/config/firebase.js with the following content:

// src/config/firebase.js

import * as firebase from 'firebase';

const firebaseConfig = {
    apiKey: "",
    authDomain: "",
    databaseURL: "",
    projectId: "",
    storageBucket: "",
    messagingSenderId: "",
    appId: ""
};

firebase.initializeApp(firebaseConfig);

export default firebase;

Then log in to Firebase Console, create a new project, go to Project settings > Firebase SDK snippet > Config and copy the configuration details to the firebaseConfig constant in the above file.

Now we have to import firebase in the App.js file:

// App.js

(...)
import firebase from './src/firebase';
(...)

Creating a text field for a message

To do this, we use the TextInput component from react-native for the message body and the TochableOpacity component which will be our button responsible for sending it. The button will also contain an icon from the Expo library (check full icon list here).

So let's modify the import statement as shown below:

// App.js

(...)
import { TextInput, TouchableOpacity, ToastAndroid, StatusBar, Keyboard, StyleSheet, View, Text } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
(...)

We also import:

  • ToastAndroid - to display a toast message after sending the message,
  • StatusBar - will help us get the current status bar height to calculate the distance between text input and the top of the screen,
  • Keyboard - will allow us to use the Keyboard.dismiss() method to hide the keyboard after sending the message.

Add new fields to the application state:

// App.js

(...)
  state = {
    (...)
    messageText: null,
    sendButtonActive: false
  }
(...)
  • messageText - here we will store the value entered into the text input,
  • sendButtonActive - this value will be used to determine if the send button should be active and allow you to send the message or not.

Then display TextInput in the render() method:

// App.js

(...)
  render() {
    return (
      <View style={styles.container}>
        <View style={styles.inputWrapper}>
          <TextInput
            style={styles.input}
            placeholder="Type your message here"
            onChangeText={messageText => this.onChangeText(messageText)}
            value={this.state.messageText}
          />
          <View style={{ ...styles.sendButton, ...(this.state.sendButtonActive ? styles.sendButtonActive : {}) }}>
            <TouchableOpacity onPress={this.onSendPress.bind(this)}>
              <MaterialIcons name="send" size={32} color="#fe4027" />
            </TouchableOpacity>
          </View>
        </View>
        <MapView>(...)</MapView>
      </View>
    );
  }
(...)

We will also need to add some styles:

// App.js

(...)
const styles = StyleSheet.create({
  (...)
  inputWrapper: {
    width: '100%',
    position: 'absolute',
    padding: 10,
    top: StatusBar.currentHeight,
    left: 0,
    zIndex: 100
  },
  input: {
    height: 46,
    paddingVertical: 10,
    paddingRight: 50,
    paddingLeft: 10,
    borderColor: 'gray',
    borderWidth: 1,
    borderRadius: 6,
    borderColor: '#ccc',
    backgroundColor: '#fff'
  },
  sendButton: {
    position: 'absolute',
    top: 17,
    right: 20,
    opacity: 0.4
  },
  sendButtonActive: {
    opacity: 1
  }
});

Current application state with text input

Sending the message

Our TextInput component already calls the onChangeText method every time the user changes the input's value and passes the current value to it. Let's fill this currently empty method with the code, which will modify our application state accordingly. Additionally it will also decide whether the send button should be active (if at least one character is entered).

// App.js

(...)
   onChangeText(messageText) {
    this.setState({
      messageText: messageText,
      sendButtonActive: messageText.length > 0
    });
  }
(...)

Next, we have to handle the button press event that calls the onSendPress method, which will send the message to Firebase Realtime Database.

// App.js

(...)
  onSendPress() {
    if (this.state.sendButtonActive) {
      firebase.database().ref('messages').push({
        text: this.state.messageText,
        latitude: this.state.location.latitude,
        longitude: this.state.location.longitude,
        timestamp: firebase.database.ServerValue.TIMESTAMP
      }).then(() => {
        this.setState({ messageText: null });

        ToastAndroid.show('Your message has been sent!', ToastAndroid.SHORT);

        Keyboard.dismiss();
      }).catch((error) => {
        console.log(error);
      });
    }
  }
(...)

As you can see in the code above, we are also sending coordinates (latitude, longitude) and the timestamp returned by the Firebase server at which the message was created.

If everything has been done correctly, in the Firebase console you will be able see the messages sent in real time.

Firebase Realtime Database

Displaying markers and messages

To display the marker, we will use the Marker component from react-native-maps package. Above it, we will render the Callout component with the message text. Let's import them:

// App.js

(...)
import MapView, { Marker, Callout } from 'react-native-maps';
import moment from 'moment';
(...)

We also imported the moment.js library that was installed earlier, it will allow us to convert the timestamp a message was created into human-readable format ("added X minutes ago").

Add empty messages array to the default app state, where messages from Firebase will be stored:

// App.js

(...)
   state = {
    (...)
    messages: []
  };
(...)

Now we have to fetch the messages from the Firebase. The right place for it is the componentDidMount() method:

// App.js

(...)
  componentDidMount() {
    this.getLocationAsync();

    firebase.database().ref('messages').limitToLast(10).on('child_added', (data) => {
      let messages = [...this.state.messages, data.val()];

      this.setState({ messages }, () => {
        let { latitude, longitude } = [...messages].pop();

        this.map.animateToRegion(getRegion(latitude, longitude, 16000));

        if (this.marker !== undefined) {
          setTimeout(() => {
            this.marker.showCallout();
          }, 100);
        }
      });
    });
  }
(...)

The code above will fetch the latest 10 messages and then listens to the child_added event, which will be called each time a new message is added. During each such event, the messages array in the application state is updated with new messages. Then the map is scrolled to the location from the latest message, and using the reference to the most recently added marker (which we will create in a moment), the showCallout() method is called on it to display the message text.

Now add the Marker and Callout components to the render() method:

// App.js

(...)
  render() {
    return (
      (...)
        <MapView
          ref={(ref) => this.map = ref}
          style={styles.map}
          initialRegion={getRegion(48.860831, 2.341129, 160000)}
        >
          {this.state.messages.map((message, index) => {
            let { latitude, longitude, text, timestamp } = message;

            return (
              <Marker
                ref={(ref) => this.marker = ref}
                key={index}
                identifier={'marker_' + index}
                coordinate={{ latitude, longitude }}
              >
                <Callout>
                  <View>
                    <Text>{text}</Text>
                    <Text style={{'color': '#999'}}>{moment(timestamp).fromNow()}</Text>
                  </View>
                </Callout>
              </Marker>
            )
          })}
        </MapView>
      </View>
    );
  }
(...)

And that's all! New messages are displayed on the map.

Final application screen

The ready-to-run code from this tutorial is available on GitHub.

Exercises for you

  1. Assign each user (device) a different marker color on the map.
  2. Handle the denial of access to the device location by displaying the appropriate message.
  3. Add option for the users allowing them to disable automatic map scroll to the new markers.