Google Maps in React Native Part 3: Advanced Usage
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.
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, usingReact.createRef();
. - Changing the region the map displays, will be done explicitly, so we won’t be needing
this.state.region
and the usages ofthis.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:
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.