In my Easier React Native Development With Expo post, you learned about how Expo makes it easier for beginners to begin creating apps with React Native. You also learned that Expo allows developers to get up and running with developing React Native apps faster because there's no longer a need to set up Android Studio, Xcode, or other development tools.
But as you have also seen, Expo doesn't support all of the native features that an app might need. Though the Expo team is always working to support more native functionality, it's a good idea to learn how to convert an existing Expo project to a standard native project so you can easily transition if the need arises.
So, in this two-part series, we're taking a look at how to do that. In the first part of the series, you learned the basic concepts of ExpoKit. In this post, we'll continue where we left off by actually detaching the app to ExpoKit and continue coding the location-sharing app.
Detaching to ExpoKit
In order to detach to ExpoKit, you first have to edit the app.json and package.json files.
In the app.json file, make sure that a name
has been set. The platforms
should be the platforms you want to build to.
{ "expo": { "name": "ocdmom", "platforms": [ "ios", "android" ],
If you want to build for iOS, you must specify the ios
option:
"ios": { "bundleIdentifier": "com.ocd.mom" },
If you want to support Android, then also specify the following option:
"android": { "package": "com.ocd.mom" }
There are other options that were prefilled by the exp
command-line tool when the project was created. But the only important ones are the bundleIdentifier
for iOS and package
for Android. These will be the unique IDs for the app once they get published to the Apple or Play store. Detaching requires those details because it actually generates the native code for the app to be run on a device. You can find more information about the different configuration options for the app.json file in the Expo documentation.
You can view the full contents of the file in the GitHub repo.
Next, open the package.json file and add the name of the project:
"name": "ocdmom"
This should be the name that you used when you created the project using exp init
. It's very important that they are the same because the name
you specify in the package.json is used when compiling the app. Inconsistencies in this name will cause an error.
Now we're ready to detach to ExpoKit. Execute the following command at the root of the project directory:
exp detach
This will download the native Expo packages for Android and iOS locally.
You should see an output similar to the following if it succeeded:
If you're deploying to iOS, you need to install the latest version of Xcode. At the time of writing of this tutorial, the latest version is 9. Next, install CocoaPods by executing sudo gem install cocoapods
. This allows you to install the native iOS dependencies of the project. Once that's done, navigate to the ios directory of the project and execute pod install
to install all the native dependencies.
Installing Custom Native Packages
Now that we have detached, we can now install native packages just like in a standard React Native project.
For this app, we'll need the React Native Background Timer and Pusher packages.
First, install the Pusher package because it's easier:
npm install --save pusher-js
This allows us to communicate with the Pusher app you created earlier.
Next, install the React Native Background Timer. This allows us to periodically execute code (even when the app is in the background) based on a specific interval:
npm install --save react-native-background-timer
Unlike the Pusher package, this requires a native library (either iOS or Android) to be linked to the app. Executing the following command does that for you:
react-native link
Once it's done, it should also initialize the module on android/app/src/main/host/exp/exponent/MainApplication.java. But just to make sure, check if the following exists in that file:
import com.ocetnik.timer.BackgroundTimerPackage; // check this public List<ReactPackage> getPackages() { return Arrays.<ReactPackage>asList( new BackgroundTimerPackage() // also check this ); }
If you're building for iOS, open the Podfile inside the ios directory and make sure the following is added before the post_install
declaration:
pod 'react-native-background-timer', :path => '../node_modules/react-native-background-timer'
Once that's done, execute pod install
inside the ios
directory to install the native module.
For Android, this is already done automatically when you run the app using Android Studio.
Update the Android Manifest File
If you're building for Android, open the Android manifest file (android/app/src/main/AndroidManifest.xml) and make sure the following permissions are added:
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /><uses-permission android:name="android.permission.INTERNET" /><uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /><uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
This allows us to ask permission for Pusher to access the internet and Expo to get the user's current location on Android devices.
Running the App
We're not yet done, but it's better to run the app now so you can already see if it works or not. That way, you can also see the changes while we're developing the app.
The first step in running the app is to execute exp start
from the root directory of the project. This will start the development server so that any change you make to the source code will get reflected in the app preview.
If you're building for Android, open Android Studio and select Open an existing Android Studio project. In the directory selector that shows up, select the android folder inside the Expo project. Once you've selected the folder, it should index the files in that folder. At that point, you should now be able to rebuild the project by selecting Build > Rebuild Project from the top menu. Once that's done, run the app by selecting Run > Run 'app'.
Android Studio can run the app on any Android device connected to your computer, on one of the emulators you installed via Android Studio, or via Genymotion (Android Studio automatically detects a running emulator instance). For this app, I recommend you use Genymotion emulator since it has a nice GPS emulation widget that allows you to change the location via a Google Maps interface:
(If you're having problems running the app on your device, be sure to check out this Stack Overflow question on getting Android Studio to recognize your device.)
Once that's done, open the ios/ocdmom.xcworkspace file with Xcode. Once Xcode is done indexing the files, you should be able to hit that big play button and it will automatically run the app on your selected iOS simulator.
Xcode also allows you to mock the location, but only when you build the app for running in the simulator. Making a change to the code and having the development server refresh the app won't actually change the location. To change the location, click on the send icon and select the location you want to use:
Continue Coding the App
Now we're ready to continue writing the code for the app. This time, we'll be adding the functionality to run some code while the app is in the background.
Adding a Background Task
Import the Pusher and Background Timer package that you installed earlier:
import BackgroundTimer from 'react-native-background-timer'; import Pusher from 'pusher-js/react-native';
Set the value for the Google API key of the Google project you created earlier:
const GOOGLE_API_KEY = 'YOUR GOOGLE PROJECT API KEY';
Use the Location and Permissions API from Expo:
const { Location, Permissions } = Expo;
Expo's APIs work cross-platform—this is not unlike a standard React Native project where you have to install a package like React Native Permissions to gain access to a permissions API that works cross-platform.
Next, set the interval (in milliseconds) that the code for tracking the user's current location is going to execute. In this case, we want it to execute every 30 minutes. Note that in the code below we're using the value of the location_status
variable to check whether the permission to access the user's current location was granted or not. We'll be setting the value of this variable later, once the component is mounted:
var interval_ms = 1800 * 100; // 1800 seconds = 30 minutes, times 100 to convert to milliseconds var location_status = null; // whether accessing the user's location is allowed or not BackgroundTimer.runBackgroundTimer(() => { // run the background task if(location_status == 'granted'){ // if permission to access the location is granted by the user // next: add code for getting the user's current location } }, interval_ms);
Getting the Current Location
Get the current location by using Expo's Location API:
Location.getCurrentPositionAsync({ // get the user's coordinates enableHighAccuracy: true // enable fetching of high accuracy location }) .then((res) => { let { latitude, longitude } = res.coords; // extract the latitude and longitude values // next: add code for getting the address based on the coordinates });
Next, using the Google Maps Geocoding API, make a request to the reverse geocoding endpoint by supplying the latitude and longitude values. This returns a formatted address based on those coordinates:
fetch(`https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}&key=${GOOGLE_API_KEY}`) .then((response) => response.json()) .then((responseJson) => { let addr = responseJson.results[0].formatted_address; // next: send the location with Pusher }) .catch((error) => { console.error(error); });
Sending the Location With Pusher
The next step is to send the location using Pusher. Later on, we're going to create the server which will serve as the auth endpoint and at the same time display the page which shows the user's current location.
Update the constructor to set a default value for the Pusher instance:
constructor() { /* the code for generating unique code from earlier */ this.pusher = null; }
When the component is mounted, we want to initialize Pusher. You can now supply the Pusher API key and cluster from the setting of the Pusher app you created earlier:
componentWillMount() { this.pusher = new Pusher('YOUR PUSHER APP KEY', { authEndpoint: 'YOUR AUTH SERVER ENDPOINT (TO BE ADDED LATER)', cluster: 'YOUR PUSHER CLUSTER', encrypted: true // whether the connection will be encrypted or not. This requires https if set to true }); }
Next, you can now add the code for sending the current location. In Pusher, this is done by calling the trigger()
method. The first argument is the name of the event being triggered, and the second argument is an object containing the data you want to send.
Later on, in the server, we'll subscribe to the same channel which we will subscribe to once the component is mounted. Then we'll bind to the client-location
event so that every time it's triggered from somewhere, the server will also get notified (although only when the page it's serving is also subscribed to the same channel):
fetch(...) .then(...) .then((responseJson) => { let addr = responseJson.results[0].formatted_address; current_location_channel.trigger('client-location', { addr: addr, lat: latitude, lng: longitude }); }) .catch(...);
The only time we're going to ask for permission to access the user's current location is when the component is mounted. We will then update the location_status
based on the user's selection. The value can either be "granted" or "denied".
Remember that the code for checking the user's current location is executed periodically. This means that the new value of the location_status
variable will also be used at a later time when the function is executed. After that, we also want to subscribe to the Pusher channel where the location updates will be sent:
componentDidMount() { try { Permissions.askAsync(Permissions.LOCATION).then(({ status }) => { location_status = status; }); }catch(error){ console.log('err: ', error); } // subscribe to the Pusher channel current_location_channel = this.pusher.subscribe('private-current-location-' + this.state.unique_code); }
Creating the Server
Now we're ready to create the server. First, create your working directory (ocdmom-server) outside of the project directory of the app. Navigate inside that directory and execute npm init
. Just press Enter until it creates the package.json file.
Next, install the packages that we need:
npm install --save express body-parser pusher
Here's an overview of what each package does:
express
: used for creating a server. This is responsible for serving the tracking page as well as responding to the auth endpoint.body-parser
: Express middleware which parses the request body and makes it available as a JavaScript object.pusher
: used for communicating with the Pusher app you created earlier.
Once that's done, your package.json file should now look like this:
{ "name": "ocdmom-server", "version": "1.0.0", "description": "", "main": "server.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "node server.js" }, "author": "", "license": "ISC", "dependencies": { "body-parser": "^1.18.2", "express": "^4.16.2", "pusher": "^1.5.1" } }
Create a server.js file and import the packages we just installed:
var express = require('express'); var bodyParser = require('body-parser'); var Pusher = require('pusher');
Configure the server to use the body-parser
package and set the public folder as the static files directory:
var app = express(); app.use(bodyParser.json()); // set middleware to parse request body to JavaScript object app.use(bodyParser.urlencoded({ extended: false })); // for parsing URL encoded request body app.use(express.static('public')); // specify the directory where the static files like css, JavaScript and image files lives
Initialize Pusher. The values supplied here will come from the environment variables. We will add those later, when we deploy the server:
var pusher = new Pusher({ appId: process.env.APP_ID, key: process.env.APP_KEY, secret: process.env.APP_SECRET, cluster: process.env.APP_CLUSTER, });
Serve the tracking page when the base URL is accessed:
app.get('/', function(req, res){ res.sendFile(__dirname + '/public/tracker.html'); });
Next, create the route for responding to requests to the auth endpoint. This will be hit every time the app initializes the connection to Pusher, as well as when the tracking page is accessed. What this does is authenticate the user so they can communicate to the Pusher app directly from the client side.
Note that this doesn't really have any security measures in place. This means anyone can just make a request to your auth endpoint if they have access to your Pusher App key. In a production app, you'd want more robust security!
app.post('/pusher/auth', function(req, res) { var socketId = req.body.socket_id; var channel = req.body.channel_name; var auth = pusher.authenticate(socketId, channel); var app_key = req.body.app_key; var auth = pusher.authenticate(socketId, channel); res.send(auth); });
Lastly, make the server listen to the port specified in the environment variables. By default, it's port 80, but we're also setting it as an alternate value just in case it doesn't exist:
var port = process.env.PORT || 80; app.listen(port);
Tracking Page
The tracking page displays a map which gets updated every time the client-location
event is triggered from the app. Don't forget to supply your Google API key:
<!DOCTYPE html><html><head><meta name="viewport" content="initial-scale=1.0, user-scalable=no"><meta charset="utf-8"><title>OCDMom Tracker</title><script src="https://js.pusher.com/4.2/pusher.min.js"></script> <!-- the pusher library --><link rel="stylesheet" href="css/style.css"></head><body><div id="map"></div><script src="js/tracker.js"></script> <!-- the main JavaScript file for this page --><script async defer src="https://maps.googleapis.com/maps/api/js?key=YOUR-GOOGLE-API-KEY&callback=initMap"> </script> <!-- the google maps library --></body></html>
Next, create a public/js/tracker.js file and add the following:
function getParameterByName(name, url) { if (!url) url = window.location.href; name = name.replace(/[\[\]]/g, "\\$&"); var regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)"), results = regex.exec(url); if (!results) return null; if (!results[2]) return ''; return decodeURIComponent(results[2].replace(/\+/g, " ")); }
The function above extracts the query parameter from the URL. The unique code (the one displayed in the app) needs to be included as a query parameter when the base URL of the server is accessed on a browser. This allows us to keep track of the user's location because it will subscribe us to the same channel as the one subscribed to by the app.
Next, initialize Pusher. The code is similar to the code in the server earlier. The only difference is that we only need to specify the Pusher app key, auth endpoint, and cluster:
var pusher = new Pusher('YOUR PUSHER APP KEY', { authEndpoint: 'YOUR PUSHER AUTH ENDPOINT', cluster: 'YOUR PUSHER CLUSTER', encrypted: true });
Check if the code
is supplied as a query parameter, and only subscribe to the Pusher channel if it's supplied:
var channel = null; if(getParameterByName('code') == null){ alert('Make sure that the code is supplied as a query parameter, then refresh the page.'); }else{ channel = pusher.subscribe('private-current-location-' + getParameterByName('code')); }
Add the function for initializing the map. This will display the map along with a marker pointing to the default location we've specified:
var map = null; var marker = null; function initMap(){ var myLatLng = { // set the default location displayed on the map lat: -25.363, lng: 131.044 }; map = new google.maps.Map(document.getElementById('map'), { zoom: 16, center: myLatLng }); marker = new google.maps.Marker({ position: myLatLng, map: map }); }
Bind to the client-location
event. The callback function gets executed every time the app triggers a client-location
event which has the same unique code as the one the user supplied as a query parameter:
if(channel){ channel.bind('client-location', function(data) { console.log('message received: ', data); var position = new google.maps.LatLng(data.lat, data.lng); // create a new Google maps position object // set it as the position for the marker and the map marker.setPosition(position); map.setCenter(position); }); }
Next, add the styles for the tracking page (public/css/style.css):
#map { height: 100%; } html, body { height: 100%; margin: 0; padding: 0; }
Deploying the Server
We'll be using Now to deploy the server. It's free for open-source projects.
Install Now globally:
npm install now
Once it's installed, you can now add the Pusher app credentials as secrets. As mentioned earlier, Now is free for open-source projects. This means that once the server has been deployed, its source code will be available at the /_src
path. This isn't really good because everyone can also see your Pusher app credentials. So what we'll do is add them as a secret so that they can be accessed as an environment variable.
Remember the process.env.APP_ID
or process.env.APP_KEY
from the server code earlier? Those are being set as environment variables via secrets. pusher_app_id
is the name assigned to the secret, and YOUR_PUSHER_APP_ID
is the ID of your Pusher app. Execute the following commands to add your Pusher app credentials as secrets:
now secret add pusher_app_id YOUR_PUSHER_APP_ID now secret add pusher_app_key YOUR_PUSHER_APP_KEY now secret add pusher_app_secret YOUR_PUSHER_APP_SECRET now secret add pusher_app_cluster YOUR_PUSHER_APP_CLUSTER
Once you've added those, you can now deploy the server. APP_ID
is the name of the environment variable, and pusher_app_id
is the name of the secret you want to access:
now -e APP_ID=@pusher_app_id -e APP_KEY=@pusher_app_key -e APP_SECRET=@pusher_app_secret APP_CLUSTER=@pusher_app_cluster
This is how it looks once it's done deploying. The URL it returns is the base URL of the server:
Copy that URL over to the App.js file and save the changes:
this.pusher = new Pusher('YOUR PUSHER APP KEY', { authEndpoint: 'https://BASE-URL-OF-YOUR-SERVER/pusher/auth', cluster: 'YOUR PUSHER APP CLUSTER', encrypted: true });
At this point, the app should now be fully functional.
Conclusion
That's it! In this two-part series, you've learned how to detach an existing Expo project to ExpoKit. ExpoKit is a good way to use some of the tools that the Expo platform provides while your app is already converted to a standard native project. This allows you to use existing native modules for React Native and to create your own.
While you're here, check out some of our other posts on React Native app development!
Code an App With GraphQL, React Native, and AWS AppSync: The Back-End
Get Started With React Native Layouts
Tools for React Native Development
Practical Animation Examples in React Native