Los mapas se han convertido en una de las interfaces más populares de muchas de las aplicaciones que tenemos instaladas en nuestros teléfonos. Aprender a trabajar sobre mapas, representar la información de forma adecuada y crear una buena interfaz de navegación resulta cada vez más importante.

En este post veremos cómo integrar Google Maps en una aplicación desarrollada con React Native usando la librería react-native-maps para iOS y Android. Para desarrollar un ejemplo lo más realista posible, recrearemos una interfaz del estilo Uber utilizando un Bottom Sheet.

Al final del post conseguiremos desarrollar una aplicación como esta de aquí.

App de mapas en React Native
  1. Creación del proyecto
  2. Instalar la librería de react-native-maps con Google Maps
    2.1 Obtener la API Key de Google Maps
    2.2 Añadir la API Key en Expo
    2.3 Añadir la API Key en Android
    2.4 Añadir la API Key en iOS
  3. Añadir y personalizar un mapa en React Native
  4. Añadir Marcadores a Google Mapas en React Native
  5. Personalizar los marcadores de Google Maps en React Native
  6. Gestionar la navegación por el mapa

Creación del proyecto

Para este proyecto vamos a utilizar Expo para agilizar el proceso de instalación y facilitar que cualquier persona que quiera descargar el repositorio pueda probar la aplicación fácilmente. Si todavía no tenéis expo instalado podéis seguir la guía de instalación oficial donde lo explica perfectamente.

Lo primero que haremos es crear un proyecto en blanco utilizando la cli de expo.

#Creamos un proyecto llamado google-maps-example. Seleccionamos el "blank" templae
$ expo init google-maps-example

$ cd google-maps-example

$ expo start

Instalar la librería react-native-maps con Google Maps

Una vez creado el proyecto el siguiente paso es añadir la librería de mapas para ello podemos utilizar el siguiente comando.

expo install react-native-maps

Para instalarlo con un proyecto de React Native sin Expo lo podemos hacer con siguiente comando.

npm install react-native-maps --save-exact

o

yarn add react-native-maps -E

La diferencia entre el primer comando y el segundo es que usando el cli de Expo nos aseguramos de utilizar la última versión de la librería compatible.

Cabe mencionar que podemos utilizar la librería de react-native-maps tanto con Apple Maps como con Google Maps. En este tutorial nos centraremos en utilizar Google Maps como proveedor de mapas, pero los pasos para integrar Apple Maps son muy similares.

Obtener la API Key de Google Maps

Para poder utilizar Google Maps en nuestra aplicación será necesario habilitar el SDK de iOS y Android en un proyecto de Google con una cuenta de facturación activa en Google Cloud Console y generar una API key para añadirla al código de nuestra aplicación.

Vamos a ver paso a paso cómo obtener la API Key de Google Maps.

1. Lo primero que haremos será entrar en Google Cloud Console y crear un nuevo proyecto al que llamaremos google-maps-example-reboot.

Crear proyecto en Google Cloud

2. Una vez creado dentro de la biblioteca de APIs y servicios será necesario habilitar el Maps SDK de Android y el Maps SDK de iOS.

Biblioteca de APIs de Google Cloud
Activar SDK de Google Maps para iOS
Activar SDK de Google Maps para Android

3. Una vez habilitados los sdks será necesario crear una clave de API, para ello vamos al Panel de control → Crear Credenciales → Clave de API.

Panel de Administración de Google Cloud
Crear Clave de API en Google Cloud
Copiar la Clave de API generada

4. Una vez creada la clave de API es muy recomendable sobre todo en un proyecto de producción limitarla a las librerías que queremos usar y a las aplicaciones que tendrán permiso para usarlas usando la huella digital de la aplicación y el bundle indentifier.

Limitar el uso de la API

Con esto obtenemos el API Key y ahora falta añadirlo a nuestra aplicación. En función de si estamos utilizando expo o un proyecto bare cambiará la forma de hacerlo.

Añadir la API Key en Expo

En el caso de Expo simplemente vamos al app.json y añadimos lo siguiente:

// app.json

{
  "expo": {
    "name": "google-maps-example",
    "slug": "google-maps-example",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./src/assets/icon.png",
    "splash": {
      "image": "./src/assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },
    "updates": {
      "fallbackToCacheTimeout": 0
    },
    "assetBundlePatterns": [
      "**/*"
    ],
    "ios": {
      "supportsTablet": true,
      "config": {
          "googleMapsApiKey": "REPLACE_FOR_API_KEY"
      }
    },
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./src/assets/adaptive-icon.png",
        "backgroundColor": "#FFFFFF"
      },
      "config": {
        "googleMaps": {
          "apiKey": "REPLACE_FOR_API_KEY"
        }
      }
    },
    "web": {
      "favicon": "./src/assets/favicon.png"
    }
  }
}

Añadir la API Key en Android

Si se trata de un proyecto Bare, en Android será necesario añadir el API Key en google_maps_api.xml en la ruta android/app/src/main/res/values.

<resources>
  <string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">(api key here)</string>
</resources>

Añadir la API Key en iOS

En iOS tendrás que editar el archivo AppDelegate.m para incluir el siguiente fragmento.

+ #import <GoogleMaps/GoogleMaps.h>
@implementation AppDelegate
...
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
+ [GMSServices provideAPIKey:@"_YOUR_API_KEY_"]; // add this line using the api key obtained from Google Console
...
  # React Native Maps dependencies
  rn_maps_path = '../node_modules/react-native-maps'
  pod 'react-native-google-maps', :path => rn_maps_path
  pod 'GoogleMaps'
  pod 'Google-Maps-iOS-Utils'

Es importante señalar que al usar permisos de localización deberás indicar a Apple por qué necesitas acceder a la ubicación del usuario, de lo contrario Apple rechazará tu aplicación cuando la subas a la App Store. Esto se puede hacer en el archivo Info.plist editando el campo de NSLocationWhenInUseUsageDescription explicando de forma clara y concisa por qué necesitas conocer la ubicación.

Añadir y personalizar un mapa en React Native

Ahora que ya tenemos la librería de mapas integrada vamos a empezar por crear una pantalla con la visualización del mapa y personalizar el estilo con las diferentes opciones que nos proporciona. Para ello vamos a crear un componente Map.js como el siguiente.

import React from 'react';
import { StyleSheet, View, Dimensions } from 'react-native';
import MapView, { PROVIDER_GOOGLE } from 'react-native-maps';
import { mapStyle } from './mapStyle';

export function MapScreen() {
  return (
    <View style={styles.container}>
      <MapView
        customMapStyle={mapStyle}
        provider={PROVIDER_GOOGLE}
        style={styles.mapStyle}
        initialRegion={{
          latitude: 41.3995345,
          longitude: 2.1909796,
          latitudeDelta: 0.003,
          longitudeDelta: 0.003,
        }}
        mapType="standard"
      ></MapView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: 'black',
    alignItems: 'center',
    justifyContent: 'center',
  },
  mapStyle: {
    width: Dimensions.get('window').width,
    height: Dimensions.get('window').height,
  },
});

Como podemos ver el componente principal es MapView que cuenta con multiples props para personalizar su comportamiento. En este caso los más importantes son provider donde le indicamos que queremos utilizar Google Maps, initialRegion que será la localización que cargará el mapa en un inicio, mapType donde podemos definir el tipo de mapa que se carga y por último customMapStyle donde indicaremos el estilo personalizado del mapa que queremos utilizar.

react-native-maps Google Maps

Si miramos en la documentación oficial de google vemos que podemos personalizar prácticamente todos los elementos del mapa, en este caso buscamos realizar una interfaz bastante minimalista para ello utilizaremos los siguientes estilos.

//mapStyle.js
export const mapStyle = [
  {
    featureType: 'water',
    elementType: 'geometry',
    stylers: [
      {
        color: '#e9e9e9',
      },
      {
        lightness: 17,
      },
    ],
  },
  {
    featureType: 'landscape',
    elementType: 'geometry',
    stylers: [
      {
        color: '#f5f5f5',
      },
      {
        lightness: 20,
      },
    ],
  },
  {
    featureType: 'road.highway',
    elementType: 'geometry.fill',
    stylers: [
      {
        color: '#ffffff',
      },
      {
        lightness: 17,
      },
    ],
  },
  {
    featureType: 'road.highway',
    elementType: 'geometry.stroke',
    stylers: [
      {
        color: '#ffffff',
      },
      {
        lightness: 29,
      },
      {
        weight: 0.2,
      },
    ],
  },
  {
    featureType: 'road.arterial',
    elementType: 'geometry',
    stylers: [
      {
        color: '#ffffff',
      },
      {
        lightness: 18,
      },
    ],
  },
  {
    featureType: 'road.local',
    elementType: 'geometry',
    stylers: [
      {
        color: '#ffffff',
      },
      {
        lightness: 16,
      },
    ],
  },
  {
    featureType: 'poi',
    elementType: 'geometry',
    stylers: [
      {
        color: '#f5f5f5',
      },
      {
        lightness: 21,
      },
    ],
  },
  {
    featureType: 'poi.park',
    elementType: 'geometry',
    stylers: [
      {
        color: '#dedede',
      },
      {
        lightness: 21,
      },
    ],
  },
  {
    elementType: 'labels.text.stroke',
    stylers: [
      {
        visibility: 'on',
      },
      {
        color: '#ffffff',
      },
      {
        lightness: 16,
      },
    ],
  },
  {
    elementType: 'labels.text.fill',
    stylers: [
      {
        saturation: 36,
      },
      {
        color: '#333333',
      },
      {
        lightness: 40,
      },
    ],
  },
  {
    elementType: 'labels.icon',
    stylers: [
      {
        visibility: 'off',
      },
    ],
  },
  {
    featureType: 'transit',
    elementType: 'geometry',
    stylers: [
      {
        color: '#f2f2f2',
      },
      {
        lightness: 19,
      },
    ],
  },
  {
    featureType: 'administrative',
    elementType: 'geometry.fill',
    stylers: [
      {
        color: '#fefefe',
      },
      {
        lightness: 20,
      },
    ],
  },
  {
    featureType: 'administrative',
    elementType: 'geometry.stroke',
    stylers: [
      {
        color: '#fefefe',
      },
      {
        lightness: 17,
      },
      {
        weight: 1.2,
      },
    ],
  },
];

Personalizar un mapa de Google puede llegar a resultar tedioso, por eso existen páginas como Snazzymaps que reúnen plantillas con diferentes estilos que podemos copiar directamente sus atributos y usarlas como base.

Personalizar Google Maps en React Native

Añadir Marcadores a Google Maps en React Native

Lo siguiente que haremos será añadir marcadores a nuestro mapa. Para ello crearemos una constante MARKERS_DATA con la siguiente estructura.

import { default as Reboot } from '../assets/reboot.png';
import { default as Cravy } from '../assets/cravy.png';
import { default as Dribbble } from '../assets/dribbble.png';
import { default as Basecamp } from '../assets/basecamp.png';
import { default as Discord } from '../assets/discord.png';
import { default as OnePassword } from '../assets/onepassword.png';

export const MARKERS_DATA = [
  {
    id: '1',
    latitude: 41.3997999,
    longitude: 2.1909796,
    color: '#2F3136',
    name: 'Reboot Studio',
    direction: 'Carrer de Pujades, 100',
    img: Reboot,
  },
  {
    id: '2',
    latitude: 41.3995445,
    longitude: 2.1915268,
    color: '#A3EAD8',
    name: 'Cravy',
    direction: 'Carrer de Pujades, 101',
    img: Cravy,
  },
  {
    id: '3',
    latitude: 41.4009999,
    longitude: 2.1919999,
    color: '#E990BB',
    name: 'Dribbble',
    direction: 'Carrer de Pujades, 102',
    img: Dribbble,
  },
  {
    id: '4',
    latitude: 41.4001999,
    longitude: 2.1900096,
    color: '#EFD080',
    name: 'Basecamp',
    direction: 'Carrer de Pujades, 103',
    img: Basecamp,
  },
  {
    id: '5',
    latitude: 41.40009,
    longitude: 2.1909796,
    color: '#98AFE9',
    name: 'Discord',
    direction: 'Carrer de Pujades, 104',
    img: Discord,
  },
  {
    id: '6',
    latitude: 41.4009999,
    longitude: 2.1909796,
    color: '#4E87EB',
    name: '1 Password',
    direction: 'Carrer de Pujades, 105',
    img: OnePassword,
  },
];

Una vez tenemos nuestros datos preparados podemos añadirlos al mapa importando el componente Marker que nos da la librería de mapas y añadirlos dentro de la MapView. Para ello usaremos una función de Array.map juntamente con la estructura de datos MARKERS_DATA que hemos creado.

//Map.js
import React from 'react';
import { StyleSheet, View, Dimensions } from 'react-native';
import MapView, { PROVIDER_GOOGLE, Marker } from 'react-native-maps';
import { mapStyle } from './mapStyle';
import { MARKERS_DATA } from '../../data';

export function MapScreen() {
  return (
    <View style={styles.container}>
      <MapView
        customMapStyle={mapStyle}
        provider={PROVIDER_GOOGLE}
        style={styles.mapStyle}
        initialRegion={{
          latitude: 41.3995345,
          longitude: 2.1909796,
          latitudeDelta: 0.003,
          longitudeDelta: 0.003,
        }}
        mapType="standard"
      >
        {MARKERS_DATA.map((marker) => (
          <Marker
            key={marker.id}
            coordinate={{
              latitude: marker.latitude,
              longitude: marker.longitude,
            }}
          ></Marker>
        ))}
      </MapView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: 'black',
    alignItems: 'center',
    justifyContent: 'center',
  },
  mapStyle: {
    width: Dimensions.get('window').width,
    height: Dimensions.get('window').height,
  },
});

¡Voilà! Ya tenemos nuestros marcadores sobre el mapa. Pero todavía parece como cualquier mapa estándar de Google Maps, así que en el siguiente paso vamos a darle personalidad customizando el estilo de los marcadores.

react-native-maps markers

Personalizar los marcadores de Google Maps en React Native

La propia librería de react-native-maps incluye varios prop para personalizar el estilo de los marcadores, pero una de las mejores opciones si quieres crear marcadores completamente personalizados es utilizar el componente Marker como un wrapper y crear un componente propio con el estilo que queramos.

Para seguir con la interfaz minimalista queremos crear unos marcadores circulares de diferentes colores en función de la ubicación y que tengan la capacidad de animarse aumentando su tamaño cuando es seleccionado.

Vamos a crear el componente CustomMarker y un hook useMarkerAnimation para gestionar la interacción de la animación.

//Custom Marker
import React from 'react';
import { Marker } from 'react-native-maps';
import Animated from 'react-native-reanimated';
import { StyleSheet, View } from 'react-native';
import { useMarkerAnimation } from './useMarkerAnimation';

export function CustomMarker({
  id,
  selectedMarker,
  color,
  latitude,
  longitude,
}) {
  const scale = useMarkerAnimation({ id, selectedMarker });

  return (
    <Marker
      coordinate={{
        latitude: latitude,
        longitude: longitude,
      }}
    >
      <View style={styles.markerWrapper}>
        <Animated.View
          style={[
            styles.marker,
            {
              backgroundColor: color,
              transform: [{ scale: scale }],
            },
          ]}
        ></Animated.View>
      </View>
    </Marker>
  );
}

const styles = StyleSheet.create({
  markerWrapper: {
    height: 50,
    width: 50,
    alignItems: 'center',
    justifyContent: 'center',
  },
  marker: {
    height: 22,
    width: 22,
    borderRadius: 20,
    borderColor: 'white',
    borderWidth: 2,
  },
});

Para gestionar las animaciones hemos añadido las librerías de Reanimated y Redash.

//useMarkerAnimation
import { useState, useEffect } from 'react';
import Animated from 'react-native-reanimated';
import { useTimingTransition } from 'react-native-redash';

export function useMarkerAnimation({ id, selectedMarker }) {
  const [active, setActive] = useState(0);

  useEffect(() => {
    const isActive = id === selectedMarker ? 1 : 0;
    setActive(isActive);
  }, [id, selectedMarker]);

  const transition = useTimingTransition(active, {
    duration: 200,
  });

  const scale = Animated.interpolate(transition, {
    inputRange: [0, 1],
    outputRange: [1, 1.5],
  });

  return scale;
}

Finalmente lo remplazamos en la vista de mapa por el Marker de la librería

//Map.js
import React from 'react';
import { StyleSheet, View, Dimensions } from 'react-native';
import MapView, { PROVIDER_GOOGLE } from 'react-native-maps';
import { CustomMarker } from '../../components';
import { MARKERS_DATA } from '../../data';
import { mapStyle } from './mapStyle';

export function MapScreen() {
  return (
    <View style={styles.container}>
      <MapView
        customMapStyle={mapStyle}
        provider={PROVIDER_GOOGLE}
        style={styles.mapStyle}
        initialRegion={{
          latitude: 41.3995345,
          longitude: 2.1909796,
          latitudeDelta: 0.003,
          longitudeDelta: 0.003,
        }}
        mapType="standard"
      >
        {MARKERS_DATA.map((marker) => (
          <CustomMarker
            key={marker.id}
            id={marker.id}
            selectedMarker={null}
            color={marker.color}
            latitude={marker.latitude}
            longitude={marker.longitude}
          ></CustomMarker>
        ))}
      </MapView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: 'black',
    alignItems: 'center',
    justifyContent: 'center',
  },
  mapStyle: {
    width: Dimensions.get('window').width,
    height: Dimensions.get('window').height,
  },
});

Listo. Ya tenemos nuestros marcadores personalizados en nuestra aplicación de mapas. Pero todavía queda un paso fundamental: necesitamos poder navegar entre los diferentes marcadores. Para ello crearemos una interfaz basada en un Bottom Sheet similar al que encontramos en aplicaciones como Uber o Google Maps desde donde gestionaremos la navegación entre marcadores.

Marcadores personalizados en react-native-maps

Gestionar la navegación por el mapa

Vamos a ver cómo podemos navegar por el mapa usando tanto la función de animateCamera como la función de animateToRegion. Para ello deberemos crear una referencia del mapa para poder utlizarla y llamar a estas funciones, en nuestro caso hemos decidido crear un hook para gestionar está lógica.

//useMap.js
import { useState, useRef, useCallback } from 'react';

const DEVIATION = 0.0002;

export function useMap() {
  const mapRef = useRef(null);
  const [selectedMarker, setSelectedMarker] = useState(null);

  const handleNavigateToPoint = useCallback(
    (id, lat, long) => {
      if (mapRef) {
        mapRef.current.animateCamera(
          {
            center: {
              latitude: lat - DEVIATION,
              longitude: long,
            },
            zoom: 18.5,
          },
          500
        );
      }
      setSelectedMarker(id);
    },
    [mapRef, setSelectedMarker]
  );

  const handelResetInitialPosition = useCallback(() => {
    if (mapRef) {
      mapRef.current.animateToRegion(
        {
          latitude: 41.3995345,
          longitude: 2.1909796,
          latitudeDelta: 0.003,
          longitudeDelta: 0.003,
        },
        500
      );
      setSelectedMarker(null);
    }
  }, [mapRef, setSelectedMarker]);

  return {
    mapRef,
    selectedMarker,
    handleNavigateToPoint,
    handelResetInitialPosition,
  };
}

Como podemos ver en el código las funciones son bastante sencillas. A animateCamera le pasamos el centro con la latitud y la longitud el Zoom y el tiempo que tardará la animación. En el caso de animateToRegion es muy similar pero en cambio de utilizar el Type Camera utiliza el Type Region.

En nuestro caso también le hemos añadido un setSelectedMarker para poder realizar la ampliación del marcador cuando la cámara utilice este como centro.

Para utilizar el hook simplemente tenemos que añadirlo en nuestro componente de Mapa, pero antes de eso crearemos el componente por encima del mapa para poder utilizar las funciones del hook.

Concretamente vamos a crear un componente Bottom Sheet con el listado de localizaciones y cuando se pulse encima de una de estas la cámara se moverá a ese punto y el marcador seleccionado se ampliará. Para el componente hemos utilizado la librería 'react-native-scroll-bottom-sheet' que utiliza Reanimated para gestionar las animaciones del componente.

//BottomSheet.js
import React from 'react';
import { Dimensions, StyleSheet, View } from 'react-native';
import ScrollBottomSheet from 'react-native-scroll-bottom-sheet';
import { MARKERS_DATA } from '../../data';
import { ListItem } from './ListItem';

const windowHeight = Dimensions.get('window').height;

export function BottomSheet({ onPressElement }) {
  return (
    <ScrollBottomSheet
      componentType="FlatList"
      snapPoints={[100, '50%', windowHeight - 200]}
      initialSnapIndex={1}
      renderHandle={() => (
        <View style={styles.header}>
          <View style={styles.panelHandle} />
        </View>
      )}
      data={MARKERS_DATA}
      keyExtractor={(i) => i.id}
      renderItem={({ item }) => (
        <ListItem item={item} onPressElement={onPressElement} />
      )}
      contentContainerStyle={styles.contentContainerStyle}
    />
  );
}

const styles = StyleSheet.create({
  contentContainerStyle: {
    flex: 1,
    backgroundColor: 'white',
  },
  header: {
    alignItems: 'center',
    backgroundColor: 'white',
    paddingVertical: 20,
  },
  panelHandle: {
    width: 41,
    height: 4,
    backgroundColor: '#E1E1E1',
    borderRadius: 17,
  },
});

También añadiremos un menú superior que nos permitirá reiniciar el estado de nuestro mapa.

//TopBar.js
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { Avatar } from './Avatar';
import { RefreshButton } from './RefreshButton';

export function TopBar({ onPressElement }) {
  return (
    <View style={styles.container}>
      <Avatar />
      <RefreshButton onPressElement={onPressElement} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    position: 'absolute',
    left: 0,
    top: 40,
    width: '100%',
    zIndex: 1,
    flexDirection: 'row',
    justifyContent: 'space-between',
    paddingHorizontal: 10,
  },
});

Finalmente el componente de mapa quedaría así:

import React from 'react';
import { StyleSheet, View, Dimensions } from 'react-native';
import MapView, { PROVIDER_GOOGLE } from 'react-native-maps';
import { TopBar, BottomSheet, CustomMarker } from '../../components';
import { MARKERS_DATA } from '../../data';
import { useMap } from './useMap';
import { mapStyle } from './mapStyle';

export function MapScreen() {
  const {
    mapRef,
    selectedMarker,
    handleNavigateToPoint,
    handelResetInitialPosition,
  } = useMap();

  return (
    <View style={styles.container}>
      <TopBar onPressElement={handelResetInitialPosition} />
      <MapView
        ref={mapRef}
        customMapStyle={mapStyle}
        provider={PROVIDER_GOOGLE}
        style={styles.mapStyle}
        initialRegion={{
          latitude: 41.3995345,
          longitude: 2.1909796,
          latitudeDelta: 0.003,
          longitudeDelta: 0.003,
        }}
        mapType="standard"
      >
        {MARKERS_DATA.map((marker) => (
          <CustomMarker
            key={marker.id}
            id={marker.id}
            selectedMarker={selectedMarker}
            color={marker.color}
            latitude={marker.latitude}
            longitude={marker.longitude}
          ></CustomMarker>
        ))}
      </MapView>
      <BottomSheet onPressElement={handleNavigateToPoint} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: 'black',
    alignItems: 'center',
    justifyContent: 'center',
  },
  mapStyle: {
    width: Dimensions.get('window').width,
    height: Dimensions.get('window').height,
  },
});

Con esto tendríamos una aplicación de mapas con una interfaz muy simple que nos permite gestionar la navegación entre los diferentes puntos de interés de forma muy intuitiva. Sobre esta base se pueden construir productos mucho más complejos, pero es un buen punto de partida si estás desarrollando una aplicación de mapas en React Native en 2020.

Aplicación de mapas React Native

El proyecto completo se encuentra disponible en GitHub para que podáis descargarlo y trabajar sobre él.