Create a carpooling app with React Native - Part 2: Creating the frontend

  • Wern Ancheta
June 7th, 2018
You will need to have completed the first part of the series. For part two you need experience with React Native.

This is the second part of a two-part series on creating a carpooling app with React Native. This is the part where we will be creating the actual app. I’ll be showing you how to set up the app so you can run it on an Android emulator (Genymotion) or an iOS device.

Prerequisites

You need to have completed part one before moving on to this tutorial.

To effectively follow this tutorial, you should have a good grasp of the following React concepts:

  • props
  • refs
  • state
  • component lifecycle

As for building the app with React Native, knowing how to do the following will be helpful:

  • How to use primitive React Native components such as the View or Text.
  • How to add styles to the components.
  • How to create your own components.

What we’ll be building

The complete details on what we’ll be building are available in the first part of the series. As a refresher, we’ll be building a carpooling app. This allows the user to share the vehicle they’re currently riding in so someone else can hop in the same vehicle. The app is responsible for:

  • Matching the users so that only the users who are going the same route can share a ride with each other.
  • After two users are matched, the app provides realtime tracking on where each other currently are.

For the rest of the tutorial, I’ll be referring to the user who is sharing the ride as the “rider”. While the user who is searching for a ride as the “hiker”.

Installing the dependencies

Start by generating a new React Native project:

    react-native init Ridesharer

This will create a Ridesharer directory. This will serve as the root directory that we’ll be using for the rest of the tutorial.

The app relies on the following libraries to implement specific features:

To ensure that we’re both using the same package versions, open the package.json file and update the dependencies with the following:

    "dependencies": {
      "axios": "0.18.0",
      "prop-types": "15.6.1",
      "pusher-js": "4.2.2",
      "react": "16.3.1",
      "react-native": "0.55.4",
      "react-native-geocoding": "0.3.0",
      "react-native-google-places-autocomplete": "1.3.6",
      "react-native-maps": "0.20.1",
      "react-native-maps-directions": "1.6.0",
      "react-native-vector-icons": "4.6.0",
      "react-navigation": "2.0.1"
    },

Once that’s done, save the file and execute npm install.

Setting up the dependencies

Now that you’ve installed all the dependencies, there’s one more thing you have to do before you can start coding the app. Additional setup is required for the following dependencies:

Instructions on how to set up the dependencies are available on the Github repos for each library. Here are the links to the setup instructions to the specific version we’re using:

Note that if you’re reading this sometime in the future, you’ll probably have to install the latest package versions and follow their latest installation instructions.

Building the app

Now we’re ready to build the app. Navigate inside the Ridesharer directory as that’s going to be our working directory.

Note that anytime you feel confused on where to add a specific code, you can always visit the GitHub repo and view the file.

Index

Open the index.js file and make sure you’re registering the same name that you used when you generated the project. In this case, it should be Ridesharer:

    // Ridesharer/index.js
    import { AppRegistry } from 'react-native';
    import App from './App';

    AppRegistry.registerComponent('Ridesharer', () => App);

Root component

Create a Root.js file. This will serve as the Root component of the app. This is where we set up the navigation so we include the two pages of the app: Home and Map. We will be creating these pages later:

    // Ridesharer/Root.js
    import React from 'react';
    import { StackNavigator } from 'react-navigation';

    import HomePage from './app/screens/Home';
    import MapPage from './app/screens/Map';

    const RootStack = StackNavigator(
      {
        Home: {
          screen: HomePage
        },
        Map: {
          screen: MapPage
        }
      },
      { 
        initialRouteName: 'Home', // set the home page as the default page 
      }
    );

    export default RootStack;

In the above code, we’re using the StackNavigator, one of the navigators that comes with the React Navigation library. This allows us to push and pop pages to and from a stack. Navigating to a page means pushing it in front of the stack, going back means popping the page that’s currently in front of the stack.

App component

Open the App.js file and render the App component:

    // Ridesharer/App.js
    import React, { Component } from 'react';
    import {
      StyleSheet,
      View
    } from 'react-native';

    import Root from './Root';

    export default class App extends Component {

      render() {
        return (
          <View style={styles.container}>
            <Root />
          </View>
        );
      }

    }

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

Tapper component

The Tapper component is simply a button created for convenience. We can’t really apply a custom style to the built-in React Native Button component so we’re creating this one. This component wraps the Button component in a View in which the styles are applied:

    // Ridesharer/app/components/Tapper/Tapper.js
    import React from 'react';
    import { View, Button } from 'react-native';

    import styles from './styles';

    const Tapper = (props) => {
      return (
        <View style={styles.button_container}>
          <Button
            onPress={props.onPress}
            title={props.title}
            color={props.color}
          />
        </View>
      );
    }

    export default Tapper;

Here’s the style declaration:

    // Ridesharer/app/components/Tapper/styles.js
    import { StyleSheet } from 'react-native';

    export default StyleSheet.create({
      button_container: {
        margin: 10
      },
    });

Lastly, we export it using an index.js file so that we can simply refer to the component as Tapper without including the Tapper.js file in the import statement later on:

    // Ridesharer/app/components/Tapper/index.js
    import Tapper from './Tapper';

    export default Tapper;

If you don’t want to create a separate component, you can always use the TouchableOpacity and TouchableHighlight components. Those two allow you to add a custom style.

Home page

The Home page is the default page the user sees when they open the app.

Start by including all the React Native packages that we need:

    // Ridesharer/app/screens/Home.js
    import React, { Component } from 'react';
    import { 
      View, 
      Text, 
      StyleSheet, 
      TextInput, 
      Alert, 
      ActivityIndicator, 
      PermissionsAndroid, 
      KeyboardAvoidingView 
    } from 'react-native';

Among the packages above, only these three warrants an explanation:

  • PermissionsAndroid - for asking permissions to use the device’s Geolocation feature on Android.
  • KeyboardAvoidingView - for automatically adjusting the View when the on-screen keyboard pops out. This allows the user to see what they’re inputting while the keyboard is open. Most of the time, especially on devices with small screen, the input is hidden when the keyboard is open.

Next, include the third-party packages we installed earlier:

    import axios from 'axios';
    import Icon from 'react-native-vector-icons/FontAwesome';
    import Tapper from '../components/Tapper';

Add your ngrok URL (this was created in the first part of the series):

    const base_url = 'YOUR NGROK URL';

Declare the function that will ask for the Geolocation permission and then call it:

    async function requestGeolocationPermission() {
      try{
        const granted = await PermissionsAndroid.request(
          PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
          {
            'title': 'Ridesharer Geolocation Permission',
            'message': 'Ridesharer needs access to your current location so you can share or search for a ride'
          }
        );

        if(granted === PermissionsAndroid.RESULTS.GRANTED){
          console.log("You can use the geolocation")
        }else{
          console.log("Geolocation permission denied")
        }
      }catch(err){
        console.warn(err)
      }
    }

    requestGeolocationPermission();

Hide the header. The Home page doesn’t need it:

    export default class Home extends Component {
      static navigationOptions = {
        header: null,
      };
    }

Set the default state for the loader (for controlling the visibility of the ActivityIndicator) and username:

    state = {
      is_loading: false,
      username: ''
    }

Render the Home page. In this page we have:

  • An input that asks for the user’s name
  • A button for sharing a ride
  • A button for hitching a ride

Note that we’re using the KeyboardAvoidingView as a wrapper. This way, everything inside it will adjust accordingly when the on-screen keyboard becomes visible:

    render() {

      return (
        <KeyboardAvoidingView style={styles.container} behavior="padding" enabled>
          <View style={styles.jumbo_container}>
            <Icon name="question-circle" size={35} color="#464646" />
            <Text style={styles.jumbo_text}>What do you want to do?</Text>
          </View>

          <View>
            <TextInput
              placeholder="Enter your username"
              style={styles.text_field}
              onChangeText={(username) => this.setState({username})}
              value={this.state.username}
              clearButtonMode={"always"}
              returnKeyType={"done"}
            />
            <ActivityIndicator size="small" color="#007ff5" style={{marginTop: 10}} animating={this.state.is_loading} />
          </View>

          <View style={styles.close_container}>
            <Tapper
              title="Share a Ride"
              color="#007ff5"
              onPress={() => {
                this.enterUser('share');
              }}
            />

            <Tapper 
              title="Hitch a Ride" 
              color="#00bcf5" 
              onPress={() => {
                this.enterUser('hike');
              }} 
            />
          </View>

        </KeyboardAvoidingView>
      );
    }

When either of the buttons is pressed, the function below gets executed. All it does is create the user if they don’t already exist:

    enterUser = (action) => {
      if(this.state.username){ // user should enter a username before they can enter

        this.setState({
          is_loading: true
        });

        // make a POST request to the server for creating the user
        axios.post(`${base_url}/save-user.php`, {
          username: this.state.username // the username entered in the text field
        })
        .then((response) => {

          if(response.data == 'ok'){
            // hide the ActivityIndicator
            this.setState({
              is_loading: false
            });

            // navigate to the Map page, submitting the user's action (ride or hike) and their username as a navigation param (so it becomes available on the Map page)
            this.props.navigation.navigate('Map', {
              action: action,
              username: this.state.username
            });
          }

        });

      }else{
        Alert.alert(
          'Username required',
          'Please enter a username'
        );
      }
    }

Add the styles for the Home page:

    const styles = StyleSheet.create({
      container: {
        flex: 1,
        alignItems: 'center',
        justifyContent: 'space-around'
      },
      jumbo_container: {
        padding: 50,
        alignItems: 'center'
      },
      jumbo_text: {
        marginTop: 20,
        textAlign: 'center',
        fontSize: 25,
        fontWeight: 'bold'
      },
      text_field: {
        width: 200,
        height: 50,
        padding: 10,
        backgroundColor: '#FFF', 
        borderColor: 'gray', 
        borderWidth: 1
      }
    });

Map page

The Map page contains the main meat of the app. This allows the user to share or search for a ride. The tracking of location is implemented via Google Maps, Pusher Channels, and React Native’s Geolocation feature.

Start by including all the React Native packages that we need:

    // Ridesharer/app/screens/Map.js
    import React, { Component } from 'react';
    import { 
      View, 
      Text, 
      StyleSheet, 
      Alert, 
      Dimensions, 
      ActivityIndicator
    } from 'react-native';

Next, include the packages that we installed earlier:

    import { GooglePlacesAutocomplete } from 'react-native-google-places-autocomplete';
    import MapView, { Marker, Callout } from 'react-native-maps';
    import MapViewDirections from 'react-native-maps-directions';
    import Icon from 'react-native-vector-icons/FontAwesome';
    import Pusher from 'pusher-js/react-native'; 
    import Geocoder from 'react-native-geocoding';
    import axios from 'axios';

Include the location library. We will be creating this later, but for now, know that these functions are used to center the map correctly (regionFrom()) and getting the difference of two coordinates in meters (getLatLonDiffInMeters()):

    import { regionFrom, getLatLonDiffInMeters } from '../lib/location';
    import Tapper from '../components/Tapper';

Initialize your API keys and ngrok base URL:

    const google_api_key = 'YOUR GOOGLE PROJECT API KEY';
    const base_url = 'YOUR NGROK BASE URL';
    const pusher_app_key = 'YOUR PUSHER APP KEY';
    const pusher_app_cluster = 'YOUR PUSHER APP CLUSTER';

    Geocoder.init(google_api_key); // initialize the geocoder

Next, also declare the timeouts for searching and sharing a ride. We will be using these values later to reset the app’s UI if it couldn’t match two users within these timeouts:

    const search_timeout = 1000 * 60 * 10; // 10 minutes
    const share_timeout = 1000 * 60 * 5; // 5 minutes

Setup a default region that the map will display:

    const default_region = {
      latitude: 37.78825,
      longitude: -122.4324,
      latitudeDelta: 0.0922,
      longitudeDelta: 0.0421,
    };

Get the device width. We will be using this later to set the width of the auto-complete text field for searching places:

    var device_width = Dimensions.get('window').width; 

Next, create the Map component and set the navigationOptions. Unlike the Home page earlier, we need to set a few options for the navigation. This includes the header title and the styles applied to it. Putting these navigation options will automatically add a back button to the header to allow the user to go back to the Home page:

    export default class Map extends Component {

      static navigationOptions = ({navigation}) => ({
        headerTitle: 'Map',
        headerStyle: {
          backgroundColor: '#007ff5'
        },
        headerTitleStyle: {
          color: '#FFF'
        }
      });

      // next: add the code for initializing the state
    }

Next, initialize the state:

    state = {
      start_location: null, // the coordinates (latitude and longitude values) of the user's origin
      end_location: null, // the coordinates of the user's destination
      region: default_region, // the region displayed in the map
      from: '', // the name of the place where the user is from (origin)
      to: '', // the name of the place where the user is going (destination)
      rider_location: null, // the coordinates of the rider's current location
      hiker_location: null, // the coordinates of the hiker's origin
      is_loading: false, // for controlling the visibility of the ActivityIndicator
      has_journey: false // whether the rider has accepted a hiker's request or a hiker's request has been accepted by a rider 
    }

    // next: add the constructor

Next, add the constructor:

    constructor(props) {
      super(props);
      this.from_region = null;
      this.watchId = null; // unique ID for the geolocation watcher. Storing it in a variable allows us to stop it at a later time (for example: when the user is done using the app)
      this.pusher = null; // variable for storing the Pusher instance
      this.user_channel = null; // the Pusher channel for the current user
      this.journey_id = null; // the hiker's route ID
      this.riders_channel = []; // if current user is a hiker, the value of this will be the riders channel
      this.users_channel = null; // the current user's channel
      this.hiker = null // for storing the hiker's origin coordinates; primarily used for getting the distance between the rider and the hiker
    }

Once the component is mounted, you want to get the username that was passed from the Home page earlier. This username is used later on as the unique key for identifying each user that connects to Pusher Channels:

    componentDidMount() {
      const { navigation } = this.props;
      const username = navigation.getParam('username');

      this.pusher = new Pusher(pusher_app_key, {
        authEndpoint: `${base_url}/pusher-auth.php`,
        cluster: pusher_app_cluster,
        encrypted: true
      });  

      // next: add the code for subscribing to the current user's own channel
    }  

Next, add the code for subscribing to the current user's own channel. This allows the user to send and receive data in realtime through this channel. In the hiker’s case, they use it to make a request to the matching rider. In the rider’s case, they use it to receive requests coming from hikers as well as sending an acceptance and their current location to the hiker:

    this.users_channel = this.pusher.subscribe(`private-user-${username}`); // note that the private-* is required when using private channels 

When a rider receives a request, the code below is executed. This alerts the rider that someone wants to ride with them. They can either accept or decline it:

    this.users_channel.bind('client-rider-request', (hiker) => {

      Alert.alert(
        `${hiker.username} wants to ride with you`,
        `Pickup: ${hiker.origin} \nDrop off: ${hiker.dest}`,
        [
          {
            text: "Decline",
            onPress: () => {
              // do nothing
            },
            style: "cancel"
          },
          {
            text: "Accept", 
            onPress: () => {
              this.acceptRide(hiker);
            }
          },
        ],
        { cancelable: false } // no cancel button
      );

    });

    // next: add code for getting the user's origin

Note that in the code above, we’re not really handling declines. This is to keep the focus on the key feature of the app.

Next, get the user’s current location via the Geolocation API. At this point, we can already use the API without problems (unless the user didn’t approve the permission). We’ll just focus our attention on the “happy path” to keep things simple so we’ll assume that the user approved the permission request:

    navigator.geolocation.getCurrentPosition(
      (position) => {
        // get the region (this return the latitude and longitude delta values to be used by React Native Maps)
        var region = regionFrom(
          position.coords.latitude, 
          position.coords.longitude, 
          position.coords.accuracy
        );

        // convert the coordinates to the descriptive name of the place
        Geocoder.from({
          latitude: position.coords.latitude,
          longitude: position.coords.longitude
        })
        .then((response) => {
          // the response object is the same as what's returned in the HTTP API: https://developers.google.com/maps/documentation/geocoding/intro

          this.from_region = region; // for storing the region in case the user presses the "reset" button

          // update the state to indicate the user's origin on the map (using a marker)
          this.setState({
            start_location: {
              latitude: position.coords.latitude,
              longitude: position.coords.longitude
            },
            region: region, // the region displayed on the map
            from: response.results[0].formatted_address // the descriptive name of the place
          });

        });

      }
    );

Next, add the acceptRide() function. This function is executed when the rider accepts a hiker’s ride request:

    acceptRide = (hiker) => {

      const username = this.props.navigation.getParam('username');

      let rider_data = {
        username: username,
        origin: this.state.from, // descriptive name of the rider's origin
        dest: this.state.to, // descriptive name of the rider's destination
        coords: this.state.start_location // the rider's origin coordinates
      };

      this.users_channel.trigger('client-rider-accepted', rider_data); // inform hiker that the rider accepted their request; send along the rider's info

      // make a request to delete the route so other hikers can no longer search for it (remember the 1:1 ratio for a rider to hiker?)
      axios.post(`${base_url}/delete-route.php`, {
        username: username
      })
      .then((response) => {
        console.log(response.data);
      })
      .catch((err) => {
        console.log('error excluding rider: ', err);
      });

      this.hiker = hiker; // store the hiker's info

      // update the state to stop the loading animation and show the hiker's location
      this.setState({
        is_loading: false,
        has_journey: true,
        hiker_location: hiker.origin_coords
      });

    }

Next, add the function for rendering the UI:

    render() {
      const { navigation } = this.props;
      // get the navigation params passed from the Home page earlier
      const action = navigation.getParam('action'); // action is either "ride" or "hike"
      const username = navigation.getParam('username');

      let action_button_label = (action == 'share') ? 'Share Ride' : 'Search Ride';

      // next: add code for rendering the UI
    }

The map UI contains the following:

  • MapView component for rendering the map. Inside it are the following:
    • Marker component for showing the origin and destination of the user, as well as for showing the location of the rider (if the user is a hiker), or the hiker (if the user is a rider).
    • MapViewDirections component for showing the route from the origin to the destination of the current user.
  • GooglePlacesAutocomplete component for rendering an auto-complete text field for searching and selecting a destination.
  • ActivityIndicator for showing a loading animation while the rider waits for someone to request a ride, or when the hiker waits for the app to find a matching rider.
  • Tapper component for sharing a ride or searching a ride.
  • Tapper component for resetting the selection (auto-complete text field and marker).
    return (
      <View style={styles.container}>

        <MapView
          style={styles.map}
          region={this.state.region}
          zoomEnabled={true}
          zoomControlEnabled={true}
        >
          {
            this.state.start_location &&
            <Marker coordinate={this.state.start_location}>
              <Callout>
                <Text>You are here</Text>
              </Callout>
            </Marker>
          }

          {
            this.state.end_location &&
            <Marker
              pinColor="#4196ea"
              coordinate={this.state.end_location}
              draggable={true}
              onDragEnd={this.tweakDestination}
            />
          }

          {
            this.state.rider_location &&
            <Marker 
              pinColor="#25a25a"
              coordinate={this.state.rider_location}
            >
              <Callout>
                <Text>Rider is here</Text>
              </Callout>
            </Marker>
          }

          {
            this.state.hiker_location &&
            <Marker 
              pinColor="#25a25a"
              coordinate={this.state.hiker_location}
            >
              <Callout>
                <Text>Hiker is here</Text>
              </Callout>
            </Marker>
          }

          {
            this.state.start_location && this.state.end_location &&
            <MapViewDirections
              origin={{
                'latitude': this.state.start_location.latitude,
                'longitude': this.state.start_location.longitude
              }}
              destination={{
                'latitude': this.state.end_location.latitude,
                'longitude': this.state.end_location.longitude
              }}
              strokeWidth={5}
              strokeColor={"#2d8cea"}
              apikey={google_api_key}
            />
          }

        </MapView>

        <View style={styles.search_field_container}>

          <GooglePlacesAutocomplete
            ref="endlocation"
            placeholder='Where do you want to go?'
            minLength={5} 
            returnKeyType={'search'} 
            listViewDisplayed='auto' 
            fetchDetails={true}            
            onPress={this.selectDestination}

            query={{
              key: google_api_key,
              language: 'en', 
            }}

            styles={{
              textInputContainer: {
                width: '100%',
                backgroundColor: '#FFF'
              },
              listView: {
                backgroundColor: '#FFF'
              }
            }}
            debounce={200} 
          />
        </View>

        <ActivityIndicator size="small" color="#007ff5" style={{marginBottom: 10}} animating={this.state.is_loading} />

        {
          !this.state.is_loading && !this.state.has_journey &&
          <View style={styles.input_container}>

            <Tapper 
              title={action_button_label}
              color={"#007ff5"}
              onPress={() => {
                this.onPressActionButton();
              }} />

            <Tapper
              title={"Reset"}
              color={"#555"}
              onPress={this.resetSelection} 
            />

          </View>
        }

      </View>
    );

The code above should be pretty self-explanatory. If you’re unsure what a specific prop does, how the component works, or what children is it expecting, you can always check the Github repo of the package we’re using.

Next, let’s move on to the functions used in the UI. The resetSelection() is executed when the reset button is pressed by the user. This empties the auto-complete text field for searching for places, it also updates the state so the UI reverts back to its previous state before the destination was selected. This effectively removes the marker showing the user’s destination, as well as the route going to it:

    resetSelection = () => {
      this.refs.endlocation.setAddressText('');
      this.setState({
        end_location: null,
        region: this.from_region,
        to: ''
      });
    }

The tweakDestination() function is executed when the user drops the destination marker somewhere else:

    tweakDestination = () => {
      // get the name of the place
      Geocoder.from({
        latitude: evt.nativeEvent.coordinate.latitude,
        longitude: evt.nativeEvent.coordinate.longitude
      })
      .then((response) => {
        this.setState({
          to: response.results[0].formatted_address
        });
      });

      this.setState({
        end_location: evt.nativeEvent.coordinate
      });
    }

The selectDestination() function is executed when the user selects their destination. This function will update the state so it shows the user’s destination in the map:

    selectDestination = (data, details = null) => {

      const latDelta = Number(details.geometry.viewport.northeast.lat) - Number(details.geometry.viewport.southwest.lat)
      const lngDelta = Number(details.geometry.viewport.northeast.lng) - Number(details.geometry.viewport.southwest.lng)

      let region = {
        latitude: details.geometry.location.lat,
        longitude: details.geometry.location.lng,
        latitudeDelta: latDelta,
        longitudeDelta: lngDelta
      };

      this.setState({
        end_location: {
          latitude: details.geometry.location.lat,
          longitude: details.geometry.location.lng,
        },
        region: region,
        to: this.refs.endlocation.getAddressText() // get the full address of the user's destination
      });

    }

When the user presses the Share a Ride or Search a Ride button, the onPressActionButton() function is executed. This executes either the shareRide() function or the hikeRide() function depending on the action selected from the Home page earlier:

    onPressActionButton = () => {

      const action = this.props.navigation.getParam('action');
      const username = this.props.navigation.getParam('username');

      this.setState({
        is_loading: true
      });

      if(action == 'share'){
        this.shareRide(username);
      }else if(action == 'hike'){
        this.hikeRide(username);      
      }

    }

The shareRide() function is executed when a rider shares their ride after selecting a destination. This makes a request to the server to save the route. The response contains the unique ID assigned to the rider’s route. This ID is assigned as the value of this.journey_id. This will be used later to:

  • Make a request to the server to update the route record stored in the Elasticsearch index.
  • Know when to start doing something with the current location data. This is because the current position begins to be watched right after the user presses on the Share a Ride button as you’ll see on the code block after this:
    shareRide = (username) => {

      axios.post(`${base_url}/save-route.php`, {
        username: username,
        from: this.state.from, 
        to: this.state.to, 
        start_location: this.state.start_location,
        end_location: this.state.end_location
      })
      .then((response) => {
        this.journey_id = response.data.id;
        Alert.alert(
          'Ride was shared!',
          'Wait until someone makes a request.'
        );
      })
      .catch((error) => {
        console.log('error occurred while saving route: ', error);
      });

      // next: add code for watching the rider's current location

    }

Next, start watching the user’s current location. Note that we won’t actually do anything with the location data unless the rider has already shared their ride and that they have already approved a hiker to ride with them. Once both conditions are met, we make a request to the server to update the previously saved route with the rider’s current location. This way, when a hiker searches for a ride, the results will be biased based on the rider’s current location and not their origin:

    this.watchId = navigator.geolocation.watchPosition(
      (position) => {

        let latitude = position.coords.latitude;
        let longitude = position.coords.longitude;
        let accuracy = position.coords.accuracy;

        if(this.journey_id && this.hiker){ // needs to have a destination and a hiker
          // update the route with the rider's current location
          axios.post(`${base_url}/update-route.php`, {
            id: this.journey_id,
            lat: latitude,
            lon: longitude
          })
          .then((response) => {
            console.log(response);
          });

          // next: add code for sending rider's current location to the hiker

        }

      },
      (error) => {
        console.log('error occured while watching position: ', error);
      },
      { 
        enableHighAccuracy: true, // get more accurate location
        timeout: 20000, // timeout after 20 seconds of not being able to get location
        maximumAge: 2000, // location has to be atleast 2 seconds old for it to be relevant
        distanceFilter: 10 // allow up to 10-meter difference from the previous location before executing the callback function again
      }
    );

    // last: add code for resetting the UI after 5 minutes of sharing a ride 

Next, we send a client-rider-location event to the rider’s own channel. Later, we’ll have the hiker subscribe to the rider’s channel (the one they matched with) so that they’ll receive the location updates:

    let location_data = {
      username: username,
      lat: latitude,
      lon: longitude,
      accy: accuracy 
    };

    this.users_channel.trigger('client-rider-locationchange', location_data); // note: client-* is required when sending client events through Pusher

    // update the state so that the rider’s current location is displayed on the map and indicated with a marker
    this.setState({
      region: regionFrom(latitude, longitude, accuracy),
      start_location: {
        latitude: latitude,
        longitude: longitude
      }
    });

    // next: add code for updating the app based on how near the rider and hiker are from each other

Next, we need to get the difference (in meters) between the rider’s coordinates and the hiker’s origin:

    let diff_in_meters = getLatLonDiffInMeters(latitude, longitude, this.hiker.origin_coords.latitude, this.hiker.origin_coords.longitude);

    if(diff_in_meters <= 20){
      this.resetUI();
    }else if(diff_in_meters <= 50){
      Alert.alert(
        'Hiker is near',
        'Hiker is around 50 meters from your current location'
      );
    }

Next, add the code for resetting the UI after five minutes without anyone requesting to share a ride with the rider:

    setTimeout(() => {
      this.resetUI();
    }, share_timeout);

Here’s the code for resetting the UI:

    resetUI = () => {

      this.from_region = null;
      this.watchId = null; 
      this.pusher = null; 
      this.user_channel = null; 
      this.journey_id = null;
      this.riders_channel = []; 
      this.users_channel = null; 
      this.hiker = null;

      this.setState({
        start_location: null,
        end_location: null,
        region: default_region,
        from: '',
        to: '',
        rider_location: null, 
        hiker_location: null,
        is_loading: false,
        has_journey: false
      });

      this.props.navigation.goBack(); // go back to the Home page

      Alert.alert('Awesome!', 'Thanks for using the app!');

    }

Now let’s move on to the hiker’s side of things. When the hiker presses the Search a Ride button, the hikeRide() function is executed. This function is executed every five seconds until it finds a rider which matches the hiker’s route. If a rider cannot be found within ten minutes, the function stops. Once the server returns a suitable rider, it responds with the rider’s information (username, origin, destination, coordinates). This is then used to subscribe to the rider’s channel so the hiker can request for a ride and receive location updates. Note that this is done automatically, so the hiker doesn’t have control over who they share a ride with:

    hikeRide = (username) => {

      var interval = setInterval(() => {
        // make a request to the server to get riders that matches the hiker's route
        axios.post(`${base_url}/search-routes.php`, {
          origin: this.state.start_location,
          dest: this.state.end_location
        })
        .then((response) => {

          if(response.data){

            clearInterval(interval); // assumes the rider will accept the request

            let rider = response.data; // the rider's info

            // subscribe to the rider's channel so the hiker can make a request and receive updates from the rider
            this.riders_channel = this.pusher.subscribe(`private-user-${rider.username}`);

            this.riders_channel.bind('pusher:subscription_succeeded', () => {
              // when subscription succeeds, make a request to the rider to share the ride with them
              this.riders_channel.trigger('client-rider-request', {
                username: username, // username of the hiker
                origin: this.state.from, // descriptive name of the hiker's origin
                dest: this.state.to, // descriptive name of the hiker's destination
                origin_coords: this.state.start_location // coordinates of the hiker's origin
              });
            });

            // next: add code for listening for when the rider accepts their request
          }      
        })
        .catch((error) => {
          console.log('error occurred while searching routes: ', error);
        });

      }, 5000);

      setTimeout(() => {
        clearInterval(interval);
        this.resetUI();
      }, ten_minutes);

    }

Once the rider accepts the ride request, the function below is executed:

    this.riders_channel.bind('client-rider-accepted', (rider_data) => {
      Alert.alert(
        `${rider_data.username} accepted your request`,
        `You will now receive updates of their current location`
      );

      // update the map to show the rider's origin
      this.setState({
        is_loading: false,
        has_journey: true,
        rider_location: rider_data.coords
      });

      // next: add code for subscribing to the rider's location change
    });

As you’ve seen earlier, when the rider’s location changes, it triggers an event called client-rider-location-change. Any user who is subscribed to the rider’s channel and is listening for that event will get the location data in realtime:

    this.riders_channel.bind('client-rider-locationchange', (data) => {
      // update the map with the rider's current location
      this.setState({
        region: regionFrom(data.lat, data.lon, data.accy),
        rider_location: {
          latitude: data.lat,
          longitude: data.lon
        }
      });

      let hikers_origin = this.state.start_location;
      let diff_in_meters = getLatLonDiffInMeters(data.lat, data.lon, hikers_origin.latitude, hikers_origin.longitude);

      if(diff_in_meters <= 20){
        this.resetUI();
      }else if(diff_in_meters <= 50){
        Alert.alert(
          'Rider is near',
          'Rider is around 50 meters from your location'
        );
      }
    });

Add the styles for the Map page:

    const styles = StyleSheet.create({
      container: {
        position: 'absolute',
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
        justifyContent: 'flex-end',
        alignItems: 'center',
      },
      map: {
        position: 'absolute',
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
      },
      search_field_container: {
        height: 150, 
        width: device_width, 
        position: 'absolute', 
        top: 10
      },
      input_container: {
        alignSelf: 'center',
        backgroundColor: '#FFF',
        opacity: 0.80,
        marginBottom: 25
      }
    });

Location library

Here’s the code for getting the latitude and longitude delta values. As you have seen from the code earlier, this function is mainly used to get the region displayed on the map:

    // Ridesharer/app/lib/location.js
    export function regionFrom(lat, lon, accuracy) {
      const oneDegreeOfLongitudeInMeters = 111.32 * 1000;
      const circumference = (40075 / 360) * 1000;

      const latDelta = accuracy * (1 / (Math.cos(lat) * circumference));
      const lonDelta = (accuracy / oneDegreeOfLongitudeInMeters);

      return {
        latitude: lat,
        longitude: lon,
        latitudeDelta: Math.max(0, latDelta),
        longitudeDelta: Math.max(0, lonDelta)
      };
    }

And here’s the function for getting the difference (in meters) between two coordinates. This is mainly used for notifying the users when they’re already near each other, and to reset the app UI when they’re already very near each other:

    export function getLatLonDiffInMeters(lat1, lon1, lat2, lon2) {
      var R = 6371; // radius of the earth in km
      var dLat = deg2rad(lat2-lat1);  // deg2rad below
      var dLon = deg2rad(lon2-lon1); 
      var a = 
        Math.sin(dLat/2) * Math.sin(dLat/2) +
        Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * 
        Math.sin(dLon/2) * Math.sin(dLon/2)
        ; 
      var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); 
      var d = R * c; // distance in km
      return d * 1000;
    }

The deg2rad() function used above converts the degrees value to radians:

    function deg2rad(deg) {
      return deg * (Math.PI/180)
    }

Running the app

Before you can run the app on Android, you need to make sure you have the following Android SDK packages installed, you can find these under SDK Tools on the SDK manager:

  • Google Play services
  • Android Support Repository
  • Google Repository

If you’re going to test the app on Genymotion, you need to install Google Play services first. Since the app is using Google Maps, you need Google Play services for the feature to work. If you have version 2.10 or above, they provide an easy way to install it. Just click on Open GAPPS on a running emulator instance and go through the installation wizard. After that, restart the device and you should be good to go:

To run the app on Android, execute the following command. This will run the app either on an opened emulator instance (for example: Genymotion) or an Android device (if you have connected one):

    react-native run-android

If you’re having problems with getting the app to run on Android, be sure to check my article on Debugging common React Native issues on Android.

For iOS, you just have to make sure you have the latest version of Xcode installed. Note that if you want to run the app on a device, you can only do it via Xcode by opening the .xcworkspace file.

To run the app on an iOS device, select your device on Xcode and click the big play button.

To run the app in the iOS simulator, you can also do it via Xcode using the method above. But if you want to run it from the terminal, you can execute the following command from the root directory of your project:

    react-native run-ios

If you want to run the app on a specific simulator, you first have to list which devices are available:

    xcrun simctl list devicetypes

This will return the list of devices:

You can then copy the device name (for example: iPhone 5s) and specify it as a value for the --simulator option:

    react-native run-ios --simulator="iPhone 5s"

If you’re having problems with running the app on an iOS simulator or device, be sure to check my article on Debugging common React Native issues on iOS.

Conclusion

That’s it! In this series, you’ve learned how to create a carpooling app with React Native. Along the way, you also learned the following:

  • How to use axios to make requests to the server.
  • How to use React Native’s Geolocation feature.
  • How to add Google Play Services to Genymotion.
  • How to use Genymotion’s GPS emulation tool.
  • How to use Pusher Channels.
  • How to use Google’s Geocoding API.

You can find all the codes used in this series on this GitHub repo.

  • Channels

© 2018 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.