Very famous npm package called "react-native-maps" is easy to use and configure, but it can be remarkably slow when you're working with lots of markers. I noticed this when using an Android device in particular. This is mostly because of React's declarative way of rendering things.
In most npm packages for Google Maps, markers and all other map objects are declared as a React component. As you touch the map, hundreds of React components are being triggered. They may not being re-rendered or mounted, but render() and other lifecycle methods are being called and checked. This causes the extreme lag.
In my opinion, Google Maps API is not built to be used declarative. It should be used imperative be it JavaScript API or Android SDK. I had the same issues when I used a React wrapper instead of using Javascript API directly. If you need more performance, you should create the map, get reference and create all the objects using the direct methods and let Google API handle the rest.
Enough talk, let me try to explain how I solved my problems using native code. This tutorial may not be suitable for people who are just starting to use react and/or react-native.
- Create a new application:
react-native init reactNativeGoogleMapNativeAndroid
cd reactNativeGoogleMapNativeAndroid
yarn install
- Open
reactNativeGoogleMapNativeAndroid/android
folder using Android Studio - Add below dependencies to build.gradle (Module:app) file. After adding below lines, gradle will start syncing.
dependencies {
implementation 'com.google.maps.android:android-maps-utils:0.5+'
implementation 'com.google.android.gms:play-services-maps:16.1.0'
implementation 'com.google.android.gms:play-services-base:16.1.0'
}
- Add your Google Maps API key into AndroidManifest.xml
<application>
<meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_API_KEY"/>
</application>
- Below is how my Android Studio will look like after I add java files:
- Add a new package called NativeModules and create a new Java class that extends SimpleViewManager<MapView>. Name it as `GMap`. This will be our React Component on JavaScript side.
- You will need to overwrite 2 methods(createViewInstance and getName)
package com.reactnativegooglemapnativeandroid.NativeModules;
import com.facebook.react.uimanager.SimpleViewManager;
import com.facebook.react.uimanager.ThemedReactContext;
import com.google.android.gms.maps.MapView;
import javax.annotation.Nonnull;
public class GMap extends SimpleViewManager<MapView> {
@Nonnull
@Override
public String getName() {
return "GMap";
}
@Nonnull
@Override
protected MapView createViewInstance(@Nonnull ThemedReactContext reactContext) {
MapView view = new MapView(reactContext);
view.onCreate(null);
view.onResume();
return view;
}
}
- Now, we need to add this Module into a package that extends ReactPackage and add our GMap reference into ViewManager list in createViewManagers method. Below createNativeModules is empty because we did not add a headless native module. View manager is also a native module but it's a specialised one. Create a file called NativePackages and edit it as below. I normally use this module to include every NativeModule or ViewManager I create. So, I add it into root folder of my main package.
package com.reactnativegooglemapnativeandroid;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import com.reactnativegooglemapnativeandroid.NativeModules.GMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class NativePackages implements ReactPackage {
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Arrays.<ViewManager>asList(
new GMap()
);
}
@Override
public List<NativeModule> createNativeModules(
ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
return modules;
}
}
- Now we will add this module MainApplication.java. I only added
new NativePackages()
part. The rest was previously created by react-native init command.
package com.reactnativegooglemapnativeandroid;
import android.app.Application;
import com.facebook.react.ReactApplication;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.react.shell.MainReactPackage;
import com.facebook.soloader.SoLoader;
import java.util.Arrays;
import java.util.List;
public class MainApplication extends Application implements ReactApplication {
private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
}
@Override
protected List<ReactPackage> getPackages() {
return Arrays.<ReactPackage>asList(
new MainReactPackage(),
new NativePackages()
);
}
@Override
protected String getJSMainModuleName() {
return "index";
}
};
@Override
public ReactNativeHost getReactNativeHost() {
return mReactNativeHost;
}
@Override
public void onCreate() {
super.onCreate();
SoLoader.init(this, /* native exopackage */ false);
}
}
- Finally, some time for JavaScript. Call your native SimpleViewManager (MapView) from App.js. For simplicity let's remove all content that was created by
react-native init
, and add below. This will replace whole page with native Google Map.
import React, {Component} from 'react';
import {Platform, StyleSheet, Text, View, requireNativeComponent} from 'react-native';
import Map from './app/Map'
const GMap = requireNativeComponent('GMap')
export default class App extends Component {
render() {
return (
<GMap style={StyleSheet.absoluteFillObject} />
);
}
}
Start it with your favourite emulator and see it's loading a bare minimum native google map.
We are not done yet. Now let's see how we can add markers to this map. I will fetch a json file in JS side, and will send it to a native method to render. Let's find some sample data. I have found this perfect example (forked from GitHub user Miserlou). Top 1000 most populated cities in USA. We will see if the natively implemented google maps has a good performance.
We can add this file into our repo or fetch async. For the sake of simplicity for now I have added it as a file called us_cities.json to my root folder.
Let's make the changes to our GMap class to handle adding markers which are fetched in JS side of react-native. MapView has a method called getMapAsync()
. As the name suggests it's an asynchronous method that gets the map instance as a GoogleMap object. With this instance we can use everything that Android SDK has to offer.
Let's extend our java class to implement interface called OnMapReadyCallback:
public class GMap extends SimpleViewManager<MapView> implements OnMapReadyCallback {}
And change the createViewInstance method to get the map instance.
Then override the onMapReady method from the interface.
Also add class variables for GoogleMap, ClusterManager and ReactContext
private GoogleMap googleMap;
private ClusterManager mClusterManager;
private ThemedReactContext context;
protected MapView createViewInstance(@Nonnull ThemedReactContext reactContext) {
context = reactContext;
MapView view = new MapView(reactContext);
view.onCreate(null);
view.onResume();
view.getMapAsync(this);
return view;
}
@Override
public void onMapReady(GoogleMap gmap) {
googleMap = gmap;
mClusterManager = new ClusterManager<MyClusterItem>(context, googleMap);
sendEvent(context, "onMapReady", null);
}
private void sendEvent(ReactContext reactContext, String eventName, @Nullable WritableMap params) {
reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, params);
}
When map object finishes loading, it invokes the onMapReady method. Here we are ready to create markers. But we also need to send a signal to JS side of our project. After this signal, JavaScript code can send the markers. We have created sendEvent
method that can send any signal that could be listened by addListener method in JS.
Update App.js file so it looks like this:
import React, {Component} from 'react';
import {DeviceEventEmitter, StyleSheet, requireNativeComponent, UIManager, findNodeHandle} from 'react-native';
import cities from './us_cities.json'
const mapRef = React.createRef()
const GMap = requireNativeComponent('GMap')
export default class App extends Component {
constructor(props){
super(props)
this.state = {}
this.onMapReady = this.onMapReady.bind(this)
}
onMapReady(){
cities.map((city, i) => {
UIManager.dispatchViewManagerCommand(this.mapViewHandle, 0, [
'marker' + 0,
city.latitude,
city.longitude,
city.city,
city.state,
city.population
]);
});
}
componentDidMount(){
this.mapViewHandle = findNodeHandle(mapRef.current);
DeviceEventEmitter.addListener('onMapReady', this.onMapReady);
}
render() {
return (
<GMap ref={mapRef} style={StyleSheet.absoluteFillObject} />
);
}
}
Above "onMapReady" listener is the event we dispatched from Java when the map object was created. This listener needs to be added on DeviceEventEmitter, not on window object. Now the JavaScript knows that the map is ready, we can create markers.
But, how to trigger a method in Java from JavaScript. Normally when we use a NativeModule we can easily invoke methods that are created with @ReactMethod using NativeModules object of react-native. But MapView is not a NativeModule, it's a ViewManager. receiveCommand should be used.
Implement receiveCommand of ViewManager (in GMap.java)
// Receives commands from JavaScript using UIManager.dispatchViewManagerCommand
@Override
public void receiveCommand(MapView view, int commandId, @Nullable ReadableArray args) {
super.receiveCommand(view, commandId, args);
switch (commandId) {
case 0:
addMarker(args); // we havent implemented this yet. Read on..
break;
}
}
When we send a command to Java, we need to use a unique command id (eg. 0, 1, 2 etc.) Then depending on this id, we can decide what to do. If you have noticed, in JavaScript file we used UIManager's dispatchViewManager method with view handle, an integer and an array. This integer will be the commandId in receiveCommand method. Last parameter will be an array of our custom parameters that we want to send. I want to send marker parameters (eg. marker id, lat, lng, Info Window title and content etc.)
UIManager.dispatchViewManagerCommand(this.mapViewHandle, 0, [
'marker' + 0,
city.latitude,
city.longitude,
city.city,
city.state,
city.population
]);
MapView object (Gmap) in Java and the React component (also called GMap) are actually identical. So, to convert the React object into a form that Java method understands, we need to use findNodeHandle method. findNodeHandle accepts object ref. So create a ref for it using React.createRef().
Finally, let's add addMarker method in Java side
public void addMarker(@Nullable final ReadableArray args) {
MyClusterItem ci = new MyClusterItem(
args.getString(0),
new LatLng(args.getDouble(1),
args.getDouble(2)),
args.getString(3),
args.getString(4),
args.getString(5)
);
mClusterManager.addItem(ci);
mClusterManager.cluster();
}
We will be adding lots of markers. So, I always prefer using clusters for this. Let's create a custom cluster item. Create a new file called MyClusterItem.java and edit it as below:
package com.reactnativegooglemapnativeandroid.NativeModules;
import com.google.android.gms.maps.model.LatLng;
import com.google.maps.android.clustering.ClusterItem;
public class MyClusterItem implements ClusterItem {
private LatLng position;
private String title;
private String snippet;
private String population;
public String tag;
public MyClusterItem(String g, LatLng pos, String t, String s, String p){
tag = g;
position =pos;
title = t;
snippet = s + "\n" + p;
}
@Override
public LatLng getPosition() {
return position;
}
@Override
public String getTitle() {
return title;
}
@Override
public String getSnippet() {
return snippet;
}
public String getTag(){
return tag;
}
}
We are ready to run:
But, if you zoom in or click the clusters, you'll notice that clusters are not updated. This is not what we want. We want the cluster manager to respond the zoom and bounds change events dynamically. Ad below lines into onMapReady method
googleMap.setOnCameraIdleListener(mClusterManager);
googleMap.setOnMarkerClickListener(mClusterManager);
googleMap.setOnInfoWindowClickListener(mClusterManager);
Those will responde to camera & zoom changes and info window click event. If we want to zoom in when we click clusters, we need to implement setOnClusterClickListener of cluster manager. Add below lines into onMapReady method:
mClusterManager.setOnClusterClickListener(new ClusterManager.OnClusterClickListener() {
@Override
public boolean onClusterClick(Cluster cluster) {
LatLng clusterPos = new LatLng(cluster.getPosition().latitude, cluster.getPosition().longitude);
float newZoom = googleMap.getCameraPosition().zoom + 2;
if (newZoom > googleMap.getMaxZoomLevel())
newZoom = googleMap.getMaxZoomLevel();
googleMap.animateCamera(CameraUpdateFactory.newLatLngZoom(clusterPos, newZoom));
return true;
}
});
Now we have a native, ultra fast Google Maps running in a React Native app.
You can find the complete code for this tutorial below. Clone it, edit AndroidManifest.xml file then you can run it:
https://github.com/aliustaoglu/react-native-google-map-native-android