Saltar a contenido

Mapas y localización

MapKit

Aspectos básicos de MapKit

Map Kit es el framework de Apple para trabajar con mapas.

Permite muchas funcionalidades: pan y zoom, anotaciones, localización, overlays, búsquedas, rutas, direcciones, etc.

Junto con los servicios de localización proporciona la forma de incluir datos geográficos en nuestras apps.

Los mapas permiten a los usuarios a visualizar datos geográficos de una forma fácil de entender. Por ejemplo, un mapa puede mostrar datos de satélite de un área, o una visualización tridimensional de una perspectiva de la zona.

El framework Map Kit permite embeber en tu app un map completamente funcional, que soporta funcionalidades similares a la de la app Mapas.

Con Map Kit puedes incorporar en tu app vistas de un punto geográfico concreto. Además, el framework te permite añadir capas de información sobre el mapa, moverlo, o tomar instantáneas de un mapa para imprimir.

Un ejemplo de la app Mapas mostrando una vista 3D de Alicante:

Geometría de los mapas

Map Kit usa una proyección Mercator, que es un tipo específico de proyección cilíndrica.

Una coordenada se define por una latitud y una longitud.

  • La latitud es la distancia angular (en grados: de -90.0 a 90.0) desde el punto de la superficie hasta el ecuador. Las latitudes positivas definen puntos por encima del ecuador y las negativas por debajo.

  • La longitud es la distancia angular (en grados: de -180.0 a 180.0) desde el punto de la superficie hasta el meridiano 0 (meridianto de Greenwich). Las longitudes positivas definen puntos al este del meridiano y las negativas al oeste.

La estructura CLLocationCoordinate2D representa esta estructura. Por ejemplo, para crear una localización situada en Alicante:

let alicanteLocation =  CLLocationCoordinate2D(latitude: 38.3453, 
                                               longitude: -0.4831)

Un punto en el mapa se define por los valores x e y en la proyección de Mercator. Se define utilizando la estructura MKMapPoint. Se utiliza para especificar la posición y forma de los overlays que podemos pintar sobre el mapa.

Un punto es una unidad gráfica asociada con el sistema de coordenadas de una vista. Los puntos en el mapa y las coordenadas deben convertirse en puntos antes de dibujar contenido en una vista. Los puntos individuales se definen usando la estructura CGPoint y las áreas usando CGSize y CGRect. Consultar las funciones del API de geometría y los tipos de datos en este enlace

Para almacenar los datos en ficheros es preferible usar coordenadas de mapas.

Añadir un mapa en nuestra app

Para poder distribuir apps que trabajen con el servicio de mapas es necesario activar en la app el entitlement correspondiente, activando los servicios que necesitamos. Debemos tener un perfil de aprovisionamiento aprobado con un App ID que soporte estos servicios.

No es necesario para el desarrollo y para las pruebas en el simulador. No utilizaremos por tanto ningún perfil de aprovisionamiento especial para la práctica.

La clase MKMapView es una interfaz autocontenida para presentar los mapas en tu app. Proporciona todo el soporte para mostrar los datos del mapa, gestionar las interacciones del usuario y hospedar el contenido proporcionado por tu app. Debes importar MapKit.

No debes hacer una subclase de MKMapView sino embeberla tal cual en la jerarquía de vistas de tu app:

  • Usando el Interface Builder puedes arrastrar un objeto Map view a la vista o ventana apropiada.
  • Para añadir un mapa por programa, crea una instancia de la clase MKMapView, inicialízala con el método initWithFrame: y añádela como una subvista a tu ventana o a tu vista.

Por último debes actualizar el delegado con un objeto que cumpla el protocolo MKMapViewDelegate.

Ejemplo de código para añadir un mapa mediante programa:

import UIKit
import MapKit

class ViewController: UIViewController, MKMapViewDelegate {

    override func viewDidLoad() {
        super.viewDidLoad()
        let map = MKMapView(frame:
                                CGRect(x: 0, y: 0,
                                       width: self.view.frame.width,
                                       height: self.view.frame.height))
        self.view.addSubview(map)
        map.delegate = self
    }
}

El resultado tiene el siguiente aspecto:

Inicialización del mapa

Podemos también inicializar el mapa cuando se crea usando el Interface Builder, usando un didSet en el outlet mapView que definimos arrastrando desde el storyboard:

class ViewController: UIViewController, MKMapViewDelegate  {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

    @IBOutlet weak var mapView: MKMapView! {
        didSet {
            mapView.mapType = .standard
            mapView.delegate = self
            let alicanteLocation = 
                CLLocationCoordinate2D(latitude: 38.3453, 
                                       longitude: -0.4831)
            centerMapOnLocation(mapView: mapView, loc: alicanteLocation)
        }
    }

    func centerMapOnLocation(mapView: MKMapView, loc: CLLocationCoordinate2D) {
        let regionRadius: CLLocationDistance = 4000
        let coordinateRegion =
            MKCoordinateRegion(center: loc,
                               latitudinalMeters: regionRadius,
                               longitudinalMeters: regionRadius)
        mapView.setRegion(coordinateRegion, animated: true)
    }

    ...
}

Para que los controles del mapa funcionen correctamente es necesario asignar el delegado MKMapViewDelegatea un objeto que defina las funciones de este delegado. Lo más sencillo es definir el propio View Controller como el delegado.

La propiedad region del mapa controla el área del mapa mostrada. Contiene al mismo tiempo el punto de longitud y latitud en el que el mapa está centrado y la zona visible, determinando de forma implícita el zoom del mapa.

Por ejemplo, el código anterior centra el mapa en Alicante y muestra una zona de 4 km. de alto y ancho.

Tipos de mapas

La definición del tipo de mapa se controla con la propiedad mapType del mapa.

Puede tener los valores:

enum MKMapType : UInt {
    case standard
    case satellite
    case hybrid
}

Ejemplo de selección del tipo de mapa con un SegmentedControl

Supongamos un SegmentedControl con los valores Mapa y Satélite. Podemos cambiar la visualización del mapa en la acción definida en el View Controller que contiene el mapView:

enum TipoMapa: Int {
    case mapa = 0
    case satelite
}

...

// En el ViewController

@IBAction func seleccion(sender: UISegmentedControl) {
    let tipoMapa = TipoMapa(rawValue: sender.selectedSegmentIndex)!
    switch (tipoMapa) {
        case .mapa:
            mapView.mapType = MKMapType.standard
        case .satelite:
            mapView.mapType = MKMapType.satellite
    }
}

Uso del delegado

Hemos visto que Lo más sencillo es definir como delegado el view controller en el que se incluye el mapa.

El objeto delegado puede implementar las funciones del protocolo MKMapViewDelegate donde recibe los eventos relacionados con el mapa:

  • Cambios en la región visible del mapa.
  • La carga de zonas del mapa de la red.
  • Cambios en la localización del usuario.
  • Cambios asociados con anotaciones y overlys.

Por ejemplo:

func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
    print("Cambiada la posición del mapa: \(mapView.centerCoordinate)")
}

Anotaciones

Las anotaciones permiten resaltar coordenadas específicas del mapa y proporcionar información adicional sobre ellas.

Puedes usar anotaciones para resaltar direcciones, puntos de interés y otros tipos de destinos.

Cuando se muestran en el mapa, las anotaciones tienen algún tipo de imagen para identificar su localización y también pueden tener un bocadillo (callout) que proporciona información y enlaces hacia más contenido.

En la imagen se muestra una vista estándar en forma de chincheta para marcar un lugar y un callout que muestra más información.

Clases relacionadas

Para mostrar una anotación en un mapa necesitamos dos objetos:

  • Un objeto annotation, que es un objeto que cumple el protocolo MKAnnotation y que gestiona los datos de la anotación.

  • Una vista de la anotación, que es una vista (derivada de la clase MKAnnotationView usada para dibujar la representación visual de la anotación sobre la superficie del mapa (una "chincheta" por defecto).

El protocolo MKAnnotation

El protocolo MKAnnotation define los métodos que deben cumplir los objetos que vayan a implementar una anotación:

var coordinate: CLLocationCoordinate2D { get }
var title: String? { get }
var subtitle: String? { get }
  • coordinate: coordenadas de la anotación
  • title: cadena mostrada en el callout
  • subtitle: cadena subtítulo mostrada en el callout

Podemos conformar el protocolo en cualquier clase. Por ejemplo, podemos definir una clase Pin:

class Pin:  NSObject, MKAnnotation {
    var coordinate: CLLocationCoordinate2D
    var title: String?
    var subtitle: String?

    init(num: Int, coordinate: CLLocationCoordinate2D) {
        self.title = "Pin \(num)"
        self.subtitle = "Un bonito lugar"
        self.coordinate = coordinate
        super.init()
    }
}

MKAnnotationView

La clase MKAnnotationView permite bastante flexibilidad para definir las distintas características de las vistas de las anotaciones.

Permite definir la imagen de la anotación, con su propiedad image y definir las características del callout que aparecerá cuando el usuario pinche sobre la imagen, así como mantener el estado del mismo. Cuando la anotación está seleccionada, el callaout está activo.

La subclase MKPinAnnotationView proporciona unos valores por defecto que podemos usar (por ejemplo, la imagen de la chincheta).

Para crear una anotación (o, más precisamente, una vista de una anotación), debemos usar la función mapView(_:viewFor:) en el objeto delegado del mapa. Esta función proporciona una vista cuando las coordenadas de la anotación están la región visible y el mapa la solicita.

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) 
                          -> MKAnnotationView?

En la implementación de esta función debemos construir una vista asociada a la anotación que nos pasan y devolverla para que el mapView la gestione o devolver nil si queremos que se muestre la vista estándar.

Por ejemplo:

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
    print("Devolviendo vista para anotación: \(annotation)")
    let view = MKPinAnnotationView(annotation: annotation, reuseIdentifier: nil)
    view.pinTintColor = UIColor.red
    view.animatesDrop = true
    view.canShowCallout = true
    return view;
}

Añadir anotaciones en el mapa

Para añadir una anotación al mapa hay que usar el método addAnnotation del viewMap.

Por ejemplo, podemos crear una anotación en el centro del mapa creando una instancia de Pin (la clase definida anteriormente, que cumple el protocolo MKAnnotation) que inicializamos con un número (variable definida en el viewController que vamos incrementado):

let pin = Pin(num: numPin, coordinate: mapView.centerCoordinate)
mapView.addAnnotation(pin)

Elementos en el callout

Es posible definir en el callout una imagen en su parte izquierda y un botón en la parte derecha.

Hay que actualizar las propiedades de la vista leftCalloutAccessoryView y rightCalloutAccessoryView con objetos UIView. En la parte derecha es común usar un objeto UIButton con tipo UIButtonTypeDetailDisclosure.

Por ejemplo, podemos mostrar imágenes en la parte izquierda del callout, un thumbnail con la foto del sitio en el que está situada la anotación. Podemos guardar la imagen en el objeto modelo annotation y después inicializar la imagen del callout con esa imagen.

Por simplificar, guardamos dos imágenes predefinidas según el número del pin sea par o impar. Podríamos también tener una colección de imágenes y guardar en el pin la más cercana a sus coordenadas.

class Pin:  NSObject, MKAnnotation {
    var coordinate: CLLocationCoordinate2D
    var title: String
    var subtitle: String
    var thumbImage: UIImage

    init(num: Int, coordinate: CLLocationCoordinate2D) {
        self.title = "Pin \(num)"
        self.subtitle = "Un bonito lugar"
        self.coordinate = coordinate
        if (num % 2 == 0) {
            self.thumbImage = UIImage(named: "alicante1_thumb.png")!
        } else {
            self.thumbImage = UIImage(named: "alicante2_thumb.png")!
        }
        super.init()
    }
}

La actualización del callout se hace en el mismo método mapView(_:viewFor) que devuelve la vista de una anotación:

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
    print("Devolviendo vista para anotación: \(annotation)")
    let pin = annotation as? Pin
    let view = MKPinAnnotationView(annotation: annotation, reuseIdentifier: nil)

    view.pinTintColor = UIColor.red
    view.animatesDrop = true
    view.canShowCallout = true
    let thumbnailImageView = UIImageView(frame: CGRect(x:0, y:0, width: 59, height: 59))
    thumbnailImageView.image = pin?.thumbImage
    view.leftCalloutAccessoryView = thumbnailImageView
    view.rightCalloutAccessoryView = UIButton(type:UIButton.ButtonType.detailDisclosure)
    return view;
}

Podemos saber que se ha pulsado un botón de un callout usando el método del delegado mapView(_:annotationView:calloutAccessoryControlTapped:). En ese caso podríamos, por ejemplo, activar un segue, pasándole como parámetro la vista de la anotación que se ha pulsado.

    func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
        performSegue(withIdentifier: "DetalleImagen", sender: view)
    }

En el método prepare(for segue: UIStoryboardSegue, sender: Any?) recibiremos la vista de la anotación en el parámetro sender. Podemos acceder a la anotación a partir de la mKAnnotationView usando el atributo annotation.

Overlays

Los overlays permiten definir capas de contenido sobre una región arbitraria del mapa.

Están definidos por coordenadas en las que es posible definir conjuntos de líneas, rectángulos y otras formas.

Por ejemplo, se podría usar usar overlays para añadir información de tráfico sobre carreteras, o marcar los límites de un parque o de una región.

Puedes ver una demostración del uso de overlays en la app ParkView del tutorial de raywenderlich.com.

Para mostrar un overlay sobre un mapa se deben proporcionar dos objetos:

  • Un objeto overlay, que es un objeto que cumple el protocolo MKOverlay y gestiona los puntos de datos del overlay.

  • Un renderizador del overlay, que es una clase derivada de MKOverlayRenderer y que debe usarse para dibujar la representación visual del overlay sobre la superficie del mapa.

Un ejemplo de código de la aplicación demo Park View:

class ParkMapOverlay: NSObject, MKOverlay {

  var coordinate: CLLocationCoordinate2D
  var boundingMapRect: MKMapRect

  init(park: Park) {
    boundingMapRect = park.overlayBoundingMapRect
    coordinate = park.midCoordinate
  }
}
class ParkMapOverlayView: MKOverlayRenderer {
  var overlayImage: UIImage

  init(overlay:MKOverlay, overlayImage:UIImage) {
    self.overlayImage = overlayImage
    super.init(overlay: overlay)
  }

  override func draw(_ mapRect: MKMapRect, zoomScale: MKZoomScale, in context: CGContext) {
    guard let imageReference = overlayImage.cgImage else { return }

    let rect = self.rect(for: overlay.boundingMapRect)
    context.scaleBy(x: 1.0, y: -1.0)
    context.translateBy(x: 0.0, y: -rect.size.height)
    context.draw(imageReference, in: rect)
  }
}

El overlay debe añadirse al mapView:

let overlay = ParkMapOverlay(park: park)
mapView.add(overlay)

Para su visualización debemos implementar el método mapView:rendererForOverlay: en el mapView delegado. En el siguiente código se dibujan distintos tipos de overlays:

  func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    if overlay is ParkMapOverlay {
      return ParkMapOverlayView(overlay: overlay, overlayImage: #imageLiteral(resourceName: "overlay_park"))
    } else if overlay is MKPolyline {
      let lineView = MKPolylineRenderer(overlay: overlay)
      lineView.strokeColor = UIColor.green
      return lineView
      else if ...
    }

    return MKOverlayRenderer()
  }

Geocoding

El API de MapKit proporciona funcionalidades para realizar geocoding, transformar coordenadas del mapa en nombres de lugares y vicersa.

La clase CLGeocoder proporciona un API que realiza estas operaciones realizando peticiones a un servicio de Apple.

Debemos crear un objeto geocoder y realizar una petición llamando a uno de sus métodos de forward geocoding o reverse geocoding.

Las peticiones de reverse geocoding toman una longitud y latitud y obtienen una dirección con nombres.

Las peticiones de forward geocoding hacen al revés: toman una dirección con nombres y buscan la correspondiente latitud y longitud. Estas peticiones pueden también devolver información adicional acerca de la localización especificada, como un punto de interés o un edificio en esa localización.

El objeto devuelto en ambos tipos de peticiones es un CLPlacemark el caso de peticiones forward geocoding se puede devolver una lista de lugares a los que corresponde la dirección suministrada.

Un placemark (marca de lugar) contiene propiedades para especificar el nombre de una calle, de una ciudad o de un país. También contienen propiedades que describen características geográficas relevantes o puntos de interés en la localización, como los nombres de montañas, ríos, negocios o localizaciones.

Existe un límite en el ratio de peticiones de geocoding que puede hacer una app. Si se hacen demasiadas peticiones en un tiempo pequeño puede producirse un error.

Conversión de localización en placemarks

Con el método reverseGeocodeLocation se puede obtener una lista de placemarks asociadas a unas coordenadas. Las llamadas al objeto geocoder son asíncronas y hay que pasarle al método una clausura completion handler.

Un ejemplo de uso:

func printLocationPlacemark(location: CLLocation) {
    let geocoder = CLGeocoder()
    geocoder.reverseGeocodeLocation(location, 
                completionHandler: { (placemarks, error) in
                    if error == nil {
                        let firstLocation: CLPlacemark = placemarks?[0]
                        print(firstLocation ?? "No hay localización")
                    }
                    else {
                       print("An error occurred during geocoding")
                    }
                })
}

Conversión de placemarks en localizaciones

Con el método geocodeAddressString se puede pasar una dirección al geocoder y obtener una lista de lugares asociados (placemarks). Se obtendrán menos lugares cuanto más precisa sea la dirección.

Ejemplo:

func getCoordinate( addressString : String, 
        completionHandler: @escaping(CLLocationCoordinate2D, NSError?) -> Void ) {
    let geocoder = CLGeocoder()
    geocoder.geocodeAddressString(addressString) { (placemarks, error) in
        if error == nil {
            if let placemark = placemarks?[0] {
                let location = placemark.location!

                completionHandler(location.coordinate, nil)
                return
            }
        }

        completionHandler(kCLLocationCoordinate2DInvalid, error as NSError?)
    }
}

Otras características: búsquedas, rutas y 3D

No tenemos tiempo de verlo, pero el API también proporciona la posibilidad de realizar búsquedas y rutas en los mapas:

Así como la posibilidad de mostrar el mapa en 3D:

Localización

Mediante el framework Core Location es posible obtener la localización del dispositivo móvil.

Los datos de localización pueden ser muy útiles para proporcionar servicios al usuario en distintos tipos de apps, como redes sociales, compras o navegación.

Este framework proporciona bastantes funcionalidades que podemos usar para obtener y monitorizar la localización actual del dispositivo:

  • El servicio de localización de cambios-significativos proporciona una forma de bajo consumo de obtener la localización actual y ser notificado cuando ha ocurrido un cambio significativo.

  • El servicio de localización estándar ofrece una forma altamente configurable de obtener la localización actual y de hacer un seguimiento de los cambios.

  • La monitorización de regiones nos permite monitorizar regiones geográficas y regiones definidas por beacons de Bluetooth de baja energía.

La clase principal del framework es CLLocationManager

Activación de los servicios de localización

Si la app requiere servicios de localización para funcionar correctamente, debes incluir la clave UIRequiredDeviceCapabilities en el fichero Info.plist de la app. La App Store usa la información en esta clava para prevenir la descarga de la app a dispositivos que no contienen estos servicios. Puedes no añadir esta clave si quieres permitir descargar la app aunque no esté disponible el servicio.

El valor de la clave es un array de cadenas indicando las características que requiere la app. En el caso de los servicios de localización son relevantes las cadenas location-services y gps. La primera si se requieren servicios de localización en general y la segundo si se requiere la precisión ofrecida por el GPS.

Solicitar información al usuario

Es necesario añadir también en Info.plist una cadena asociada a la clave NSLocationWhenInUseUsageDescription. Esta clave tiene la descripción en Xcode Privacy - Location When in Use Usage Description.

Se solicita autorización al usuario llamando al método request​When​In​Use​Authorization() o requestAlwaysAuthorization() del objeto CLLocationManager.

La cadena se mostrará como subtítulo en el diálogo en el que se solicita al usuario la autorización.

Clase CLLocationManager

Se debe crear una instancia de la clase CLLocation​Manager necesita mantener una referencia a esta instancia que han terminado todas las tareas en las que participa.

Debido a que las tareas de gestión de localización se ejecutan asíncronamente, no debemos almacenar una referencia al location manager en una variable local.

La clase AppDelegate también puede funcionar como CLLocationManagerDelegate. Inicializamos ahí el CLLocationManager.

En el método didFinishLaunchingWithOptions podemos actualizar el gestor de localización:

  • Inicializamos el delegado del gestor de localización.
  • Solicitamos permiso al usuario de que la app va a usar los servicios de localización invocando al método requestWhenInUseAuthorization()

Se debe configurar la precisión de la localización, actualizando la propiedad desiredAccuracy del gestor de localización, asignándole el valor en metros de la precisión deseada. Cuanto mayor sea la precisión deseada, mayor será el consumo de batería del dispositivo.

Después se debe llamar al método startUpdatingLocation().

Ejemplo de código:

import UIKit
import CoreLocation

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, CLLocationManagerDelegate {

    var window: UIWindow?
    let locationManager = CLLocationManager()

    func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
        // Override point for customization after application launch.
        locationManager.delegate = self
        locationManager.requestWhenInUseAuthorization()
        locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
        locationManager.startUpdatingLocation()
        return true
    }

    ...

Monitorización de la localización

Cuando suceda un cambio en localización se notificará al delegado llamando a su método didUpdateLocations pasándole un array de localizaciones (objetos CLLocation):

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
   // Código para gestionar las localizaciones
}

Clase CLLocation

La clase CLLocation permite representar una posición outdoor o indoor y el instante de tiempo asociado a ella.

Atributos:

  • coordinate
  • altitude
  • floor
  • horizontalAccuracy
  • verticalAccuracy
  • speed
  • course
  • timestamp
  • description

Activación de la localización en el mapa

Una vez activado el servicio de localización se puede visualizar la localización en el mapa obteniendo el MKUserTrackingBarButtonItem y añadiéndolo a la barra de navegación.

No hace falta llamar a startUpdatingLocation().

Se puede hacer en el ViewController que contiene el mapa:

override func viewDidLoad() {
    super.viewDidLoad()
    let userTrackingButton = MKUserTrackingBarButtonItem(mapView: mapView)
    self.navigationItem.leftBarButtonItem = userTrackingButton
}

Prueba de la localización en el simulador

Es posible probar los servicios de localización desde el simulador. Pare ello se debe seleccionar la simulación del movimiento y localización del dispositivo en Debug > Location y escoger una de las siguientes opciones:

  • Ninguna (se desactiva la localizadión)
  • Custom (se puede definir una localización)
  • Apple (localización de Apple en San Francisco)
  • City Bicycle Ride (Simulación de un paseo en bicicleta)
  • City Run (Simulación de una carrera por la ciudad)
  • Freeway Ride (Simulación de un recorrido en coche)

Referencias