Jakallergis.com Blog

Personal thoughts and notes of a software engineer.

Google Maps in React Native Part 3: Advanced Usage

25/05/2019☕☕☕☕ 17 Min Read — In React Native

TL;DR: We'll have a look on animations, Polygons and custom Markers.

This is part of a react-native tutorial series, in which we’re going to dive into how we can display maps inside a React-Native app.

If you want to follow along this series I have setup a repo in which every part will have its starting and ending points in their corresponding branches.

About This Article

We’re going to have a more in-depth look into react-native-maps by using animations to change regions, zoom in / out, and drawing polygons / custom markers.

  • This article’s starting source code is here.
  • This article’s ending source code is here.

A bit of restructure

For this part of the series, I changed the structure of the project a bit, including react-navigation so that each feature we implement is in its own screen.This means that if you checkout the part-3-start or part-3-end branches, you need to install the added dependencies through the terminal by running npm install or yarn inside the project’s root directory.

What we’ve done so far is just a simple MapView with 3 buttons that change the region currently displayed. This is moved into src/screens/RegionsChangeScreen.js and src/screens is where we’ll put everything else. src/screens/Home.js will only contain buttons to navigate to each feature’s screen. The coordinate objects of our places of interest are moved into ./src/utilities/poi.js in order to be accessible for us to use in any of the screens. I added a few more combinations of them as we’re going to need various zoom levels, etc.

I’m also using a timeout inside componentDidMount() to delay loading the MapView until the navigation is fully transitioned to the current screen, and avoid the lagging behaviour.

Animated Region Changes

Let’s create a new screen component in src/screens/AnimatedRegionChangeScreen.js and copy everything from src/screens/RegionChangeScreen.js.

In order to animate the region currently displayed inside our MapView, we have to use the animateToRegion method of the MapView component.

This means:

  • We have to create a ref to that component in our code, using React.createRef();.
  • Changing the region the map displays, will be done explicitly, so we won’t be needing this.state.region and the usages of this.setState({ region: FACEBOOK }); inside our button handlers.

To change the region explicitly while also animating, we will be using the animateToRegion(); method exposed from MapView instances which accepts two parameters:

  • region: Region - The new region we want to animate towards.
  • duration?: number - The duration we want the animation to have

Duration is optional, but lets give it 2000 ms and see what happens.

This is our code:

// ...
export default class AnimatedRegionsChangeScreen extends Component<Props, State> {

  _mapView = React.createRef();
  /** Button Handlers */
  
  _showApple = (): void => {
    if (this._mapView.current) {      this._mapView.current.animateToRegion(APPLE, 2000);    }  };

  _showFacebook = (): void => {
    if (this._mapView.current) {      this._mapView.current.animateToRegion(FACEBOOK, 2000);    }  };

  _showGoogle = (): void => {
    if (this._mapView.current) {      this._mapView.current.animateToRegion(GOOGLE_PLEX, 2000);    }  };

  /** Renderers */

  render() {
    // ...
    return (
      <View style={ styles.container }>
        <MapView
          ref={ this._mapView }          provider={ PROVIDER_GOOGLE }
          initialRegion={ APPLE }          style={ styles.mapViewContainer }>
          {/* ... */}
        </MapView>
        {/* ... */}
      </View>
    );
  }
}

Having an initialRegion prop set is not mandatory, but I added it so that all POIs are close to each other.

This is what this looks like:

Animated Region Changes Using Animated APIs

Let’s create a new screen component in src/screens/AnimatedAPIRegionChangeScreen.js and copy everything from src/screens/AnimatedRegionChangeScreen.js.

As with all components in React Native that want to use Animated values, we have to use a special animated-enabled component - <MapView.Animated /> in our case. But how do we animate the region prop since it is an object? How do we animate it’s properties?

Fortunately enough, react-native-maps exports MapView.AnimatedRegion() wich is special animated type that can hold a region as a value.

Here is it’s definition:

export class AnimatedRegion extends Animated.AnimatedWithChildren {
        latitude: Animated.Value
        longitude: Animated.Value
        latitudeDelta: Animated.Value
        longitudeDelta: Animated.Value

        constructor(region?: Region);

        setValue(value: Region): void;
        setOffset(offset: Region): void;
        flattenOffset(): void;
        stopAnimation(callback?: (region: Region) => void): void;
        addListener(callback: (region: Region) => void): string;
        removeListener(id: string): void;
        spring(config: AnimatedRegionSpringConfig): Animated.CompositeAnimation;
        timing(config: AnimatedRegionTimingConfig): Animated.CompositeAnimation;
    }

So we have to store a property locally to hold the current region, and instead of using animateToRegion() with a new region every time, we just have to use one of animation methods provided and the view will animate accordingly. This also means we no longer need the reference to the MapView component:

// ...imports
import MapView, { PROVIDER_GOOGLE, Marker } from 'react-native-maps';
// ...
export default class AnimatedAPIRegionsChangeScreen extends Component<Props, State> {

  _currentRegion: MapView.AnimatedRegion;
  constructor(props: Props) {
    // ...
    this._currentRegion = new MapView.AnimatedRegion(APPLE);  }

	// ...

  /** Button Handlers */

  _showApple = (): void => {
    this._currentRegion      .timing({ ...APPLE, duration: 2000 })      .start();  };

  _showFacebook = (): void => {
    this._currentRegion      .timing({ ...FACEBOOK, duration: 2000 })      .start();  };

  _showGoogle = (): void => {
    this._currentRegion      .timing({ ...GOOGLE_PLEX, duration: 2000 })      .start();  };

  /** Renderers */

  render() {
    // ...
    return (
      <View style={ styles.container }>
        <MapView.Animated
          provider={ PROVIDER_GOOGLE }
          region={ this._currentRegion }          style={ styles.mapViewContainer }>
          {/** ... */}
        </MapView.Animated>
        {/** ... */}
      </View>
    );
  }
}
// ...

The result is exactly the same, only now we have a bit more control over the animation.

Here’s what this looks like:

Troubleshooting

As you can see there is a problem in the experience we created. If we zoom out/in, or pan away to some random area, and then press one of the buttons again, we see that the animation starts from the region this._currentRegion was at that moment and not from the region we manually navigated to.

To avoid this we need to add an onRegionChangeComplete method prop to our <MapView.Animated /> component. This will run every time the region changing completes, so we can assign this._currentRegion with the region we’re currently at:

// ...
export default class AnimatedAPIRegionsChangeScreen extends Component<Props, State> {
  // ...

  /** Generic Handlers */

  _onRegionChangeComplete = (region: Region): void => {    this._currentRegion.setValue(region);  };
  /** Renderers */

  render() {
    // ...
    return (
      <View style={ styles.container }>
        <MapView.Animated
          provider={ PROVIDER_GOOGLE }
          region={ this._currentRegion }
          onRegionChangeComplete={ this._onRegionChangeComplete }          style={ styles.mapViewContainer }>
          {/** ... */}
        </MapView.Animated>
        {/** ... */}
      </View>
    );
  }
}

And now it’s fixed:

Animated Marker Changes Using Animated APIs

For this we’ll need to display a region that is a little more zoomed out so that we can watch our marker move without needing to move the region as well. Let’s use APPLE_OUT for that.

There is an animateMarkerToCoordinate() method exposed from Marker instances, which we could use to animate the coordinates of a marker explicitly just as we did with animateToRegion() of MapView, but this is only available for Android devices at the moment, so I’m going to skip that.

Because we are going to use <Marker.Animated /> we will need a type of Animated.Value property for this._markerLatLng to hold our animated latitude and longitude values, but since Animated.Value() and Animated.ValueXY() won’t work with that component, we’re going to use MapView.AnimatedRegion() here as well. Then, like with the previous example, we just need to use the animation methods of this._marketLatLng:

// ...imports
import MapView, { PROVIDER_GOOGLE, Marker } from 'react-native-maps';
// ...
export default class AnimatedMarkerChangeScreen extends Component<Props, State> {

  _mapView = React.createRef();  _markerLatLng: MapView.AnimatedRegion;
  constructor(props: Props) {
    super(props);
    // ...
    this._markerLatLng = new MapView.AnimatedRegion(APPLE_OUT);  }

	// ...

  /** Button Handlers */

  _showApple = (): void => {
    this._markerLatLng.timing(APPLE_COORDINATES).start();  };

  _showFacebook = (): void => {
    this._markerLatLng.timing(FACEBOOK_COORDINATES).start();  };

  _showGoogle = (): void => {
    this._markerLatLng.timing(GOOGLE_PLEX_COORDINATES).start();  };

  /** Renderers */

  render() {
    // ...
    return (
      <View style={ styles.container }>
        <MapView
          provider={ PROVIDER_GOOGLE }
          initialRegion={ APPLE_OUT }          style={ styles.mapViewContainer }>
          <Marker.Animated coordinate={ this._markerLatLng } />        </MapView>
        {/** ... */}
      </View>
    );
  }
}
// ...

And the result is this:

[TIP]: Get a Region Out of Multiple Markers

Some times you have to show various points on the map, but you also want to display a region that will be centered accordingly, and zoomed out enough to include them all.

Say we want a region that will display all three of our POIs. There are some methods in MapView like fitToElements, fitToSuppliedMarkers and fitToCoordinates but I’d like to have more control, and most of all, direct access to the resulting region, I created a function that would return a region based on given markers. The center point of the region will have a latitude equal to the average latitude of the two farmost markers, and, similarly, a longitude equal to the average longitude of the farmost two farmost markers. Since we know the min and max lattitudes/longitudes, we can easily find out their difference and get the deltas.

Here is the utility function that implements this logic:

/** Models / Types */
import type { LatLng } from 'react-native-maps';

function getRegionFromMarkers(markers: LatLng[], delta: number = 0.025, offset: number = 1.3) {
  let minLat = 0, maxLat = 0, minLng = 0, maxLng = 0;
  for (let i = 0, { length } = markers; i < length; i++) {
    const marker = markers[i];
    if (i === 0) {
      minLat = marker.latitude;
      maxLat = marker.latitude;
      minLng = marker.longitude;
      maxLng = marker.longitude;
      continue;
    }
    if (marker.latitude <= minLat) minLat = marker.latitude;
    if (marker.latitude >= maxLat) maxLat = marker.latitude;
    if (marker.longitude <= minLng) minLng = marker.longitude;
    if (marker.longitude >= maxLng) maxLng = marker.longitude;
  }
  const latitude = (minLat + maxLat) / 2;
  const longitude = (minLng + maxLng) / 2;
  const latDelta = (Math.abs(minLat - maxLat) || delta) * offset;
  const lngDelta = (Math.abs(minLng - maxLng) || delta) * offset;
  return { latitude, longitude, latitudeDelta: latDelta, longitudeDelta: lngDelta };
}

I created a new screen component in src/screens/RegionOutOfMarkers.js and a usage example, with a component that adds markers on press:

// ...imports

type State = {
  markers: LatLng[]
}
export default class RegionOutOfMarkers extends Component<Props, State> {
  // ...
  constructor(props: Props) {
    // ...
    this.state = {
      markers: [FACEBOOK, GOOGLE_PLEX, APPLE]
    };
  }
	// ...

  /** Button Handlers */

  _onMapPress = (e): void => {
    const coordinate = e.nativeEvent.coordinate;
    this.setState({ markers: [...this.state.markers, coordinate] }, this._showPOIs);
  };

  _showPOIs = (): void => {
    const markers = this.state.markers;
    const region = getRegionFromMarkers(markers);
    this._currentRegion
      .timing({ ...region, duration: 1000 })
      .start();
  };

  // ...

  /** Renderers */

  render() {
    // ...
    return (
      <View style={ styles.container }>
        <MapView.Animated
          provider={ PROVIDER_GOOGLE }
          region={ this._currentRegion }
          onPress={ this._onMapPress }
          onRegionChangeComplete={ this._onRegionChangeComplete }
          style={ styles.mapViewContainer }>
          { this.state.markers.map(m => (
            <Marker
              key={ `${ m.latitude }.${ m.longitude }` }
              coordinate={ m } />
          )) }
        </MapView.Animated>
        <View style={ styles.buttonsContainer }>
          <Button
            title={ 'Show all POIs' }
            onPress={ this._showPOIs } />
        </View>
      </View>
    );
  }
}
// ...

And the result:

Custom Markers

Let’s create a new screen component in src/screens/CustomMarkersScreen.js with just a MapView.

Adding custom markers is actually super easy. All you need to do is add children to the Marker component and they will be rendered instead of the default component.

So we can do:

// ...

<Marker coordinate={ FACEBOOK } />
<Marker coordinate={ GOOGLE_PLEX }>
  <Text style={ styles.markerText }>{ GOOGLE_PLEX.latitude },{ GOOGLE_PLEX.longitude }</Text>
</Marker>
<Marker coordinate={ APPLE }>
  <Text style={ styles.markerText }>😎</Text>
</Marker>

// ...

And as a result we get:

9 custom markers

Creating a Polygon

Let’s create a new screen component in src/screens/PolygonScreen.js with just a MapView. We will use the POIs we use throught the tutorials to create a polygon from their coordinates, and then we will add some functionality to provide more coordinates to that Polygon, or remove them.

For this we will use the Polygon component exposed from from react-native-maps. It’s a React component of the following props interface:

export interface MapPolygonProps extends ViewProperties {
        coordinates: LatLng[];
        holes?: LatLng[][];
        onPress?: (event: MapEvent) => void;
        tappable?: boolean;
        strokeWidth?: number;
        strokeColor?: string;
        fillColor?: string;
        zIndex?: number;
        lineCap?: LineCapType;
        lineJoin?: LineJoinType;
        miterLimit?: number;
        geodesic?: boolean;
        lineDashPhase?: number;
        lineDashPattern?: number[];
    }

Having all the markers in the state, all we need to render a Polygon component is pass the markers array as the coordinates prop to it.

this.state = { markers: [FACEBOOK, GOOGLE_PLEX, APPLE] };
...
<MapView {...}>
	<Polygon coordinates={ this.state.markers } />
  { this.state.markers.map((m, i) => (
    	<Marker
        key={ `${ m.identifier }.${ i }` }
        coordinate={ m } />
  )) }
</MapView>

Along with the functionality that adds new points on map presses, this would display a polygon such as this:

But the problem now is that, the new points are not automatically sorted, but displayed in the order they were added, which quite often has unwanted results. See what happens when we add points in more irrelevant positions:

It is obvious that such a polygon can not be used anywhere. In most cases we need a polygon that just doesnt intersect itself no matter where a new marker is added. Some less often times, we may need a polygon that always shifts into its simplest shape - like a “convex” polygon.

Let’s see how we could achieve the first scenario.

Sort Polygon Points - Avoid Intersection

There are a few algorithms out there for sorting points of multidimensional shapes, but since we are in 2D shapes, I’m going to use the Graham’s Scan algorithm.

Graham’s scan is a method of finding the convex hull of a finite set of points in the plane with time complexity O(n log n). It is named after Ronald Graham, who published the original algorithm in 1972. The algorithm finds all vertices of the convex hull ordered along its boundary. It uses a stack to detect and remove concavities in the boundary efficiently.

Part of what the algorithm is doing is to sort the points of the polygon in increasing order of the angle they and the point P make with the x-axis, where P is the lowest rightmost point of the polygon.

This alone will not ensure a convex shape, but at least the polygon will not intercept itself.

Here’s my implementation of the algorithm’s sorting part:

export function sortPoints(S: Point[]): Point[] {
  // Select the rightmost lowest point P0 in S
  const P0 = { x: 0, y: 0 };
  // Get the lowest y first
  P0.y = Math.min.apply(null, S.map(p => p.y));
  // Get all the points on that y
  const yPoints = S.filter(p => p.y === P0.y);
  // Get the rightmost point of that y
  P0.x = Math.max.apply(null, yPoints.map(p => p.x));
  // Sort S radially (ccw) with P0 as a center
  S.sort((a, b) => angleCompare(P0, a, b));
  return S;
}

// Use isLeft() comparisons
// For ties, discard the closer points
function angleCompare(P: Point, A: Point, B: Point): number {
  const left = isLeftCompare(P, A, B);
  if (left === 0) return distCompare(P, A, B);
  return left;
}

// To determine which side of the line A(x1, y1)B(x2, y2)
// a point P(x, y) falls on, the formula is:
// d = (x - x1)(y2 - y1) - (y - y1)(x2 - x1)
// If d < 0 then the point lies on one side of the line
// and if d > 0 then it lies on the other side.
// If d = 0 then the point lies exactly on the line.
function isLeftCompare(P: Point, A: Point, B: Point): number {
  return (P.x - A.x) * (B.y - A.y) - (P.y - A.y) * (B.x - A.x);
}

// Distance between two points A(x1,y1) and B(x2,y2)
// is given by: d = √((x2 - x1)² + (y2 - y1)²).
// Since we only care about the sign of the outcome
// and not the outcome it self, we dont need to find
// the square roots of the two segments, we can use
// the d² just as fine.
function distCompare(P: Point, A: Point, B: Point): number {
  const distAP = Math.pow(P.x - A.x, 2) + Math.pow(P.y - A.y, 2);
  const distBP = Math.pow(P.x - B.x, 2) + Math.pow(P.y - B.y, 2);
  return distAP - distBP;
}

In order to avoid mapping through the arrays 2 more times, to map latitudes and longitudes to (x,y) values, and also avoid implementing the algorithm using latitudes and longitudes instead of (x,y) values, I decided to create a Point class that would have these properties already mapped.

class Point {  constructor(c, identifier) {    this.latitude = c.latitude;    this.longitude = c.longitude;    this.identifier = identifier;  }  get x(): number { return this.latitude; }  set x(value: number) { this.latitude = value; }  get y(): number { return this.longitude; }  set y(value: number) { this.longitude = value; }}
const FB = new Point(FACEBOOK, 'facebook');
const GP = new Point(GOOGLE_PLEX, 'google-plex');
const AP = new Point(APPLE, 'apple');

//...
export default class PolygonScreen extends Component<Props, State> {
  constructor(props: Props) {
    // ...
      this.state = { points: [FB, GP, AP] };    }

  // ...

  _onMapPress = (e): void => {    const coordinates = e.nativeEvent.coordinate;    const newPoint = new Point(coordinates, `${ Date.now() }.${ Math.random() }`);    const points = grahamScan.sortPoints([...this.state.points, newPoint]);    this.setState({ points }, this._showAll);  };
  // ...

	render() {
   // ...
    { this.state.points.map((m, i) => (
    	<Marker key={ `${ m.identifier }.${ i }` } coordinate={ m } />  	)) }
   // ...
  }
}

// ...

This would display the new points, in a polygon that no longer intersect itself:

Creating a Convex Polygon

Let’s see how to implement the rest of the Graham’s Scan algorithm, so that we can get a convex polygon for the second - less often scenario.

Here is the definition of a “convex polygon” according to wikipedia:

A convex polygon is a simple polygon (not self-intersecting) in which no line segment between two points on the boundary ever goes outside the polygon. Equivalently, it is a simple polygon whose interior is a convex set. In a convex polygon, all interior angles are less than or equal to 180 degrees, while in a strictly convex polygon all interior angles are strictly less than 180 degrees.

That is, we want to make sure that the polygon, always expands when new points are added in irrelevant positions, discarding the inner ones.

Here’s my implementation of the rest of the algorithm:

export function convexHull(points: Point[]): Point[] {
  // Input: a  set of points S = {P = (P.x,P.y)}
  const S = points.splice(0);
  // Let P[N] be the sorted array of points with P[0]=P0
  const P = sortPoints(S);
  // Push P[0] and P[1] onto a stack Ω
  const OMEGA = [];
  OMEGA.push(P[0], P[1]);
  // while i < N
  for (let i = 0, { length } = P; i < length;) {
    // Let PT1 = the top point on Ω
    const PT1 = OMEGA[OMEGA.length - 1];
    // If (PT1 == P[0]) {
    //    Push P[i] onto Ω
    //    increment i
    // }
    if (PT1 === P[0]) {
      OMEGA.push(P[i]);
      i++;
    } else {
      // Let PT2 = the second top point on Ω
      const PT2 = OMEGA[OMEGA.length - 2];
      // If (P[i] is strictly left of the line  PT2 to PT1) {
      //    Push P[i] onto Ω
      //    increment i
      // }
      const PT2isLeft = isLeftCompare(P[i], PT2, PT1);
      if (PT2isLeft < 0) {
        OMEGA.push(P[i]);
        i++;
      } else {
        // Pop the top point PT1 off the stack
        OMEGA.pop();
      }
    }
  }
  return OMEGA;
}

If we swap this method with the sortPoints we used in the functionality that adds new markers:

_onMapPress = (e) => {
  const coordinates = e.nativeEvent.coordinate;
  const newPoint = new Point(coordinates, `${ Date.now() }.${ Math.random() }`);
  const points = grahamScan.convexHull([...this.state.points, newPoint]);
  this.setState({ points }, this._showAll);
};

We get the following result:

As you can see, whenever we add a new marker, the polygon assumes a new shape, the simplest one, discarding all the points it no longer needs.